openclaw/src/infra/exec-approvals-safe-bins.test.ts

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