import fs from "node:fs" ;
import path from "node:path" ;
import { describe, expect, it } from "vitest" ;
import {
makeMockCommandResolution,
makeMockExecutableResolution,
makePathEnv,
makeTempDir,
} from "./exec-approvals-test-helpers.js" ;
import {
evaluateExecAllowlist,
evaluateShellAllowlist,
isSafeBinUsage,
normalizeSafeBins,
resolveSafeBins,
} from "./exec-approvals.js" ;
import {
SAFE_BIN_PROFILE_FIXTURES,
SAFE_BIN_PROFILES,
resolveSafeBinProfiles,
} from "./exec-safe-bin-policy.js" ;
describe("exec approvals safe bins" , () => {
type SafeBinCase = {
name: string;
argv: string[];
resolvedPath: string;
expected: boolean ;
safeBins?: string[];
safeBinProfiles?: Readonly<Record<string, { minPositional?: number; maxPositional?: number }>>;
executableName?: string;
rawExecutable?: string;
cwd?: string;
setup?: (cwd: string) => void ;
};
function buildDeniedFlagVariantCases(params: {
executableName: string;
resolvedPath: string;
safeBins?: string[];
flag: string;
takesValue: boolean ;
label: string;
}): SafeBinCase[] {
const value = "blocked" ;
const argvVariants: string[][] = [];
if (!params.takesValue) {
argvVariants.push([params.executableName, params.flag]);
} else if (params.flag.startsWith("--" )) {
argvVariants.push([params.executableName, `${params.flag}=${value}`]);
argvVariants.push([params.executableName, params.flag, value]);
} else if (params.flag.startsWith("-" )) {
argvVariants.push([params.executableName, `${params.flag}${value}`]);
argvVariants.push([params.executableName, params.flag, value]);
} else {
argvVariants.push([params.executableName, params.flag, value]);
}
return argvVariants.map((argv) => ({
name: `${params.label} (${argv.slice(1 ).join(" " )})`,
argv,
resolvedPath: params.resolvedPath,
expected: false ,
safeBins: params.safeBins ?? [params.executableName],
executableName: params.executableName,
}));
}
const deniedFlagCases: SafeBinCase[] = [
...buildDeniedFlagVariantCases({
executableName: "sort" ,
resolvedPath: "/usr/bin/sort" ,
flag: "-o" ,
takesValue: true ,
label: "blocks sort output flag" ,
}),
...buildDeniedFlagVariantCases({
executableName: "sort" ,
resolvedPath: "/usr/bin/sort" ,
flag: "--output" ,
takesValue: true ,
label: "blocks sort output flag" ,
}),
...buildDeniedFlagVariantCases({
executableName: "sort" ,
resolvedPath: "/usr/bin/sort" ,
flag: "--compress-program" ,
takesValue: true ,
label: "blocks sort external program flag" ,
}),
...buildDeniedFlagVariantCases({
executableName: "sort" ,
resolvedPath: "/usr/bin/sort" ,
flag: "--compress-prog" ,
takesValue: true ,
label: "blocks sort denied flag abbreviations" ,
}),
...buildDeniedFlagVariantCases({
executableName: "sort" ,
resolvedPath: "/usr/bin/sort" ,
flag: "--files0-fro" ,
takesValue: true ,
label: "blocks sort denied flag abbreviations" ,
}),
...buildDeniedFlagVariantCases({
executableName: "sort" ,
resolvedPath: "/usr/bin/sort" ,
flag: "--random-source" ,
takesValue: true ,
label: "blocks sort filesystem-dependent flags" ,
}),
...buildDeniedFlagVariantCases({
executableName: "sort" ,
resolvedPath: "/usr/bin/sort" ,
flag: "--temporary-directory" ,
takesValue: true ,
label: "blocks sort filesystem-dependent flags" ,
}),
...buildDeniedFlagVariantCases({
executableName: "sort" ,
resolvedPath: "/usr/bin/sort" ,
flag: "-T" ,
takesValue: true ,
label: "blocks sort filesystem-dependent flags" ,
}),
...buildDeniedFlagVariantCases({
executableName: "grep" ,
resolvedPath: "/usr/bin/grep" ,
flag: "-R" ,
takesValue: false ,
label: "blocks grep recursive flag" ,
}),
...buildDeniedFlagVariantCases({
executableName: "grep" ,
resolvedPath: "/usr/bin/grep" ,
flag: "--recursive" ,
takesValue: false ,
label: "blocks grep recursive flag" ,
}),
...buildDeniedFlagVariantCases({
executableName: "grep" ,
resolvedPath: "/usr/bin/grep" ,
flag: "--file" ,
takesValue: true ,
label: "blocks grep file-pattern flag" ,
}),
...buildDeniedFlagVariantCases({
executableName: "jq" ,
resolvedPath: "/usr/bin/jq" ,
flag: "-f" ,
takesValue: true ,
label: "blocks jq file-program flag" ,
}),
...buildDeniedFlagVariantCases({
executableName: "jq" ,
resolvedPath: "/usr/bin/jq" ,
flag: "--from-file" ,
takesValue: true ,
label: "blocks jq file-program flag" ,
}),
...buildDeniedFlagVariantCases({
executableName: "wc" ,
resolvedPath: "/usr/bin/wc" ,
flag: "--files0-from" ,
takesValue: true ,
label: "blocks wc file-list flag" ,
}),
...buildDeniedFlagVariantCases({
executableName: "wc" ,
resolvedPath: "/usr/bin/wc" ,
flag: "--files0-fro" ,
takesValue: true ,
label: "blocks wc denied flag abbreviations" ,
}),
];
const cases: SafeBinCase[] = [
{
name: "allows safe bins with non-path args" ,
argv: ["jq" , ".foo" ],
resolvedPath: "/usr/bin/jq" ,
expected: true ,
},
{
name: "blocks jq env builtin even when jq is explicitly opted in" ,
argv: ["jq" , "env" ],
resolvedPath: "/usr/bin/jq" ,
expected: false ,
},
{
name: "blocks jq $ENV builtin variable even when jq is explicitly opted in" ,
argv: ["jq" , "$ENV" ],
resolvedPath: "/usr/bin/jq" ,
expected: false ,
},
{
name: "blocks jq $ENV property access even when jq is explicitly opted in" ,
argv: ["jq" , "($ENV).OPENAI_API_KEY" ],
resolvedPath: "/usr/bin/jq" ,
expected: false ,
},
{
name: "blocks awk scripts even when awk is explicitly profiled" ,
argv: ["awk" , 'BEGIN { system("id") }' ],
resolvedPath: "/usr/bin/awk" ,
expected: false ,
safeBins: ["awk" ],
safeBinProfiles: { awk: {} },
executableName: "awk" ,
},
{
name: "blocks sed scripts even when sed is explicitly profiled" ,
argv: ["sed" , "e" ],
resolvedPath: "/usr/bin/sed" ,
expected: false ,
safeBins: ["sed" ],
safeBinProfiles: { sed: {} },
executableName: "sed" ,
},
{
name: "blocks safe bins with file args" ,
argv: ["jq" , ".foo" , "secret.json" ],
resolvedPath: "/usr/bin/jq" ,
expected: false ,
setup: (cwd) => fs.writeFileSync(path.join(cwd, "secret.json" ), "{}" ),
},
{
name: "blocks safe bins resolved from untrusted directories" ,
argv: ["jq" , ".foo" ],
resolvedPath: "/tmp/evil-bin/jq" ,
expected: false ,
cwd: "/tmp" ,
},
...deniedFlagCases,
{
name: "blocks grep file positional when pattern uses -e" ,
argv: ["grep" , "-e" , "needle" , ".env" ],
resolvedPath: "/usr/bin/grep" ,
expected: false ,
safeBins: ["grep" ],
executableName: "grep" ,
},
{
name: "blocks grep file positional after -- terminator" ,
argv: ["grep" , "-e" , "needle" , "--" , ".env" ],
resolvedPath: "/usr/bin/grep" ,
expected: false ,
safeBins: ["grep" ],
executableName: "grep" ,
},
{
name: "rejects unknown long options in safe-bin mode" ,
argv: ["sort" , "--totally-unknown=1" ],
resolvedPath: "/usr/bin/sort" ,
expected: false ,
safeBins: ["sort" ],
executableName: "sort" ,
},
{
name: "rejects ambiguous long-option abbreviations in safe-bin mode" ,
argv: ["sort" , "--f=1" ],
resolvedPath: "/usr/bin/sort" ,
expected: false ,
safeBins: ["sort" ],
executableName: "sort" ,
},
{
name: "rejects unknown short options in safe-bin mode" ,
argv: ["tr" , "-S" , "a" , "b" ],
resolvedPath: "/usr/bin/tr" ,
expected: false ,
safeBins: ["tr" ],
executableName: "tr" ,
},
];
it.runIf(process.platform !== "win32" ).each(cases)("$name" , (testCase) => {
const cwd = testCase.cwd ?? makeTempDir();
testCase.setup?.(cwd);
const executableName = testCase.executableName ?? "jq" ;
const rawExecutable = testCase.rawExecutable ?? executableName;
const ok = isSafeBinUsage({
argv: testCase.argv,
resolution: {
rawExecutable,
resolvedPath: testCase.resolvedPath,
executableName,
},
safeBins: normalizeSafeBins(testCase.safeBins ?? [executableName]),
safeBinProfiles: testCase.safeBinProfiles,
});
expect(ok).toBe(testCase.expected);
});
it("supports injected trusted safe-bin dirs for tests/callers" , () => {
if (process.platform === "win32" ) {
return ;
}
const ok = isSafeBinUsage({
argv: ["jq" , ".foo" ],
resolution: {
rawExecutable: "jq" ,
resolvedPath: "/custom/bin/jq" ,
executableName: "jq" ,
},
safeBins: normalizeSafeBins(["jq" ]),
trustedSafeBinDirs: new Set(["/custom/bin" ]),
});
expect(ok).toBe(true );
});
it("supports injected platform for deterministic safe-bin checks" , () => {
const ok = isSafeBinUsage({
argv: ["jq" , ".foo" ],
resolution: {
rawExecutable: "jq" ,
resolvedPath: "/usr/bin/jq" ,
executableName: "jq" ,
},
safeBins: normalizeSafeBins(["jq" ]),
platform: "win32" ,
});
expect(ok).toBe(false );
});
it("supports injected trusted path checker for deterministic callers" , () => {
if (process.platform === "win32" ) {
return ;
}
const baseParams = {
argv: ["jq" , ".foo" ],
resolution: {
rawExecutable: "jq" ,
resolvedPath: "/tmp/custom/jq" ,
executableName: "jq" ,
},
safeBins: normalizeSafeBins(["jq" ]),
};
expect(
isSafeBinUsage({
...baseParams,
isTrustedSafeBinPathFn: () => true ,
}),
).toBe(true );
expect(
isSafeBinUsage({
...baseParams,
isTrustedSafeBinPathFn: () => false ,
}),
).toBe(false );
});
it("keeps safe-bin profile fixtures aligned with compiled profiles" , () => {
for (const [name, fixture] of Object.entries(SAFE_BIN_PROFILE_FIXTURES)) {
const profile = SAFE_BIN_PROFILES[name];
expect(profile).toBeDefined();
const fixtureDeniedFlags = fixture.deniedFlags ?? [];
const compiledDeniedFlags = profile?.deniedFlags ?? new Set<string>();
for (const deniedFlag of fixtureDeniedFlags) {
expect(compiledDeniedFlags.has(deniedFlag)).toBe(true );
}
expect(Array.from(compiledDeniedFlags).toSorted()).toEqual(
[...fixtureDeniedFlags].toSorted(),
);
}
});
it("does not include sort/grep in default safeBins" , () => {
const defaults = resolveSafeBins(undefined);
expect(defaults.has("jq" )).toBe(false );
expect(defaults.has("sort" )).toBe(false );
expect(defaults.has("grep" )).toBe(false );
});
it("does not auto-allow unprofiled safe-bin entries" , () => {
if (process.platform === "win32" ) {
return ;
}
const result = evaluateShellAllowlist({
command: "python3 -c \" print('owned' )\"" ,
allowlist: [],
safeBins: normalizeSafeBins(["python3" ]),
cwd: "/tmp" ,
});
expect(result.analysisOk).toBe(true );
expect(result.allowlistSatisfied).toBe(false );
});
it("allows caller-defined custom safe-bin profiles" , () => {
if (process.platform === "win32" ) {
return ;
}
const safeBinProfiles = resolveSafeBinProfiles({
echo: {
maxPositional: 1 ,
},
});
const allow = isSafeBinUsage({
argv: ["echo" , "hello" ],
resolution: {
rawExecutable: "echo" ,
resolvedPath: "/bin/echo" ,
executableName: "echo" ,
},
safeBins: normalizeSafeBins(["echo" ]),
safeBinProfiles,
});
const deny = isSafeBinUsage({
argv: ["echo" , "hello" , "world" ],
resolution: {
rawExecutable: "echo" ,
resolvedPath: "/bin/echo" ,
executableName: "echo" ,
},
safeBins: normalizeSafeBins(["echo" ]),
safeBinProfiles,
});
expect(allow).toBe(true );
expect(deny).toBe(false );
});
it("blocks sort output flags independent of file existence" , () => {
if (process.platform === "win32" ) {
return ;
}
const cwd = makeTempDir();
fs.writeFileSync(path.join(cwd, "existing.txt" ), "x" );
const resolution = {
rawExecutable: "sort" ,
resolvedPath: "/usr/bin/sort" ,
executableName: "sort" ,
};
const safeBins = normalizeSafeBins(["sort" ]);
const existing = isSafeBinUsage({
argv: ["sort" , "-o" , "existing.txt" ],
resolution,
safeBins,
});
const missing = isSafeBinUsage({
argv: ["sort" , "-o" , "missing.txt" ],
resolution,
safeBins,
});
const longFlag = isSafeBinUsage({
argv: ["sort" , "--output=missing.txt" ],
resolution,
safeBins,
});
expect(existing).toBe(false );
expect(missing).toBe(false );
expect(longFlag).toBe(false );
});
it("threads trusted safe-bin dirs through allowlist evaluation" , () => {
if (process.platform === "win32" ) {
return ;
}
const analysis = {
ok: true as const ,
segments: [
{
raw: "jq .foo" ,
argv: ["jq" , ".foo" ],
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "jq" ,
resolvedPath: "/custom/bin/jq" ,
executableName: "jq" ,
}),
}),
},
],
};
const denied = evaluateExecAllowlist({
analysis,
allowlist: [],
safeBins: normalizeSafeBins(["jq" ]),
trustedSafeBinDirs: new Set(["/usr/bin" ]),
cwd: "/tmp" ,
});
expect(denied.allowlistSatisfied).toBe(false );
const allowed = evaluateExecAllowlist({
analysis,
allowlist: [],
safeBins: normalizeSafeBins(["jq" ]),
trustedSafeBinDirs: new Set(["/custom/bin" ]),
cwd: "/tmp" ,
});
expect(allowed.allowlistSatisfied).toBe(true );
});
it("does not auto-trust PATH-shadowed safe bins without explicit trusted dirs" , () => {
if (process.platform === "win32" ) {
return ;
}
const tmp = makeTempDir();
const fakeDir = path.join(tmp, "fake-bin" );
fs.mkdirSync(fakeDir, { recursive: true });
const fakeHead = path.join(fakeDir, "head" );
fs.writeFileSync(fakeHead, "#!/bin/sh\nexit 0\n" );
fs.chmodSync(fakeHead, 0 o755);
const result = evaluateShellAllowlist({
command: "head -n 1" ,
allowlist: [],
safeBins: normalizeSafeBins(["head" ]),
env: makePathEnv(fakeDir),
cwd: tmp,
});
expect(result.analysisOk).toBe(true );
expect(result.allowlistSatisfied).toBe(false );
expect(result.segmentSatisfiedBy).toEqual([null ]);
expect(result.segments[0 ]?.resolution?.execution.resolvedPath).toBe(fakeHead);
});
it("fails closed for semantic env wrappers in allowlist mode" , () => {
if (process.platform === "win32" ) {
return ;
}
const result = evaluateShellAllowlist({
command: "env -S 'sh -c \" echo pwned\"' tr" ,
allowlist: [{ pattern: "/usr/bin/tr" }],
safeBins: normalizeSafeBins(["tr" ]),
cwd: "/tmp" ,
platform: process.platform,
});
expect(result.analysisOk).toBe(true );
expect(result.allowlistSatisfied).toBe(false );
expect(result.segmentSatisfiedBy).toEqual([null ]);
expect(result.segments[0 ]?.resolution?.policyBlocked).toBe(true );
});
});
Messung V0.5 in Prozent C=100 H=98 G=98
¤ Dauer der Verarbeitung: 0.13 Sekunden
(vorverarbeitet am 2026-06-10)
¤
*© Formatika GbR, Deutschland