fix(exec): require explicit safe-bin profiles

This commit is contained in:
Peter Steinberger
2026-02-22 12:57:53 +01:00
parent d055b948fb
commit 47c3f742b6
15 changed files with 226 additions and 9 deletions

View File

@@ -11,7 +11,6 @@ import {
} from "./exec-approvals-analysis.js";
import type { ExecAllowlistEntry } from "./exec-approvals.js";
import {
SAFE_BIN_GENERIC_PROFILE,
SAFE_BIN_PROFILES,
type SafeBinProfile,
validateSafeBinArgv,
@@ -41,7 +40,6 @@ export function isSafeBinUsage(params: {
platform?: string | null;
trustedSafeBinDirs?: ReadonlySet<string>;
safeBinProfiles?: Readonly<Record<string, SafeBinProfile>>;
safeBinGenericProfile?: SafeBinProfile;
isTrustedSafeBinPathFn?: typeof isTrustedSafeBinPath;
}): boolean {
// Windows host exec uses PowerShell, which has different parsing/expansion rules.
@@ -75,8 +73,10 @@ export function isSafeBinUsage(params: {
}
const argv = params.argv.slice(1);
const safeBinProfiles = params.safeBinProfiles ?? SAFE_BIN_PROFILES;
const genericSafeBinProfile = params.safeBinGenericProfile ?? SAFE_BIN_GENERIC_PROFILE;
const profile = safeBinProfiles[execName] ?? genericSafeBinProfile;
const profile = safeBinProfiles[execName];
if (!profile) {
return false;
}
return validateSafeBinArgv(argv, profile);
}
@@ -93,6 +93,7 @@ function evaluateSegments(
params: {
allowlist: ExecAllowlistEntry[];
safeBins: Set<string>;
safeBinProfiles?: Readonly<Record<string, SafeBinProfile>>;
cwd?: string;
platform?: string | null;
trustedSafeBinDirs?: ReadonlySet<string>;
@@ -122,6 +123,7 @@ function evaluateSegments(
argv: segment.argv,
resolution: segment.resolution,
safeBins: params.safeBins,
safeBinProfiles: params.safeBinProfiles,
platform: params.platform,
trustedSafeBinDirs: params.trustedSafeBinDirs,
});
@@ -147,6 +149,7 @@ export function evaluateExecAllowlist(params: {
analysis: ExecCommandAnalysis;
allowlist: ExecAllowlistEntry[];
safeBins: Set<string>;
safeBinProfiles?: Readonly<Record<string, SafeBinProfile>>;
cwd?: string;
platform?: string | null;
trustedSafeBinDirs?: ReadonlySet<string>;
@@ -165,6 +168,7 @@ export function evaluateExecAllowlist(params: {
const result = evaluateSegments(chainSegments, {
allowlist: params.allowlist,
safeBins: params.safeBins,
safeBinProfiles: params.safeBinProfiles,
cwd: params.cwd,
platform: params.platform,
trustedSafeBinDirs: params.trustedSafeBinDirs,
@@ -184,6 +188,7 @@ export function evaluateExecAllowlist(params: {
const result = evaluateSegments(params.analysis.segments, {
allowlist: params.allowlist,
safeBins: params.safeBins,
safeBinProfiles: params.safeBinProfiles,
cwd: params.cwd,
platform: params.platform,
trustedSafeBinDirs: params.trustedSafeBinDirs,
@@ -354,6 +359,7 @@ export function evaluateShellAllowlist(params: {
command: string;
allowlist: ExecAllowlistEntry[];
safeBins: Set<string>;
safeBinProfiles?: Readonly<Record<string, SafeBinProfile>>;
cwd?: string;
env?: NodeJS.ProcessEnv;
trustedSafeBinDirs?: ReadonlySet<string>;
@@ -384,6 +390,7 @@ export function evaluateShellAllowlist(params: {
analysis,
allowlist: params.allowlist,
safeBins: params.safeBins,
safeBinProfiles: params.safeBinProfiles,
cwd: params.cwd,
platform: params.platform,
trustedSafeBinDirs: params.trustedSafeBinDirs,
@@ -419,6 +426,7 @@ export function evaluateShellAllowlist(params: {
analysis,
allowlist: params.allowlist,
safeBins: params.safeBins,
safeBinProfiles: params.safeBinProfiles,
cwd: params.cwd,
platform: params.platform,
trustedSafeBinDirs: params.trustedSafeBinDirs,

View File

@@ -29,7 +29,11 @@ import {
type ExecAllowlistEntry,
type ExecApprovalsFile,
} from "./exec-approvals.js";
import { SAFE_BIN_PROFILE_FIXTURES, SAFE_BIN_PROFILES } from "./exec-safe-bin-policy.js";
import {
SAFE_BIN_PROFILE_FIXTURES,
SAFE_BIN_PROFILES,
resolveSafeBinProfiles,
} from "./exec-safe-bin-policy.js";
function makePathEnv(binDir: string): NodeJS.ProcessEnv {
if (process.platform !== "win32") {
@@ -798,6 +802,53 @@ describe("exec approvals safe bins", () => {
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;

View File

@@ -37,6 +37,8 @@ export type SafeBinProfileFixture = {
deniedFlags?: readonly string[];
};
export type SafeBinProfileFixtures = Readonly<Record<string, SafeBinProfileFixture>>;
const NO_FLAGS: ReadonlySet<string> = new Set();
const toFlagSet = (flags?: readonly string[]): ReadonlySet<string> => {
@@ -63,8 +65,6 @@ function compileSafeBinProfiles(
) as Record<string, SafeBinProfile>;
}
export const SAFE_BIN_GENERIC_PROFILE_FIXTURE: SafeBinProfileFixture = {};
export const SAFE_BIN_PROFILE_FIXTURES: Record<string, SafeBinProfileFixture> = {
jq: {
maxPositional: 1,
@@ -184,11 +184,49 @@ export const SAFE_BIN_PROFILE_FIXTURES: Record<string, SafeBinProfileFixture> =
},
};
export const SAFE_BIN_GENERIC_PROFILE = compileSafeBinProfile(SAFE_BIN_GENERIC_PROFILE_FIXTURE);
export const SAFE_BIN_PROFILES: Record<string, SafeBinProfile> =
compileSafeBinProfiles(SAFE_BIN_PROFILE_FIXTURES);
function normalizeSafeBinProfileName(raw: string): string | null {
const name = raw.trim().toLowerCase();
return name.length > 0 ? name : null;
}
function normalizeSafeBinProfileFixtures(
fixtures?: SafeBinProfileFixtures | null,
): Record<string, SafeBinProfileFixture> {
const normalized: Record<string, SafeBinProfileFixture> = {};
if (!fixtures) {
return normalized;
}
for (const [rawName, fixture] of Object.entries(fixtures)) {
const name = normalizeSafeBinProfileName(rawName);
if (!name) {
continue;
}
normalized[name] = {
minPositional: fixture.minPositional,
maxPositional: fixture.maxPositional,
allowedValueFlags: fixture.allowedValueFlags,
deniedFlags: fixture.deniedFlags,
};
}
return normalized;
}
export function resolveSafeBinProfiles(
fixtures?: SafeBinProfileFixtures | null,
): Record<string, SafeBinProfile> {
const normalizedFixtures = normalizeSafeBinProfileFixtures(fixtures);
if (Object.keys(normalizedFixtures).length === 0) {
return SAFE_BIN_PROFILES;
}
return {
...SAFE_BIN_PROFILES,
...compileSafeBinProfiles(normalizedFixtures),
};
}
export function resolveSafeBinDeniedFlags(
fixtures: Readonly<Record<string, SafeBinProfileFixture>> = SAFE_BIN_PROFILE_FIXTURES,
): Record<string, string[]> {