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(); 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); }); });