feat(security): add sandbox env sanitization helpers + tests

This commit is contained in:
Peter Steinberger
2026-02-18 02:17:57 +01:00
parent 71ad357bbe
commit 5487c9adeb
2 changed files with 163 additions and 0 deletions

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { sanitizeEnvVars } from "./sanitize-env-vars.js";
describe("sanitizeEnvVars", () => {
it("keeps normal env vars and blocks obvious credentials", () => {
const result = sanitizeEnvVars({
NODE_ENV: "test",
OPENAI_API_KEY: "sk-live-xxx",
FOO: "bar",
GITHUB_TOKEN: "gh-token",
});
expect(result.allowed).toEqual({
NODE_ENV: "test",
FOO: "bar",
});
expect(result.blocked).toEqual(expect.arrayContaining(["OPENAI_API_KEY", "GITHUB_TOKEN"]));
});
it("blocks credentials even when suffix pattern matches", () => {
const result = sanitizeEnvVars({
MY_TOKEN: "abc",
MY_SECRET: "def",
USER: "alice",
});
expect(result.allowed).toEqual({ USER: "alice" });
expect(result.blocked).toEqual(expect.arrayContaining(["MY_TOKEN", "MY_SECRET"]));
});
it("adds warnings for suspicious values", () => {
const base64Like =
"YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYQ==";
const result = sanitizeEnvVars({
USER: "alice",
SAFE_TEXT: base64Like,
NULL: "a\0b",
});
expect(result.allowed).toEqual({ USER: "alice", SAFE_TEXT: base64Like });
expect(result.blocked).toContain("NULL");
expect(result.warnings).toContain("SAFE_TEXT: Value looks like base64-encoded credential data");
});
it("supports strict mode with explicit allowlist", () => {
const result = sanitizeEnvVars(
{
NODE_ENV: "test",
FOO: "bar",
},
{ strictMode: true },
);
expect(result.allowed).toEqual({ NODE_ENV: "test" });
expect(result.blocked).toEqual(["FOO"]);
});
});

View File

@@ -0,0 +1,106 @@
const BLOCKED_ENV_VAR_PATTERNS: ReadonlyArray<RegExp> = [
/^ANTHROPIC_API_KEY$/i,
/^OPENAI_API_KEY$/i,
/^GEMINI_API_KEY$/i,
/^OPENROUTER_API_KEY$/i,
/^MINIMAX_API_KEY$/i,
/^ELEVENLABS_API_KEY$/i,
/^SYNTHETIC_API_KEY$/i,
/^TELEGRAM_BOT_TOKEN$/i,
/^DISCORD_BOT_TOKEN$/i,
/^SLACK_(BOT|APP)_TOKEN$/i,
/^LINE_CHANNEL_SECRET$/i,
/^LINE_CHANNEL_ACCESS_TOKEN$/i,
/^OPENCLAW_GATEWAY_(TOKEN|PASSWORD)$/i,
/^AWS_(SECRET_ACCESS_KEY|SECRET_KEY|SESSION_TOKEN)$/i,
/^(GH|GITHUB)_TOKEN$/i,
/^(AZURE|AZURE_OPENAI|COHERE|AI_GATEWAY|OPENROUTER)_API_KEY$/i,
/_?(API_KEY|TOKEN|PASSWORD|PRIVATE_KEY|SECRET)$/i,
];
const ALLOWED_ENV_VAR_PATTERNS: ReadonlyArray<RegExp> = [
/^LANG$/,
/^LC_.*$/i,
/^PATH$/i,
/^HOME$/i,
/^USER$/i,
/^SHELL$/i,
/^TERM$/i,
/^TZ$/i,
/^NODE_ENV$/i,
];
export type EnvVarSanitizationResult = {
allowed: Record<string, string>;
blocked: string[];
warnings: string[];
};
export type EnvSanitizationOptions = {
strictMode?: boolean;
customBlockedPatterns?: ReadonlyArray<RegExp>;
customAllowedPatterns?: ReadonlyArray<RegExp>;
};
function validateEnvVarValue(value: string): string | undefined {
if (value.includes("\0")) {
return "Contains null bytes";
}
if (value.length > 32768) {
return "Value exceeds maximum length";
}
if (/^[A-Za-z0-9+/=]{100,}$/.test(value)) {
return "Value looks like base64-encoded credential data";
}
return undefined;
}
function matchesAnyPattern(value: string, patterns: readonly RegExp[]): boolean {
return patterns.some((pattern) => pattern.test(value));
}
export function sanitizeEnvVars(
envVars: Record<string, string>,
options: EnvSanitizationOptions = {},
): EnvVarSanitizationResult {
const allowed: Record<string, string> = {};
const blocked: string[] = [];
const warnings: string[] = [];
const blockedPatterns = [...BLOCKED_ENV_VAR_PATTERNS, ...(options.customBlockedPatterns ?? [])];
const allowedPatterns = [...ALLOWED_ENV_VAR_PATTERNS, ...(options.customAllowedPatterns ?? [])];
for (const [rawKey, value] of Object.entries(envVars)) {
const key = rawKey.trim();
if (!key) {
continue;
}
if (matchesAnyPattern(key, blockedPatterns)) {
blocked.push(key);
continue;
}
if (options.strictMode && !matchesAnyPattern(key, allowedPatterns)) {
blocked.push(key);
continue;
}
const warning = validateEnvVarValue(value);
if (warning) {
warnings.push(`${key}: ${warning}`);
}
allowed[key] = value;
}
return { allowed, blocked, warnings };
}
export function getBlockedPatterns(): string[] {
return BLOCKED_ENV_VAR_PATTERNS.map((pattern) => pattern.source);
}
export function getAllowedPatterns(): string[] {
return ALLOWED_ENV_VAR_PATTERNS.map((pattern) => pattern.source);
}