test: optimize auth and audit test runtime
This commit is contained in:
@@ -175,53 +175,38 @@ describe("daemon-cli coverage", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "plain output", includeJsonFlag: false },
|
||||
{ label: "json output", includeJsonFlag: true },
|
||||
])("installs the daemon ($label)", async ({ includeJsonFlag }) => {
|
||||
it("installs the daemon (json output)", async () => {
|
||||
resetRuntimeCapture();
|
||||
serviceIsLoaded.mockResolvedValueOnce(false);
|
||||
serviceInstall.mockClear();
|
||||
|
||||
const args = includeJsonFlag
|
||||
? ["daemon", "install", "--port", "18789", "--json"]
|
||||
: ["daemon", "install", "--port", "18789"];
|
||||
await runDaemonCommand(args);
|
||||
await runDaemonCommand(["daemon", "install", "--port", "18789", "--json"]);
|
||||
|
||||
expect(serviceInstall).toHaveBeenCalledTimes(1);
|
||||
if (includeJsonFlag) {
|
||||
const parsed = parseFirstJsonRuntimeLine<{
|
||||
ok?: boolean;
|
||||
action?: string;
|
||||
result?: string;
|
||||
}>();
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.action).toBe("install");
|
||||
expect(parsed.result).toBe("installed");
|
||||
}
|
||||
const parsed = parseFirstJsonRuntimeLine<{
|
||||
ok?: boolean;
|
||||
action?: string;
|
||||
result?: string;
|
||||
}>();
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.action).toBe("install");
|
||||
expect(parsed.result).toBe("installed");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "plain output", includeJsonFlag: false },
|
||||
{ label: "json output", includeJsonFlag: true },
|
||||
])("starts and stops daemon ($label)", async ({ includeJsonFlag }) => {
|
||||
it("starts and stops daemon (json output)", async () => {
|
||||
resetRuntimeCapture();
|
||||
serviceRestart.mockClear();
|
||||
serviceStop.mockClear();
|
||||
serviceIsLoaded.mockResolvedValue(true);
|
||||
|
||||
const startArgs = includeJsonFlag ? ["daemon", "start", "--json"] : ["daemon", "start"];
|
||||
const stopArgs = includeJsonFlag ? ["daemon", "stop", "--json"] : ["daemon", "stop"];
|
||||
await runDaemonCommand(startArgs);
|
||||
await runDaemonCommand(stopArgs);
|
||||
await runDaemonCommand(["daemon", "start", "--json"]);
|
||||
await runDaemonCommand(["daemon", "stop", "--json"]);
|
||||
|
||||
expect(serviceRestart).toHaveBeenCalledTimes(1);
|
||||
expect(serviceStop).toHaveBeenCalledTimes(1);
|
||||
if (includeJsonFlag) {
|
||||
const jsonLines = runtimeLogs.filter((line) => line.trim().startsWith("{"));
|
||||
const parsed = jsonLines.map((line) => JSON.parse(line) as { action?: string; ok?: boolean });
|
||||
expect(parsed.some((entry) => entry.action === "start" && entry.ok === true)).toBe(true);
|
||||
expect(parsed.some((entry) => entry.action === "stop" && entry.ok === true)).toBe(true);
|
||||
}
|
||||
const jsonLines = runtimeLogs.filter((line) => line.trim().startsWith("{"));
|
||||
const parsed = jsonLines.map((line) => JSON.parse(line) as { action?: string; ok?: boolean });
|
||||
expect(parsed.some((entry) => entry.action === "start" && entry.ok === true)).toBe(true);
|
||||
expect(parsed.some((entry) => entry.action === "stop" && entry.ok === true)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,7 +112,7 @@ describe("gateway-cli coverage", () => {
|
||||
|
||||
expect(callGateway).toHaveBeenCalledTimes(1);
|
||||
expect(runtimeLogs.join("\n")).toContain('"ok": true');
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
it("registers gateway probe and routes to gatewayStatusCommand", async () => {
|
||||
resetRuntimeCapture();
|
||||
@@ -121,27 +121,9 @@ describe("gateway-cli coverage", () => {
|
||||
await runGatewayCommand(["gateway", "probe", "--json"]);
|
||||
|
||||
expect(gatewayStatusCommand).toHaveBeenCalledTimes(1);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "json output",
|
||||
args: ["gateway", "discover", "--json"],
|
||||
expectedOutput: ['"beacons"', '"wsUrl"', "ws://"],
|
||||
},
|
||||
{
|
||||
label: "human output",
|
||||
args: ["gateway", "discover", "--timeout", "1"],
|
||||
expectedOutput: [
|
||||
"Gateway Discovery",
|
||||
"Found 1 gateway(s)",
|
||||
"- Studio openclaw.internal.",
|
||||
" tailnet: studio.tailnet.ts.net",
|
||||
" host: studio.openclaw.internal",
|
||||
" ws: ws://studio.openclaw.internal:18789",
|
||||
],
|
||||
},
|
||||
])("registers gateway discover and prints $label", async ({ args, expectedOutput }) => {
|
||||
it("registers gateway discover and prints json output", async () => {
|
||||
resetRuntimeCapture();
|
||||
discoverGatewayBeacons.mockClear();
|
||||
discoverGatewayBeacons.mockResolvedValueOnce([
|
||||
@@ -157,11 +139,11 @@ describe("gateway-cli coverage", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
await runGatewayCommand(args);
|
||||
await runGatewayCommand(["gateway", "discover", "--json"]);
|
||||
|
||||
expect(discoverGatewayBeacons).toHaveBeenCalledTimes(1);
|
||||
const out = runtimeLogs.join("\n");
|
||||
for (const text of expectedOutput) {
|
||||
for (const text of ['"beacons"', '"wsUrl"', "ws://"]) {
|
||||
expect(out).toContain(text);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -296,14 +296,14 @@ describe("gateway server auth/connect", () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("closes silent handshakes after timeout", { timeout: 60_000 }, async () => {
|
||||
test("closes silent handshakes after timeout", async () => {
|
||||
vi.useRealTimers();
|
||||
const prevHandshakeTimeout = process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS;
|
||||
process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS = "50";
|
||||
process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS = "20";
|
||||
try {
|
||||
const ws = await openWs(port);
|
||||
const handshakeTimeoutMs = getHandshakeTimeoutMs();
|
||||
const closed = await waitForWsClose(ws, handshakeTimeoutMs + 250);
|
||||
const closed = await waitForWsClose(ws, handshakeTimeoutMs + 120);
|
||||
expect(closed).toBe(true);
|
||||
} finally {
|
||||
if (prevHandshakeTimeout === undefined) {
|
||||
@@ -567,54 +567,50 @@ describe("gateway server auth/connect", () => {
|
||||
await new Promise<void>((resolve) => ws.once("close", () => resolve()));
|
||||
});
|
||||
|
||||
test(
|
||||
"invalid connect params surface in response and close reason",
|
||||
{ timeout: 60_000 },
|
||||
async () => {
|
||||
const ws = await openWs(port);
|
||||
const closeInfoPromise = new Promise<{ code: number; reason: string }>((resolve) => {
|
||||
ws.once("close", (code, reason) => resolve({ code, reason: reason.toString() }));
|
||||
});
|
||||
test("invalid connect params surface in response and close reason", async () => {
|
||||
const ws = await openWs(port);
|
||||
const closeInfoPromise = new Promise<{ code: number; reason: string }>((resolve) => {
|
||||
ws.once("close", (code, reason) => resolve({ code, reason: reason.toString() }));
|
||||
});
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "h-bad",
|
||||
method: "connect",
|
||||
params: {
|
||||
minProtocol: PROTOCOL_VERSION,
|
||||
maxProtocol: PROTOCOL_VERSION,
|
||||
client: {
|
||||
id: "bad-client",
|
||||
version: "dev",
|
||||
platform: "web",
|
||||
mode: "webchat",
|
||||
},
|
||||
device: {
|
||||
id: 123,
|
||||
publicKey: "bad",
|
||||
signature: "bad",
|
||||
signedAt: "bad",
|
||||
},
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "h-bad",
|
||||
method: "connect",
|
||||
params: {
|
||||
minProtocol: PROTOCOL_VERSION,
|
||||
maxProtocol: PROTOCOL_VERSION,
|
||||
client: {
|
||||
id: "bad-client",
|
||||
version: "dev",
|
||||
platform: "web",
|
||||
mode: "webchat",
|
||||
},
|
||||
}),
|
||||
);
|
||||
device: {
|
||||
id: 123,
|
||||
publicKey: "bad",
|
||||
signature: "bad",
|
||||
signedAt: "bad",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await onceMessage<{
|
||||
ok: boolean;
|
||||
error?: { message?: string };
|
||||
}>(
|
||||
ws,
|
||||
(o) => (o as { type?: string }).type === "res" && (o as { id?: string }).id === "h-bad",
|
||||
);
|
||||
expect(res.ok).toBe(false);
|
||||
expect(String(res.error?.message ?? "")).toContain("invalid connect params");
|
||||
const res = await onceMessage<{
|
||||
ok: boolean;
|
||||
error?: { message?: string };
|
||||
}>(
|
||||
ws,
|
||||
(o) => (o as { type?: string }).type === "res" && (o as { id?: string }).id === "h-bad",
|
||||
);
|
||||
expect(res.ok).toBe(false);
|
||||
expect(String(res.error?.message ?? "")).toContain("invalid connect params");
|
||||
|
||||
const closeInfo = await closeInfoPromise;
|
||||
expect(closeInfo.code).toBe(1008);
|
||||
expect(closeInfo.reason).toContain("invalid connect params");
|
||||
},
|
||||
);
|
||||
const closeInfo = await closeInfoPromise;
|
||||
expect(closeInfo.code).toBe(1008);
|
||||
expect(closeInfo.reason).toContain("invalid connect params");
|
||||
});
|
||||
});
|
||||
|
||||
describe("password auth", () => {
|
||||
@@ -932,97 +928,85 @@ describe("gateway server auth/connect", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts device token auth for paired device", async () => {
|
||||
test("device token auth matrix", async () => {
|
||||
const { server, ws, port, prevToken } = await startServerWithClient("secret");
|
||||
const { deviceToken } = await ensurePairedDeviceTokenForCurrentIdentity(ws);
|
||||
|
||||
ws.close();
|
||||
|
||||
const ws2 = await openWs(port);
|
||||
const res2 = await connectReq(ws2, { token: deviceToken });
|
||||
expect(res2.ok).toBe(true);
|
||||
const scenarios: Array<{
|
||||
name: string;
|
||||
opts: Parameters<typeof connectReq>[1];
|
||||
assert: (res: Awaited<ReturnType<typeof connectReq>>) => void;
|
||||
}> = [
|
||||
{
|
||||
name: "accepts device token auth for paired device",
|
||||
opts: { token: deviceToken },
|
||||
assert: (res) => {
|
||||
expect(res.ok).toBe(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accepts explicit auth.deviceToken when shared token is omitted",
|
||||
opts: {
|
||||
skipDefaultAuth: true,
|
||||
deviceToken,
|
||||
},
|
||||
assert: (res) => {
|
||||
expect(res.ok).toBe(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "uses explicit auth.deviceToken fallback when shared token is wrong",
|
||||
opts: {
|
||||
token: "wrong",
|
||||
deviceToken,
|
||||
},
|
||||
assert: (res) => {
|
||||
expect(res.ok).toBe(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keeps shared token mismatch reason when fallback device-token check fails",
|
||||
opts: { token: "wrong" },
|
||||
assert: (res) => {
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message ?? "").toContain("gateway token mismatch");
|
||||
expect(res.error?.message ?? "").not.toContain("device token mismatch");
|
||||
expect((res.error?.details as { code?: string } | undefined)?.code).toBe(
|
||||
ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH,
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reports device token mismatch when explicit auth.deviceToken is wrong",
|
||||
opts: {
|
||||
skipDefaultAuth: true,
|
||||
deviceToken: "not-a-valid-device-token",
|
||||
},
|
||||
assert: (res) => {
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message ?? "").toContain("device token mismatch");
|
||||
expect((res.error?.details as { code?: string } | undefined)?.code).toBe(
|
||||
ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH,
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
ws2.close();
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
});
|
||||
|
||||
test("accepts explicit auth.deviceToken when shared token is omitted", async () => {
|
||||
const { server, ws, port, prevToken } = await startServerWithClient("secret");
|
||||
const { deviceToken } = await ensurePairedDeviceTokenForCurrentIdentity(ws);
|
||||
|
||||
ws.close();
|
||||
|
||||
const ws2 = await openWs(port);
|
||||
const res2 = await connectReq(ws2, {
|
||||
skipDefaultAuth: true,
|
||||
deviceToken,
|
||||
});
|
||||
expect(res2.ok).toBe(true);
|
||||
|
||||
ws2.close();
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
});
|
||||
|
||||
test("uses explicit auth.deviceToken fallback when shared token is wrong", async () => {
|
||||
const { server, ws, port, prevToken } = await startServerWithClient("secret");
|
||||
const { deviceToken } = await ensurePairedDeviceTokenForCurrentIdentity(ws);
|
||||
|
||||
ws.close();
|
||||
|
||||
const ws2 = await openWs(port);
|
||||
const res2 = await connectReq(ws2, {
|
||||
token: "wrong",
|
||||
deviceToken,
|
||||
});
|
||||
expect(res2.ok).toBe(true);
|
||||
|
||||
ws2.close();
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
});
|
||||
|
||||
test("keeps shared token mismatch reason when token fallback device-token check fails", async () => {
|
||||
const { server, ws, port, prevToken } = await startServerWithClient("secret");
|
||||
await ensurePairedDeviceTokenForCurrentIdentity(ws);
|
||||
|
||||
ws.close();
|
||||
|
||||
const ws2 = await openWs(port);
|
||||
const res2 = await connectReq(ws2, { token: "wrong" });
|
||||
expect(res2.ok).toBe(false);
|
||||
expect(res2.error?.message ?? "").toContain("gateway token mismatch");
|
||||
expect(res2.error?.message ?? "").not.toContain("device token mismatch");
|
||||
expect((res2.error?.details as { code?: string } | undefined)?.code).toBe(
|
||||
ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH,
|
||||
);
|
||||
|
||||
ws2.close();
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
});
|
||||
|
||||
test("reports device token mismatch when explicit auth.deviceToken is wrong", async () => {
|
||||
const { server, ws, port, prevToken } = await startServerWithClient("secret");
|
||||
await ensurePairedDeviceTokenForCurrentIdentity(ws);
|
||||
|
||||
ws.close();
|
||||
|
||||
const ws2 = await openWs(port);
|
||||
const res2 = await connectReq(ws2, {
|
||||
skipDefaultAuth: true,
|
||||
deviceToken: "not-a-valid-device-token",
|
||||
});
|
||||
expect(res2.ok).toBe(false);
|
||||
expect(res2.error?.message ?? "").toContain("device token mismatch");
|
||||
expect((res2.error?.details as { code?: string } | undefined)?.code).toBe(
|
||||
ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH,
|
||||
);
|
||||
|
||||
ws2.close();
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
try {
|
||||
for (const scenario of scenarios) {
|
||||
const ws2 = await openWs(port);
|
||||
try {
|
||||
const res = await connectReq(ws2, scenario.opts);
|
||||
scenario.assert(res);
|
||||
} finally {
|
||||
ws2.close();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps shared-secret lockout separate from device-token auth", async () => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { captureEnv, withEnvAsync } from "../test-utils/env.js";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
import { collectPluginsCodeSafetyFindings } from "./audit-extra.js";
|
||||
import type { SecurityAuditOptions, SecurityAuditReport } from "./audit.js";
|
||||
import { runSecurityAudit } from "./audit.js";
|
||||
@@ -207,12 +207,14 @@ describe("security audit", () => {
|
||||
expectWarn: false,
|
||||
},
|
||||
];
|
||||
for (const testCase of cases) {
|
||||
const res = await audit(testCase.cfg, { env: {} });
|
||||
expect(hasFinding(res, "gateway.auth_no_rate_limit", "warn"), testCase.name).toBe(
|
||||
testCase.expectWarn,
|
||||
);
|
||||
}
|
||||
await Promise.all(
|
||||
cases.map(async (testCase) => {
|
||||
const res = await audit(testCase.cfg, { env: {} });
|
||||
expect(hasFinding(res, "gateway.auth_no_rate_limit", "warn"), testCase.name).toBe(
|
||||
testCase.expectWarn,
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("scores dangerous gateway.tools.allow over HTTP by exposure", async () => {
|
||||
@@ -244,13 +246,15 @@ describe("security audit", () => {
|
||||
expectedSeverity: "critical",
|
||||
},
|
||||
];
|
||||
for (const testCase of cases) {
|
||||
const res = await audit(testCase.cfg, { env: {} });
|
||||
expect(
|
||||
hasFinding(res, "gateway.tools_invoke_http.dangerous_allow", testCase.expectedSeverity),
|
||||
testCase.name,
|
||||
).toBe(true);
|
||||
}
|
||||
await Promise.all(
|
||||
cases.map(async (testCase) => {
|
||||
const res = await audit(testCase.cfg, { env: {} });
|
||||
expect(
|
||||
hasFinding(res, "gateway.tools_invoke_http.dangerous_allow", testCase.expectedSeverity),
|
||||
testCase.name,
|
||||
).toBe(true);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("warns when sandbox exec host is selected while sandbox mode is off", async () => {
|
||||
@@ -308,10 +312,12 @@ describe("security audit", () => {
|
||||
checkId: "tools.exec.host_sandbox_no_sandbox_agents",
|
||||
},
|
||||
];
|
||||
for (const testCase of cases) {
|
||||
const res = await audit(testCase.cfg);
|
||||
expect(hasFinding(res, testCase.checkId, "warn"), testCase.name).toBe(true);
|
||||
}
|
||||
await Promise.all(
|
||||
cases.map(async (testCase) => {
|
||||
const res = await audit(testCase.cfg);
|
||||
expect(hasFinding(res, testCase.checkId, "warn"), testCase.name).toBe(true);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("warns for interpreter safeBins only when explicit profiles are missing", async () => {
|
||||
@@ -377,13 +383,15 @@ describe("security audit", () => {
|
||||
expected: false,
|
||||
},
|
||||
];
|
||||
for (const testCase of cases) {
|
||||
const res = await audit(testCase.cfg);
|
||||
expect(
|
||||
hasFinding(res, "tools.exec.safe_bins_interpreter_unprofiled", "warn"),
|
||||
testCase.name,
|
||||
).toBe(testCase.expected);
|
||||
}
|
||||
await Promise.all(
|
||||
cases.map(async (testCase) => {
|
||||
const res = await audit(testCase.cfg);
|
||||
expect(
|
||||
hasFinding(res, "tools.exec.safe_bins_interpreter_unprofiled", "warn"),
|
||||
testCase.name,
|
||||
).toBe(testCase.expected);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("evaluates loopback control UI and logging exposure findings", async () => {
|
||||
@@ -430,10 +438,12 @@ describe("security audit", () => {
|
||||
severity: "warn",
|
||||
},
|
||||
];
|
||||
for (const testCase of cases) {
|
||||
const res = await audit(testCase.cfg, testCase.opts);
|
||||
expect(hasFinding(res, testCase.checkId, testCase.severity), testCase.name).toBe(true);
|
||||
}
|
||||
await Promise.all(
|
||||
cases.map(async (testCase) => {
|
||||
const res = await audit(testCase.cfg, testCase.opts);
|
||||
expect(hasFinding(res, testCase.checkId, testCase.severity), testCase.name).toBe(true);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("treats Windows ACL-only perms as secure", async () => {
|
||||
@@ -705,14 +715,16 @@ describe("security audit", () => {
|
||||
detailIncludes: ["mistral-8b", "sandbox=all"],
|
||||
},
|
||||
];
|
||||
for (const testCase of cases) {
|
||||
const res = await audit(testCase.cfg);
|
||||
const finding = res.findings.find((f) => f.checkId === "models.small_params");
|
||||
expect(finding?.severity, testCase.name).toBe(testCase.expectedSeverity);
|
||||
for (const text of testCase.detailIncludes) {
|
||||
expect(finding?.detail, `${testCase.name}:${text}`).toContain(text);
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
cases.map(async (testCase) => {
|
||||
const res = await audit(testCase.cfg);
|
||||
const finding = res.findings.find((f) => f.checkId === "models.small_params");
|
||||
expect(finding?.severity, testCase.name).toBe(testCase.expectedSeverity);
|
||||
for (const text of testCase.detailIncludes) {
|
||||
expect(finding?.detail, `${testCase.name}:${text}`).toContain(text);
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("checks sandbox docker mode-off findings with/without agent override", async () => {
|
||||
@@ -751,12 +763,14 @@ describe("security audit", () => {
|
||||
expectedPresent: false,
|
||||
},
|
||||
];
|
||||
for (const testCase of cases) {
|
||||
const res = await audit(testCase.cfg);
|
||||
expect(hasFinding(res, "sandbox.docker_config_mode_off"), testCase.name).toBe(
|
||||
testCase.expectedPresent,
|
||||
);
|
||||
}
|
||||
await Promise.all(
|
||||
cases.map(async (testCase) => {
|
||||
const res = await audit(testCase.cfg);
|
||||
expect(hasFinding(res, "sandbox.docker_config_mode_off"), testCase.name).toBe(
|
||||
testCase.expectedPresent,
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("flags dangerous sandbox docker config (binds/network/seccomp/apparmor)", async () => {
|
||||
@@ -836,19 +850,21 @@ describe("security audit", () => {
|
||||
expectedPresent: false,
|
||||
},
|
||||
];
|
||||
for (const testCase of cases) {
|
||||
const res = await audit(testCase.cfg);
|
||||
const finding = res.findings.find(
|
||||
(f) => f.checkId === "sandbox.browser_cdp_bridge_unrestricted",
|
||||
);
|
||||
expect(Boolean(finding), testCase.name).toBe(testCase.expectedPresent);
|
||||
if (testCase.expectedPresent) {
|
||||
expect(finding?.severity, testCase.name).toBe(testCase.expectedSeverity);
|
||||
if (testCase.detailIncludes) {
|
||||
expect(finding?.detail, testCase.name).toContain(testCase.detailIncludes);
|
||||
await Promise.all(
|
||||
cases.map(async (testCase) => {
|
||||
const res = await audit(testCase.cfg);
|
||||
const finding = res.findings.find(
|
||||
(f) => f.checkId === "sandbox.browser_cdp_bridge_unrestricted",
|
||||
);
|
||||
expect(Boolean(finding), testCase.name).toBe(testCase.expectedPresent);
|
||||
if (testCase.expectedPresent) {
|
||||
expect(finding?.severity, testCase.name).toBe(testCase.expectedSeverity);
|
||||
if (testCase.detailIncludes) {
|
||||
expect(finding?.detail, testCase.name).toContain(testCase.detailIncludes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("flags ineffective gateway.nodes.denyCommands entries", async () => {
|
||||
@@ -898,15 +914,17 @@ describe("security audit", () => {
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const res = await audit(testCase.cfg);
|
||||
const finding = res.findings.find(
|
||||
(f) => f.checkId === "gateway.nodes.allow_commands_dangerous",
|
||||
);
|
||||
expect(finding?.severity, testCase.name).toBe(testCase.expectedSeverity);
|
||||
expect(finding?.detail, testCase.name).toContain("camera.snap");
|
||||
expect(finding?.detail, testCase.name).toContain("screen.record");
|
||||
}
|
||||
await Promise.all(
|
||||
cases.map(async (testCase) => {
|
||||
const res = await audit(testCase.cfg);
|
||||
const finding = res.findings.find(
|
||||
(f) => f.checkId === "gateway.nodes.allow_commands_dangerous",
|
||||
);
|
||||
expect(finding?.severity, testCase.name).toBe(testCase.expectedSeverity);
|
||||
expect(finding?.detail, testCase.name).toContain("camera.snap");
|
||||
expect(finding?.detail, testCase.name).toContain("screen.record");
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not flag dangerous allowCommands entries when denied again", async () => {
|
||||
@@ -1148,13 +1166,15 @@ describe("security audit", () => {
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const res = await audit(testCase.cfg);
|
||||
expect(
|
||||
hasFinding(res, "gateway.real_ip_fallback_enabled", testCase.expectedSeverity),
|
||||
testCase.name,
|
||||
).toBe(true);
|
||||
}
|
||||
await Promise.all(
|
||||
cases.map(async (testCase) => {
|
||||
const res = await audit(testCase.cfg);
|
||||
expect(
|
||||
hasFinding(res, "gateway.real_ip_fallback_enabled", testCase.expectedSeverity),
|
||||
testCase.name,
|
||||
).toBe(true);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("scores mDNS full mode risk by gateway bind mode", async () => {
|
||||
@@ -1197,13 +1217,15 @@ describe("security audit", () => {
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const res = await audit(testCase.cfg);
|
||||
expect(
|
||||
hasFinding(res, "discovery.mdns_full_mode", testCase.expectedSeverity),
|
||||
testCase.name,
|
||||
).toBe(true);
|
||||
}
|
||||
await Promise.all(
|
||||
cases.map(async (testCase) => {
|
||||
const res = await audit(testCase.cfg);
|
||||
expect(
|
||||
hasFinding(res, "discovery.mdns_full_mode", testCase.expectedSeverity),
|
||||
testCase.name,
|
||||
).toBe(true);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("evaluates trusted-proxy auth guardrails", async () => {
|
||||
@@ -1280,17 +1302,19 @@ describe("security audit", () => {
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const res = await audit(testCase.cfg);
|
||||
expect(
|
||||
hasFinding(res, testCase.expectedCheckId, testCase.expectedSeverity),
|
||||
testCase.name,
|
||||
).toBe(true);
|
||||
if (testCase.suppressesGenericSharedSecretFindings) {
|
||||
expect(hasFinding(res, "gateway.bind_no_auth"), testCase.name).toBe(false);
|
||||
expect(hasFinding(res, "gateway.auth_no_rate_limit"), testCase.name).toBe(false);
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
cases.map(async (testCase) => {
|
||||
const res = await audit(testCase.cfg);
|
||||
expect(
|
||||
hasFinding(res, testCase.expectedCheckId, testCase.expectedSeverity),
|
||||
testCase.name,
|
||||
).toBe(true);
|
||||
if (testCase.suppressesGenericSharedSecretFindings) {
|
||||
expect(hasFinding(res, "gateway.bind_no_auth"), testCase.name).toBe(false);
|
||||
expect(hasFinding(res, "gateway.auth_no_rate_limit"), testCase.name).toBe(false);
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("warns when multiple DM senders share the main session", async () => {
|
||||
@@ -1707,15 +1731,17 @@ describe("security audit", () => {
|
||||
},
|
||||
},
|
||||
];
|
||||
for (const testCase of cases) {
|
||||
const res = await audit(cfg, {
|
||||
deep: true,
|
||||
deepTimeoutMs: 50,
|
||||
probeGatewayFn: testCase.probeGatewayFn,
|
||||
});
|
||||
testCase.assertDeep?.(res);
|
||||
expect(hasFinding(res, "gateway.probe_failed", "warn"), testCase.name).toBe(true);
|
||||
}
|
||||
await Promise.all(
|
||||
cases.map(async (testCase) => {
|
||||
const res = await audit(cfg, {
|
||||
deep: true,
|
||||
deepTimeoutMs: 50,
|
||||
probeGatewayFn: testCase.probeGatewayFn,
|
||||
});
|
||||
testCase.assertDeep?.(res);
|
||||
expect(hasFinding(res, "gateway.probe_failed", "warn"), testCase.name).toBe(true);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("classifies legacy and weak-tier model identifiers", async () => {
|
||||
@@ -1742,17 +1768,19 @@ describe("security audit", () => {
|
||||
expectedAbsentCheckId: "models.weak_tier",
|
||||
},
|
||||
];
|
||||
for (const testCase of cases) {
|
||||
const res = await audit({
|
||||
agents: { defaults: { model: { primary: testCase.model } } },
|
||||
});
|
||||
for (const expected of testCase.expectedFindings ?? []) {
|
||||
expect(hasFinding(res, expected.checkId, expected.severity), testCase.name).toBe(true);
|
||||
}
|
||||
if (testCase.expectedAbsentCheckId) {
|
||||
expect(hasFinding(res, testCase.expectedAbsentCheckId), testCase.name).toBe(false);
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
cases.map(async (testCase) => {
|
||||
const res = await audit({
|
||||
agents: { defaults: { model: { primary: testCase.model } } },
|
||||
});
|
||||
for (const expected of testCase.expectedFindings ?? []) {
|
||||
expect(hasFinding(res, expected.checkId, expected.severity), testCase.name).toBe(true);
|
||||
}
|
||||
if (testCase.expectedAbsentCheckId) {
|
||||
expect(hasFinding(res, testCase.expectedAbsentCheckId), testCase.name).toBe(false);
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("warns when hooks token looks short", async () => {
|
||||
@@ -1819,16 +1847,18 @@ describe("security audit", () => {
|
||||
expectedSeverity: "critical",
|
||||
},
|
||||
];
|
||||
for (const testCase of cases) {
|
||||
const res = await audit(testCase.cfg);
|
||||
expect(
|
||||
hasFinding(res, "hooks.request_session_key_enabled", testCase.expectedSeverity),
|
||||
testCase.name,
|
||||
).toBe(true);
|
||||
if (testCase.expectsPrefixesMissing) {
|
||||
expect(hasFinding(res, "hooks.request_session_key_prefixes_missing", "warn")).toBe(true);
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
cases.map(async (testCase) => {
|
||||
const res = await audit(testCase.cfg);
|
||||
expect(
|
||||
hasFinding(res, "hooks.request_session_key_enabled", testCase.expectedSeverity),
|
||||
testCase.name,
|
||||
).toBe(true);
|
||||
if (testCase.expectsPrefixesMissing) {
|
||||
expect(hasFinding(res, "hooks.request_session_key_prefixes_missing", "warn")).toBe(true);
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("scores gateway HTTP no-auth findings by exposure", async () => {
|
||||
@@ -1863,16 +1893,18 @@ describe("security audit", () => {
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const res = await audit(testCase.cfg, { env: {} });
|
||||
expectFinding(res, "gateway.http.no_auth", testCase.expectedSeverity);
|
||||
if (testCase.detailIncludes) {
|
||||
const finding = res.findings.find((entry) => entry.checkId === "gateway.http.no_auth");
|
||||
for (const text of testCase.detailIncludes) {
|
||||
expect(finding?.detail, `${testCase.name}:${text}`).toContain(text);
|
||||
await Promise.all(
|
||||
cases.map(async (testCase) => {
|
||||
const res = await audit(testCase.cfg, { env: {} });
|
||||
expectFinding(res, "gateway.http.no_auth", testCase.expectedSeverity);
|
||||
if (testCase.detailIncludes) {
|
||||
const finding = res.findings.find((entry) => entry.checkId === "gateway.http.no_auth");
|
||||
for (const text of testCase.detailIncludes) {
|
||||
expect(finding?.detail, `${testCase.name}:${text}`).toContain(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not report gateway.http.no_auth when auth mode is token", async () => {
|
||||
@@ -2481,18 +2513,6 @@ description: test skill
|
||||
});
|
||||
|
||||
describe("maybeProbeGateway auth selection", () => {
|
||||
let envSnapshot: ReturnType<typeof captureEnv>;
|
||||
|
||||
beforeEach(() => {
|
||||
envSnapshot = captureEnv(["OPENCLAW_GATEWAY_TOKEN", "OPENCLAW_GATEWAY_PASSWORD"]);
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
envSnapshot.restore();
|
||||
});
|
||||
|
||||
const makeProbeCapture = () => {
|
||||
let capturedAuth: { token?: string; password?: string } | undefined;
|
||||
return {
|
||||
@@ -2507,15 +2527,15 @@ description: test skill
|
||||
};
|
||||
};
|
||||
|
||||
const setProbeEnv = (env?: { token?: string; password?: string }) => {
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
|
||||
const makeProbeEnv = (env?: { token?: string; password?: string }) => {
|
||||
const probeEnv: NodeJS.ProcessEnv = {};
|
||||
if (env?.token !== undefined) {
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = env.token;
|
||||
probeEnv.OPENCLAW_GATEWAY_TOKEN = env.token;
|
||||
}
|
||||
if (env?.password !== undefined) {
|
||||
process.env.OPENCLAW_GATEWAY_PASSWORD = env.password;
|
||||
probeEnv.OPENCLAW_GATEWAY_PASSWORD = env.password;
|
||||
}
|
||||
return probeEnv;
|
||||
};
|
||||
|
||||
it("applies token precedence across local/remote gateway modes", async () => {
|
||||
@@ -2577,12 +2597,18 @@ description: test skill
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
setProbeEnv(testCase.env);
|
||||
const { probeGatewayFn, getAuth } = makeProbeCapture();
|
||||
await audit(testCase.cfg, { deep: true, deepTimeoutMs: 50, probeGatewayFn });
|
||||
expect(getAuth()?.token, testCase.name).toBe(testCase.expectedToken);
|
||||
}
|
||||
await Promise.all(
|
||||
cases.map(async (testCase) => {
|
||||
const { probeGatewayFn, getAuth } = makeProbeCapture();
|
||||
await audit(testCase.cfg, {
|
||||
deep: true,
|
||||
deepTimeoutMs: 50,
|
||||
probeGatewayFn,
|
||||
env: makeProbeEnv(testCase.env),
|
||||
});
|
||||
expect(getAuth()?.token, testCase.name).toBe(testCase.expectedToken);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("applies password precedence for remote gateways", async () => {
|
||||
@@ -2615,12 +2641,18 @@ description: test skill
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
setProbeEnv(testCase.env);
|
||||
const { probeGatewayFn, getAuth } = makeProbeCapture();
|
||||
await audit(testCase.cfg, { deep: true, deepTimeoutMs: 50, probeGatewayFn });
|
||||
expect(getAuth()?.password, testCase.name).toBe(testCase.expectedPassword);
|
||||
}
|
||||
await Promise.all(
|
||||
cases.map(async (testCase) => {
|
||||
const { probeGatewayFn, getAuth } = makeProbeCapture();
|
||||
await audit(testCase.cfg, {
|
||||
deep: true,
|
||||
deepTimeoutMs: 50,
|
||||
probeGatewayFn,
|
||||
env: makeProbeEnv(testCase.env),
|
||||
});
|
||||
expect(getAuth()?.password, testCase.name).toBe(testCase.expectedPassword);
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -763,6 +763,7 @@ function collectExecRuntimeFindings(cfg: OpenClawConfig): SecurityAuditFinding[]
|
||||
|
||||
async function maybeProbeGateway(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
timeoutMs: number;
|
||||
probe: typeof probeGateway;
|
||||
}): Promise<SecurityAuditReport["deep"]> {
|
||||
@@ -775,8 +776,8 @@ async function maybeProbeGateway(params: {
|
||||
|
||||
const auth =
|
||||
!isRemoteMode || remoteUrlMissing
|
||||
? resolveGatewayProbeAuth({ cfg: params.cfg, mode: "local" })
|
||||
: resolveGatewayProbeAuth({ cfg: params.cfg, mode: "remote" });
|
||||
? resolveGatewayProbeAuth({ cfg: params.cfg, env: params.env, mode: "local" })
|
||||
: resolveGatewayProbeAuth({ cfg: params.cfg, env: params.env, mode: "remote" });
|
||||
const res = await params.probe({ url, auth, timeoutMs: params.timeoutMs }).catch((err) => ({
|
||||
ok: false,
|
||||
url,
|
||||
@@ -874,6 +875,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
|
||||
opts.deep === true
|
||||
? await maybeProbeGateway({
|
||||
cfg,
|
||||
env,
|
||||
timeoutMs: Math.max(250, opts.deepTimeoutMs ?? 5000),
|
||||
probe: opts.probeGatewayFn ?? probeGateway,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user