mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-04 20:45:40 +00:00
fix(security): harden allow-always wrapper persistence
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
||||
isWindowsPlatform,
|
||||
matchAllowlist,
|
||||
resolveAllowlistCandidatePath,
|
||||
resolveCommandResolutionFromArgv,
|
||||
splitCommandChain,
|
||||
type ExecCommandAnalysis,
|
||||
type CommandResolution,
|
||||
@@ -16,6 +17,11 @@ import {
|
||||
validateSafeBinArgv,
|
||||
} from "./exec-safe-bin-policy.js";
|
||||
import { isTrustedSafeBinPath } from "./exec-safe-bin-trust.js";
|
||||
import {
|
||||
DISPATCH_WRAPPER_EXECUTABLES,
|
||||
basenameLower,
|
||||
unwrapKnownDispatchWrapperInvocation,
|
||||
} from "./exec-wrapper-resolution.js";
|
||||
|
||||
function hasShellLineContinuation(command: string): boolean {
|
||||
return /\\(?:\r\n|\n|\r)/.test(command);
|
||||
@@ -255,6 +261,27 @@ function isShellWrapperSegment(segment: ExecCommandSegment): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function isDispatchWrapperSegment(segment: ExecCommandSegment): boolean {
|
||||
const candidates = [
|
||||
normalizeExecutableName(segment.resolution?.executableName),
|
||||
normalizeExecutableName(segment.resolution?.rawExecutable),
|
||||
normalizeExecutableName(segment.argv[0]),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
if (DISPATCH_WRAPPER_EXECUTABLES.has(candidate)) {
|
||||
return true;
|
||||
}
|
||||
const base = basenameLower(candidate);
|
||||
if (DISPATCH_WRAPPER_EXECUTABLES.has(base)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function extractShellInlineCommand(argv: string[]): string | null {
|
||||
for (let i = 1; i < argv.length; i += 1) {
|
||||
const token = argv[i];
|
||||
@@ -296,6 +323,30 @@ function collectAllowAlwaysPatterns(params: {
|
||||
depth: number;
|
||||
out: Set<string>;
|
||||
}) {
|
||||
if (params.depth >= 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDispatchWrapperSegment(params.segment)) {
|
||||
const unwrappedArgv = unwrapKnownDispatchWrapperInvocation(params.segment.argv);
|
||||
if (!unwrappedArgv || unwrappedArgv.length === 0) {
|
||||
return;
|
||||
}
|
||||
collectAllowAlwaysPatterns({
|
||||
segment: {
|
||||
raw: unwrappedArgv.join(" "),
|
||||
argv: unwrappedArgv,
|
||||
resolution: resolveCommandResolutionFromArgv(unwrappedArgv, params.cwd, params.env),
|
||||
},
|
||||
cwd: params.cwd,
|
||||
env: params.env,
|
||||
platform: params.platform,
|
||||
depth: params.depth + 1,
|
||||
out: params.out,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const candidatePath = resolveAllowlistCandidatePath(params.segment.resolution, params.cwd);
|
||||
if (!candidatePath) {
|
||||
return;
|
||||
@@ -304,9 +355,6 @@ function collectAllowAlwaysPatterns(params: {
|
||||
params.out.add(candidatePath);
|
||||
return;
|
||||
}
|
||||
if (params.depth >= 3) {
|
||||
return;
|
||||
}
|
||||
const inlineCommand = extractShellInlineCommand(params.segment.argv);
|
||||
if (!inlineCommand) {
|
||||
return;
|
||||
|
||||
@@ -293,6 +293,17 @@ describe("exec approvals command resolution", () => {
|
||||
expect(resolution?.rawExecutable).toBe("bash");
|
||||
expect(resolution?.executableName.toLowerCase()).toContain("bash");
|
||||
});
|
||||
|
||||
it("unwraps nice wrapper argv to resolve the effective executable", () => {
|
||||
const resolution = resolveCommandResolutionFromArgv([
|
||||
"/usr/bin/nice",
|
||||
"bash",
|
||||
"-lc",
|
||||
"echo hi",
|
||||
]);
|
||||
expect(resolution?.rawExecutable).toBe("bash");
|
||||
expect(resolution?.executableName.toLowerCase()).toContain("bash");
|
||||
});
|
||||
});
|
||||
|
||||
describe("exec approvals shell parsing", () => {
|
||||
@@ -1486,4 +1497,93 @@ describe("resolveAllowAlwaysPatterns", () => {
|
||||
});
|
||||
expect(patterns).toEqual([whoami]);
|
||||
});
|
||||
|
||||
it("unwraps known dispatch wrappers before shell wrappers", () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const dir = makeTempDir();
|
||||
const whoami = makeExecutable(dir, "whoami");
|
||||
const patterns = resolveAllowAlwaysPatterns({
|
||||
segments: [
|
||||
{
|
||||
raw: "/usr/bin/nice /bin/zsh -lc whoami",
|
||||
argv: ["/usr/bin/nice", "/bin/zsh", "-lc", "whoami"],
|
||||
resolution: {
|
||||
rawExecutable: "/usr/bin/nice",
|
||||
resolvedPath: "/usr/bin/nice",
|
||||
executableName: "nice",
|
||||
},
|
||||
},
|
||||
],
|
||||
cwd: dir,
|
||||
env: makePathEnv(dir),
|
||||
platform: process.platform,
|
||||
});
|
||||
expect(patterns).toEqual([whoami]);
|
||||
expect(patterns).not.toContain("/usr/bin/nice");
|
||||
});
|
||||
|
||||
it("fails closed for unresolved dispatch wrappers", () => {
|
||||
const patterns = resolveAllowAlwaysPatterns({
|
||||
segments: [
|
||||
{
|
||||
raw: "sudo /bin/zsh -lc whoami",
|
||||
argv: ["sudo", "/bin/zsh", "-lc", "whoami"],
|
||||
resolution: {
|
||||
rawExecutable: "sudo",
|
||||
resolvedPath: "/usr/bin/sudo",
|
||||
executableName: "sudo",
|
||||
},
|
||||
},
|
||||
],
|
||||
platform: process.platform,
|
||||
});
|
||||
expect(patterns).toEqual([]);
|
||||
});
|
||||
|
||||
it("prevents allow-always bypass for dispatch-wrapper + shell-wrapper chains", () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const dir = makeTempDir();
|
||||
const echo = makeExecutable(dir, "echo");
|
||||
makeExecutable(dir, "id");
|
||||
const safeBins = resolveSafeBins(undefined);
|
||||
const env = makePathEnv(dir);
|
||||
|
||||
const first = evaluateShellAllowlist({
|
||||
command: "/usr/bin/nice /bin/zsh -lc 'echo warmup-ok'",
|
||||
allowlist: [],
|
||||
safeBins,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
});
|
||||
const persisted = resolveAllowAlwaysPatterns({
|
||||
segments: first.segments,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
});
|
||||
expect(persisted).toEqual([echo]);
|
||||
|
||||
const second = evaluateShellAllowlist({
|
||||
command: "/usr/bin/nice /bin/zsh -lc 'id > marker'",
|
||||
allowlist: [{ pattern: echo }],
|
||||
safeBins,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
});
|
||||
expect(second.allowlistSatisfied).toBe(false);
|
||||
expect(
|
||||
requiresExecApproval({
|
||||
ask: "on-miss",
|
||||
security: "allowlist",
|
||||
analysisOk: second.analysisOk,
|
||||
allowlistSatisfied: second.allowlistSatisfied,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,30 @@ export const MAX_DISPATCH_WRAPPER_DEPTH = 4;
|
||||
export const POSIX_SHELL_WRAPPERS = new Set(["ash", "bash", "dash", "fish", "ksh", "sh", "zsh"]);
|
||||
export const WINDOWS_CMD_WRAPPERS = new Set(["cmd.exe", "cmd"]);
|
||||
export const POWERSHELL_WRAPPERS = new Set(["powershell", "powershell.exe", "pwsh", "pwsh.exe"]);
|
||||
export const DISPATCH_WRAPPER_EXECUTABLES = new Set([
|
||||
"chrt",
|
||||
"chrt.exe",
|
||||
"doas",
|
||||
"doas.exe",
|
||||
"env",
|
||||
"env.exe",
|
||||
"ionice",
|
||||
"ionice.exe",
|
||||
"nice",
|
||||
"nice.exe",
|
||||
"nohup",
|
||||
"nohup.exe",
|
||||
"setsid",
|
||||
"setsid.exe",
|
||||
"stdbuf",
|
||||
"stdbuf.exe",
|
||||
"sudo",
|
||||
"sudo.exe",
|
||||
"taskset",
|
||||
"taskset.exe",
|
||||
"timeout",
|
||||
"timeout.exe",
|
||||
]);
|
||||
|
||||
const POSIX_INLINE_COMMAND_FLAGS = new Set(["-lc", "-c", "--command"]);
|
||||
const POWERSHELL_INLINE_COMMAND_FLAGS = new Set(["-c", "-command", "--command"]);
|
||||
@@ -21,6 +45,10 @@ const ENV_OPTIONS_WITH_VALUE = new Set([
|
||||
"--block-signal",
|
||||
]);
|
||||
const ENV_FLAG_OPTIONS = new Set(["-i", "--ignore-environment", "-0", "--null"]);
|
||||
const NICE_OPTIONS_WITH_VALUE = new Set(["-n", "--adjustment", "--priority"]);
|
||||
const STDBUF_OPTIONS_WITH_VALUE = new Set(["-i", "--input", "-o", "--output", "-e", "--error"]);
|
||||
const TIMEOUT_FLAG_OPTIONS = new Set(["--foreground", "--preserve-status", "-v", "--verbose"]);
|
||||
const TIMEOUT_OPTIONS_WITH_VALUE = new Set(["-k", "--kill-after", "-s", "--signal"]);
|
||||
|
||||
type ShellWrapperKind = "posix" | "cmd" | "powershell";
|
||||
|
||||
@@ -122,20 +150,198 @@ export function unwrapEnvInvocation(argv: string[]): string[] | null {
|
||||
return idx < argv.length ? argv.slice(idx) : null;
|
||||
}
|
||||
|
||||
function unwrapNiceInvocation(argv: string[]): string[] | null {
|
||||
let idx = 1;
|
||||
let expectsOptionValue = false;
|
||||
while (idx < argv.length) {
|
||||
const token = argv[idx]?.trim() ?? "";
|
||||
if (!token) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (expectsOptionValue) {
|
||||
expectsOptionValue = false;
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--") {
|
||||
idx += 1;
|
||||
break;
|
||||
}
|
||||
if (token.startsWith("-") && token !== "-") {
|
||||
const lower = token.toLowerCase();
|
||||
const [flag] = lower.split("=", 2);
|
||||
if (/^-\d+$/.test(lower)) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (NICE_OPTIONS_WITH_VALUE.has(flag)) {
|
||||
if (!lower.includes("=") && lower === flag) {
|
||||
expectsOptionValue = true;
|
||||
}
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (lower.startsWith("-n") && lower.length > 2) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (expectsOptionValue) {
|
||||
return null;
|
||||
}
|
||||
return idx < argv.length ? argv.slice(idx) : null;
|
||||
}
|
||||
|
||||
function unwrapNohupInvocation(argv: string[]): string[] | null {
|
||||
let idx = 1;
|
||||
while (idx < argv.length) {
|
||||
const token = argv[idx]?.trim() ?? "";
|
||||
if (!token) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--") {
|
||||
idx += 1;
|
||||
break;
|
||||
}
|
||||
if (token.startsWith("-") && token !== "-") {
|
||||
const lower = token.toLowerCase();
|
||||
if (lower === "--help" || lower === "--version") {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return idx < argv.length ? argv.slice(idx) : null;
|
||||
}
|
||||
|
||||
function unwrapStdbufInvocation(argv: string[]): string[] | null {
|
||||
let idx = 1;
|
||||
let expectsOptionValue = false;
|
||||
while (idx < argv.length) {
|
||||
const token = argv[idx]?.trim() ?? "";
|
||||
if (!token) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (expectsOptionValue) {
|
||||
expectsOptionValue = false;
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--") {
|
||||
idx += 1;
|
||||
break;
|
||||
}
|
||||
if (token.startsWith("-") && token !== "-") {
|
||||
const lower = token.toLowerCase();
|
||||
const [flag] = lower.split("=", 2);
|
||||
if (STDBUF_OPTIONS_WITH_VALUE.has(flag)) {
|
||||
if (!lower.includes("=")) {
|
||||
expectsOptionValue = true;
|
||||
}
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (expectsOptionValue) {
|
||||
return null;
|
||||
}
|
||||
return idx < argv.length ? argv.slice(idx) : null;
|
||||
}
|
||||
|
||||
function unwrapTimeoutInvocation(argv: string[]): string[] | null {
|
||||
let idx = 1;
|
||||
let expectsOptionValue = false;
|
||||
while (idx < argv.length) {
|
||||
const token = argv[idx]?.trim() ?? "";
|
||||
if (!token) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (expectsOptionValue) {
|
||||
expectsOptionValue = false;
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--") {
|
||||
idx += 1;
|
||||
break;
|
||||
}
|
||||
if (token.startsWith("-") && token !== "-") {
|
||||
const lower = token.toLowerCase();
|
||||
const [flag] = lower.split("=", 2);
|
||||
if (TIMEOUT_FLAG_OPTIONS.has(flag)) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (TIMEOUT_OPTIONS_WITH_VALUE.has(flag)) {
|
||||
if (!lower.includes("=")) {
|
||||
expectsOptionValue = true;
|
||||
}
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (expectsOptionValue || idx >= argv.length) {
|
||||
return null;
|
||||
}
|
||||
idx += 1; // duration
|
||||
return idx < argv.length ? argv.slice(idx) : null;
|
||||
}
|
||||
|
||||
export function unwrapKnownDispatchWrapperInvocation(argv: string[]): string[] | null | undefined {
|
||||
const token0 = argv[0]?.trim();
|
||||
if (!token0) {
|
||||
return undefined;
|
||||
}
|
||||
const base = basenameLower(token0);
|
||||
const normalizedBase = base.endsWith(".exe") ? base.slice(0, -4) : base;
|
||||
switch (normalizedBase) {
|
||||
case "env":
|
||||
return unwrapEnvInvocation(argv);
|
||||
case "nice":
|
||||
return unwrapNiceInvocation(argv);
|
||||
case "nohup":
|
||||
return unwrapNohupInvocation(argv);
|
||||
case "stdbuf":
|
||||
return unwrapStdbufInvocation(argv);
|
||||
case "timeout":
|
||||
return unwrapTimeoutInvocation(argv);
|
||||
case "chrt":
|
||||
case "doas":
|
||||
case "ionice":
|
||||
case "setsid":
|
||||
case "sudo":
|
||||
case "taskset":
|
||||
return null;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function unwrapDispatchWrappersForResolution(
|
||||
argv: string[],
|
||||
maxDepth = MAX_DISPATCH_WRAPPER_DEPTH,
|
||||
): string[] {
|
||||
let current = argv;
|
||||
for (let depth = 0; depth < maxDepth; depth += 1) {
|
||||
const token0 = current[0]?.trim();
|
||||
if (!token0) {
|
||||
const unwrapped = unwrapKnownDispatchWrapperInvocation(current);
|
||||
if (unwrapped === undefined) {
|
||||
break;
|
||||
}
|
||||
if (basenameLower(token0) !== "env") {
|
||||
break;
|
||||
}
|
||||
const unwrapped = unwrapEnvInvocation(current);
|
||||
if (!unwrapped || unwrapped.length === 0) {
|
||||
break;
|
||||
}
|
||||
@@ -213,8 +419,8 @@ function extractShellWrapperCommandInternal(
|
||||
}
|
||||
|
||||
const base0 = basenameLower(token0);
|
||||
if (base0 === "env") {
|
||||
const unwrapped = unwrapEnvInvocation(argv);
|
||||
if (DISPATCH_WRAPPER_EXECUTABLES.has(base0)) {
|
||||
const unwrapped = unwrapKnownDispatchWrapperInvocation(argv);
|
||||
if (!unwrapped) {
|
||||
return { isWrapper: false, command: null };
|
||||
}
|
||||
|
||||
@@ -36,6 +36,22 @@ describe("system run command helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("extractShellCommandFromArgv unwraps known dispatch wrappers before shell wrappers", () => {
|
||||
expect(extractShellCommandFromArgv(["/usr/bin/nice", "/bin/bash", "-lc", "echo hi"])).toBe(
|
||||
"echo hi",
|
||||
);
|
||||
expect(
|
||||
extractShellCommandFromArgv([
|
||||
"/usr/bin/timeout",
|
||||
"--signal=TERM",
|
||||
"5",
|
||||
"zsh",
|
||||
"-lc",
|
||||
"echo hi",
|
||||
]),
|
||||
).toBe("echo hi");
|
||||
});
|
||||
|
||||
test("extractShellCommandFromArgv supports fish and pwsh wrappers", () => {
|
||||
expect(extractShellCommandFromArgv(["fish", "-c", "echo hi"])).toBe("echo hi");
|
||||
expect(extractShellCommandFromArgv(["pwsh", "-Command", "Get-Date"])).toBe("Get-Date");
|
||||
|
||||
Reference in New Issue
Block a user