mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-17 20:21:13 +00:00
perf(test): split security audit coverage
This commit is contained in:
46
src/security/audit-config-basics.test.ts
Normal file
46
src/security/audit-config-basics.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
62
src/security/audit-config-symlink.test.ts
Normal file
62
src/security/audit-config-symlink.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
162
src/security/audit-exec-safe-bins.test.ts
Normal file
162
src/security/audit-exec-safe-bins.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
65
src/security/audit-exec-sandbox-host.test.ts
Normal file
65
src/security/audit-exec-sandbox-host.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
125
src/security/audit-exec-surface.test.ts
Normal file
125
src/security/audit-exec-surface.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
185
src/security/audit-extension-tool-reachability.test.ts
Normal file
185
src/security/audit-extension-tool-reachability.test.ts
Normal 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));
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
61
src/security/audit-feishu-doc-risk.test.ts
Normal file
61
src/security/audit-feishu-doc-risk.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
100
src/security/audit-filesystem-windows.test.ts
Normal file
100
src/security/audit-filesystem-windows.test.ts
Normal 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);
|
||||
})(),
|
||||
]);
|
||||
});
|
||||
});
|
||||
415
src/security/audit-gateway-exposure.test.ts
Normal file
415
src/security/audit-gateway-exposure.test.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
88
src/security/audit-gateway-http-auth.test.ts
Normal file
88
src/security/audit-gateway-http-auth.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
59
src/security/audit-gateway-tools-http.test.ts
Normal file
59
src/security/audit-gateway-tools-http.test.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
117
src/security/audit-hooks-routing.test.ts
Normal file
117
src/security/audit-hooks-routing.test.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
188
src/security/audit-install-metadata.test.ts
Normal file
188
src/security/audit-install-metadata.test.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
72
src/security/audit-loopback-logging.test.ts
Normal file
72
src/security/audit-loopback-logging.test.ts
Normal 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,
|
||||
);
|
||||
})(),
|
||||
]);
|
||||
});
|
||||
});
|
||||
55
src/security/audit-model-hygiene.test.ts
Normal file
55
src/security/audit-model-hygiene.test.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
129
src/security/audit-node-command-findings.test.ts
Normal file
129
src/security/audit-node-command-findings.test.ts
Normal 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"],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
144
src/security/audit-sandbox-browser.test.ts
Normal file
144
src/security/audit-sandbox-browser.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
48
src/security/audit-small-model-risk.test.ts
Normal file
48
src/security/audit-small-model-risk.test.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
17
src/security/audit-synced-folder.test.ts
Normal file
17
src/security/audit-synced-folder.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
157
src/security/audit-trust-model.test.ts
Normal file
157
src/security/audit-trust-model.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
76
src/security/audit-workspace-skill-escape.test.ts
Normal file
76
src/security/audit-workspace-skill-escape.test.ts
Normal 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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user