493 lines
14 KiB
TypeScript
493 lines
14 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { describe, expect, it } from "vitest";
|
|
import { 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[];
|
|
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 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",
|
|
},
|
|
];
|
|
|
|
for (const testCase of cases) {
|
|
it(testCase.name, () => {
|
|
if (process.platform === "win32") {
|
|
return;
|
|
}
|
|
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]),
|
|
});
|
|
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(true);
|
|
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: {
|
|
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, 0o755);
|
|
|
|
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?.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);
|
|
});
|
|
});
|