refactor(exec): centralize safe-bin policy checks

This commit is contained in:
Peter Steinberger
2026-02-22 13:18:17 +01:00
parent bcad4f67a2
commit 0d0f4c6992
15 changed files with 806 additions and 68 deletions

View File

@@ -192,7 +192,44 @@ function normalizeSafeBinProfileName(raw: string): string | null {
return name.length > 0 ? name : null;
}
function normalizeSafeBinProfileFixtures(
function normalizeFixtureLimit(raw: number | undefined): number | undefined {
if (typeof raw !== "number" || !Number.isFinite(raw)) {
return undefined;
}
const next = Math.trunc(raw);
return next >= 0 ? next : undefined;
}
function normalizeFixtureFlags(
flags: readonly string[] | undefined,
): readonly string[] | undefined {
if (!Array.isArray(flags) || flags.length === 0) {
return undefined;
}
const normalized = Array.from(
new Set(flags.map((flag) => flag.trim()).filter((flag) => flag.length > 0)),
).toSorted((a, b) => a.localeCompare(b));
return normalized.length > 0 ? normalized : undefined;
}
function normalizeSafeBinProfileFixture(fixture: SafeBinProfileFixture): SafeBinProfileFixture {
const minPositional = normalizeFixtureLimit(fixture.minPositional);
const maxPositionalRaw = normalizeFixtureLimit(fixture.maxPositional);
const maxPositional =
minPositional !== undefined &&
maxPositionalRaw !== undefined &&
maxPositionalRaw < minPositional
? minPositional
: maxPositionalRaw;
return {
minPositional,
maxPositional,
allowedValueFlags: normalizeFixtureFlags(fixture.allowedValueFlags),
deniedFlags: normalizeFixtureFlags(fixture.deniedFlags),
};
}
export function normalizeSafeBinProfileFixtures(
fixtures?: SafeBinProfileFixtures | null,
): Record<string, SafeBinProfileFixture> {
const normalized: Record<string, SafeBinProfileFixture> = {};
@@ -204,12 +241,7 @@ function normalizeSafeBinProfileFixtures(
if (!name) {
continue;
}
normalized[name] = {
minPositional: fixture.minPositional,
maxPositional: fixture.maxPositional,
allowedValueFlags: fixture.allowedValueFlags,
deniedFlags: fixture.deniedFlags,
};
normalized[name] = normalizeSafeBinProfileFixture(fixture);
}
return normalized;
}

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import {
isInterpreterLikeSafeBin,
listInterpreterLikeSafeBins,
resolveExecSafeBinRuntimePolicy,
resolveMergedSafeBinProfileFixtures,
} from "./exec-safe-bin-runtime-policy.js";
describe("exec safe-bin runtime policy", () => {
const interpreterCases: Array<{ bin: string; expected: boolean }> = [
{ bin: "python3", expected: true },
{ bin: "python3.12", expected: true },
{ bin: "node", expected: true },
{ bin: "node20", expected: true },
{ bin: "ruby3.2", expected: true },
{ bin: "bash", expected: true },
{ bin: "myfilter", expected: false },
{ bin: "jq", expected: false },
];
for (const testCase of interpreterCases) {
it(`classifies interpreter-like safe bin '${testCase.bin}'`, () => {
expect(isInterpreterLikeSafeBin(testCase.bin)).toBe(testCase.expected);
});
}
it("lists interpreter-like bins from a mixed set", () => {
expect(listInterpreterLikeSafeBins(["jq", "python3", "myfilter", "node"])).toEqual([
"node",
"python3",
]);
});
it("merges and normalizes safe-bin profile fixtures", () => {
const merged = resolveMergedSafeBinProfileFixtures({
global: {
safeBinProfiles: {
" MyFilter ": {
deniedFlags: ["--file", " --file ", ""],
},
},
},
local: {
safeBinProfiles: {
myfilter: {
maxPositional: 0,
},
},
},
});
expect(merged).toEqual({
myfilter: {
maxPositional: 0,
},
});
});
it("computes unprofiled interpreter entries separately from custom profiled bins", () => {
const policy = resolveExecSafeBinRuntimePolicy({
local: {
safeBins: ["python3", "myfilter"],
safeBinProfiles: {
myfilter: { maxPositional: 0 },
},
},
});
expect(policy.safeBins.has("python3")).toBe(true);
expect(policy.safeBins.has("myfilter")).toBe(true);
expect(policy.unprofiledSafeBins).toEqual(["python3"]);
expect(policy.unprofiledInterpreterSafeBins).toEqual(["python3"]);
});
});

View File

@@ -0,0 +1,127 @@
import { resolveSafeBins } from "./exec-approvals-allowlist.js";
import {
normalizeSafeBinProfileFixtures,
resolveSafeBinProfiles,
type SafeBinProfile,
type SafeBinProfileFixture,
type SafeBinProfileFixtures,
} from "./exec-safe-bin-policy.js";
import { getTrustedSafeBinDirs } from "./exec-safe-bin-trust.js";
export type ExecSafeBinConfigScope = {
safeBins?: string[] | null;
safeBinProfiles?: SafeBinProfileFixtures | null;
};
const INTERPRETER_LIKE_SAFE_BINS = new Set([
"ash",
"bash",
"bun",
"cmd",
"cmd.exe",
"cscript",
"dash",
"deno",
"fish",
"ksh",
"lua",
"node",
"nodejs",
"perl",
"php",
"powershell",
"powershell.exe",
"pypy",
"pwsh",
"pwsh.exe",
"python",
"python2",
"python3",
"ruby",
"sh",
"wscript",
"zsh",
]);
const INTERPRETER_LIKE_PATTERNS = [
/^python\d+(?:\.\d+)?$/,
/^ruby\d+(?:\.\d+)?$/,
/^perl\d+(?:\.\d+)?$/,
/^php\d+(?:\.\d+)?$/,
/^node\d+(?:\.\d+)?$/,
];
function normalizeSafeBinName(raw: string): string {
const trimmed = raw.trim().toLowerCase();
if (!trimmed) {
return "";
}
const tail = trimmed.split(/[\\/]/).at(-1);
return tail ?? trimmed;
}
export function isInterpreterLikeSafeBin(raw: string): boolean {
const normalized = normalizeSafeBinName(raw);
if (!normalized) {
return false;
}
if (INTERPRETER_LIKE_SAFE_BINS.has(normalized)) {
return true;
}
return INTERPRETER_LIKE_PATTERNS.some((pattern) => pattern.test(normalized));
}
export function listInterpreterLikeSafeBins(entries: Iterable<string>): string[] {
return Array.from(entries)
.map((entry) => normalizeSafeBinName(entry))
.filter((entry) => entry.length > 0 && isInterpreterLikeSafeBin(entry))
.toSorted();
}
export function resolveMergedSafeBinProfileFixtures(params: {
global?: ExecSafeBinConfigScope | null;
local?: ExecSafeBinConfigScope | null;
}): Record<string, SafeBinProfileFixture> | undefined {
const global = normalizeSafeBinProfileFixtures(params.global?.safeBinProfiles);
const local = normalizeSafeBinProfileFixtures(params.local?.safeBinProfiles);
if (Object.keys(global).length === 0 && Object.keys(local).length === 0) {
return undefined;
}
return {
...global,
...local,
};
}
export function resolveExecSafeBinRuntimePolicy(params: {
global?: ExecSafeBinConfigScope | null;
local?: ExecSafeBinConfigScope | null;
pathEnv?: string | null;
}): {
safeBins: Set<string>;
safeBinProfiles: Readonly<Record<string, SafeBinProfile>>;
trustedSafeBinDirs: ReadonlySet<string>;
unprofiledSafeBins: string[];
unprofiledInterpreterSafeBins: string[];
} {
const safeBins = resolveSafeBins(params.local?.safeBins ?? params.global?.safeBins);
const safeBinProfiles = resolveSafeBinProfiles(
resolveMergedSafeBinProfileFixtures({
global: params.global,
local: params.local,
}),
);
const unprofiledSafeBins = Array.from(safeBins)
.filter((entry) => !safeBinProfiles[entry])
.toSorted();
const trustedSafeBinDirs = params.pathEnv
? getTrustedSafeBinDirs({ pathEnv: params.pathEnv })
: getTrustedSafeBinDirs();
return {
safeBins,
safeBinProfiles,
trustedSafeBinDirs,
unprofiledSafeBins,
unprofiledInterpreterSafeBins: listInterpreterLikeSafeBins(unprofiledSafeBins),
};
}