feat(security): add sandbox env sanitization helpers + tests
This commit is contained in:
57
src/agents/sandbox/sanitize-env-vars.test.ts
Normal file
57
src/agents/sandbox/sanitize-env-vars.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
106
src/agents/sandbox/sanitize-env-vars.ts
Normal file
106
src/agents/sandbox/sanitize-env-vars.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user