mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-28 03:00:34 +00:00
fix(status): bound deep docker audit probes (#85476)
* fix(status): bound deep docker audit probes * chore(status): defer changelog entry to landing * docs(changelog): note status docker probe timeout * fix(status): surface Docker probe timeouts --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -54,6 +54,7 @@ Docs: https://docs.openclaw.ai
|
||||
### Fixes
|
||||
|
||||
- CLI/tasks: reject partially numeric `openclaw tasks audit --limit` values so audit limits must be real positive integers instead of accepting strings like `5abc`. (#84901) Thanks @jbetala7.
|
||||
- Status/diagnostics: bound deep Docker audit probes so `openclaw status --deep` reports slow container checks instead of hanging behind unbounded inspection. (#85476) Thanks @giodl73-repo.
|
||||
- Twitch: keep stale message-handler cleanup callbacks from removing newer handler registrations for the same account, preserving inbound message delivery after reconnects. Fixes #83888. (#85425) Thanks @alkor2000.
|
||||
- Memory/LanceDB: expose public memory artifacts through the active memory provider bridge so memory-wiki imports durable memory files, daily notes, dream reports, and event logs without depending on memory-core internals. Fixes #83604. (#85060) Thanks @brokemac79.
|
||||
- Docker setup: stop printing the Gateway bearer token in setup logs and printed follow-up commands.
|
||||
|
||||
@@ -326,6 +326,7 @@ describe("status-runtime-shared", () => {
|
||||
config: { gateway: {} },
|
||||
sourceConfig: { gateway: { mode: "local" } },
|
||||
deep: false,
|
||||
deepTimeoutMs: 1234,
|
||||
includeFilesystem: true,
|
||||
includeChannelSecurity: true,
|
||||
loadPluginSecurityCollectors: false,
|
||||
|
||||
@@ -27,6 +27,7 @@ function loadGatewayCallModule() {
|
||||
export async function resolveStatusSecurityAudit(params: {
|
||||
config: OpenClawConfig;
|
||||
sourceConfig: OpenClawConfig;
|
||||
timeoutMs?: number;
|
||||
}) {
|
||||
const { runSecurityAudit } = await loadSecurityAuditModule();
|
||||
const readOnlyPlugins = resolveReadOnlyChannelPluginsForConfig(params.config, {
|
||||
@@ -37,6 +38,7 @@ export async function resolveStatusSecurityAudit(params: {
|
||||
config: params.config,
|
||||
sourceConfig: params.sourceConfig,
|
||||
deep: false,
|
||||
...(params.timeoutMs !== undefined ? { deepTimeoutMs: params.timeoutMs } : {}),
|
||||
includeFilesystem: true,
|
||||
includeChannelSecurity: true,
|
||||
loadPluginSecurityCollectors: false,
|
||||
@@ -221,6 +223,7 @@ export async function resolveStatusRuntimeSnapshot(params: {
|
||||
resolveSecurityAudit?: (input: {
|
||||
config: OpenClawConfig;
|
||||
sourceConfig: OpenClawConfig;
|
||||
timeoutMs?: number;
|
||||
}) => Promise<StatusSecurityAudit>;
|
||||
resolveUsage?: (input: StatusUsageSummaryOptions) => Promise<StatusUsageSummary>;
|
||||
resolveHealth?: (input: {
|
||||
@@ -232,6 +235,7 @@ export async function resolveStatusRuntimeSnapshot(params: {
|
||||
? await (params.resolveSecurityAudit ?? resolveStatusSecurityAudit)({
|
||||
config: params.config,
|
||||
sourceConfig: params.sourceConfig,
|
||||
timeoutMs: params.timeoutMs,
|
||||
})
|
||||
: undefined;
|
||||
const runtimeDetails = await resolveStatusRuntimeDetails({
|
||||
|
||||
@@ -39,6 +39,8 @@ type ExecDockerRawFn = (
|
||||
opts?: { allowFailure?: boolean; input?: Buffer | string; signal?: AbortSignal },
|
||||
) => Promise<import("../agents/sandbox/docker.js").ExecDockerRawResult>;
|
||||
|
||||
const DEFAULT_SANDBOX_BROWSER_DOCKER_PROBE_TIMEOUT_MS = 5000;
|
||||
|
||||
type CodeSafetySummaryCache = Map<string, Promise<unknown>>;
|
||||
let skillsModulePromise: Promise<typeof import("../agents/skills.js")> | undefined;
|
||||
let configModulePromise: Promise<typeof import("../config/config.js")> | undefined;
|
||||
@@ -274,13 +276,63 @@ function normalizeDockerLabelValue(raw: string | undefined): string | null {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
async function listSandboxBrowserContainers(
|
||||
execDockerRawFn: ExecDockerRawFn,
|
||||
): Promise<string[] | null> {
|
||||
class DockerProbeTimeoutError extends Error {
|
||||
constructor(timeoutMs: number) {
|
||||
super(`Docker probe timed out after ${timeoutMs}ms`);
|
||||
this.name = "DockerProbeTimeoutError";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDockerProbeTimeoutMs(timeoutMs: number | undefined): number {
|
||||
if (Number.isFinite(timeoutMs) && timeoutMs !== undefined) {
|
||||
return Math.max(250, Math.floor(timeoutMs));
|
||||
}
|
||||
return DEFAULT_SANDBOX_BROWSER_DOCKER_PROBE_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
async function withDockerProbeTimeout<T>(
|
||||
timeoutMs: number,
|
||||
run: (signal: AbortSignal) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
let timedOut = false;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
reject(new DockerProbeTimeoutError(timeoutMs));
|
||||
}, timeoutMs);
|
||||
});
|
||||
try {
|
||||
const result = await execDockerRawFn(
|
||||
["ps", "-a", "--filter", "label=openclaw.sandboxBrowser=1", "--format", "{{.Names}}"],
|
||||
{ allowFailure: true },
|
||||
return await Promise.race([run(controller.signal), timeoutPromise]);
|
||||
} catch (err) {
|
||||
if (timedOut || controller.signal.aborted) {
|
||||
throw new DockerProbeTimeoutError(timeoutMs);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isDockerProbeTimeoutError(error: unknown): boolean {
|
||||
return error instanceof DockerProbeTimeoutError;
|
||||
}
|
||||
|
||||
async function listSandboxBrowserContainers(params: {
|
||||
execDockerRawFn: ExecDockerRawFn;
|
||||
timeoutMs: number;
|
||||
onTimeout?: () => void;
|
||||
}): Promise<string[] | null> {
|
||||
try {
|
||||
const result = await withDockerProbeTimeout(params.timeoutMs, (signal) =>
|
||||
params.execDockerRawFn(
|
||||
["ps", "-a", "--filter", "label=openclaw.sandboxBrowser=1", "--format", "{{.Names}}"],
|
||||
{ allowFailure: true, signal },
|
||||
),
|
||||
);
|
||||
if (result.code !== 0) {
|
||||
return null;
|
||||
@@ -290,7 +342,10 @@ async function listSandboxBrowserContainers(
|
||||
.split(/\r?\n/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
} catch (err) {
|
||||
if (isDockerProbeTimeoutError(err)) {
|
||||
params.onTimeout?.();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -298,16 +353,20 @@ async function listSandboxBrowserContainers(
|
||||
async function readSandboxBrowserHashLabels(params: {
|
||||
containerName: string;
|
||||
execDockerRawFn: ExecDockerRawFn;
|
||||
timeoutMs: number;
|
||||
onTimeout?: () => void;
|
||||
}): Promise<{ configHash: string | null; epoch: string | null } | null> {
|
||||
try {
|
||||
const result = await params.execDockerRawFn(
|
||||
[
|
||||
"inspect",
|
||||
"-f",
|
||||
'{{ index .Config.Labels "openclaw.configHash" }}\t{{ index .Config.Labels "openclaw.browserConfigEpoch" }}',
|
||||
params.containerName,
|
||||
],
|
||||
{ allowFailure: true },
|
||||
const result = await withDockerProbeTimeout(params.timeoutMs, (signal) =>
|
||||
params.execDockerRawFn(
|
||||
[
|
||||
"inspect",
|
||||
"-f",
|
||||
'{{ index .Config.Labels "openclaw.configHash" }}\t{{ index .Config.Labels "openclaw.browserConfigEpoch" }}',
|
||||
params.containerName,
|
||||
],
|
||||
{ allowFailure: true, signal },
|
||||
),
|
||||
);
|
||||
if (result.code !== 0) {
|
||||
return null;
|
||||
@@ -317,7 +376,10 @@ async function readSandboxBrowserHashLabels(params: {
|
||||
configHash: normalizeDockerLabelValue(hashRaw),
|
||||
epoch: normalizeDockerLabelValue(epochRaw),
|
||||
};
|
||||
} catch {
|
||||
} catch (err) {
|
||||
if (isDockerProbeTimeoutError(err)) {
|
||||
params.onTimeout?.();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -349,11 +411,16 @@ function isLoopbackPublishHost(host: string): boolean {
|
||||
async function readSandboxBrowserPortMappings(params: {
|
||||
containerName: string;
|
||||
execDockerRawFn: ExecDockerRawFn;
|
||||
timeoutMs: number;
|
||||
onTimeout?: () => void;
|
||||
}): Promise<string[] | null> {
|
||||
try {
|
||||
const result = await params.execDockerRawFn(["port", params.containerName], {
|
||||
allowFailure: true,
|
||||
});
|
||||
const result = await withDockerProbeTimeout(params.timeoutMs, (signal) =>
|
||||
params.execDockerRawFn(["port", params.containerName], {
|
||||
allowFailure: true,
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
if (result.code !== 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -362,21 +429,37 @@ async function readSandboxBrowserPortMappings(params: {
|
||||
.split(/\r?\n/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
} catch (err) {
|
||||
if (isDockerProbeTimeoutError(err)) {
|
||||
params.onTimeout?.();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function collectSandboxBrowserHashLabelFindings(params?: {
|
||||
execDockerRawFn?: ExecDockerRawFn;
|
||||
timeoutMs?: number;
|
||||
}): Promise<SecurityAuditFinding[]> {
|
||||
const findings: SecurityAuditFinding[] = [];
|
||||
const timeoutMs = normalizeDockerProbeTimeoutMs(params?.timeoutMs);
|
||||
let timedOut = false;
|
||||
const markTimedOut = () => {
|
||||
timedOut = true;
|
||||
};
|
||||
const [execFn, browserHashEpoch] = await Promise.all([
|
||||
params?.execDockerRawFn ? Promise.resolve(params.execDockerRawFn) : loadExecDockerRaw(),
|
||||
loadSandboxBrowserSecurityHashEpoch(),
|
||||
]);
|
||||
const containers = await listSandboxBrowserContainers(execFn);
|
||||
const containers = await listSandboxBrowserContainers({
|
||||
execDockerRawFn: execFn,
|
||||
timeoutMs,
|
||||
onTimeout: markTimedOut,
|
||||
});
|
||||
if (!containers || containers.length === 0) {
|
||||
if (timedOut) {
|
||||
findings.push(buildSandboxBrowserDockerProbeTimeoutFinding(timeoutMs));
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
@@ -385,7 +468,15 @@ export async function collectSandboxBrowserHashLabelFindings(params?: {
|
||||
const nonLoopbackPublished: string[] = [];
|
||||
|
||||
for (const containerName of containers) {
|
||||
const labels = await readSandboxBrowserHashLabels({ containerName, execDockerRawFn: execFn });
|
||||
const labels = await readSandboxBrowserHashLabels({
|
||||
containerName,
|
||||
execDockerRawFn: execFn,
|
||||
timeoutMs,
|
||||
onTimeout: markTimedOut,
|
||||
});
|
||||
if (timedOut) {
|
||||
break;
|
||||
}
|
||||
if (!labels) {
|
||||
continue;
|
||||
}
|
||||
@@ -398,7 +489,12 @@ export async function collectSandboxBrowserHashLabelFindings(params?: {
|
||||
const portMappings = await readSandboxBrowserPortMappings({
|
||||
containerName,
|
||||
execDockerRawFn: execFn,
|
||||
timeoutMs,
|
||||
onTimeout: markTimedOut,
|
||||
});
|
||||
if (timedOut) {
|
||||
break;
|
||||
}
|
||||
if (!portMappings?.length) {
|
||||
continue;
|
||||
}
|
||||
@@ -449,9 +545,26 @@ export async function collectSandboxBrowserHashLabelFindings(params?: {
|
||||
});
|
||||
}
|
||||
|
||||
if (timedOut) {
|
||||
findings.push(buildSandboxBrowserDockerProbeTimeoutFinding(timeoutMs));
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
function buildSandboxBrowserDockerProbeTimeoutFinding(timeoutMs: number): SecurityAuditFinding {
|
||||
return {
|
||||
checkId: "sandbox.browser_container.docker_probe_timeout",
|
||||
severity: "warn",
|
||||
title: "Sandbox browser Docker audit probe timed out",
|
||||
detail:
|
||||
`Docker did not answer within ${timeoutMs}ms while checking sandbox browser containers. ` +
|
||||
"OpenClaw skipped any remaining sandbox browser container drift checks for this status run.",
|
||||
remediation:
|
||||
"Retry after Docker is responsive, or recreate sandbox browser containers if drift is suspected.",
|
||||
};
|
||||
}
|
||||
|
||||
export async function collectIncludeFilePermFindings(params: {
|
||||
configSnapshot: ConfigFileSnapshot;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
|
||||
@@ -76,6 +76,62 @@ describe("security audit sandbox browser findings", () => {
|
||||
expect(hasFinding("sandbox.browser_container.hash_epoch_stale", "warn", findings)).toBe(false);
|
||||
});
|
||||
|
||||
it("bounds sandbox browser Docker probes that do not return", async () => {
|
||||
let probeSignal: AbortSignal | undefined;
|
||||
const startedAt = Date.now();
|
||||
|
||||
const findings = await collectSandboxBrowserHashLabelFindings({
|
||||
timeoutMs: 1,
|
||||
execDockerRawFn: async (_args, opts) => {
|
||||
probeSignal = opts?.signal;
|
||||
return await new Promise((_, reject) =>
|
||||
opts?.signal?.addEventListener("abort", () => reject(new Error("aborted")), {
|
||||
once: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
expect(Date.now() - startedAt).toBeLessThan(1000);
|
||||
expect(probeSignal?.aborted).toBe(true);
|
||||
expect(findings).toEqual([
|
||||
expect.objectContaining({
|
||||
checkId: "sandbox.browser_container.docker_probe_timeout",
|
||||
severity: "warn",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("stops probing remaining sandbox browser containers after a Docker timeout", async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const findings = await collectSandboxBrowserHashLabelFindings({
|
||||
timeoutMs: 1,
|
||||
execDockerRawFn: async (args, opts) => {
|
||||
calls.push(`${args[0] ?? ""}:${args.at(-1) ?? ""}`);
|
||||
if (args[0] === "ps") {
|
||||
return {
|
||||
stdout: Buffer.from("openclaw-sbx-browser-hung\nopenclaw-sbx-browser-next\n"),
|
||||
stderr: Buffer.alloc(0),
|
||||
code: 0,
|
||||
};
|
||||
}
|
||||
return await new Promise((_, reject) =>
|
||||
opts?.signal?.addEventListener("abort", () => reject(new Error("aborted")), {
|
||||
once: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
expect(calls).toEqual(["ps:{{.Names}}", "inspect:openclaw-sbx-browser-hung"]);
|
||||
expect(findings).toEqual([
|
||||
expect.objectContaining({
|
||||
checkId: "sandbox.browser_container.docker_probe_timeout",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("flags sandbox browser containers with non-loopback published ports", async () => {
|
||||
const findings = await collectSandboxBrowserHashLabelFindings({
|
||||
execDockerRawFn: async (args: string[]) => {
|
||||
|
||||
@@ -1096,6 +1096,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
|
||||
findings.push(
|
||||
...(await auditNonDeep.collectSandboxBrowserHashLabelFindings({
|
||||
execDockerRawFn: context.execDockerRawFn,
|
||||
timeoutMs: context.deepTimeoutMs,
|
||||
})),
|
||||
);
|
||||
findings.push(...(await auditNonDeep.collectPluginsTrustFindings({ cfg, stateDir })));
|
||||
|
||||
Reference in New Issue
Block a user