mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
feat: add ClawScan shadow diagnostics
Run OSS ClawScan in artifact-only shadow mode after successful production security scans.
This commit is contained in:
@@ -46,6 +46,10 @@ jobs:
|
||||
CODEX_SECURITY_SCAN_MAX_JOBS: ${{ inputs['max-jobs'] || '' }}
|
||||
CODEX_SECURITY_SCAN_MAX_RUNTIME_MINUTES: ${{ inputs['max-runtime-minutes'] || '8' }}
|
||||
CODEX_SECURITY_SCAN_TIMEOUT_MS: ${{ vars.CODEX_SECURITY_SCAN_TIMEOUT_MS || '240000' }}
|
||||
CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN: "1"
|
||||
CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX: ${{ vars.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX || 'docker' }}
|
||||
CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX_IMAGE: ${{ vars.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX_IMAGE || 'ghcr.io/openclaw/clawscan-runtime@sha256:d85bfe671fe597edc6802f9d6a07dd91b59c69cec4faa6e8f89778037507dc3b' }}
|
||||
CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_TIMEOUT_MS: ${{ vars.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_TIMEOUT_MS || '300000' }}
|
||||
CODEX_SECURITY_SCAN_LEASE_MINUTES: "60"
|
||||
CODEX_SECURITY_SCAN_DIAGNOSTICS_DIR: codex-security-scan-diagnostics-${{ matrix.shard }}
|
||||
CODEX_SECURITY_SCAN_SHARD: ${{ matrix.shard }}
|
||||
@@ -68,6 +72,12 @@ jobs:
|
||||
fi
|
||||
codex --version
|
||||
|
||||
- name: Install ClawScan CLI
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm install -g @openclaw/clawscan@0.1.1
|
||||
clawscan --version
|
||||
|
||||
- name: Install SkillSpector
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* @vitest-environment node */
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { chmod, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
processJob,
|
||||
resolveSkillSpectorScanInput,
|
||||
resolveSkillSpectorScanInputs,
|
||||
runClawScanShadow,
|
||||
shouldClaimSecurityScanBatch,
|
||||
writeArtifactWorkspace,
|
||||
writeJobDiagnostic,
|
||||
@@ -838,6 +839,309 @@ describe("run-codex-scan-worker diagnostics", () => {
|
||||
else process.env.GITHUB_ACTIONS = previousGitHubActions;
|
||||
});
|
||||
|
||||
it("runs OSS ClawScan shadow mode with recorded VirusTotal evidence", async () => {
|
||||
const workspace = await tempDir();
|
||||
await mkdir(join(workspace, "artifact"), { recursive: true });
|
||||
await writeFile(join(workspace, "artifact", "SKILL.md"), "# Demo\n");
|
||||
const fakeClawScan = join(workspace, "fake-clawscan");
|
||||
await writeFile(
|
||||
fakeClawScan,
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
out=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--output)
|
||||
out="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
mkdir -p "$(dirname "$out")"
|
||||
cat > "$out" <<'JSON'
|
||||
{"schemaVersion":"clawscan-run-v1","profile":"clawhub","scanners":{"skillspector":{"status":"completed"},"virustotal":{"status":"completed"},"clawscan-static":{"status":"completed"}},"judge":{"status":"completed","result":{"verdict":"benign","confidence":"high"}}}
|
||||
JSON
|
||||
echo "targets: 1"
|
||||
`,
|
||||
);
|
||||
await chmod(fakeClawScan, 0o755);
|
||||
const previousEnabled = process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN;
|
||||
const previousCommand = process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_COMMAND;
|
||||
const previousSandbox = process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX;
|
||||
process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN = "1";
|
||||
process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_COMMAND = fakeClawScan;
|
||||
process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX = "docker";
|
||||
|
||||
const shadow = await runClawScanShadow(
|
||||
{
|
||||
job: {
|
||||
_id: "securityScanJobs:shadow",
|
||||
hasMaliciousSignal: false,
|
||||
leaseToken: "lease-secret",
|
||||
source: "vt-update",
|
||||
targetKind: "skillVersion",
|
||||
waitForVtUntil: 0,
|
||||
},
|
||||
target: {
|
||||
version: {
|
||||
vtAnalysis: {
|
||||
status: "clean",
|
||||
engineStats: { malicious: 0, suspicious: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace,
|
||||
{
|
||||
checkedAt: 123,
|
||||
confidence: "medium",
|
||||
status: "clean",
|
||||
verdict: "benign",
|
||||
},
|
||||
);
|
||||
|
||||
expect(shadow).toMatchObject({
|
||||
prod: { confidence: "medium", status: "clean", verdict: "benign" },
|
||||
shadow: {
|
||||
confidence: "high",
|
||||
judgeStatus: "completed",
|
||||
profile: "clawhub",
|
||||
scannerStatuses: {
|
||||
"clawscan-static": "completed",
|
||||
skillspector: "completed",
|
||||
virustotal: "completed",
|
||||
},
|
||||
status: "clean",
|
||||
verdict: "benign",
|
||||
},
|
||||
status: "completed",
|
||||
});
|
||||
expect(shadow.command).toEqual(
|
||||
expect.arrayContaining([
|
||||
fakeClawScan,
|
||||
"./artifact",
|
||||
"--profile",
|
||||
"clawhub",
|
||||
"--scanner-result",
|
||||
expect.stringMatching(/^virustotal=/),
|
||||
"--sandbox",
|
||||
"docker",
|
||||
"--sandbox-image",
|
||||
expect.stringContaining("@sha256:"),
|
||||
]),
|
||||
);
|
||||
expect(shadow.vtFixturePath).toBeDefined();
|
||||
const vtFixture = JSON.parse(await readFile(String(shadow.vtFixturePath), "utf8"));
|
||||
expect(vtFixture).toMatchObject({ status: "clean" });
|
||||
|
||||
if (previousEnabled === undefined) delete process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN;
|
||||
else process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN = previousEnabled;
|
||||
if (previousCommand === undefined)
|
||||
delete process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_COMMAND;
|
||||
else process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_COMMAND = previousCommand;
|
||||
if (previousSandbox === undefined)
|
||||
delete process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX;
|
||||
else process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX = previousSandbox;
|
||||
});
|
||||
|
||||
it("marks OSS ClawScan shadow mode failed when the judge fails", async () => {
|
||||
const workspace = await tempDir();
|
||||
await mkdir(join(workspace, "artifact"), { recursive: true });
|
||||
await writeFile(join(workspace, "artifact", "SKILL.md"), "# Demo\n");
|
||||
const fakeClawScan = join(workspace, "fake-clawscan");
|
||||
await writeFile(
|
||||
fakeClawScan,
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
out=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--output)
|
||||
out="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
mkdir -p "$(dirname "$out")"
|
||||
cat > "$out" <<'JSON'
|
||||
{"schemaVersion":"clawscan-run-v1","profile":"clawhub","scanners":{"skillspector":{"status":"completed"},"virustotal":{"status":"completed"},"clawscan-static":{"status":"completed"}},"judge":{"status":"failed","error":"schema mismatch"}}
|
||||
JSON
|
||||
`,
|
||||
);
|
||||
await chmod(fakeClawScan, 0o755);
|
||||
const previousEnabled = process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN;
|
||||
const previousCommand = process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_COMMAND;
|
||||
const previousSandbox = process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX;
|
||||
process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN = "1";
|
||||
process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_COMMAND = fakeClawScan;
|
||||
process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX = "off";
|
||||
|
||||
const shadow = await runClawScanShadow(
|
||||
{
|
||||
job: {
|
||||
_id: "securityScanJobs:shadow-failed",
|
||||
hasMaliciousSignal: false,
|
||||
leaseToken: "lease-secret",
|
||||
source: "publish",
|
||||
targetKind: "skillVersion",
|
||||
waitForVtUntil: 0,
|
||||
},
|
||||
target: {},
|
||||
},
|
||||
workspace,
|
||||
{
|
||||
checkedAt: 123,
|
||||
confidence: "medium",
|
||||
status: "clean",
|
||||
verdict: "benign",
|
||||
},
|
||||
);
|
||||
|
||||
expect(shadow).toMatchObject({
|
||||
error: "ClawScan judge status was failed",
|
||||
shadow: {
|
||||
judgeStatus: "failed",
|
||||
profile: "clawhub",
|
||||
scannerStatuses: {
|
||||
"clawscan-static": "completed",
|
||||
skillspector: "completed",
|
||||
virustotal: "completed",
|
||||
},
|
||||
},
|
||||
status: "failed",
|
||||
});
|
||||
|
||||
if (previousEnabled === undefined) delete process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN;
|
||||
else process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN = previousEnabled;
|
||||
if (previousCommand === undefined)
|
||||
delete process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_COMMAND;
|
||||
else process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_COMMAND = previousCommand;
|
||||
if (previousSandbox === undefined)
|
||||
delete process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX;
|
||||
else process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX = previousSandbox;
|
||||
});
|
||||
|
||||
it("marks OSS ClawScan shadow mode failed when an expected scanner fails", async () => {
|
||||
const workspace = await tempDir();
|
||||
await mkdir(join(workspace, "artifact"), { recursive: true });
|
||||
await writeFile(join(workspace, "artifact", "SKILL.md"), "# Demo\n");
|
||||
const fakeClawScan = join(workspace, "fake-clawscan");
|
||||
await writeFile(
|
||||
fakeClawScan,
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
out=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--output)
|
||||
out="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
mkdir -p "$(dirname "$out")"
|
||||
cat > "$out" <<'JSON'
|
||||
{"schemaVersion":"clawscan-run-v1","profile":"clawhub","scanners":{"skillspector":{"status":"failed"},"virustotal":{"status":"completed"},"clawscan-static":{"status":"completed"}},"judge":{"status":"completed","result":{"verdict":"benign","confidence":"high"}}}
|
||||
JSON
|
||||
`,
|
||||
);
|
||||
await chmod(fakeClawScan, 0o755);
|
||||
const previousEnabled = process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN;
|
||||
const previousCommand = process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_COMMAND;
|
||||
const previousSandbox = process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX;
|
||||
process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN = "1";
|
||||
process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_COMMAND = fakeClawScan;
|
||||
process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX = "off";
|
||||
|
||||
const shadow = await runClawScanShadow(
|
||||
{
|
||||
job: {
|
||||
_id: "securityScanJobs:shadow-scanner-failed",
|
||||
hasMaliciousSignal: false,
|
||||
leaseToken: "lease-secret",
|
||||
source: "publish",
|
||||
targetKind: "skillVersion",
|
||||
waitForVtUntil: 0,
|
||||
},
|
||||
target: {},
|
||||
},
|
||||
workspace,
|
||||
{
|
||||
checkedAt: 123,
|
||||
confidence: "medium",
|
||||
status: "clean",
|
||||
verdict: "benign",
|
||||
},
|
||||
);
|
||||
|
||||
expect(shadow).toMatchObject({
|
||||
error: "ClawScan scanner skillspector status was failed",
|
||||
shadow: {
|
||||
judgeStatus: "completed",
|
||||
scannerStatuses: {
|
||||
"clawscan-static": "completed",
|
||||
skillspector: "failed",
|
||||
virustotal: "completed",
|
||||
},
|
||||
verdict: "benign",
|
||||
},
|
||||
status: "failed",
|
||||
});
|
||||
|
||||
if (previousEnabled === undefined) delete process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN;
|
||||
else process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN = previousEnabled;
|
||||
if (previousCommand === undefined)
|
||||
delete process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_COMMAND;
|
||||
else process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_COMMAND = previousCommand;
|
||||
if (previousSandbox === undefined)
|
||||
delete process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX;
|
||||
else process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX = previousSandbox;
|
||||
});
|
||||
|
||||
it("skips OSS ClawScan shadow mode for unsupported parity contexts", async () => {
|
||||
const workspace = await tempDir();
|
||||
const previousEnabled = process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN;
|
||||
process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN = "1";
|
||||
|
||||
const shadow = await runClawScanShadow(
|
||||
{
|
||||
job: {
|
||||
_id: "securityScanJobs:shadow-skipped",
|
||||
hasMaliciousSignal: false,
|
||||
leaseToken: "lease-secret",
|
||||
source: "publish",
|
||||
targetKind: "packageRelease",
|
||||
waitForVtUntil: 0,
|
||||
},
|
||||
target: {},
|
||||
},
|
||||
workspace,
|
||||
{
|
||||
checkedAt: 123,
|
||||
confidence: "medium",
|
||||
status: "clean",
|
||||
verdict: "benign",
|
||||
},
|
||||
);
|
||||
|
||||
expect(shadow).toMatchObject({
|
||||
error: "ClawScan shadow parity is only enabled for skillVersion jobs, got packageRelease",
|
||||
status: "skipped",
|
||||
});
|
||||
|
||||
if (previousEnabled === undefined) delete process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN;
|
||||
else process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN = previousEnabled;
|
||||
});
|
||||
|
||||
it("writes redacted Codex diagnostics without copying submitted artifact files or signed URLs", async () => {
|
||||
const diagnosticsRoot = await tempDir();
|
||||
const artifactWorkspace = await tempDir();
|
||||
@@ -884,6 +1188,24 @@ describe("run-codex-scan-worker diagnostics", () => {
|
||||
},
|
||||
},
|
||||
llmAnalysis: { confidence: "low", status: "clean", verdict: "benign" },
|
||||
clawscanShadow: {
|
||||
command: ["clawscan", "./artifact", "--profile", "clawhub"],
|
||||
prod: { confidence: "low", status: "clean", verdict: "benign" },
|
||||
shadow: {
|
||||
confidence: "high",
|
||||
judgeStatus: "completed",
|
||||
profile: "clawhub",
|
||||
scannerStatuses: {
|
||||
"clawscan-static": "completed",
|
||||
skillspector: "completed",
|
||||
virustotal: "completed",
|
||||
},
|
||||
schemaVersion: "clawscan-run-v1",
|
||||
status: "clean",
|
||||
verdict: "benign",
|
||||
},
|
||||
status: "completed",
|
||||
},
|
||||
skillSpectorAnalysis: {
|
||||
status: "suspicious",
|
||||
issueCount: 1,
|
||||
@@ -961,6 +1283,22 @@ describe("run-codex-scan-worker diagnostics", () => {
|
||||
status: "failed",
|
||||
});
|
||||
expect(diagnostic.job.leaseToken).toBeUndefined();
|
||||
expect(diagnostic.clawscanShadow).toMatchObject({
|
||||
prod: { confidence: "low", status: "clean", verdict: "benign" },
|
||||
shadow: {
|
||||
confidence: "high",
|
||||
judgeStatus: "completed",
|
||||
profile: "clawhub",
|
||||
scannerStatuses: {
|
||||
"clawscan-static": "completed",
|
||||
skillspector: "completed",
|
||||
virustotal: "completed",
|
||||
},
|
||||
status: "clean",
|
||||
verdict: "benign",
|
||||
},
|
||||
status: "completed",
|
||||
});
|
||||
expect(diagnostic.error).toBe(
|
||||
"Codex result did not match ClawScan schema: [redacted result body]",
|
||||
);
|
||||
@@ -981,6 +1319,57 @@ describe("run-codex-scan-worker diagnostics", () => {
|
||||
expect(allDiagnosticText).not.toContain("sk-short-fixture");
|
||||
expect(allDiagnosticText).not.toContain("quoted artifact payload");
|
||||
expect(allDiagnosticText).not.toContain("SkillSpector artifact payload");
|
||||
const comparison = JSON.parse(
|
||||
await readFile(join(jobDir, "clawscan-shadow-comparison.json"), "utf8"),
|
||||
);
|
||||
expect(comparison).toMatchObject({
|
||||
prod: { status: "clean", verdict: "benign" },
|
||||
shadow: { status: "clean", verdict: "benign" },
|
||||
status: "completed",
|
||||
});
|
||||
expect(await readdir(jobDir)).not.toContain("artifact");
|
||||
});
|
||||
|
||||
it("preserves sanitized ClawScan shadow failure reasons in comparison artifacts", async () => {
|
||||
const diagnosticsRoot = await tempDir();
|
||||
|
||||
await writeJobDiagnostic({
|
||||
completedAt: 2,
|
||||
diagnosticsRoot,
|
||||
job: {
|
||||
job: {
|
||||
_id: "job-shadow-failed",
|
||||
hasMaliciousSignal: false,
|
||||
leaseToken: "lease-secret",
|
||||
source: "publish",
|
||||
targetKind: "skillVersion",
|
||||
waitForVtUntil: 0,
|
||||
},
|
||||
target: {},
|
||||
},
|
||||
clawscanShadow: {
|
||||
error: "clawscan timed out with api_key=sk-shadow-secret",
|
||||
status: "failed",
|
||||
stderr: "provider stderr token=shadow-secret",
|
||||
},
|
||||
startedAt: 1,
|
||||
status: "completed",
|
||||
});
|
||||
|
||||
const comparison = JSON.parse(
|
||||
await readFile(
|
||||
join(diagnosticsRoot, "job-shadow-failed", "clawscan-shadow-comparison.json"),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
|
||||
expect(comparison).toMatchObject({
|
||||
error: expect.stringContaining("clawscan timed out"),
|
||||
status: "failed",
|
||||
stderr: expect.stringMatching(/^\[redacted \d+ chars\]$/),
|
||||
});
|
||||
const comparisonText = JSON.stringify(comparison);
|
||||
expect(comparisonText).not.toContain("sk-shadow-secret");
|
||||
expect(comparisonText).not.toContain("shadow-secret");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -93,7 +93,36 @@ type CodexCommandDiagnostic = {
|
||||
stdout?: string;
|
||||
};
|
||||
|
||||
type ClawScanShadowDiagnostic = {
|
||||
artifactPath?: string;
|
||||
command?: string[];
|
||||
completedAt?: number;
|
||||
durationMs?: number;
|
||||
error?: string;
|
||||
exitCode?: number | null;
|
||||
prod?: {
|
||||
confidence?: string;
|
||||
status?: string;
|
||||
verdict?: string;
|
||||
};
|
||||
shadow?: {
|
||||
confidence?: string;
|
||||
judgeStatus?: string;
|
||||
profile?: string;
|
||||
scannerStatuses: Record<string, string>;
|
||||
schemaVersion?: string;
|
||||
status?: string;
|
||||
verdict?: string;
|
||||
};
|
||||
startedAt?: number;
|
||||
status: "completed" | "failed" | "skipped";
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
vtFixturePath?: string;
|
||||
};
|
||||
|
||||
type JobDiagnosticInput = {
|
||||
clawscanShadow?: ClawScanShadowDiagnostic;
|
||||
codex?: CodexCommandDiagnostic;
|
||||
completedAt: number;
|
||||
diagnosticsRoot?: string;
|
||||
@@ -119,6 +148,10 @@ const DEFAULT_BATCH_LIMIT = 4;
|
||||
const DEFAULT_MAX_RUNTIME_MS = 40 * 60 * 1000;
|
||||
const DEFAULT_CODEX_SCAN_TIMEOUT_MS = 20 * 60 * 1000;
|
||||
const CLAIM_WINDOW_SHUTDOWN_BUFFER_MS = 3 * 60 * 1000;
|
||||
const DEFAULT_CLAWSCAN_SHADOW_TIMEOUT_MS = 20 * 60 * 1000;
|
||||
const DEFAULT_CLAWSCAN_SHADOW_SANDBOX_IMAGE =
|
||||
"ghcr.io/openclaw/clawscan-runtime@sha256:d85bfe671fe597edc6802f9d6a07dd91b59c69cec4faa6e8f89778037507dc3b";
|
||||
const EXPECTED_CLAWHUB_SHADOW_SCANNERS = ["clawscan-static", "skillspector", "virustotal"];
|
||||
const MAX_DIAGNOSTIC_TEXT_CHARS = 20_000;
|
||||
const MAX_STORED_SKILLSPECTOR_ISSUES = 25;
|
||||
const MAX_STORED_SKILLSPECTOR_TEXT_CHARS = 2_000;
|
||||
@@ -245,6 +278,16 @@ const DIAGNOSTIC_PUBLIC_TEXT_PATHS = new Set([
|
||||
"codexstdout.item.type",
|
||||
"codexstdout.status",
|
||||
"codexstdout.type",
|
||||
"clawscanshadow.prod.confidence",
|
||||
"clawscanshadow.prod.status",
|
||||
"clawscanshadow.prod.verdict",
|
||||
"clawscanshadow.shadow.confidence",
|
||||
"clawscanshadow.shadow.judgestatus",
|
||||
"clawscanshadow.shadow.profile",
|
||||
"clawscanshadow.shadow.schemaversion",
|
||||
"clawscanshadow.shadow.status",
|
||||
"clawscanshadow.shadow.verdict",
|
||||
"clawscanshadow.status",
|
||||
"llmanalysis.confidence",
|
||||
"llmanalysis.status",
|
||||
"llmanalysis.verdict",
|
||||
@@ -276,9 +319,12 @@ function isDiagnosticSecretPath(path: string[]) {
|
||||
}
|
||||
|
||||
function shouldPreserveDiagnosticText(path: string[], original: string, redacted: string) {
|
||||
const key = diagnosticPathKey(path);
|
||||
if (key === "clawscanshadow.error") return true;
|
||||
return (
|
||||
original === redacted &&
|
||||
DIAGNOSTIC_PUBLIC_TEXT_PATHS.has(diagnosticPathKey(path)) &&
|
||||
(DIAGNOSTIC_PUBLIC_TEXT_PATHS.has(key) ||
|
||||
key.startsWith("clawscanshadow.shadow.scannerstatuses.")) &&
|
||||
DIAGNOSTIC_PUBLIC_TEXT_VALUE_PATTERN.test(redacted)
|
||||
);
|
||||
}
|
||||
@@ -434,6 +480,9 @@ export async function writeJobDiagnostic(input: JobDiagnosticInput) {
|
||||
},
|
||||
llmAnalysis: redactDiagnosticValue(input.llmAnalysis, ["llmAnalysis"]),
|
||||
runId: input.runId,
|
||||
clawscanShadow: input.clawscanShadow
|
||||
? redactDiagnosticValue(input.clawscanShadow, ["clawscanShadow"])
|
||||
: undefined,
|
||||
skillSpectorAnalysis: redactDiagnosticValue(input.skillSpectorAnalysis, [
|
||||
"skillSpectorAnalysis",
|
||||
]),
|
||||
@@ -457,6 +506,13 @@ export async function writeJobDiagnostic(input: JobDiagnosticInput) {
|
||||
};
|
||||
|
||||
await writeFile(join(jobDir, "diagnostic.json"), `${JSON.stringify(diagnostic, null, 2)}\n`);
|
||||
|
||||
if (input.clawscanShadow) {
|
||||
await writeFile(
|
||||
join(jobDir, "clawscan-shadow-comparison.json"),
|
||||
`${JSON.stringify(redactDiagnosticValue(input.clawscanShadow, ["clawscanShadow"]), null, 2)}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function safeOutputPath(workspace: string, artifactPath: string) {
|
||||
@@ -1141,6 +1197,16 @@ function codexScanTimeoutMs() {
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_CODEX_SCAN_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
function clawScanShadowTimeoutMs() {
|
||||
const parsed = Number(process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_TIMEOUT_MS);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_CLAWSCAN_SHADOW_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
function clawScanShadowEnabled() {
|
||||
const value = process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN?.trim().toLowerCase();
|
||||
return value === "1" || value === "true" || value === "yes";
|
||||
}
|
||||
|
||||
export function minimumClaimWindowMs(maxRuntimeMs: number, scanTimeoutMs: number) {
|
||||
return Math.min(maxRuntimeMs, scanTimeoutMs + CLAIM_WINDOW_SHUTDOWN_BUFFER_MS);
|
||||
}
|
||||
@@ -1219,6 +1285,182 @@ async function runCodex(
|
||||
return toStoredLlmAnalysis(parsed);
|
||||
}
|
||||
|
||||
function targetVirusTotalAnalysis(job: ClaimedJob) {
|
||||
return (
|
||||
(job.target.version as Record<string, unknown> | undefined)?.vtAnalysis ??
|
||||
(job.target.release as Record<string, unknown> | undefined)?.vtAnalysis ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveClawScanShadowTarget(workspace: string, job: ClaimedJob) {
|
||||
if (job.job.targetKind === "packageRelease") {
|
||||
const packageRoot = join(workspace, "artifact", "package");
|
||||
if (await fileExists(join(packageRoot, "package.json"))) return "./artifact/package";
|
||||
}
|
||||
return "./artifact";
|
||||
}
|
||||
|
||||
function clawScanShadowUnsupportedContext(job: ClaimedJob) {
|
||||
if (job.job.targetKind !== "skillVersion") {
|
||||
return `ClawScan shadow parity is only enabled for skillVersion jobs, got ${job.job.targetKind}`;
|
||||
}
|
||||
if (job.job.source !== "publish" && job.job.source !== "vt-update") {
|
||||
return `ClawScan shadow parity is only enabled for publish or vt-update jobs, got ${job.job.source}`;
|
||||
}
|
||||
if (job.target.trustedOpenClawPlugin) {
|
||||
return "ClawScan 0.1.1 cannot preserve trusted OpenClaw plugin context";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function clawScanShadowVerdictFromArtifact(artifact: unknown) {
|
||||
const record = asRecord(artifact);
|
||||
const judge = asRecord(record?.judge);
|
||||
const result = asRecord(judge?.result);
|
||||
const scanners = asRecord(record?.scanners);
|
||||
const scannerStatuses: Record<string, string> = {};
|
||||
if (scanners) {
|
||||
for (const [scanner, value] of Object.entries(scanners)) {
|
||||
const scannerRecord = asRecord(value);
|
||||
const status = readString(scannerRecord ?? {}, ["status"]);
|
||||
scannerStatuses[scanner] = status ?? "unknown";
|
||||
}
|
||||
}
|
||||
const verdict = readString(result ?? {}, ["verdict", "status"]);
|
||||
return {
|
||||
confidence: readString(result ?? {}, ["confidence"]),
|
||||
judgeStatus: readString(judge ?? {}, ["status"]),
|
||||
profile: readString(record ?? {}, ["profile"]),
|
||||
scannerStatuses,
|
||||
schemaVersion: readString(record ?? {}, ["schemaVersion"]),
|
||||
status: verdict ? verdictToStatus(verdict) : undefined,
|
||||
verdict,
|
||||
};
|
||||
}
|
||||
|
||||
function validateClawScanShadowResult(shadow: NonNullable<ClawScanShadowDiagnostic["shadow"]>) {
|
||||
for (const scanner of EXPECTED_CLAWHUB_SHADOW_SCANNERS) {
|
||||
if (shadow.scannerStatuses[scanner] !== "completed") {
|
||||
return `ClawScan scanner ${scanner} status was ${shadow.scannerStatuses[scanner] ?? "missing"}`;
|
||||
}
|
||||
}
|
||||
if (shadow.judgeStatus !== "completed") {
|
||||
return `ClawScan judge status was ${shadow.judgeStatus ?? "missing"}`;
|
||||
}
|
||||
if (!shadow.verdict) {
|
||||
return "ClawScan judge did not return a verdict";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function runClawScanShadow(
|
||||
job: ClaimedJob,
|
||||
workspace: string,
|
||||
llmAnalysis: StoredLlmAnalysis | undefined,
|
||||
): Promise<ClawScanShadowDiagnostic> {
|
||||
if (!clawScanShadowEnabled()) {
|
||||
return { status: "skipped" };
|
||||
}
|
||||
const unsupportedContext = clawScanShadowUnsupportedContext(job);
|
||||
if (unsupportedContext) {
|
||||
return {
|
||||
status: "skipped",
|
||||
error: unsupportedContext,
|
||||
};
|
||||
}
|
||||
if (!llmAnalysis) {
|
||||
return {
|
||||
status: "skipped",
|
||||
error: "authoritative ClawHub scan did not produce llmAnalysis",
|
||||
};
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
const diagnostic: ClawScanShadowDiagnostic = {
|
||||
prod: {
|
||||
confidence: llmAnalysis.confidence,
|
||||
status: llmAnalysis.status,
|
||||
verdict: llmAnalysis.verdict,
|
||||
},
|
||||
startedAt,
|
||||
status: "failed",
|
||||
};
|
||||
|
||||
try {
|
||||
const shadowDir = join(workspace, "clawscan-shadow");
|
||||
await mkdir(shadowDir, { recursive: true });
|
||||
const vtFixturePath = join(shadowDir, "virustotal-prod.json");
|
||||
const artifactPath = join(shadowDir, "artifact.json");
|
||||
await writeFile(vtFixturePath, `${JSON.stringify(targetVirusTotalAnalysis(job), null, 2)}\n`);
|
||||
const target = await resolveClawScanShadowTarget(workspace, job);
|
||||
const command = process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_COMMAND ?? "clawscan";
|
||||
const sandboxMode = process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX ?? "docker";
|
||||
const sandboxImage =
|
||||
process.env.CODEX_SECURITY_SCAN_SHADOW_CLAWSCAN_SANDBOX_IMAGE ??
|
||||
DEFAULT_CLAWSCAN_SHADOW_SANDBOX_IMAGE;
|
||||
const args = [
|
||||
target,
|
||||
"--profile",
|
||||
"clawhub",
|
||||
"--scanner-result",
|
||||
`virustotal=${vtFixturePath}`,
|
||||
"--output",
|
||||
artifactPath,
|
||||
"--sandbox",
|
||||
sandboxMode,
|
||||
];
|
||||
if (sandboxMode === "docker") {
|
||||
args.push("--sandbox-image", sandboxImage);
|
||||
}
|
||||
diagnostic.artifactPath = artifactPath;
|
||||
diagnostic.command = [command, ...args];
|
||||
diagnostic.vtFixturePath = vtFixturePath;
|
||||
|
||||
const output = await runCommand(command, args, {
|
||||
cwd: workspace,
|
||||
timeoutMs: clawScanShadowTimeoutMs(),
|
||||
});
|
||||
const raw = await readFile(artifactPath, "utf8");
|
||||
const artifact = JSON.parse(raw) as unknown;
|
||||
const completedAt = Date.now();
|
||||
const shadow = clawScanShadowVerdictFromArtifact(artifact);
|
||||
const resultError = validateClawScanShadowResult(shadow);
|
||||
return {
|
||||
...diagnostic,
|
||||
completedAt,
|
||||
durationMs: completedAt - startedAt,
|
||||
...(resultError ? { error: resultError } : {}),
|
||||
shadow,
|
||||
status: resultError ? "failed" : "completed",
|
||||
stderr: output.stderr,
|
||||
stdout: output.stdout,
|
||||
};
|
||||
} catch (error) {
|
||||
const completedAt = Date.now();
|
||||
let exitCode: number | null | undefined;
|
||||
let stderr: string | undefined;
|
||||
let stdout: string | undefined;
|
||||
let publicError = error instanceof Error ? error.message : String(error);
|
||||
if (error instanceof CommandFailure) {
|
||||
exitCode = error.exitCode;
|
||||
stderr = error.stderr;
|
||||
stdout = error.stdout;
|
||||
publicError = error.message;
|
||||
}
|
||||
return {
|
||||
...diagnostic,
|
||||
completedAt,
|
||||
durationMs: completedAt - startedAt,
|
||||
error: sanitizeWorkerErrorMessage(publicError),
|
||||
exitCode,
|
||||
stderr,
|
||||
stdout,
|
||||
status: "failed",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function processJob(
|
||||
client: CodexScanWorkerClient,
|
||||
token: string,
|
||||
@@ -1232,6 +1474,7 @@ export async function processJob(
|
||||
let errorMessage: string | undefined;
|
||||
let llmAnalysis: StoredLlmAnalysis | undefined;
|
||||
let skillSpectorAnalysis: SkillSpectorAnalysis | undefined;
|
||||
let clawscanShadow: ClawScanShadowDiagnostic | undefined;
|
||||
let status: JobDiagnosticInput["status"] = "failed";
|
||||
try {
|
||||
await writeArtifactWorkspace(job, workspace);
|
||||
@@ -1253,6 +1496,23 @@ export async function processJob(
|
||||
runId: process.env.GITHUB_RUN_ID,
|
||||
});
|
||||
status = "completed";
|
||||
clawscanShadow = await runClawScanShadow(job, workspace, llmAnalysis);
|
||||
if (clawscanShadow.status !== "skipped") {
|
||||
logger.info(
|
||||
{
|
||||
durationMs: clawscanShadow.durationMs,
|
||||
event: "security_scan_clawscan_shadow_completed",
|
||||
jobId: job.job._id,
|
||||
prodStatus: llmAnalysis.status,
|
||||
prodVerdict: llmAnalysis.verdict,
|
||||
shadowStatus: clawscanShadow.shadow?.status,
|
||||
shadowVerdict: clawscanShadow.shadow?.verdict,
|
||||
shadowRunStatus: clawscanShadow.status,
|
||||
targetKind: job.job.targetKind,
|
||||
},
|
||||
"ClawScan shadow run completed",
|
||||
);
|
||||
}
|
||||
logger.info(
|
||||
{
|
||||
durationMs: Date.now() - startedAt,
|
||||
@@ -1297,6 +1557,7 @@ export async function processJob(
|
||||
await writeJobDiagnostic({
|
||||
codex,
|
||||
completedAt: Date.now(),
|
||||
clawscanShadow,
|
||||
diagnosticsRoot,
|
||||
error: errorMessage,
|
||||
job,
|
||||
|
||||
Reference in New Issue
Block a user