fix(security): remove VirusTotal from ClawScan workers (#3156)

This commit is contained in:
Patrick Erichsen
2026-07-17 10:55:54 -07:00
committed by GitHub
parent 28675af04a
commit f9e58d4f0c
8 changed files with 25 additions and 153 deletions
@@ -86,7 +86,7 @@ jobs:
- name: Install ClawScan CLI
run: |
set -euo pipefail
npm install -g @openclaw/clawscan@0.1.5
npm install -g @openclaw/clawscan@0.1.6
clawscan --version
- name: Run pre-publication publish worker
@@ -94,6 +94,5 @@ jobs:
CODEX_API_KEY: ${{ secrets.CODEX_API_KEY || secrets.OPENAI_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
SECURITY_SCAN_WORKER_TOKEN: ${{ secrets.SECURITY_SCAN_WORKER_TOKEN }}
VIRUSTOTAL_API_KEY: ${{ secrets.VT_API_KEY }}
run: |
bun run publish:prepublication-worker
+1 -1
View File
@@ -91,7 +91,7 @@ jobs:
- name: Install ClawScan CLI
run: |
set -euo pipefail
npm install -g @openclaw/clawscan@0.1.5
npm install -g @openclaw/clawscan@0.1.6
clawscan --version
- name: Install SkillSpector
@@ -126,7 +126,7 @@ describe("pre-publication publish worker workflow", () => {
expect(runStep?.run).not.toContain("--version");
expect(runStep?.run).not.toContain("--max-jobs");
expect(steps.find((step) => step.name === "Install ClawScan CLI")?.run).toContain(
"npm install -g @openclaw/clawscan@0.1.5",
"npm install -g @openclaw/clawscan@0.1.6",
);
expect(steps.find((step) => step.name === "Install Codex CLI")?.run).toContain(
"npm install -g @openai/codex@0.142.3",
@@ -138,7 +138,6 @@ describe("pre-publication publish worker workflow", () => {
CODEX_API_KEY: "${{ secrets.CODEX_API_KEY || secrets.OPENAI_API_KEY }}",
OPENAI_API_KEY: "${{ secrets.OPENAI_API_KEY }}",
SECURITY_SCAN_WORKER_TOKEN: "${{ secrets.SECURITY_SCAN_WORKER_TOKEN }}",
VIRUSTOTAL_API_KEY: "${{ secrets.VT_API_KEY }}",
});
for (const step of steps) {
@@ -152,9 +151,7 @@ describe("pre-publication publish worker workflow", () => {
expect(stepUsesSecret(step, "OPENAI_API_KEY"), stepName).toBe(
stepName === "Run pre-publication publish worker",
);
expect(stepUsesSecret(step, "VT_API_KEY"), stepName).toBe(
stepName === "Run pre-publication publish worker",
);
expect(stepUsesSecret(step, "VT_API_KEY"), stepName).toBe(false);
}
});
});
@@ -161,14 +161,13 @@ function clawScanArtifactJson(options?: {
completedAt?: string;
includeCompletedAt?: boolean;
judgeResult?: Record<string, unknown>;
scannerStatuses?: Partial<Record<"clawscan-static" | "skillspector" | "virustotal", string>>;
scannerStatuses?: Partial<Record<"clawscan-static" | "skillspector", string>>;
verdict?: ClawScanVerdict;
}) {
const verdict = options?.verdict ?? "benign";
const scannerStatuses = {
"clawscan-static": "completed",
skillspector: "completed",
virustotal: "completed",
...options?.scannerStatuses,
};
const artifact: Record<string, unknown> = {
@@ -186,12 +185,6 @@ function clawScanArtifactJson(options?: {
issues: [{ id: "SDI-1", severity: "HIGH", explanation: "test finding" }],
},
},
virustotal: {
status: scannerStatuses.virustotal,
raw: {
status: scannerStatuses.virustotal === "skipped" ? "skipped" : "clean",
},
},
"clawscan-static": {
status: scannerStatuses["clawscan-static"],
raw: {
@@ -277,7 +270,6 @@ JSON`,
const workspace = await tempDir();
const fakeClawScan = join(workspace, "fake-clawscan");
const argsLog = join(workspace, "clawscan-args.log");
const virusTotalLog = join(workspace, "virustotal-input.json");
const artifactJson = clawScanArtifactJson({ verdict });
await writeFakeClawScanCommand(
fakeClawScan,
@@ -287,13 +279,9 @@ JSON`,
fi
printf '%s\n' "$@" > ${JSON.stringify(argsLog)}
out=""
while [[ $# -gt 0 ]]; do
case "$1" in
--scanner-result)
cp "\${2#virustotal=}" ${JSON.stringify(virusTotalLog)}
shift 2
;;
--output)
while [[ $# -gt 0 ]]; do
case "$1" in
--output)
out="$2"
shift 2
;;
@@ -348,15 +336,7 @@ JSON`,
expect(invocationArgs).toContain("--profile");
expect(invocationArgs).toContain("clawhub");
expect(invocationArgs).not.toContain("--context");
expect(invocationArgs).toContain("--scanner-result");
const invocationLines = invocationArgs.trim().split("\n");
const scannerResultIndex = invocationLines.indexOf("--scanner-result");
const scannerResult = invocationLines[scannerResultIndex + 1];
expect(scannerResult).toMatch(/^virustotal=/);
expect(JSON.parse(await readFile(virusTotalLog, "utf8"))).toEqual({
status: "completed",
source: "cached-skill-version",
});
expect(invocationArgs).not.toContain("--scanner-result");
} finally {
if (previousCommand === undefined) delete process.env.CODEX_SECURITY_SCAN_CLAWSCAN_COMMAND;
else process.env.CODEX_SECURITY_SCAN_CLAWSCAN_COMMAND = previousCommand;
@@ -422,20 +402,15 @@ JSON`,
const fakeClawScan = join(workspace, "fake-clawscan");
const argsLog = join(workspace, "clawscan-args.log");
const filesLog = join(workspace, "clawscan-files.log");
const virusTotalLog = join(workspace, "virustotal-input.json");
await writeFakeClawScanCommand(
fakeClawScan,
`target="$1"
printf '%s\n' "$@" > ${JSON.stringify(argsLog)}
find "$target" -type f -print | sort > ${JSON.stringify(filesLog)}
out=""
while [[ $# -gt 0 ]]; do
case "$1" in
--scanner-result)
cp "\${2#virustotal=}" ${JSON.stringify(virusTotalLog)}
shift 2
;;
--output)
while [[ $# -gt 0 ]]; do
case "$1" in
--output)
out="$2"
shift 2
;;
@@ -495,14 +470,7 @@ JSON`,
expect.arrayContaining(["--profile", "clawhub", "--output"]),
);
expect(invocationArgs).not.toContain("--context");
const scannerResultIndex = invocationArgs.indexOf("--scanner-result");
expect(scannerResultIndex).toBeGreaterThan(-1);
const scannerResult = invocationArgs[scannerResultIndex + 1];
expect(scannerResult).toMatch(/^virustotal=/);
expect(JSON.parse(await readFile(virusTotalLog, "utf8"))).toEqual({
status: "completed",
source: `${targetKind}-${source}`,
});
expect(invocationArgs).not.toContain("--scanner-result");
expect((await readFile(filesLog, "utf8")).trim().split("\n")).toContain(expectedFile);
} finally {
if (previousCommand === undefined) delete process.env.CODEX_SECURITY_SCAN_CLAWSCAN_COMMAND;
@@ -511,69 +479,6 @@ JSON`,
},
);
it("passes a completed null VirusTotal fixture when cached telemetry is unavailable", async () => {
const workspace = await tempDir();
const fakeClawScan = join(workspace, "fake-clawscan");
const argsLog = join(workspace, "clawscan-args.log");
const virusTotalLog = join(workspace, "virustotal-input.json");
await writeFakeClawScanCommand(
fakeClawScan,
`printf '%s\n' "$@" > ${JSON.stringify(argsLog)}
out=""
while [[ $# -gt 0 ]]; do
case "$1" in
--scanner-result)
cp "\${2#virustotal=}" ${JSON.stringify(virusTotalLog)}
shift 2
;;
--output)
out="$2"
shift 2
;;
*)
shift
;;
esac
done
mkdir -p "$(dirname "$out")"
cat > "$out" <<'JSON'
${clawScanArtifactJson()}
JSON`,
);
const previousCommand = process.env.CODEX_SECURITY_SCAN_CLAWSCAN_COMMAND;
process.env.CODEX_SECURITY_SCAN_CLAWSCAN_COMMAND = fakeClawScan;
try {
const client = {
action: vi.fn(async (..._args: unknown[]) => ({})),
};
const result = await processJob(
client,
"worker-auth",
claimedJob({
jobId: "securityScanJobs:no-cached-vt",
source: "manual",
target: fileTarget("SKILL.md", "# Uploaded skill\n"),
targetKind: "skillScanRequest",
}),
undefined,
);
expect(result).toEqual({
completed: true,
hardFailed: false,
retryableFailed: false,
});
const invocationArgs = (await readFile(argsLog, "utf8")).trim().split("\n");
const scannerResult = invocationArgs[invocationArgs.indexOf("--scanner-result") + 1];
expect(scannerResult).toMatch(/^virustotal=/);
expect(JSON.parse(await readFile(virusTotalLog, "utf8"))).toBeNull();
} finally {
if (previousCommand === undefined) delete process.env.CODEX_SECURITY_SCAN_CLAWSCAN_COMMAND;
else process.env.CODEX_SECURITY_SCAN_CLAWSCAN_COMMAND = previousCommand;
}
});
it.each([
{
source: "bulk-rescan",
@@ -634,11 +539,11 @@ JSON`,
},
);
it("fails the job when VirusTotal scanner status is skipped", async () => {
it("fails the job when SkillSpector scanner status is skipped", async () => {
const workspace = await tempDir();
const fakeClawScan = join(workspace, "fake-clawscan");
const artifactJson = clawScanArtifactJson({
scannerStatuses: { virustotal: "skipped" },
scannerStatuses: { skillspector: "skipped" },
});
await writeFakeClawScanCommand(
fakeClawScan,
@@ -672,7 +577,7 @@ JSON`,
const result = await processJob(
client,
"worker-auth",
skillVersionJob("securityScanJobs:vt-skipped"),
skillVersionJob("securityScanJobs:skillspector-skipped"),
undefined,
);
@@ -683,7 +588,7 @@ JSON`,
});
expect(client.action).toHaveBeenCalledTimes(1);
expect(client.action.mock.calls[0]?.[1]).toMatchObject({
error: "ClawScan scanner virustotal status was skipped",
error: "ClawScan scanner skillspector status was skipped",
});
} finally {
if (previousCommand === undefined) delete process.env.CODEX_SECURITY_SCAN_CLAWSCAN_COMMAND;
+2 -28
View File
@@ -105,7 +105,6 @@ type ClawScanCommandDiagnostic = {
};
scanners?: {
skillspectorStatus?: string;
virustotalStatus?: string;
staticStatus?: string;
};
};
@@ -135,7 +134,7 @@ type ProcessJobResult = {
const DEFAULT_BATCH_LIMIT = 4;
const DEFAULT_MAX_RUNTIME_MS = 40 * 60 * 1000;
const DEFAULT_CLAWSCAN_TIMEOUT_MS = 20 * 60 * 1000;
const REQUIRED_CLAWHUB_SCANNERS = ["clawscan-static", "skillspector", "virustotal"];
const REQUIRED_CLAWHUB_SCANNERS = ["clawscan-static", "skillspector"];
const MAX_DIAGNOSTIC_TEXT_CHARS = 20_000;
const MAX_STORED_SKILLSPECTOR_ISSUES = 25;
const MAX_STORED_SKILLSPECTOR_TEXT_CHARS = 2_000;
@@ -361,7 +360,6 @@ const DIAGNOSTIC_PUBLIC_TEXT_PATHS = new Set([
"clawscanmapping.judge.promptsha256",
"clawscanmapping.judge.outputschemasha256",
"clawscanmapping.scanners.skillspectorstatus",
"clawscanmapping.scanners.virustotalstatus",
"clawscanmapping.scanners.staticstatus",
"llmanalysis.confidence",
"llmanalysis.status",
@@ -789,14 +787,6 @@ async function fileExists(path: string) {
}
}
function cachedVirusTotalAnalysis(job: ClaimedJob) {
return (
(job.target.version as Record<string, unknown> | undefined)?.vtAnalysis ??
(job.target.release as Record<string, unknown> | undefined)?.vtAnalysis ??
null
);
}
function codexEnv() {
const env = { ...process.env };
const codexHome = resolveCodexWorkerHome(process.env, LOCAL_CODEX_HOME);
@@ -1309,7 +1299,6 @@ function clawScanDiagnosticMapping(artifact: Record<string, unknown>) {
scanners: {
skillspectorStatus: scannerStatuses.skillspector,
staticStatus: scannerStatuses["clawscan-static"],
virustotalStatus: scannerStatuses.virustotal,
},
};
}
@@ -1328,7 +1317,6 @@ function validateClawScanArtifactForClawHubProfile(artifact: Record<string, unkn
const allowedScannerStatuses: Record<string, Set<string>> = {
"clawscan-static": new Set(["completed"]),
skillspector: new Set(["completed"]),
virustotal: new Set(["completed"]),
};
for (const [scanner, status] of Object.entries(scannerStatuses)) {
const allowed = allowedScannerStatuses[scanner] ?? new Set(["completed"]);
@@ -1377,22 +1365,8 @@ export async function runClawScan(
) {
const command = process.env.CODEX_SECURITY_SCAN_CLAWSCAN_COMMAND ?? "clawscan";
const artifactPath = join(workspace, "clawscan-artifact.json");
const virusTotalResultPath = join(workspace, "clawscan-virustotal.json");
await writeFile(
virusTotalResultPath,
`${JSON.stringify(cachedVirusTotalAnalysis(job), null, 2)}\n`,
"utf8",
);
const target = await resolveClawScanTarget(workspace, job);
const args = [
target,
"--profile",
"clawhub",
"--scanner-result",
`virustotal=${virusTotalResultPath}`,
"--output",
artifactPath,
];
const args = [target, "--profile", "clawhub", "--output", artifactPath];
onDiagnostic({ args: [command, ...args], artifactPath });
const captureArtifact = async () => {
@@ -661,7 +661,7 @@ while [ "$#" -gt 0 ]; do
shift
done
cat > "$output" <<'JSON'
{"schemaVersion":"clawscan-run-v1","profile":"clawhub","scanners":{"clawscan-static":{"status":"completed"},"skillspector":{"status":"completed"},"virustotal":{"status":"completed"}},"judge":{"status":"failed","error":"Codex request was rate limited.","result":null}}
{"schemaVersion":"clawscan-run-v1","profile":"clawhub","scanners":{"clawscan-static":{"status":"completed"},"skillspector":{"status":"completed"}},"judge":{"status":"failed","error":"Codex request was rate limited.","result":null}}
JSON
`,
);
@@ -131,7 +131,7 @@ describe("security-scan-codex workflow", () => {
const skillspectorInstall = steps.find((step) => step.name === "Install SkillSpector")?.run;
expect(codexInstall).toContain("npm install -g @openai/codex@0.142.3");
expect(codexInstall).not.toContain("@latest");
expect(clawScanInstall).toContain("npm install -g @openclaw/clawscan@0.1.5");
expect(clawScanInstall).toContain("npm install -g @openclaw/clawscan@0.1.6");
expect(clawScanInstall).not.toContain("@latest");
expect(skillspectorInstall).toContain("git+https://github.com/NVIDIA/skillspector.git@8f37cfa");
expect(skillspectorInstall).not.toContain("git+https://github.com/NVIDIA/skillspector.git'");
+4 -7
View File
@@ -271,15 +271,12 @@ See also: [acceptable-usage.md](./acceptable-usage.md) for the marketplace polic
This is a product-facing model only; scanner storage, moderation decisions,
and worker behavior remain separate internally.
- ClawScan verdicts come from a GitHub Actions Codex worker, not a single
hosted LLM call. Publishes enqueue a scan job that waits at most 10 minutes
for VirusTotal telemetry, then Codex reviews the materialized artifact
workspace with static and VT signals as context.
hosted LLM call. Codex reviews the materialized artifact workspace with
SkillSpector and static scan evidence as context.
- Current skill and plugin scans are queued through `securityScanJobs` and
completed by the external Codex worker.
- ClawHub owns the single live VirusTotal scan for a published artifact. The
worker passes the stored full `vtAnalysis` JSON into OSS ClawScan as the
`virustotal` scanner result; it must not give ClawScan a VT credential or
trigger a second live VT request.
- VirusTotal telemetry remains a separate Security audit signal and is not an
input to the production ClawScan profile or judge.
- The worker's explicit artifact-only OSS ClawScan route accepts every claimed
target kind and source through the same completion/failure contract. Skill
versions and scan requests use the isolated `artifact` root; extracted