mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 20:38:27 +00:00
refactor(security): extract safeBins trust resolver
This commit is contained in:
@@ -51,7 +51,7 @@ Notes:
|
|||||||
- `tools.exec.ask` (default: `on-miss`)
|
- `tools.exec.ask` (default: `on-miss`)
|
||||||
- `tools.exec.node` (default: unset)
|
- `tools.exec.node` (default: unset)
|
||||||
- `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs (gateway + sandbox only).
|
- `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs (gateway + sandbox only).
|
||||||
- `tools.exec.safeBins`: stdin-only safe binaries that can run without explicit allowlist entries (resolved path must come from trusted binary directories).
|
- `tools.exec.safeBins`: stdin-only safe binaries that can run without explicit allowlist entries. For behavior details, see [Safe bins](/tools/exec-approvals#safe-bins-stdin-only).
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
|
|||||||
@@ -12,48 +12,7 @@ import {
|
|||||||
type CommandResolution,
|
type CommandResolution,
|
||||||
type ExecCommandSegment,
|
type ExecCommandSegment,
|
||||||
} from "./exec-approvals-analysis.js";
|
} from "./exec-approvals-analysis.js";
|
||||||
|
import { isTrustedSafeBinPath } from "./exec-safe-bin-trust.js";
|
||||||
const DEFAULT_SAFE_BIN_TRUSTED_DIRS = [
|
|
||||||
"/bin",
|
|
||||||
"/usr/bin",
|
|
||||||
"/usr/local/bin",
|
|
||||||
"/opt/homebrew/bin",
|
|
||||||
"/opt/local/bin",
|
|
||||||
"/snap/bin",
|
|
||||||
"/run/current-system/sw/bin",
|
|
||||||
];
|
|
||||||
|
|
||||||
function normalizeTrustedDir(value: string): string | null {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return path.resolve(trimmed);
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectTrustedSafeBinDirs(): Set<string> {
|
|
||||||
const trusted = new Set<string>();
|
|
||||||
for (const entry of DEFAULT_SAFE_BIN_TRUSTED_DIRS) {
|
|
||||||
const normalized = normalizeTrustedDir(entry);
|
|
||||||
if (normalized) {
|
|
||||||
trusted.add(normalized);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const pathEntries = (process.env.PATH ?? process.env.Path ?? "")
|
|
||||||
.split(path.delimiter)
|
|
||||||
.map((entry) => normalizeTrustedDir(entry))
|
|
||||||
.filter((entry): entry is string => Boolean(entry));
|
|
||||||
for (const entry of pathEntries) {
|
|
||||||
trusted.add(entry);
|
|
||||||
}
|
|
||||||
return trusted;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TRUSTED_SAFE_BIN_DIRS = collectTrustedSafeBinDirs();
|
|
||||||
|
|
||||||
function isTrustedSafeBinPath(resolvedPath: string): boolean {
|
|
||||||
return TRUSTED_SAFE_BIN_DIRS.has(path.dirname(path.resolve(resolvedPath)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPathLikeToken(value: string): boolean {
|
function isPathLikeToken(value: string): boolean {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
@@ -109,6 +68,7 @@ export function isSafeBinUsage(params: {
|
|||||||
safeBins: Set<string>;
|
safeBins: Set<string>;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
fileExists?: (filePath: string) => boolean;
|
fileExists?: (filePath: string) => boolean;
|
||||||
|
trustedSafeBinDirs?: ReadonlySet<string>;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
// Windows host exec uses PowerShell, which has different parsing/expansion rules.
|
// Windows host exec uses PowerShell, which has different parsing/expansion rules.
|
||||||
// Keep safeBins conservative there (require explicit allowlist entries).
|
// Keep safeBins conservative there (require explicit allowlist entries).
|
||||||
@@ -132,7 +92,12 @@ export function isSafeBinUsage(params: {
|
|||||||
if (!resolution?.resolvedPath) {
|
if (!resolution?.resolvedPath) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!isTrustedSafeBinPath(resolution.resolvedPath)) {
|
if (
|
||||||
|
!isTrustedSafeBinPath({
|
||||||
|
resolvedPath: resolution.resolvedPath,
|
||||||
|
trustedDirs: params.trustedSafeBinDirs,
|
||||||
|
})
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const cwd = params.cwd ?? process.cwd();
|
const cwd = params.cwd ?? process.cwd();
|
||||||
|
|||||||
@@ -385,44 +385,60 @@ describe("exec approvals shell allowlist (chained commands)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("exec approvals safe bins", () => {
|
describe("exec approvals safe bins", () => {
|
||||||
it("allows safe bins with non-path args", () => {
|
type SafeBinCase = {
|
||||||
if (process.platform === "win32") {
|
name: string;
|
||||||
return;
|
argv: string[];
|
||||||
}
|
resolvedPath: string;
|
||||||
const dir = makeTempDir();
|
expected: boolean;
|
||||||
const ok = isSafeBinUsage({
|
cwd?: string;
|
||||||
argv: ["jq", ".foo"],
|
setup?: (cwd: string) => void;
|
||||||
resolution: {
|
};
|
||||||
rawExecutable: "jq",
|
|
||||||
resolvedPath: "/usr/bin/jq",
|
|
||||||
executableName: "jq",
|
|
||||||
},
|
|
||||||
safeBins: normalizeSafeBins(["jq"]),
|
|
||||||
cwd: dir,
|
|
||||||
});
|
|
||||||
expect(ok).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("blocks safe bins with file args", () => {
|
const cases: SafeBinCase[] = [
|
||||||
if (process.platform === "win32") {
|
{
|
||||||
return;
|
name: "allows safe bins with non-path args",
|
||||||
}
|
argv: ["jq", ".foo"],
|
||||||
const dir = makeTempDir();
|
resolvedPath: "/usr/bin/jq",
|
||||||
fs.writeFileSync(path.join(dir, "secret.json"), "{}");
|
expected: true,
|
||||||
const ok = isSafeBinUsage({
|
},
|
||||||
|
{
|
||||||
|
name: "blocks safe bins with file args",
|
||||||
argv: ["jq", ".foo", "secret.json"],
|
argv: ["jq", ".foo", "secret.json"],
|
||||||
resolution: {
|
resolvedPath: "/usr/bin/jq",
|
||||||
rawExecutable: "jq",
|
expected: false,
|
||||||
resolvedPath: "/usr/bin/jq",
|
setup: (cwd) => fs.writeFileSync(path.join(cwd, "secret.json"), "{}"),
|
||||||
executableName: "jq",
|
},
|
||||||
},
|
{
|
||||||
safeBins: normalizeSafeBins(["jq"]),
|
name: "blocks safe bins resolved from untrusted directories",
|
||||||
cwd: dir,
|
argv: ["jq", ".foo"],
|
||||||
});
|
resolvedPath: "/tmp/evil-bin/jq",
|
||||||
expect(ok).toBe(false);
|
expected: false,
|
||||||
});
|
cwd: "/tmp",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
it("blocks safe bins resolved from untrusted directories", () => {
|
for (const testCase of cases) {
|
||||||
|
it(testCase.name, () => {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cwd = testCase.cwd ?? makeTempDir();
|
||||||
|
testCase.setup?.(cwd);
|
||||||
|
const ok = isSafeBinUsage({
|
||||||
|
argv: testCase.argv,
|
||||||
|
resolution: {
|
||||||
|
rawExecutable: "jq",
|
||||||
|
resolvedPath: testCase.resolvedPath,
|
||||||
|
executableName: "jq",
|
||||||
|
},
|
||||||
|
safeBins: normalizeSafeBins(["jq"]),
|
||||||
|
cwd,
|
||||||
|
});
|
||||||
|
expect(ok).toBe(testCase.expected);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("supports injected trusted safe-bin dirs for tests/callers", () => {
|
||||||
if (process.platform === "win32") {
|
if (process.platform === "win32") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -430,13 +446,14 @@ describe("exec approvals safe bins", () => {
|
|||||||
argv: ["jq", ".foo"],
|
argv: ["jq", ".foo"],
|
||||||
resolution: {
|
resolution: {
|
||||||
rawExecutable: "jq",
|
rawExecutable: "jq",
|
||||||
resolvedPath: "/tmp/evil-bin/jq",
|
resolvedPath: "/custom/bin/jq",
|
||||||
executableName: "jq",
|
executableName: "jq",
|
||||||
},
|
},
|
||||||
safeBins: normalizeSafeBins(["jq"]),
|
safeBins: normalizeSafeBins(["jq"]),
|
||||||
|
trustedSafeBinDirs: new Set(["/custom/bin"]),
|
||||||
cwd: "/tmp",
|
cwd: "/tmp",
|
||||||
});
|
});
|
||||||
expect(ok).toBe(false);
|
expect(ok).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
57
src/infra/exec-safe-bin-trust.test.ts
Normal file
57
src/infra/exec-safe-bin-trust.test.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
buildTrustedSafeBinDirs,
|
||||||
|
getTrustedSafeBinDirs,
|
||||||
|
isTrustedSafeBinPath,
|
||||||
|
} from "./exec-safe-bin-trust.js";
|
||||||
|
|
||||||
|
describe("exec safe bin trust", () => {
|
||||||
|
it("builds trusted dirs from defaults and injected PATH", () => {
|
||||||
|
const dirs = buildTrustedSafeBinDirs({
|
||||||
|
pathEnv: "/custom/bin:/alt/bin:/custom/bin",
|
||||||
|
delimiter: ":",
|
||||||
|
baseDirs: ["/usr/bin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dirs.has(path.resolve("/usr/bin"))).toBe(true);
|
||||||
|
expect(dirs.has(path.resolve("/custom/bin"))).toBe(true);
|
||||||
|
expect(dirs.has(path.resolve("/alt/bin"))).toBe(true);
|
||||||
|
expect(dirs.size).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("memoizes trusted dirs per PATH snapshot", () => {
|
||||||
|
const a = getTrustedSafeBinDirs({
|
||||||
|
pathEnv: "/first/bin",
|
||||||
|
delimiter: ":",
|
||||||
|
refresh: true,
|
||||||
|
});
|
||||||
|
const b = getTrustedSafeBinDirs({
|
||||||
|
pathEnv: "/first/bin",
|
||||||
|
delimiter: ":",
|
||||||
|
});
|
||||||
|
const c = getTrustedSafeBinDirs({
|
||||||
|
pathEnv: "/second/bin",
|
||||||
|
delimiter: ":",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(a).toBe(b);
|
||||||
|
expect(c).not.toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates resolved paths using injected trusted dirs", () => {
|
||||||
|
const trusted = new Set([path.resolve("/usr/bin")]);
|
||||||
|
expect(
|
||||||
|
isTrustedSafeBinPath({
|
||||||
|
resolvedPath: "/usr/bin/jq",
|
||||||
|
trustedDirs: trusted,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isTrustedSafeBinPath({
|
||||||
|
resolvedPath: "/tmp/evil/jq",
|
||||||
|
trustedDirs: trusted,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
101
src/infra/exec-safe-bin-trust.ts
Normal file
101
src/infra/exec-safe-bin-trust.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const DEFAULT_SAFE_BIN_TRUSTED_DIRS = [
|
||||||
|
"/bin",
|
||||||
|
"/usr/bin",
|
||||||
|
"/usr/local/bin",
|
||||||
|
"/opt/homebrew/bin",
|
||||||
|
"/opt/local/bin",
|
||||||
|
"/snap/bin",
|
||||||
|
"/run/current-system/sw/bin",
|
||||||
|
];
|
||||||
|
|
||||||
|
type TrustedSafeBinDirsParams = {
|
||||||
|
pathEnv?: string | null;
|
||||||
|
delimiter?: string;
|
||||||
|
baseDirs?: readonly string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type TrustedSafeBinPathParams = {
|
||||||
|
resolvedPath: string;
|
||||||
|
trustedDirs?: ReadonlySet<string>;
|
||||||
|
pathEnv?: string | null;
|
||||||
|
delimiter?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TrustedSafeBinCache = {
|
||||||
|
key: string;
|
||||||
|
dirs: Set<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let trustedSafeBinCache: TrustedSafeBinCache | null = null;
|
||||||
|
|
||||||
|
function normalizeTrustedDir(value: string): string | null {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return path.resolve(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTrustedSafeBinCacheKey(pathEnv: string, delimiter: string): string {
|
||||||
|
return `${delimiter}\u0000${pathEnv}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTrustedSafeBinDirs(params: TrustedSafeBinDirsParams = {}): Set<string> {
|
||||||
|
const delimiter = params.delimiter ?? path.delimiter;
|
||||||
|
const pathEnv = params.pathEnv ?? "";
|
||||||
|
const baseDirs = params.baseDirs ?? DEFAULT_SAFE_BIN_TRUSTED_DIRS;
|
||||||
|
const trusted = new Set<string>();
|
||||||
|
|
||||||
|
for (const entry of baseDirs) {
|
||||||
|
const normalized = normalizeTrustedDir(entry);
|
||||||
|
if (normalized) {
|
||||||
|
trusted.add(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathEntries = pathEnv
|
||||||
|
.split(delimiter)
|
||||||
|
.map((entry) => normalizeTrustedDir(entry))
|
||||||
|
.filter((entry): entry is string => Boolean(entry));
|
||||||
|
for (const entry of pathEntries) {
|
||||||
|
trusted.add(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return trusted;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTrustedSafeBinDirs(
|
||||||
|
params: {
|
||||||
|
pathEnv?: string | null;
|
||||||
|
delimiter?: string;
|
||||||
|
refresh?: boolean;
|
||||||
|
} = {},
|
||||||
|
): Set<string> {
|
||||||
|
const delimiter = params.delimiter ?? path.delimiter;
|
||||||
|
const pathEnv = params.pathEnv ?? process.env.PATH ?? process.env.Path ?? "";
|
||||||
|
const key = buildTrustedSafeBinCacheKey(pathEnv, delimiter);
|
||||||
|
|
||||||
|
if (!params.refresh && trustedSafeBinCache?.key === key) {
|
||||||
|
return trustedSafeBinCache.dirs;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirs = buildTrustedSafeBinDirs({
|
||||||
|
pathEnv,
|
||||||
|
delimiter,
|
||||||
|
});
|
||||||
|
trustedSafeBinCache = { key, dirs };
|
||||||
|
return dirs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTrustedSafeBinPath(params: TrustedSafeBinPathParams): boolean {
|
||||||
|
const trustedDirs =
|
||||||
|
params.trustedDirs ??
|
||||||
|
getTrustedSafeBinDirs({
|
||||||
|
pathEnv: params.pathEnv,
|
||||||
|
delimiter: params.delimiter,
|
||||||
|
});
|
||||||
|
const resolvedDir = path.dirname(path.resolve(params.resolvedPath));
|
||||||
|
return trustedDirs.has(resolvedDir);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user