perf(test): split security audit coverage

This commit is contained in:
Peter Steinberger
2026-04-06 13:02:18 +01:00
parent bc160c0613
commit f1b6b97df3
23 changed files with 2376 additions and 2183 deletions

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { collectMinimalProfileOverrideFindings } from "./audit-extra.sync.js";
import { collectElevatedFindings } from "./audit.js";
describe("security audit config basics", () => {
it("flags agent profile overrides when global tools.profile is minimal", () => {
const findings = collectMinimalProfileOverrideFindings({
tools: {
profile: "minimal",
},
agents: {
list: [
{
id: "owner",
tools: { profile: "full" },
},
],
},
});
expect(
findings.some(
(finding) =>
finding.checkId === "tools.profile_minimal_overridden" && finding.severity === "warn",
),
).toBe(true);
});
it("flags tools.elevated allowFrom wildcard as critical", () => {
const findings = collectElevatedFindings({
tools: {
elevated: {
allowFrom: { whatsapp: ["*"] },
},
},
});
expect(
findings.some(
(finding) =>
finding.checkId === "tools.elevated.allowFrom.whatsapp.wildcard" &&
finding.severity === "critical",
),
).toBe(true);
});
});

View File

@@ -0,0 +1,62 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { collectFilesystemFindings } from "./audit.js";
const isWindows = process.platform === "win32";
describe("security audit config symlink findings", () => {
let fixtureRoot = "";
let caseId = 0;
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-config-"));
});
afterAll(async () => {
if (!fixtureRoot) {
return;
}
await fs.rm(fixtureRoot, { recursive: true, force: true }).catch(() => undefined);
});
const makeTmpDir = async (label: string) => {
const dir = path.join(fixtureRoot, `case-${caseId++}-${label}`);
await fs.mkdir(dir, { recursive: true });
return dir;
};
it("uses symlink target permissions for config checks", async () => {
if (isWindows) {
return;
}
const tmp = await makeTmpDir("config-symlink");
const stateDir = path.join(tmp, "state");
await fs.mkdir(stateDir, { recursive: true, mode: 0o700 });
const targetConfigPath = path.join(tmp, "managed-openclaw.json");
await fs.writeFile(targetConfigPath, "{}\n", "utf-8");
await fs.chmod(targetConfigPath, 0o444);
const configPath = path.join(stateDir, "openclaw.json");
await fs.symlink(targetConfigPath, configPath);
const findings = await collectFilesystemFindings({
stateDir,
configPath,
});
expect(findings).toEqual(
expect.arrayContaining([expect.objectContaining({ checkId: "fs.config.symlink" })]),
);
expect(findings.some((finding) => finding.checkId === "fs.config.perms_writable")).toBe(false);
expect(findings.some((finding) => finding.checkId === "fs.config.perms_world_readable")).toBe(
false,
);
expect(findings.some((finding) => finding.checkId === "fs.config.perms_group_readable")).toBe(
false,
);
});
});

View File

@@ -0,0 +1,162 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { collectExecRuntimeFindings } from "./audit.js";
function hasFinding(
checkId:
| "tools.exec.safe_bins_interpreter_unprofiled"
| "tools.exec.safe_bins_broad_behavior"
| "tools.exec.safe_bin_trusted_dirs_risky",
findings: ReturnType<typeof collectExecRuntimeFindings>,
) {
return findings.some((finding) => finding.checkId === checkId && finding.severity === "warn");
}
describe("security audit exec safe-bin findings", () => {
it.each([
{
name: "missing profiles",
cfg: {
tools: {
exec: {
safeBins: ["python3"],
},
},
agents: {
list: [
{
id: "ops",
tools: {
exec: {
safeBins: ["node"],
},
},
},
],
},
} satisfies OpenClawConfig,
expected: true,
},
{
name: "profiles configured",
cfg: {
tools: {
exec: {
safeBins: ["python3"],
safeBinProfiles: {
python3: {
maxPositional: 0,
},
},
},
},
agents: {
list: [
{
id: "ops",
tools: {
exec: {
safeBins: ["node"],
safeBinProfiles: {
node: {
maxPositional: 0,
},
},
},
},
},
],
},
} satisfies OpenClawConfig,
expected: false,
},
])(
"warns for interpreter safeBins only when explicit profiles are missing: $name",
({ cfg, expected }) => {
expect(
hasFinding("tools.exec.safe_bins_interpreter_unprofiled", collectExecRuntimeFindings(cfg)),
).toBe(expected);
},
);
it.each([
{
name: "jq configured globally",
cfg: {
tools: {
exec: {
safeBins: ["jq"],
},
},
} satisfies OpenClawConfig,
expected: true,
},
{
name: "jq not configured",
cfg: {
tools: {
exec: {
safeBins: ["cut"],
},
},
} satisfies OpenClawConfig,
expected: false,
},
])(
"warns when risky broad-behavior bins are explicitly added to safeBins: $name",
({ cfg, expected }) => {
expect(
hasFinding("tools.exec.safe_bins_broad_behavior", collectExecRuntimeFindings(cfg)),
).toBe(expected);
},
);
it("evaluates safeBinTrustedDirs risk findings", () => {
const riskyGlobalTrustedDirs =
process.platform === "win32"
? [String.raw`C:\Users\ci-user\bin`, String.raw`C:\Users\ci-user\.local\bin`]
: ["/usr/local/bin", "/tmp/openclaw-safe-bins"];
const findings = collectExecRuntimeFindings({
tools: {
exec: {
safeBinTrustedDirs: riskyGlobalTrustedDirs,
},
},
agents: {
list: [
{
id: "ops",
tools: {
exec: {
safeBinTrustedDirs: ["./relative-bin-dir"],
},
},
},
],
},
} satisfies OpenClawConfig);
const riskyFinding = findings.find(
(finding) => finding.checkId === "tools.exec.safe_bin_trusted_dirs_risky",
);
expect(riskyFinding?.severity).toBe("warn");
expect(riskyFinding?.detail).toContain(riskyGlobalTrustedDirs[0]);
expect(riskyFinding?.detail).toContain(riskyGlobalTrustedDirs[1]);
expect(riskyFinding?.detail).toContain("agents.list.ops.tools.exec");
});
it("ignores non-risky absolute dirs", () => {
expect(
hasFinding(
"tools.exec.safe_bin_trusted_dirs_risky",
collectExecRuntimeFindings({
tools: {
exec: {
safeBinTrustedDirs: ["/usr/libexec"],
},
},
} satisfies OpenClawConfig),
),
).toBe(false);
});
});

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { collectExecRuntimeFindings } from "./audit.js";
function hasFinding(
checkId:
| "tools.exec.host_sandbox_no_sandbox_defaults"
| "tools.exec.host_sandbox_no_sandbox_agents",
findings: ReturnType<typeof collectExecRuntimeFindings>,
) {
return findings.some((finding) => finding.checkId === checkId && finding.severity === "warn");
}
describe("security audit exec sandbox host findings", () => {
it.each([
{
name: "defaults host is sandbox",
cfg: {
tools: {
exec: {
host: "sandbox",
},
},
agents: {
defaults: {
sandbox: {
mode: "off",
},
},
},
} satisfies OpenClawConfig,
checkId: "tools.exec.host_sandbox_no_sandbox_defaults" as const,
},
{
name: "agent override host is sandbox",
cfg: {
tools: {
exec: {
host: "gateway",
},
},
agents: {
defaults: {
sandbox: {
mode: "off",
},
},
list: [
{
id: "ops",
tools: {
exec: {
host: "sandbox",
},
},
},
],
},
} satisfies OpenClawConfig,
checkId: "tools.exec.host_sandbox_no_sandbox_agents" as const,
},
])("$name", ({ cfg, checkId }) => {
expect(hasFinding(checkId, collectExecRuntimeFindings(cfg))).toBe(true);
});
});

View File

@@ -0,0 +1,125 @@
import { afterEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { saveExecApprovals } from "../infra/exec-approvals.js";
import { collectExecRuntimeFindings } from "./audit.js";
function hasFinding(
checkId:
| "tools.exec.auto_allow_skills_enabled"
| "tools.exec.allowlist_interpreter_without_strict_inline_eval"
| "security.exposure.open_channels_with_exec"
| "tools.exec.security_full_configured",
severity: "warn" | "critical",
findings: ReturnType<typeof collectExecRuntimeFindings>,
) {
return findings.some((finding) => finding.checkId === checkId && finding.severity === severity);
}
afterEach(() => {
saveExecApprovals({ version: 1, agents: {} });
});
describe("security audit exec surface findings", () => {
it("warns when exec approvals enable autoAllowSkills", () => {
saveExecApprovals({
version: 1,
defaults: {
autoAllowSkills: true,
},
agents: {},
});
expect(
hasFinding("tools.exec.auto_allow_skills_enabled", "warn", collectExecRuntimeFindings({})),
).toBe(true);
});
it("warns when interpreter allowlists are present without strictInlineEval", () => {
saveExecApprovals({
version: 1,
agents: {
main: {
allowlist: [{ pattern: "/usr/bin/python3" }, { pattern: "/usr/bin/awk" }],
},
ops: {
allowlist: [{ pattern: "/usr/local/bin/node" }, { pattern: "/usr/local/bin/find" }],
},
},
});
expect(
hasFinding(
"tools.exec.allowlist_interpreter_without_strict_inline_eval",
"warn",
collectExecRuntimeFindings({
agents: {
list: [{ id: "ops" }],
},
} satisfies OpenClawConfig),
),
).toBe(true);
});
it("suppresses interpreter allowlist warnings when strictInlineEval is enabled", () => {
saveExecApprovals({
version: 1,
agents: {
main: {
allowlist: [{ pattern: "/usr/bin/python3" }, { pattern: "/usr/bin/xargs" }],
},
},
});
expect(
hasFinding(
"tools.exec.allowlist_interpreter_without_strict_inline_eval",
"warn",
collectExecRuntimeFindings({
tools: {
exec: {
strictInlineEval: true,
},
},
} satisfies OpenClawConfig),
),
).toBe(false);
});
it("flags open channel access combined with exec-enabled scopes", () => {
const findings = collectExecRuntimeFindings({
channels: {
discord: {
groupPolicy: "open",
},
},
tools: {
exec: {
security: "allowlist",
host: "gateway",
},
},
} satisfies OpenClawConfig);
expect(hasFinding("security.exposure.open_channels_with_exec", "warn", findings)).toBe(true);
});
it("escalates open channel exec exposure when full exec is configured", () => {
const findings = collectExecRuntimeFindings({
channels: {
slack: {
dmPolicy: "open",
},
},
tools: {
exec: {
security: "full",
},
},
} satisfies OpenClawConfig);
expect(hasFinding("tools.exec.security_full_configured", "critical", findings)).toBe(true);
expect(hasFinding("security.exposure.open_channels_with_exec", "critical", findings)).toBe(
true,
);
});
});

View File

@@ -0,0 +1,185 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { createPathResolutionEnv, withEnvAsync } from "../test-utils/env.js";
import { runSecurityAudit } from "./audit.js";
const execDockerRawUnavailable = async () => ({
stdout: Buffer.alloc(0),
stderr: Buffer.from("docker unavailable"),
code: 1,
});
describe("security audit extension tool reachability findings", () => {
let fixtureRoot = "";
let sharedExtensionsStateDir = "";
let isolatedHome = "";
let homedirSpy: { mockRestore(): void } | undefined;
const pathResolutionEnvKeys = [
"HOME",
"USERPROFILE",
"HOMEDRIVE",
"HOMEPATH",
"OPENCLAW_HOME",
"OPENCLAW_STATE_DIR",
"OPENCLAW_BUNDLED_PLUGINS_DIR",
] as const;
const previousPathResolutionEnv: Partial<Record<(typeof pathResolutionEnvKeys)[number], string>> =
{};
const runSharedExtensionsAudit = async (config: OpenClawConfig) => {
return runSecurityAudit({
config,
includeFilesystem: true,
includeChannelSecurity: false,
stateDir: sharedExtensionsStateDir,
configPath: path.join(sharedExtensionsStateDir, "openclaw.json"),
execDockerRawFn: execDockerRawUnavailable,
});
};
beforeAll(async () => {
const osModule = await import("node:os");
const vitestModule = await import("vitest");
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-extensions-"));
isolatedHome = path.join(fixtureRoot, "home");
const isolatedEnv = createPathResolutionEnv(isolatedHome, { OPENCLAW_HOME: isolatedHome });
for (const key of pathResolutionEnvKeys) {
previousPathResolutionEnv[key] = process.env[key];
const value = isolatedEnv[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
homedirSpy = vitestModule.vi
.spyOn(osModule.default ?? osModule, "homedir")
.mockReturnValue(isolatedHome);
await fs.mkdir(isolatedHome, { recursive: true, mode: 0o700 });
sharedExtensionsStateDir = path.join(fixtureRoot, "shared-extensions-state");
await fs.mkdir(path.join(sharedExtensionsStateDir, "extensions", "some-plugin"), {
recursive: true,
mode: 0o700,
});
});
afterAll(async () => {
homedirSpy?.mockRestore();
for (const key of pathResolutionEnvKeys) {
const value = previousPathResolutionEnv[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
if (fixtureRoot) {
await fs.rm(fixtureRoot, { recursive: true, force: true }).catch(() => undefined);
}
});
it("evaluates extension tool reachability findings", async () => {
const cases = [
{
name: "flags extensions without plugins.allow",
cfg: {} satisfies OpenClawConfig,
assert: (res: Awaited<ReturnType<typeof runSharedExtensionsAudit>>) => {
expect(
res.findings.some(
(finding) =>
finding.checkId === "plugins.extensions_no_allowlist" &&
finding.severity === "warn",
),
).toBe(true);
},
},
{
name: "flags enabled extensions when tool policy can expose plugin tools",
cfg: {
plugins: { allow: ["some-plugin"] },
} satisfies OpenClawConfig,
assert: (res: Awaited<ReturnType<typeof runSharedExtensionsAudit>>) => {
expect(
res.findings.some(
(finding) =>
finding.checkId === "plugins.tools_reachable_permissive_policy" &&
finding.severity === "warn",
),
).toBe(true);
},
},
{
name: "does not flag plugin tool reachability when profile is restrictive",
cfg: {
plugins: { allow: ["some-plugin"] },
tools: { profile: "coding" },
} satisfies OpenClawConfig,
assert: (res: Awaited<ReturnType<typeof runSharedExtensionsAudit>>) => {
expect(
res.findings.some(
(finding) => finding.checkId === "plugins.tools_reachable_permissive_policy",
),
).toBe(false);
},
},
{
name: "flags unallowlisted extensions as warn-level findings when extension inventory exists",
cfg: {
channels: {
discord: { enabled: true, token: "t" },
},
} satisfies OpenClawConfig,
assert: (res: Awaited<ReturnType<typeof runSharedExtensionsAudit>>) => {
expect(
res.findings.some(
(finding) =>
finding.checkId === "plugins.extensions_no_allowlist" &&
finding.severity === "warn",
),
).toBe(true);
},
},
{
name: "treats SecretRef channel credentials as configured for extension allowlist severity",
cfg: {
channels: {
discord: {
enabled: true,
token: {
source: "env",
provider: "default",
id: "DISCORD_BOT_TOKEN",
} as unknown as string,
},
},
} satisfies OpenClawConfig,
assert: (res: Awaited<ReturnType<typeof runSharedExtensionsAudit>>) => {
expect(
res.findings.some(
(finding) =>
finding.checkId === "plugins.extensions_no_allowlist" &&
finding.severity === "warn",
),
).toBe(true);
},
},
] as const;
await withEnvAsync(
{
DISCORD_BOT_TOKEN: undefined,
TELEGRAM_BOT_TOKEN: undefined,
SLACK_BOT_TOKEN: undefined,
SLACK_APP_TOKEN: undefined,
},
async () => {
for (const testCase of cases) {
testCase.assert(await runSharedExtensionsAudit(testCase.cfg));
}
},
);
});
});

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { collectFeishuSecurityAuditFindings } from "../../extensions/feishu/src/security-audit.js";
import type { OpenClawConfig } from "../config/config.js";
describe("security audit Feishu doc risk findings", () => {
it.each([
{
name: "warns when Feishu doc tool is enabled because create can grant requester access",
cfg: {
channels: {
feishu: {
appId: "cli_test",
appSecret: "secret_test",
},
},
} satisfies OpenClawConfig,
expectedFinding: "channels.feishu.doc_owner_open_id",
},
{
name: "treats Feishu SecretRef appSecret as configured for doc tool risk detection",
cfg: {
channels: {
feishu: {
appId: "cli_test",
appSecret: {
source: "env",
provider: "default",
id: "FEISHU_APP_SECRET",
},
},
},
} satisfies OpenClawConfig,
expectedFinding: "channels.feishu.doc_owner_open_id",
},
{
name: "does not warn for Feishu doc grant risk when doc tools are disabled",
cfg: {
channels: {
feishu: {
appId: "cli_test",
appSecret: "secret_test",
tools: { doc: false },
},
},
} satisfies OpenClawConfig,
expectedNoFinding: "channels.feishu.doc_owner_open_id",
},
])("$name", ({ cfg, expectedFinding, expectedNoFinding }) => {
const findings = collectFeishuSecurityAuditFindings({ cfg });
if (expectedFinding) {
expect(
findings.some(
(finding) => finding.checkId === expectedFinding && finding.severity === "warn",
),
).toBe(true);
}
if (expectedNoFinding) {
expect(findings.some((finding) => finding.checkId === expectedNoFinding)).toBe(false);
}
});
});

View File

@@ -0,0 +1,100 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { collectFilesystemFindings } from "./audit.js";
const windowsAuditEnv = {
USERNAME: "Tester",
USERDOMAIN: "DESKTOP-TEST",
};
describe("security audit filesystem Windows findings", () => {
let fixtureRoot = "";
let caseId = 0;
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-win-"));
});
afterAll(async () => {
if (!fixtureRoot) {
return;
}
await fs.rm(fixtureRoot, { recursive: true, force: true }).catch(() => undefined);
});
const makeTmpDir = async (label: string) => {
const dir = path.join(fixtureRoot, `case-${caseId++}-${label}`);
await fs.mkdir(dir, { recursive: true });
return dir;
};
it("evaluates Windows ACL-derived filesystem findings", async () => {
await Promise.all([
(async () => {
const tmp = await makeTmpDir("win");
const stateDir = path.join(tmp, "state");
await fs.mkdir(stateDir, { recursive: true });
const configPath = path.join(stateDir, "openclaw.json");
await fs.writeFile(configPath, "{}\n", "utf-8");
const findings = await collectFilesystemFindings({
stateDir,
configPath,
platform: "win32",
env: windowsAuditEnv,
execIcacls: async (_cmd: string, args: string[]) => ({
stdout: `${args[0]} NT AUTHORITY\\SYSTEM:(F)\n DESKTOP-TEST\\Tester:(F)\n`,
stderr: "",
}),
});
const forbidden = new Set([
"fs.state_dir.perms_world_writable",
"fs.state_dir.perms_group_writable",
"fs.state_dir.perms_readable",
"fs.config.perms_writable",
"fs.config.perms_world_readable",
"fs.config.perms_group_readable",
]);
for (const id of forbidden) {
expect(
findings.some((finding) => finding.checkId === id),
id,
).toBe(false);
}
})(),
(async () => {
const tmp = await makeTmpDir("win-open");
const stateDir = path.join(tmp, "state");
await fs.mkdir(stateDir, { recursive: true });
const configPath = path.join(stateDir, "openclaw.json");
await fs.writeFile(configPath, "{}\n", "utf-8");
const findings = await collectFilesystemFindings({
stateDir,
configPath,
platform: "win32",
env: windowsAuditEnv,
execIcacls: async (_cmd: string, args: string[]) => {
const target = args[0];
if (target.endsWith(`${path.sep}state`)) {
return {
stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n BUILTIN\\Users:(RX)\n DESKTOP-TEST\\Tester:(F)\n`,
stderr: "",
};
}
return {
stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n DESKTOP-TEST\\Tester:(F)\n`,
stderr: "",
};
},
});
expect(
findings.some(
(finding) =>
finding.checkId === "fs.state_dir.perms_readable" && finding.severity === "warn",
),
).toBe(true);
})(),
]);
});
});

View File

@@ -0,0 +1,415 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { collectGatewayConfigFindings } from "./audit.js";
function hasFinding(
checkId: string,
severity: "warn" | "critical",
findings: ReturnType<typeof collectGatewayConfigFindings>,
) {
return findings.some((finding) => finding.checkId === checkId && finding.severity === severity);
}
describe("security audit gateway exposure findings", () => {
it("warns on insecure or dangerous flags", () => {
const cases = [
{
name: "control UI allows insecure auth",
cfg: {
gateway: {
controlUi: { allowInsecureAuth: true },
},
} satisfies OpenClawConfig,
expectedFinding: {
checkId: "gateway.control_ui.insecure_auth",
severity: "warn",
},
expectedDangerousDetails: ["gateway.controlUi.allowInsecureAuth=true"],
},
{
name: "control UI device auth is disabled",
cfg: {
gateway: {
controlUi: { dangerouslyDisableDeviceAuth: true },
},
} satisfies OpenClawConfig,
expectedFinding: {
checkId: "gateway.control_ui.device_auth_disabled",
severity: "critical",
},
expectedDangerousDetails: ["gateway.controlUi.dangerouslyDisableDeviceAuth=true"],
},
{
name: "generic insecure debug flags",
cfg: {
hooks: {
gmail: { allowUnsafeExternalContent: true },
mappings: [{ allowUnsafeExternalContent: true }],
},
tools: {
exec: {
applyPatch: {
workspaceOnly: false,
},
},
},
} satisfies OpenClawConfig,
expectedDangerousDetails: [
"hooks.gmail.allowUnsafeExternalContent=true",
"hooks.mappings[0].allowUnsafeExternalContent=true",
"tools.exec.applyPatch.workspaceOnly=false",
],
},
{
name: "acpx approve-all is treated as a dangerous break-glass flag",
cfg: {
plugins: {
entries: {
acpx: {
enabled: true,
config: {
permissionMode: "approve-all",
},
},
},
},
} satisfies OpenClawConfig,
expectedDangerousDetails: ["plugins.entries.acpx.config.permissionMode=approve-all"],
},
] as const;
for (const testCase of cases) {
const findings = collectGatewayConfigFindings(testCase.cfg, testCase.cfg, {});
if ("expectedFinding" in testCase) {
expect(findings, testCase.name).toEqual(
expect.arrayContaining([expect.objectContaining(testCase.expectedFinding)]),
);
}
const finding = findings.find(
(entry) => entry.checkId === "config.insecure_or_dangerous_flags",
);
expect(finding, testCase.name).toBeTruthy();
expect(finding?.severity, testCase.name).toBe("warn");
for (const snippet of testCase.expectedDangerousDetails) {
expect(finding?.detail, `${testCase.name}:${snippet}`).toContain(snippet);
}
}
});
it.each([
{
name: "flags non-loopback Control UI without allowed origins",
cfg: {
gateway: {
bind: "lan",
auth: { mode: "token", token: "very-long-browser-token-0123456789" },
},
} satisfies OpenClawConfig,
expectedFinding: {
checkId: "gateway.control_ui.allowed_origins_required",
severity: "critical",
},
},
{
name: "flags wildcard Control UI origins by exposure level on loopback",
cfg: {
gateway: {
bind: "loopback",
controlUi: { allowedOrigins: ["*"] },
},
} satisfies OpenClawConfig,
expectedFinding: {
checkId: "gateway.control_ui.allowed_origins_wildcard",
severity: "warn",
},
},
{
name: "flags wildcard Control UI origins by exposure level when exposed",
cfg: {
gateway: {
bind: "lan",
auth: { mode: "token", token: "very-long-browser-token-0123456789" },
controlUi: { allowedOrigins: ["*"] },
},
} satisfies OpenClawConfig,
expectedFinding: {
checkId: "gateway.control_ui.allowed_origins_wildcard",
severity: "critical",
},
expectedNoFinding: "gateway.control_ui.allowed_origins_required",
},
])("$name", ({ cfg, expectedFinding, expectedNoFinding }) => {
const findings = collectGatewayConfigFindings(cfg, cfg, {});
expect(findings).toEqual(expect.arrayContaining([expect.objectContaining(expectedFinding)]));
if (expectedNoFinding) {
expect(findings.some((finding) => finding.checkId === expectedNoFinding)).toBe(false);
}
});
it("flags dangerous host-header origin fallback and suppresses missing allowed-origins finding", () => {
const cfg: OpenClawConfig = {
gateway: {
bind: "lan",
auth: { mode: "token", token: "very-long-browser-token-0123456789" },
controlUi: {
dangerouslyAllowHostHeaderOriginFallback: true,
},
},
};
const findings = collectGatewayConfigFindings(cfg, cfg, {});
expect(hasFinding("gateway.control_ui.host_header_origin_fallback", "critical", findings)).toBe(
true,
);
expect(
findings.some((finding) => finding.checkId === "gateway.control_ui.allowed_origins_required"),
).toBe(false);
const flags = findings.find(
(finding) => finding.checkId === "config.insecure_or_dangerous_flags",
);
expect(flags?.detail ?? "").toContain(
"gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true",
);
});
it.each([
{
name: "loopback gateway",
cfg: {
gateway: {
bind: "loopback",
allowRealIpFallback: true,
trustedProxies: ["127.0.0.1"],
auth: {
mode: "token",
token: "very-long-token-1234567890",
},
},
} satisfies OpenClawConfig,
expectedSeverity: "warn" as const,
},
{
name: "lan gateway",
cfg: {
gateway: {
bind: "lan",
allowRealIpFallback: true,
trustedProxies: ["10.0.0.1"],
auth: {
mode: "token",
token: "very-long-token-1234567890",
},
},
} satisfies OpenClawConfig,
expectedSeverity: "critical" as const,
},
{
name: "loopback trusted-proxy with loopback-only proxies",
cfg: {
gateway: {
bind: "loopback",
allowRealIpFallback: true,
trustedProxies: ["127.0.0.1"],
auth: {
mode: "trusted-proxy",
trustedProxy: {
userHeader: "x-forwarded-user",
},
},
},
} satisfies OpenClawConfig,
expectedSeverity: "warn" as const,
},
{
name: "loopback trusted-proxy with non-loopback proxy range",
cfg: {
gateway: {
bind: "loopback",
allowRealIpFallback: true,
trustedProxies: ["127.0.0.1", "10.0.0.0/8"],
auth: {
mode: "trusted-proxy",
trustedProxy: {
userHeader: "x-forwarded-user",
},
},
},
} satisfies OpenClawConfig,
expectedSeverity: "critical" as const,
},
{
name: "loopback trusted-proxy with 127.0.0.2",
cfg: {
gateway: {
bind: "loopback",
allowRealIpFallback: true,
trustedProxies: ["127.0.0.2"],
auth: {
mode: "trusted-proxy",
trustedProxy: {
userHeader: "x-forwarded-user",
},
},
},
} satisfies OpenClawConfig,
expectedSeverity: "critical" as const,
},
{
name: "loopback trusted-proxy with 127.0.0.0/8 range",
cfg: {
gateway: {
bind: "loopback",
allowRealIpFallback: true,
trustedProxies: ["127.0.0.0/8"],
auth: {
mode: "trusted-proxy",
trustedProxy: {
userHeader: "x-forwarded-user",
},
},
},
} satisfies OpenClawConfig,
expectedSeverity: "critical" as const,
},
])("scores X-Real-IP fallback risk by gateway exposure: $name", ({ cfg, expectedSeverity }) => {
expect(
hasFinding(
"gateway.real_ip_fallback_enabled",
expectedSeverity,
collectGatewayConfigFindings(cfg, cfg, {}),
),
).toBe(true);
});
it.each([
{
name: "loopback gateway with full mDNS",
cfg: {
gateway: {
bind: "loopback",
auth: {
mode: "token",
token: "very-long-token-1234567890",
},
},
discovery: {
mdns: { mode: "full" },
},
} satisfies OpenClawConfig,
expectedSeverity: "warn" as const,
},
{
name: "lan gateway with full mDNS",
cfg: {
gateway: {
bind: "lan",
auth: {
mode: "token",
token: "very-long-token-1234567890",
},
},
discovery: {
mdns: { mode: "full" },
},
} satisfies OpenClawConfig,
expectedSeverity: "critical" as const,
},
])("scores mDNS full mode risk by gateway bind mode: $name", ({ cfg, expectedSeverity }) => {
expect(
hasFinding(
"discovery.mdns_full_mode",
expectedSeverity,
collectGatewayConfigFindings(cfg, cfg, {}),
),
).toBe(true);
});
it("evaluates trusted-proxy auth guardrails", () => {
const cases: Array<{
name: string;
cfg: OpenClawConfig;
expectedCheckId: string;
expectedSeverity: "warn" | "critical";
suppressesGenericSharedSecretFindings?: boolean;
}> = [
{
name: "trusted-proxy base mode",
cfg: {
gateway: {
bind: "lan",
trustedProxies: ["10.0.0.1"],
auth: {
mode: "trusted-proxy",
trustedProxy: { userHeader: "x-forwarded-user" },
},
},
},
expectedCheckId: "gateway.trusted_proxy_auth",
expectedSeverity: "critical",
suppressesGenericSharedSecretFindings: true,
},
{
name: "missing trusted proxies",
cfg: {
gateway: {
bind: "lan",
trustedProxies: [],
auth: {
mode: "trusted-proxy",
trustedProxy: { userHeader: "x-forwarded-user" },
},
},
},
expectedCheckId: "gateway.trusted_proxy_no_proxies",
expectedSeverity: "critical",
},
{
name: "missing user header",
cfg: {
gateway: {
bind: "lan",
trustedProxies: ["10.0.0.1"],
auth: {
mode: "trusted-proxy",
trustedProxy: {} as never,
},
},
},
expectedCheckId: "gateway.trusted_proxy_no_user_header",
expectedSeverity: "critical",
},
{
name: "missing user allowlist",
cfg: {
gateway: {
bind: "lan",
trustedProxies: ["10.0.0.1"],
auth: {
mode: "trusted-proxy",
trustedProxy: {
userHeader: "x-forwarded-user",
allowUsers: [],
},
},
},
},
expectedCheckId: "gateway.trusted_proxy_no_allowlist",
expectedSeverity: "warn",
},
];
for (const testCase of cases) {
const findings = collectGatewayConfigFindings(testCase.cfg, testCase.cfg, {});
expect(
hasFinding(testCase.expectedCheckId, testCase.expectedSeverity, findings),
testCase.name,
).toBe(true);
if (testCase.suppressesGenericSharedSecretFindings) {
expect(findings.some((finding) => finding.checkId === "gateway.bind_no_auth")).toBe(false);
expect(findings.some((finding) => finding.checkId === "gateway.auth_no_rate_limit")).toBe(
false,
);
}
}
});
});

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
collectGatewayHttpNoAuthFindings,
collectGatewayHttpSessionKeyOverrideFindings,
} from "./audit-extra.sync.js";
describe("security audit gateway HTTP auth findings", () => {
it.each([
{
name: "scores loopback gateway HTTP no-auth as warn",
cfg: {
gateway: {
bind: "loopback",
auth: { mode: "none" },
http: { endpoints: { chatCompletions: { enabled: true } } },
},
} satisfies OpenClawConfig,
expectedFinding: { checkId: "gateway.http.no_auth", severity: "warn" as const },
detailIncludes: ["/tools/invoke", "/v1/chat/completions"],
env: {} as NodeJS.ProcessEnv,
},
{
name: "scores remote gateway HTTP no-auth as critical",
cfg: {
gateway: {
bind: "lan",
auth: { mode: "none" },
http: { endpoints: { responses: { enabled: true } } },
},
} satisfies OpenClawConfig,
expectedFinding: { checkId: "gateway.http.no_auth", severity: "critical" as const },
env: {} as NodeJS.ProcessEnv,
},
{
name: "does not report gateway.http.no_auth when auth mode is token",
cfg: {
gateway: {
bind: "loopback",
auth: { mode: "token", token: "secret" },
http: {
endpoints: {
chatCompletions: { enabled: true },
responses: { enabled: true },
},
},
},
} satisfies OpenClawConfig,
expectedNoFinding: "gateway.http.no_auth",
env: {} as NodeJS.ProcessEnv,
},
{
name: "reports HTTP API session-key override surfaces when enabled",
cfg: {
gateway: {
http: {
endpoints: {
chatCompletions: { enabled: true },
responses: { enabled: true },
},
},
},
} satisfies OpenClawConfig,
expectedFinding: {
checkId: "gateway.http.session_key_override_enabled",
severity: "info" as const,
},
},
])("$name", ({ cfg, expectedFinding, expectedNoFinding, detailIncludes, env }) => {
const findings = [
...collectGatewayHttpNoAuthFindings(cfg, env ?? process.env),
...collectGatewayHttpSessionKeyOverrideFindings(cfg),
];
if (expectedFinding) {
expect(findings).toEqual(expect.arrayContaining([expect.objectContaining(expectedFinding)]));
if (detailIncludes) {
const finding = findings.find((entry) => entry.checkId === expectedFinding.checkId);
for (const text of detailIncludes) {
expect(finding?.detail, `${expectedFinding.checkId}:${text}`).toContain(text);
}
}
}
if (expectedNoFinding) {
expect(findings.some((entry) => entry.checkId === expectedNoFinding)).toBe(false);
}
});
});

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { collectGatewayConfigFindings } from "./audit.js";
function hasFinding(
findings: ReturnType<typeof collectGatewayConfigFindings>,
checkId: string,
severity?: "warn" | "critical",
) {
return findings.some(
(finding) => finding.checkId === checkId && (severity == null || finding.severity === severity),
);
}
describe("security audit gateway HTTP tool findings", () => {
it.each([
{
name: "loopback bind",
cfg: {
gateway: {
bind: "loopback",
auth: { token: "secret" },
tools: { allow: ["sessions_spawn"] },
},
} satisfies OpenClawConfig,
expectedSeverity: "warn" as const,
},
{
name: "non-loopback bind",
cfg: {
gateway: {
bind: "lan",
auth: { token: "secret" },
tools: { allow: ["sessions_spawn", "gateway"] },
},
} satisfies OpenClawConfig,
expectedSeverity: "critical" as const,
},
{
name: "newly denied exec override",
cfg: {
gateway: {
bind: "lan",
auth: { token: "secret" },
tools: { allow: ["exec"] },
},
} satisfies OpenClawConfig,
expectedSeverity: "critical" as const,
},
])(
"scores dangerous gateway.tools.allow over HTTP by exposure: $name",
({ cfg, expectedSeverity }) => {
const findings = collectGatewayConfigFindings(cfg, cfg, {});
expect(
hasFinding(findings, "gateway.tools_invoke_http.dangerous_allow", expectedSeverity),
).toBe(true);
},
);
});

View File

@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { collectHooksHardeningFindings } from "./audit-extra.sync.js";
function hasFinding(
findings: ReturnType<typeof collectHooksHardeningFindings>,
checkId: string,
severity: "warn" | "critical",
) {
return findings.some((finding) => finding.checkId === checkId && finding.severity === severity);
}
describe("security audit hooks ingress findings", () => {
it("evaluates hooks ingress auth and routing findings", () => {
const unrestrictedBaseHooks = {
enabled: true,
token: "shared-gateway-token-1234567890",
defaultSessionKey: "hook:ingress",
} satisfies NonNullable<OpenClawConfig["hooks"]>;
const requestSessionKeyHooks = {
...unrestrictedBaseHooks,
allowRequestSessionKey: true,
} satisfies NonNullable<OpenClawConfig["hooks"]>;
const cases = [
{
name: "warns when hooks token looks short",
cfg: {
hooks: { enabled: true, token: "short" },
} satisfies OpenClawConfig,
expectedFinding: "hooks.token_too_short",
expectedSeverity: "warn" as const,
},
{
name: "flags hooks token reuse of the gateway env token as critical",
cfg: {
hooks: { enabled: true, token: "shared-gateway-token-1234567890" },
} satisfies OpenClawConfig,
env: {
OPENCLAW_GATEWAY_TOKEN: "shared-gateway-token-1234567890",
} as NodeJS.ProcessEnv,
expectedFinding: "hooks.token_reuse_gateway_token",
expectedSeverity: "critical" as const,
},
{
name: "warns when hooks.defaultSessionKey is unset",
cfg: {
hooks: { enabled: true, token: "shared-gateway-token-1234567890" },
} satisfies OpenClawConfig,
expectedFinding: "hooks.default_session_key_unset",
expectedSeverity: "warn" as const,
},
{
name: "treats wildcard hooks.allowedAgentIds as unrestricted routing",
cfg: {
hooks: {
enabled: true,
token: "shared-gateway-token-1234567890",
defaultSessionKey: "hook:ingress",
allowedAgentIds: ["*"],
},
} satisfies OpenClawConfig,
expectedFinding: "hooks.allowed_agent_ids_unrestricted",
expectedSeverity: "warn" as const,
},
{
name: "scores unrestricted hooks.allowedAgentIds by local exposure",
cfg: { hooks: unrestrictedBaseHooks } satisfies OpenClawConfig,
expectedFinding: "hooks.allowed_agent_ids_unrestricted",
expectedSeverity: "warn" as const,
},
{
name: "scores unrestricted hooks.allowedAgentIds by remote exposure",
cfg: { gateway: { bind: "lan" }, hooks: unrestrictedBaseHooks } satisfies OpenClawConfig,
expectedFinding: "hooks.allowed_agent_ids_unrestricted",
expectedSeverity: "critical" as const,
},
{
name: "scores hooks request sessionKey override by local exposure",
cfg: { hooks: requestSessionKeyHooks } satisfies OpenClawConfig,
expectedFinding: "hooks.request_session_key_enabled",
expectedSeverity: "warn" as const,
expectedExtraFinding: {
checkId: "hooks.request_session_key_prefixes_missing",
severity: "warn" as const,
},
},
{
name: "scores hooks request sessionKey override by remote exposure",
cfg: {
gateway: { bind: "lan" },
hooks: requestSessionKeyHooks,
} satisfies OpenClawConfig,
expectedFinding: "hooks.request_session_key_enabled",
expectedSeverity: "critical" as const,
},
] as const;
for (const testCase of cases) {
const env = "env" in testCase ? testCase.env : process.env;
const findings = collectHooksHardeningFindings(testCase.cfg, env);
expect(
hasFinding(findings, testCase.expectedFinding, testCase.expectedSeverity),
testCase.name,
).toBe(true);
if ("expectedExtraFinding" in testCase) {
expect(
hasFinding(
findings,
testCase.expectedExtraFinding.checkId,
testCase.expectedExtraFinding.severity,
),
testCase.name,
).toBe(true);
}
}
});
});

View File

@@ -0,0 +1,188 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { runSecurityAudit } from "./audit.js";
const execDockerRawUnavailable = async () => ({
stdout: Buffer.alloc(0),
stderr: Buffer.from("docker unavailable"),
code: 1,
});
describe("security audit install metadata findings", () => {
let fixtureRoot = "";
let sharedInstallMetadataStateDir = "";
let caseId = 0;
const makeTmpDir = async (label: string) => {
const dir = path.join(fixtureRoot, `case-${caseId++}-${label}`);
await fs.mkdir(dir, { recursive: true });
return dir;
};
const runInstallMetadataAudit = async (cfg: OpenClawConfig, stateDir: string) => {
return runSecurityAudit({
config: cfg,
includeFilesystem: true,
includeChannelSecurity: false,
stateDir,
configPath: path.join(stateDir, "openclaw.json"),
execDockerRawFn: execDockerRawUnavailable,
});
};
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-install-"));
sharedInstallMetadataStateDir = path.join(fixtureRoot, "shared-install-metadata-state");
await fs.mkdir(sharedInstallMetadataStateDir, { recursive: true });
});
afterAll(async () => {
if (fixtureRoot) {
await fs.rm(fixtureRoot, { recursive: true, force: true }).catch(() => undefined);
}
});
it("evaluates install metadata findings", async () => {
const cases = [
{
name: "warns on unpinned npm install specs and missing integrity metadata",
run: async () =>
runInstallMetadataAudit(
{
plugins: {
installs: {
"voice-call": {
source: "npm",
spec: "@openclaw/voice-call",
},
},
},
hooks: {
internal: {
installs: {
"test-hooks": {
source: "npm",
spec: "@openclaw/test-hooks",
},
},
},
},
},
sharedInstallMetadataStateDir,
),
expectedPresent: [
"plugins.installs_unpinned_npm_specs",
"plugins.installs_missing_integrity",
"hooks.installs_unpinned_npm_specs",
"hooks.installs_missing_integrity",
],
},
{
name: "does not warn on pinned npm install specs with integrity metadata",
run: async () =>
runInstallMetadataAudit(
{
plugins: {
installs: {
"voice-call": {
source: "npm",
spec: "@openclaw/voice-call@1.2.3",
integrity: "sha512-plugin",
},
},
},
hooks: {
internal: {
installs: {
"test-hooks": {
source: "npm",
spec: "@openclaw/test-hooks@1.2.3",
integrity: "sha512-hook",
},
},
},
},
},
sharedInstallMetadataStateDir,
),
expectedAbsent: [
"plugins.installs_unpinned_npm_specs",
"plugins.installs_missing_integrity",
"hooks.installs_unpinned_npm_specs",
"hooks.installs_missing_integrity",
],
},
{
name: "warns when install records drift from installed package versions",
run: async () => {
const tmp = await makeTmpDir("install-version-drift");
const stateDir = path.join(tmp, "state");
const pluginDir = path.join(stateDir, "extensions", "voice-call");
const hookDir = path.join(stateDir, "hooks", "test-hooks");
await fs.mkdir(pluginDir, { recursive: true });
await fs.mkdir(hookDir, { recursive: true });
await fs.writeFile(
path.join(pluginDir, "package.json"),
JSON.stringify({ name: "@openclaw/voice-call", version: "9.9.9" }),
"utf-8",
);
await fs.writeFile(
path.join(hookDir, "package.json"),
JSON.stringify({ name: "@openclaw/test-hooks", version: "8.8.8" }),
"utf-8",
);
return runInstallMetadataAudit(
{
plugins: {
installs: {
"voice-call": {
source: "npm",
spec: "@openclaw/voice-call@1.2.3",
integrity: "sha512-plugin",
resolvedVersion: "1.2.3",
},
},
},
hooks: {
internal: {
installs: {
"test-hooks": {
source: "npm",
spec: "@openclaw/test-hooks@1.2.3",
integrity: "sha512-hook",
resolvedVersion: "1.2.3",
},
},
},
},
},
stateDir,
);
},
expectedPresent: ["plugins.installs_version_drift", "hooks.installs_version_drift"],
},
] as const;
for (const testCase of cases) {
const res = await testCase.run();
for (const checkId of testCase.expectedPresent ?? []) {
expect(
res.findings.some(
(finding) => finding.checkId === checkId && finding.severity === "warn",
),
testCase.name,
).toBe(true);
}
for (const checkId of testCase.expectedAbsent ?? []) {
expect(
res.findings.some((finding) => finding.checkId === checkId),
testCase.name,
).toBe(false);
}
}
});
});

View File

@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { withEnvAsync } from "../test-utils/env.js";
import { collectGatewayConfigFindings, collectLoggingFindings } from "./audit.js";
function hasGatewayFinding(
checkId: "gateway.trusted_proxies_missing" | "gateway.loopback_no_auth",
severity: "warn" | "critical",
findings: ReturnType<typeof collectGatewayConfigFindings>,
) {
return findings.some((finding) => finding.checkId === checkId && finding.severity === severity);
}
function hasLoggingFinding(
checkId: "logging.redact_off",
severity: "warn",
findings: ReturnType<typeof collectLoggingFindings>,
) {
return findings.some((finding) => finding.checkId === checkId && finding.severity === severity);
}
describe("security audit loopback and logging findings", () => {
it("evaluates loopback control UI and logging exposure findings", async () => {
await Promise.all([
(async () => {
const cfg: OpenClawConfig = {
gateway: {
bind: "loopback",
controlUi: { enabled: true },
},
};
expect(
hasGatewayFinding(
"gateway.trusted_proxies_missing",
"warn",
collectGatewayConfigFindings(cfg, cfg, process.env),
),
).toBe(true);
})(),
withEnvAsync(
{
OPENCLAW_GATEWAY_TOKEN: undefined,
OPENCLAW_GATEWAY_PASSWORD: undefined,
},
async () => {
const cfg: OpenClawConfig = {
gateway: {
bind: "loopback",
controlUi: { enabled: true },
auth: {},
},
};
expect(
hasGatewayFinding(
"gateway.loopback_no_auth",
"critical",
collectGatewayConfigFindings(cfg, cfg, process.env),
),
).toBe(true);
},
),
(async () => {
const cfg: OpenClawConfig = {
logging: { redactSensitive: "off" },
};
expect(hasLoggingFinding("logging.redact_off", "warn", collectLoggingFindings(cfg))).toBe(
true,
);
})(),
]);
});
});

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { collectModelHygieneFindings } from "./audit-extra.sync.js";
describe("security audit model hygiene findings", () => {
it("classifies legacy and weak-tier model identifiers", () => {
const cases: Array<{
name: string;
cfg: OpenClawConfig;
expectedPresent?: Array<{ checkId: string; severity: "warn" }>;
expectedAbsentCheckId?: string;
}> = [
{
name: "legacy model",
cfg: {
agents: { defaults: { model: { primary: "openai/gpt-3.5-turbo" } } },
},
expectedPresent: [{ checkId: "models.legacy", severity: "warn" }],
},
{
name: "weak-tier model",
cfg: {
agents: { defaults: { model: { primary: "anthropic/claude-haiku-4-5" } } },
},
expectedPresent: [{ checkId: "models.weak_tier", severity: "warn" }],
},
{
name: "venice opus-45",
cfg: {
agents: { defaults: { model: { primary: "venice/claude-opus-45" } } },
},
expectedAbsentCheckId: "models.weak_tier",
},
];
for (const testCase of cases) {
const findings = collectModelHygieneFindings(testCase.cfg);
for (const expected of testCase.expectedPresent ?? []) {
expect(
findings.some(
(finding) =>
finding.checkId === expected.checkId && finding.severity === expected.severity,
),
testCase.name,
).toBe(true);
}
if (testCase.expectedAbsentCheckId) {
expect(
findings.some((finding) => finding.checkId === testCase.expectedAbsentCheckId),
testCase.name,
).toBe(false);
}
}
});
});

View File

@@ -0,0 +1,129 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
collectNodeDangerousAllowCommandFindings,
collectNodeDenyCommandPatternFindings,
} from "./audit-extra.sync.js";
function expectDetailText(params: {
detail: string | null | undefined;
name: string;
includes?: readonly string[];
excludes?: readonly string[];
}) {
for (const text of params.includes ?? []) {
expect(params.detail, `${params.name}:${text}`).toContain(text);
}
for (const text of params.excludes ?? []) {
expect(params.detail, `${params.name}:${text}`).not.toContain(text);
}
}
describe("security audit node command findings", () => {
it("evaluates ineffective gateway.nodes.denyCommands entries", () => {
const cases = [
{
name: "flags ineffective gateway.nodes.denyCommands entries",
cfg: {
gateway: {
nodes: {
denyCommands: ["system.*", "system.runx"],
},
},
} satisfies OpenClawConfig,
detailIncludes: ["system.*", "system.runx", "did you mean", "system.run"],
},
{
name: "suggests prefix-matching commands for unknown denyCommands entries",
cfg: {
gateway: {
nodes: {
denyCommands: ["system.run.prep"],
},
},
} satisfies OpenClawConfig,
detailIncludes: ["system.run.prep", "did you mean", "system.run.prepare"],
},
{
name: "keeps unknown denyCommands entries without suggestions when no close command exists",
cfg: {
gateway: {
nodes: {
denyCommands: ["zzzzzzzzzzzzzz"],
},
},
} satisfies OpenClawConfig,
detailIncludes: ["zzzzzzzzzzzzzz"],
detailExcludes: ["did you mean"],
},
] as const;
for (const testCase of cases) {
const findings = collectNodeDenyCommandPatternFindings(testCase.cfg);
const finding = findings.find(
(entry) => entry.checkId === "gateway.nodes.deny_commands_ineffective",
);
expect(finding?.severity, testCase.name).toBe("warn");
expectDetailText({
detail: finding?.detail,
name: testCase.name,
includes: testCase.detailIncludes,
excludes: "detailExcludes" in testCase ? testCase.detailExcludes : [],
});
}
});
it("evaluates dangerous gateway.nodes.allowCommands findings", () => {
const cases = [
{
name: "loopback gateway",
cfg: {
gateway: {
bind: "loopback",
nodes: { allowCommands: ["camera.snap", "screen.record"] },
},
} satisfies OpenClawConfig,
expectedSeverity: "warn" as const,
},
{
name: "lan-exposed gateway",
cfg: {
gateway: {
bind: "lan",
nodes: { allowCommands: ["camera.snap", "screen.record"] },
},
} satisfies OpenClawConfig,
expectedSeverity: "critical" as const,
},
{
name: "denied again suppresses dangerous allowCommands finding",
cfg: {
gateway: {
nodes: {
allowCommands: ["camera.snap", "screen.record"],
denyCommands: ["camera.snap", "screen.record"],
},
},
} satisfies OpenClawConfig,
expectedAbsent: true,
},
] as const;
for (const testCase of cases) {
const findings = collectNodeDangerousAllowCommandFindings(testCase.cfg);
const finding = findings.find(
(entry) => entry.checkId === "gateway.nodes.allow_commands_dangerous",
);
if ("expectedAbsent" in testCase && testCase.expectedAbsent) {
expect(finding, testCase.name).toBeUndefined();
continue;
}
expect(finding?.severity, testCase.name).toBe(testCase.expectedSeverity);
expectDetailText({
detail: finding?.detail,
name: testCase.name,
includes: ["camera.snap", "screen.record"],
});
}
});
});

View File

@@ -0,0 +1,144 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { collectSandboxBrowserHashLabelFindings } from "./audit-extra.async.js";
import { collectSandboxDangerousConfigFindings } from "./audit-extra.sync.js";
function hasFinding(
checkId:
| "sandbox.browser_container.hash_label_missing"
| "sandbox.browser_container.hash_epoch_stale"
| "sandbox.browser_container.non_loopback_publish"
| "sandbox.browser_cdp_bridge_unrestricted",
severity: "warn" | "critical",
findings: Array<{ checkId: string; severity: string; detail: string }>,
) {
return findings.some((finding) => finding.checkId === checkId && finding.severity === severity);
}
describe("security audit sandbox browser findings", () => {
it("warns when sandbox browser containers have missing or stale hash labels", async () => {
const findings = await collectSandboxBrowserHashLabelFindings({
execDockerRawFn: async (args: string[]) => {
if (args[0] === "ps") {
return {
stdout: Buffer.from("openclaw-sbx-browser-old\nopenclaw-sbx-browser-missing-hash\n"),
stderr: Buffer.alloc(0),
code: 0,
};
}
if (args[0] === "inspect" && args.at(-1) === "openclaw-sbx-browser-old") {
return {
stdout: Buffer.from("abc123\tepoch-v0\n"),
stderr: Buffer.alloc(0),
code: 0,
};
}
if (args[0] === "inspect" && args.at(-1) === "openclaw-sbx-browser-missing-hash") {
return {
stdout: Buffer.from("<no value>\t<no value>\n"),
stderr: Buffer.alloc(0),
code: 0,
};
}
return {
stdout: Buffer.alloc(0),
stderr: Buffer.from("not found"),
code: 1,
};
},
});
expect(hasFinding("sandbox.browser_container.hash_label_missing", "warn", findings)).toBe(true);
expect(hasFinding("sandbox.browser_container.hash_epoch_stale", "warn", findings)).toBe(true);
const staleEpoch = findings.find(
(finding) => finding.checkId === "sandbox.browser_container.hash_epoch_stale",
);
expect(staleEpoch?.detail).toContain("openclaw-sbx-browser-old");
});
it("skips sandbox browser hash label checks when docker inspect is unavailable", async () => {
const findings = await collectSandboxBrowserHashLabelFindings({
execDockerRawFn: async () => {
throw new Error("spawn docker ENOENT");
},
});
expect(hasFinding("sandbox.browser_container.hash_label_missing", "warn", findings)).toBe(
false,
);
expect(hasFinding("sandbox.browser_container.hash_epoch_stale", "warn", findings)).toBe(false);
});
it("flags sandbox browser containers with non-loopback published ports", async () => {
const findings = await collectSandboxBrowserHashLabelFindings({
execDockerRawFn: async (args: string[]) => {
if (args[0] === "ps") {
return {
stdout: Buffer.from("openclaw-sbx-browser-exposed\n"),
stderr: Buffer.alloc(0),
code: 0,
};
}
if (args[0] === "inspect" && args.at(-1) === "openclaw-sbx-browser-exposed") {
return {
stdout: Buffer.from("hash123\t2026-02-21-novnc-auth-default\n"),
stderr: Buffer.alloc(0),
code: 0,
};
}
if (args[0] === "port" && args.at(-1) === "openclaw-sbx-browser-exposed") {
return {
stdout: Buffer.from("6080/tcp -> 0.0.0.0:49101\n9222/tcp -> 127.0.0.1:49100\n"),
stderr: Buffer.alloc(0),
code: 0,
};
}
return {
stdout: Buffer.alloc(0),
stderr: Buffer.from("not found"),
code: 1,
};
},
});
expect(hasFinding("sandbox.browser_container.non_loopback_publish", "critical", findings)).toBe(
true,
);
});
it("warns when bridge network omits cdpSourceRange", () => {
const findings = collectSandboxDangerousConfigFindings({
agents: {
defaults: {
sandbox: {
mode: "all",
browser: { enabled: true, network: "bridge" },
},
},
},
} satisfies OpenClawConfig);
const finding = findings.find(
(entry) => entry.checkId === "sandbox.browser_cdp_bridge_unrestricted",
);
expect(finding?.severity).toBe("warn");
expect(finding?.detail).toContain("agents.defaults.sandbox.browser");
});
it("does not warn for dedicated default browser network", () => {
expect(
hasFinding(
"sandbox.browser_cdp_bridge_unrestricted",
"warn",
collectSandboxDangerousConfigFindings({
agents: {
defaults: {
sandbox: {
mode: "all",
browser: { enabled: true },
},
},
},
} satisfies OpenClawConfig),
),
).toBe(false);
});
});

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { collectSmallModelRiskFindings } from "./audit-extra.sync.js";
describe("security audit small-model risk findings", () => {
it("scores small-model risk by tool/sandbox exposure", () => {
const cases: Array<{
name: string;
cfg: OpenClawConfig;
expectedSeverity: "info" | "critical";
detailIncludes: string[];
}> = [
{
name: "small model with web and browser enabled",
cfg: {
agents: { defaults: { model: { primary: "ollama/mistral-8b" } } },
tools: { web: { search: { enabled: true }, fetch: { enabled: true } } },
browser: { enabled: true },
},
expectedSeverity: "critical",
detailIncludes: ["mistral-8b", "web_search", "web_fetch", "browser"],
},
{
name: "small model with sandbox all and web/browser disabled",
cfg: {
agents: {
defaults: { model: { primary: "ollama/mistral-8b" }, sandbox: { mode: "all" } },
},
tools: { web: { search: { enabled: false }, fetch: { enabled: false } } },
browser: { enabled: false },
},
expectedSeverity: "info",
detailIncludes: ["mistral-8b", "sandbox=all"],
},
];
for (const testCase of cases) {
const [finding] = collectSmallModelRiskFindings({
cfg: testCase.cfg,
env: process.env,
});
expect(finding?.severity, testCase.name).toBe(testCase.expectedSeverity);
for (const snippet of testCase.detailIncludes) {
expect(finding?.detail, `${testCase.name}:${snippet}`).toContain(snippet);
}
}
});
});

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { collectSyncedFolderFindings } from "./audit-extra.sync.js";
describe("security audit synced folder findings", () => {
it("warns when state/config look like a synced folder", () => {
const findings = collectSyncedFolderFindings({
stateDir: "/Users/test/Dropbox/.openclaw",
configPath: "/Users/test/Dropbox/.openclaw/openclaw.json",
});
expect(
findings.some(
(finding) => finding.checkId === "fs.synced_dir" && finding.severity === "warn",
),
).toBe(true);
});
});

View File

@@ -0,0 +1,157 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { saveExecApprovals } from "../infra/exec-approvals.js";
import { runSecurityAudit } from "./audit.js";
const execDockerRawUnavailable = async () => ({
stdout: Buffer.alloc(0),
stderr: Buffer.from("docker unavailable"),
code: 1,
});
async function audit(cfg: OpenClawConfig) {
saveExecApprovals({ version: 1, agents: {} });
return runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: false,
execDockerRawFn: execDockerRawUnavailable,
});
}
describe("security audit trust model findings", () => {
it("evaluates trust-model exposure findings", async () => {
const cases = [
{
name: "flags open groupPolicy when tools.elevated is enabled",
cfg: {
tools: { elevated: { enabled: true, allowFrom: { whatsapp: ["+1"] } } },
channels: { whatsapp: { groupPolicy: "open" } },
} satisfies OpenClawConfig,
assert: async () => {
const res = await audit(cases[0].cfg);
expect(
res.findings.some(
(finding) =>
finding.checkId === "security.exposure.open_groups_with_elevated" &&
finding.severity === "critical",
),
).toBe(true);
},
},
{
name: "flags open groupPolicy when runtime/filesystem tools are exposed without guards",
cfg: {
channels: { whatsapp: { groupPolicy: "open" } },
tools: { elevated: { enabled: false } },
} satisfies OpenClawConfig,
assert: async () => {
const res = await audit(cases[1].cfg);
expect(
res.findings.some(
(finding) =>
finding.checkId === "security.exposure.open_groups_with_runtime_or_fs" &&
finding.severity === "critical",
),
).toBe(true);
},
},
{
name: "does not flag runtime/filesystem exposure for open groups when sandbox mode is all",
cfg: {
channels: { whatsapp: { groupPolicy: "open" } },
tools: {
elevated: { enabled: false },
profile: "coding",
},
agents: {
defaults: {
sandbox: { mode: "all" },
},
},
} satisfies OpenClawConfig,
assert: async () => {
const res = await audit(cases[2].cfg);
expect(
res.findings.some(
(finding) => finding.checkId === "security.exposure.open_groups_with_runtime_or_fs",
),
).toBe(false);
},
},
{
name: "does not flag runtime/filesystem exposure for open groups when runtime is denied and fs is workspace-only",
cfg: {
channels: { whatsapp: { groupPolicy: "open" } },
tools: {
elevated: { enabled: false },
profile: "coding",
deny: ["group:runtime"],
fs: { workspaceOnly: true },
},
} satisfies OpenClawConfig,
assert: async () => {
const res = await audit(cases[3].cfg);
expect(
res.findings.some(
(finding) => finding.checkId === "security.exposure.open_groups_with_runtime_or_fs",
),
).toBe(false);
},
},
{
name: "warns when config heuristics suggest a likely multi-user setup",
cfg: {
channels: {
discord: {
groupPolicy: "allowlist",
guilds: {
"1234567890": {
channels: {
"7777777777": { enabled: true },
},
},
},
},
},
tools: { elevated: { enabled: false } },
} satisfies OpenClawConfig,
assert: async () => {
const res = await audit(cases[4].cfg);
const finding = res.findings.find(
(entry) => entry.checkId === "security.trust_model.multi_user_heuristic",
);
expect(finding?.severity).toBe("warn");
expect(finding?.detail).toContain(
'channels.discord.groupPolicy="allowlist" with configured group targets',
);
expect(finding?.detail).toContain("personal-assistant");
expect(finding?.remediation).toContain('agents.defaults.sandbox.mode="all"');
},
},
{
name: "does not warn for multi-user heuristic when no shared-user signals are configured",
cfg: {
channels: {
discord: {
groupPolicy: "allowlist",
},
},
tools: { elevated: { enabled: false } },
} satisfies OpenClawConfig,
assert: async () => {
const res = await audit(cases[5].cfg);
expect(
res.findings.some(
(finding) => finding.checkId === "security.trust_model.multi_user_heuristic",
),
).toBe(false);
},
},
] as const;
for (const testCase of cases) {
await testCase.assert();
}
});
});

View File

@@ -0,0 +1,76 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { collectWorkspaceSkillSymlinkEscapeFindings } from "./audit-extra.async.js";
const isWindows = process.platform === "win32";
describe("security audit workspace skill path escape findings", () => {
let fixtureRoot = "";
let caseId = 0;
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-workspace-"));
});
afterAll(async () => {
if (!fixtureRoot) {
return;
}
await fs.rm(fixtureRoot, { recursive: true, force: true }).catch(() => undefined);
});
const makeTmpDir = async (label: string) => {
const dir = path.join(fixtureRoot, `case-${caseId++}-${label}`);
await fs.mkdir(dir, { recursive: true });
return dir;
};
it("evaluates workspace skill path escape findings", async () => {
const runs = [
!isWindows
? (async () => {
const tmp = await makeTmpDir("workspace-skill-symlink-escape");
const workspaceDir = path.join(tmp, "workspace");
const outsideDir = path.join(tmp, "outside");
await fs.mkdir(path.join(workspaceDir, "skills", "leak"), { recursive: true });
await fs.mkdir(outsideDir, { recursive: true });
const outsideSkillPath = path.join(outsideDir, "SKILL.md");
await fs.writeFile(outsideSkillPath, "# outside\n", "utf-8");
await fs.symlink(
outsideSkillPath,
path.join(workspaceDir, "skills", "leak", "SKILL.md"),
);
const findings = await collectWorkspaceSkillSymlinkEscapeFindings({
cfg: { agents: { defaults: { workspace: workspaceDir } } } satisfies OpenClawConfig,
});
const finding = findings.find(
(entry) => entry.checkId === "skills.workspace.symlink_escape",
);
expect(finding?.severity).toBe("warn");
expect(finding?.detail).toContain(outsideSkillPath);
})()
: Promise.resolve(),
(async () => {
const tmp = await makeTmpDir("workspace-skill-in-root");
const workspaceDir = path.join(tmp, "workspace");
await fs.mkdir(path.join(workspaceDir, "skills", "safe"), { recursive: true });
await fs.writeFile(
path.join(workspaceDir, "skills", "safe", "SKILL.md"),
"# in workspace\n",
"utf-8",
);
const findings = await collectWorkspaceSkillSymlinkEscapeFindings({
cfg: { agents: { defaults: { workspace: workspaceDir } } } satisfies OpenClawConfig,
});
expect(findings.some((entry) => entry.checkId === "skills.workspace.symlink_escape")).toBe(
false,
);
})(),
];
await Promise.all(runs);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -212,7 +212,7 @@ function hasNonEmptyString(value: unknown): boolean {
return typeof value === "string" && value.trim().length > 0;
}
async function collectFilesystemFindings(params: {
export async function collectFilesystemFindings(params: {
stateDir: string;
configPath: string;
env?: NodeJS.ProcessEnv;
@@ -780,7 +780,7 @@ async function collectPluginSecurityAuditFindings(
return collectorResults.flat();
}
function collectLoggingFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
export function collectLoggingFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
const redact = cfg.logging?.redactSensitive;
if (redact !== "off") {
return [];
@@ -796,7 +796,7 @@ function collectLoggingFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
];
}
function collectElevatedFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
export function collectElevatedFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
const findings: SecurityAuditFinding[] = [];
const enabled = cfg.tools?.elevated?.enabled;
const allowFrom = cfg.tools?.elevated?.allowFrom ?? {};
@@ -831,7 +831,7 @@ function collectElevatedFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
return findings;
}
function collectExecRuntimeFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
export function collectExecRuntimeFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
const findings: SecurityAuditFinding[] = [];
const globalExecHost = cfg.tools?.exec?.host;
const globalStrictInlineEval = cfg.tools?.exec?.strictInlineEval === true;