mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
* fix: redact scan worker artifact errors * fix: harden worker secret logging * fix: keep worker claim failures failing * fix: scan diagnostics as files * fix: pin diagnostics scanner image * fix: narrow worker redaction boundary * fix: avoid custom worker secret patterns * fix: centralize worker transport redaction * fix: redact persisted worker failure secrets * fix: fail security worker on claim outages * fix: redact structured diagnostic secret fields * fix: harden worker failure redaction boundaries * fix: cover bracketed worker secret values * test: enforce worker workflow secret references * fix: redact quoted worker secret values * fix: redact diagnostic artifact path labels * test: cover claim failure redaction * fix: simplify worker redaction boundary * test: prove worker logger emits structured events
1304 lines
41 KiB
TypeScript
1304 lines
41 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import { mkdirSync } from "node:fs";
|
|
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { basename, dirname, join, resolve } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { ConvexHttpClient } from "convex/browser";
|
|
import { api } from "../../convex/_generated/api";
|
|
import type { Id } from "../../convex/_generated/dataModel";
|
|
import {
|
|
detectInjectionPatterns,
|
|
parseLlmEvalResponse,
|
|
type LlmEvalDimension,
|
|
SKILL_SECURITY_EVALUATOR_SYSTEM_PROMPT,
|
|
} from "../../convex/lib/securityPrompt";
|
|
import { assertCodexWorkerExecutionAllowed, resolveCodexWorkerHome } from "../codex-worker-guard";
|
|
import { createWorkerLogger } from "../lib/workerLogger";
|
|
import {
|
|
maskGitHubActionsSecret,
|
|
maskKnownWorkerSecrets,
|
|
redactWorkerPublicErrorMessage,
|
|
redactWorkerPublicText,
|
|
safeWorkerArtifactPathLabel,
|
|
} from "../lib/workerRedaction";
|
|
|
|
type ClaimedJob = {
|
|
job: {
|
|
_id: string;
|
|
leaseToken: string;
|
|
targetKind: "skillVersion" | "packageRelease" | "skillScanRequest";
|
|
source: string;
|
|
hasMaliciousSignal: boolean;
|
|
waitForVtUntil: number;
|
|
attempts?: number;
|
|
};
|
|
target: Record<string, unknown> & {
|
|
files?: Array<{
|
|
path: string;
|
|
url: string;
|
|
size: number;
|
|
sha256: string;
|
|
contentType?: string;
|
|
}>;
|
|
clawpackUrl?: string | null;
|
|
};
|
|
};
|
|
|
|
type StoredLlmAnalysis = {
|
|
status: string;
|
|
verdict?: string;
|
|
confidence?: string;
|
|
summary?: string;
|
|
dimensions?: LlmEvalDimension[];
|
|
guidance?: string;
|
|
findings?: string;
|
|
model?: string;
|
|
checkedAt: number;
|
|
};
|
|
|
|
type SkillSpectorIssue = {
|
|
issueId: string;
|
|
category?: string;
|
|
pattern?: string;
|
|
severity: string;
|
|
confidence?: number;
|
|
file?: string;
|
|
startLine?: number;
|
|
endLine?: number;
|
|
explanation: string;
|
|
remediation?: string;
|
|
finding?: string;
|
|
codeSnippet?: string;
|
|
};
|
|
|
|
type SkillSpectorAnalysis = {
|
|
status: string;
|
|
score?: number;
|
|
severity?: string;
|
|
recommendation?: string;
|
|
issueCount: number;
|
|
issues: SkillSpectorIssue[];
|
|
scannerVersion?: string;
|
|
summary?: string;
|
|
error?: string;
|
|
checkedAt: number;
|
|
};
|
|
|
|
type CodexCommandDiagnostic = {
|
|
args?: string[];
|
|
exitCode?: number | null;
|
|
rawResult?: string;
|
|
stderr?: string;
|
|
stdout?: string;
|
|
};
|
|
|
|
type JobDiagnosticInput = {
|
|
codex?: CodexCommandDiagnostic;
|
|
completedAt: number;
|
|
diagnosticsRoot?: string;
|
|
error?: string;
|
|
job: ClaimedJob;
|
|
llmAnalysis?: unknown;
|
|
runId?: string;
|
|
skillSpector?: CodexCommandDiagnostic;
|
|
skillSpectorAnalysis?: unknown;
|
|
startedAt: number;
|
|
status: "completed" | "failed";
|
|
};
|
|
|
|
type CodexScanWorkerClient = Pick<ConvexHttpClient, "action">;
|
|
|
|
const DEFAULT_BATCH_LIMIT = 4;
|
|
const DEFAULT_MAX_RUNTIME_MS = 40 * 60 * 1000;
|
|
const DEFAULT_CODEX_SCAN_TIMEOUT_MS = 20 * 60 * 1000;
|
|
const MAX_DIAGNOSTIC_TEXT_CHARS = 20_000;
|
|
const MAX_STORED_SKILLSPECTOR_ISSUES = 25;
|
|
const MAX_STORED_SKILLSPECTOR_TEXT_CHARS = 2_000;
|
|
const MAX_STORED_SKILLSPECTOR_SHORT_TEXT_CHARS = 512;
|
|
const DEFAULT_LEASE_MS = 60 * 60 * 1000;
|
|
const logger = createWorkerLogger({ name: "security-scan-worker" });
|
|
|
|
const root = resolve(new URL("../..", import.meta.url).pathname);
|
|
const schemaPath = join(root, "scripts/security/codex-scan-output.schema.json");
|
|
const DEFAULT_DIAGNOSTICS_ROOT = join(
|
|
root,
|
|
".artifacts/codex-security-scan",
|
|
process.env.GITHUB_RUN_ID ?? `local-${process.pid}`,
|
|
);
|
|
const LOCAL_CODEX_HOME = join(root, ".codex/runtime/codex-workers/security-scan");
|
|
const ARTIFACT_SIGNAL_FILE_EXTENSIONS = new Set([
|
|
".cjs",
|
|
".css",
|
|
".html",
|
|
".js",
|
|
".json",
|
|
".jsx",
|
|
".md",
|
|
".mjs",
|
|
".sh",
|
|
".toml",
|
|
".ts",
|
|
".tsx",
|
|
".txt",
|
|
".xml",
|
|
".yaml",
|
|
".yml",
|
|
]);
|
|
|
|
function parseArgs() {
|
|
const args = process.argv.slice(2);
|
|
const get = (name: string) => {
|
|
const index = args.indexOf(name);
|
|
return index === -1 ? undefined : args[index + 1];
|
|
};
|
|
const numberFrom = (value: string | undefined, fallback: number) => {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
};
|
|
const optionalNumberFrom = (value: string | undefined) => {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
};
|
|
return {
|
|
batchLimit: numberFrom(
|
|
get("--batch-limit") ?? get("--limit") ?? process.env.CODEX_SECURITY_SCAN_LIMIT,
|
|
DEFAULT_BATCH_LIMIT,
|
|
),
|
|
maxJobs: optionalNumberFrom(get("--max-jobs") ?? process.env.CODEX_SECURITY_SCAN_MAX_JOBS),
|
|
maxRuntimeMs:
|
|
numberFrom(
|
|
get("--max-runtime-minutes") ?? process.env.CODEX_SECURITY_SCAN_MAX_RUNTIME_MINUTES,
|
|
DEFAULT_MAX_RUNTIME_MS / 60_000,
|
|
) * 60_000,
|
|
leaseMs:
|
|
numberFrom(
|
|
get("--lease-minutes") ?? process.env.CODEX_SECURITY_SCAN_LEASE_MINUTES,
|
|
DEFAULT_LEASE_MS / 60_000,
|
|
) * 60_000,
|
|
diagnosticsRoot:
|
|
get("--diagnostics-dir") ??
|
|
process.env.CODEX_SECURITY_SCAN_DIAGNOSTICS_DIR ??
|
|
DEFAULT_DIAGNOSTICS_ROOT,
|
|
};
|
|
}
|
|
|
|
function requireEnv(name: string) {
|
|
const value = process.env[name]?.trim();
|
|
if (!value) throw new Error(`${name} is required`);
|
|
return value;
|
|
}
|
|
|
|
function safeDiagnosticPathSegment(value: string) {
|
|
return value.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 160) || "job";
|
|
}
|
|
|
|
function redactDiagnosticText(value: string, maxChars = MAX_DIAGNOSTIC_TEXT_CHARS) {
|
|
return redactWorkerPublicText(value, maxChars);
|
|
}
|
|
|
|
function redactDiagnosticError(value: string) {
|
|
return redactDiagnosticText(value).replace(
|
|
/(Codex result did not match ClawScan schema)(?::[\s\S]*)?/i,
|
|
"$1: [redacted result body]",
|
|
);
|
|
}
|
|
|
|
function sanitizeWorkerErrorMessage(value: string) {
|
|
return redactWorkerPublicErrorMessage(redactDiagnosticError(value));
|
|
}
|
|
|
|
const DIAGNOSTIC_CONTENT_TEXT_KEYS = new Set([
|
|
"codesnippet",
|
|
"content",
|
|
"detail",
|
|
"evidence",
|
|
"explanation",
|
|
"finding",
|
|
"findings",
|
|
"guidance",
|
|
"match",
|
|
"message",
|
|
"note",
|
|
"notes",
|
|
"output",
|
|
"rawresult",
|
|
"recommendation",
|
|
"result",
|
|
"snippet",
|
|
"stderr",
|
|
"stdout",
|
|
"summary",
|
|
"text",
|
|
"userimpact",
|
|
]);
|
|
const DIAGNOSTIC_PUBLIC_TEXT_PATHS = new Set([
|
|
"codexresult.verdict",
|
|
"codexstdout.item.id",
|
|
"codexstdout.item.type",
|
|
"codexstdout.status",
|
|
"codexstdout.type",
|
|
"llmanalysis.confidence",
|
|
"llmanalysis.status",
|
|
"llmanalysis.verdict",
|
|
"skillspectoranalysis.issues.*.issueid",
|
|
"skillspectoranalysis.issues.*.severity",
|
|
"skillspectoranalysis.recommendation",
|
|
"skillspectoranalysis.scannerversion",
|
|
"skillspectoranalysis.severity",
|
|
"skillspectoranalysis.status",
|
|
]);
|
|
const DIAGNOSTIC_PUBLIC_TEXT_VALUE_PATTERN = /^[A-Za-z0-9_.:@/-]{1,160}$/;
|
|
|
|
function normalizeDiagnosticKey(key: string) {
|
|
return key.replace(/[_-]/g, "").toLowerCase();
|
|
}
|
|
|
|
function diagnosticPathKey(path: string[]) {
|
|
return path.map((part) => (part === "*" ? part : normalizeDiagnosticKey(part))).join(".");
|
|
}
|
|
|
|
function isDiagnosticContentTextPath(path: string[]) {
|
|
const key = path.at(-1) ?? "";
|
|
return DIAGNOSTIC_CONTENT_TEXT_KEYS.has(normalizeDiagnosticKey(key));
|
|
}
|
|
|
|
function isDiagnosticSecretPath(path: string[]) {
|
|
const key = normalizeDiagnosticKey(path.at(-1) ?? "");
|
|
return /(apikey|authorization|credential|password|secret|token|webhook)/i.test(key);
|
|
}
|
|
|
|
function shouldPreserveDiagnosticText(path: string[], original: string, redacted: string) {
|
|
return (
|
|
original === redacted &&
|
|
DIAGNOSTIC_PUBLIC_TEXT_PATHS.has(diagnosticPathKey(path)) &&
|
|
DIAGNOSTIC_PUBLIC_TEXT_VALUE_PATTERN.test(redacted)
|
|
);
|
|
}
|
|
|
|
function redactDiagnosticValue(value: unknown, path: string[] = []): unknown {
|
|
if (isDiagnosticSecretPath(path)) return "[redacted-secret]";
|
|
if (typeof value === "string") {
|
|
const redacted = redactDiagnosticText(value, 2_000);
|
|
if (shouldPreserveDiagnosticText(path, value, redacted)) return redacted;
|
|
return `[redacted ${redacted.length} chars]`;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
if (isDiagnosticContentTextPath(path)) return `[redacted ${value.length} item(s)]`;
|
|
return value.map((item) => redactDiagnosticValue(item, [...path, "*"]));
|
|
}
|
|
if (!value || typeof value !== "object") return value;
|
|
return Object.fromEntries(
|
|
Object.entries(value as Record<string, unknown>).map(([entryKey, entryValue]) => [
|
|
entryKey,
|
|
redactDiagnosticValue(entryValue, [...path, entryKey]),
|
|
]),
|
|
);
|
|
}
|
|
|
|
function redactStructuredDiagnosticText(value: string, rootKey: string) {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return "";
|
|
try {
|
|
return JSON.stringify(redactDiagnosticValue(JSON.parse(trimmed), [rootKey]), null, 2);
|
|
} catch {
|
|
// Codex --json writes JSONL. Redact parseable lines structurally, then fall back to text redaction.
|
|
const lines = value.split("\n");
|
|
if (lines.some((line) => line.trim().startsWith("{"))) {
|
|
return lines
|
|
.map((line) => {
|
|
if (!line.trim()) return line;
|
|
try {
|
|
return JSON.stringify(redactDiagnosticValue(JSON.parse(line), [rootKey]));
|
|
} catch {
|
|
return redactDiagnosticText(line, 2_000);
|
|
}
|
|
})
|
|
.join("\n");
|
|
}
|
|
return redactDiagnosticText(value);
|
|
}
|
|
}
|
|
|
|
function pickIdentity(record: unknown, fields: string[]) {
|
|
if (!record || typeof record !== "object") return undefined;
|
|
const source = record as Record<string, unknown>;
|
|
const picked: Record<string, unknown> = {};
|
|
for (const field of fields) {
|
|
if (source[field] !== undefined) picked[field] = source[field];
|
|
}
|
|
return Object.keys(picked).length > 0 ? picked : undefined;
|
|
}
|
|
|
|
function sanitizedTargetForDiagnostic(target: ClaimedJob["target"]) {
|
|
return {
|
|
skill: pickIdentity(target.skill, ["_id", "slug", "displayName", "name"]),
|
|
version: pickIdentity(target.version, ["_id", "version", "sha256hash"]),
|
|
package: pickIdentity(target.package, ["_id", "name", "normalizedName"]),
|
|
release: pickIdentity(target.release, ["_id", "version", "integritySha256"]),
|
|
files: target.files?.map(({ url: _url, ...file }) => ({
|
|
...file,
|
|
path: safeWorkerArtifactPathLabel(file.path),
|
|
})),
|
|
clawpackUrl: Boolean(target.clawpackUrl),
|
|
trustedOpenClawPlugin: target.trustedOpenClawPlugin,
|
|
};
|
|
}
|
|
|
|
function sanitizedJobForArtifactContext(job: ClaimedJob["job"]) {
|
|
const { leaseToken: _leaseToken, ...safeJob } = job;
|
|
return safeJob;
|
|
}
|
|
|
|
function sanitizedTargetForArtifactContext(target: ClaimedJob["target"]) {
|
|
const { clawpackUrl, files, job: _job, ...safeTarget } = target;
|
|
return {
|
|
...safeTarget,
|
|
files: files?.map(({ url: _url, ...file }) => file),
|
|
clawpackUrl: Boolean(clawpackUrl),
|
|
};
|
|
}
|
|
|
|
async function writeDiagnosticText(
|
|
jobDir: string,
|
|
fileName: string,
|
|
value: string | undefined,
|
|
rootKey: string,
|
|
) {
|
|
if (value === undefined) return undefined;
|
|
const redacted = redactStructuredDiagnosticText(value, rootKey);
|
|
await writeFile(join(jobDir, fileName), redacted.endsWith("\n") ? redacted : `${redacted}\n`);
|
|
return fileName;
|
|
}
|
|
|
|
export async function writeJobDiagnostic(input: JobDiagnosticInput) {
|
|
if (!input.diagnosticsRoot) return;
|
|
const jobDir = join(input.diagnosticsRoot, safeDiagnosticPathSegment(input.job.job._id));
|
|
await mkdir(jobDir, { recursive: true });
|
|
|
|
const stdoutPath = await writeDiagnosticText(
|
|
jobDir,
|
|
"codex.stdout.redacted.jsonl",
|
|
input.codex?.stdout,
|
|
"codexStdout",
|
|
);
|
|
const stderrPath = await writeDiagnosticText(
|
|
jobDir,
|
|
"codex.stderr.redacted.log",
|
|
input.codex?.stderr,
|
|
"codexStderr",
|
|
);
|
|
const rawResultPath = await writeDiagnosticText(
|
|
jobDir,
|
|
"codex-result.redacted.json",
|
|
input.codex?.rawResult,
|
|
"codexResult",
|
|
);
|
|
const skillSpectorStdoutPath = await writeDiagnosticText(
|
|
jobDir,
|
|
"skillspector.stdout.redacted.log",
|
|
input.skillSpector?.stdout,
|
|
"skillSpectorStdout",
|
|
);
|
|
const skillSpectorStderrPath = await writeDiagnosticText(
|
|
jobDir,
|
|
"skillspector.stderr.redacted.log",
|
|
input.skillSpector?.stderr,
|
|
"skillSpectorStderr",
|
|
);
|
|
const skillSpectorRawResultPath = await writeDiagnosticText(
|
|
jobDir,
|
|
"skillspector-result.redacted.json",
|
|
input.skillSpector?.rawResult,
|
|
"skillSpectorResult",
|
|
);
|
|
|
|
const diagnostic = {
|
|
completedAt: input.completedAt,
|
|
durationMs: input.completedAt - input.startedAt,
|
|
error: input.error ? redactDiagnosticError(input.error) : undefined,
|
|
job: {
|
|
attempts: input.job.job.attempts,
|
|
hasMaliciousSignal: input.job.job.hasMaliciousSignal,
|
|
id: input.job.job._id,
|
|
source: input.job.job.source,
|
|
targetKind: input.job.job.targetKind,
|
|
waitForVtUntil: input.job.job.waitForVtUntil,
|
|
},
|
|
llmAnalysis: redactDiagnosticValue(input.llmAnalysis, ["llmAnalysis"]),
|
|
runId: input.runId,
|
|
skillSpectorAnalysis: redactDiagnosticValue(input.skillSpectorAnalysis, [
|
|
"skillSpectorAnalysis",
|
|
]),
|
|
startedAt: input.startedAt,
|
|
status: input.status,
|
|
target: sanitizedTargetForDiagnostic(input.job.target),
|
|
codex: {
|
|
args: input.codex?.args,
|
|
exitCode: input.codex?.exitCode,
|
|
rawResultPath,
|
|
stderrPath,
|
|
stdoutPath,
|
|
},
|
|
skillSpector: {
|
|
args: input.skillSpector?.args,
|
|
exitCode: input.skillSpector?.exitCode,
|
|
rawResultPath: skillSpectorRawResultPath,
|
|
stderrPath: skillSpectorStderrPath,
|
|
stdoutPath: skillSpectorStdoutPath,
|
|
},
|
|
};
|
|
|
|
await writeFile(join(jobDir, "diagnostic.json"), `${JSON.stringify(diagnostic, null, 2)}\n`);
|
|
}
|
|
|
|
function safeOutputPath(workspace: string, artifactPath: string) {
|
|
const normalized = artifactPath.replace(/^\/+/, "");
|
|
const out = resolve(workspace, "artifact", normalized);
|
|
const artifactRoot = resolve(workspace, "artifact");
|
|
if (!out.startsWith(`${artifactRoot}/`) && out !== artifactRoot) {
|
|
throw new Error(`Unsafe artifact path: ${safeWorkerArtifactPathLabel(artifactPath)}`);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function artifactDownloadDescription(kind: "file" | "clawpack", artifactPath: string) {
|
|
const safePath = safeWorkerArtifactPathLabel(artifactPath);
|
|
return kind === "file" ? `artifact file ${safePath}` : `artifact tarball ${safePath}`;
|
|
}
|
|
|
|
async function download(url: string, artifact: { kind: "file" | "clawpack"; path: string }) {
|
|
maskGitHubActionsSecret(url);
|
|
const description = artifactDownloadDescription(artifact.kind, artifact.path);
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(url);
|
|
} catch {
|
|
throw new Error(`Download failed for ${description}: network error`, {
|
|
cause: new Error("network error"),
|
|
});
|
|
}
|
|
if (!response.ok) throw new Error(`Download failed ${response.status} for ${description}`);
|
|
return Buffer.from(await response.arrayBuffer());
|
|
}
|
|
|
|
export async function writeArtifactWorkspace(job: ClaimedJob, workspace: string) {
|
|
await mkdir(join(workspace, "artifact"), { recursive: true });
|
|
const metadata = {
|
|
job: sanitizedJobForArtifactContext(job.job),
|
|
target: sanitizedTargetForArtifactContext(job.target),
|
|
policy: {
|
|
virusTotal: "telemetry-only; never final classifier; do not hide solely from VT",
|
|
maliciousSignalHold:
|
|
"if non-VT malicious signals held the artifact, Codex decides whether to release or hide",
|
|
openclawPluginTrust:
|
|
"plugins under @openclaw owned by the OpenClaw publisher are trusted unless artifact evidence proves malicious behavior",
|
|
},
|
|
};
|
|
await writeFile(join(workspace, "metadata.json"), `${JSON.stringify(metadata, null, 2)}\n`);
|
|
|
|
for (const file of job.target.files ?? []) {
|
|
const out = safeOutputPath(workspace, file.path);
|
|
await mkdir(dirname(out), { recursive: true });
|
|
await writeFile(out, await download(file.url, { kind: "file", path: file.path }));
|
|
}
|
|
|
|
if (job.target.clawpackUrl) {
|
|
const tarballPath = join(workspace, "artifact.tgz");
|
|
await writeFile(
|
|
tarballPath,
|
|
await download(job.target.clawpackUrl, { kind: "clawpack", path: "artifact.tgz" }),
|
|
);
|
|
const listing = await runCommand("tar", ["-tzf", tarballPath], {
|
|
cwd: workspace,
|
|
timeoutMs: 60_000,
|
|
});
|
|
for (const entry of listing.stdout.split("\n").filter(Boolean)) {
|
|
if (entry.startsWith("/") || entry.split("/").includes("..")) {
|
|
throw new Error(`Unsafe tarball entry: ${entry}`);
|
|
}
|
|
}
|
|
const verboseListing = await runCommand("tar", ["-tvzf", tarballPath], {
|
|
cwd: workspace,
|
|
timeoutMs: 60_000,
|
|
});
|
|
if (verboseListing.stdout.split("\n").some((line) => /^[lh]/.test(line))) {
|
|
throw new Error("Refusing to extract tarball containing links");
|
|
}
|
|
await runCommand("tar", ["-xzf", tarballPath, "-C", join(workspace, "artifact")], {
|
|
cwd: workspace,
|
|
timeoutMs: 60_000,
|
|
});
|
|
}
|
|
}
|
|
|
|
function shouldReadArtifactSignalFile(path: string) {
|
|
const lower = path.toLowerCase();
|
|
if (lower.endsWith("/skill.md") || lower.endsWith("/package.json")) return true;
|
|
return ARTIFACT_SIGNAL_FILE_EXTENSIONS.has(lower.slice(lower.lastIndexOf(".")));
|
|
}
|
|
|
|
async function collectArtifactSignalText(dir: string, maxBytes = 1_000_000) {
|
|
let remaining = maxBytes;
|
|
const chunks: string[] = [];
|
|
|
|
async function visit(current: string) {
|
|
if (remaining <= 0) return;
|
|
for (const entry of await readdir(current, { withFileTypes: true })) {
|
|
if (remaining <= 0) return;
|
|
const path = join(current, entry.name);
|
|
if (entry.isDirectory()) {
|
|
await visit(path);
|
|
continue;
|
|
}
|
|
if (!entry.isFile() || !shouldReadArtifactSignalFile(path)) continue;
|
|
const bytes = await readFile(path);
|
|
const slice = bytes.subarray(0, Math.min(bytes.byteLength, remaining));
|
|
chunks.push(slice.toString("utf8"));
|
|
remaining -= slice.byteLength;
|
|
}
|
|
}
|
|
|
|
await visit(dir);
|
|
return chunks.join("\n");
|
|
}
|
|
|
|
async function fileExists(path: string) {
|
|
try {
|
|
await readFile(path);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function resolveSkillSpectorScanInput(workspace: string) {
|
|
const extractedPackageRoot = join(workspace, "artifact", "package");
|
|
const hasClawPackExtraction =
|
|
(await fileExists(join(workspace, "artifact.tgz"))) &&
|
|
(await fileExists(join(extractedPackageRoot, "package.json")));
|
|
return hasClawPackExtraction ? "artifact/package" : "artifact";
|
|
}
|
|
|
|
async function runSkillSpector(
|
|
workspace: string,
|
|
onDiagnostic: (diagnostic: Partial<CodexCommandDiagnostic>) => void,
|
|
) {
|
|
const resultPath = join(workspace, "skillspector-report.json");
|
|
const scanInput = await resolveSkillSpectorScanInput(workspace);
|
|
const args = ["scan", scanInput, "--format", "json", "--output", resultPath];
|
|
onDiagnostic({ args });
|
|
try {
|
|
const output = await runCommand("skillspector", args, {
|
|
cwd: workspace,
|
|
timeoutMs: codexScanTimeoutMs(),
|
|
});
|
|
const raw = await readFile(resultPath, "utf8");
|
|
onDiagnostic({ exitCode: 0, rawResult: raw, stderr: output.stderr, stdout: output.stdout });
|
|
return normalizeSkillSpectorAnalysis(raw);
|
|
} catch (error) {
|
|
if (error instanceof CommandFailure) {
|
|
let rawResult: string | undefined;
|
|
try {
|
|
rawResult = await readFile(resultPath, "utf8");
|
|
} catch {
|
|
rawResult = undefined;
|
|
}
|
|
onDiagnostic({
|
|
exitCode: error.exitCode,
|
|
rawResult,
|
|
stderr: error.stderr,
|
|
stdout: error.stdout,
|
|
});
|
|
if (rawResult) {
|
|
try {
|
|
return normalizeSkillSpectorAnalysis(rawResult);
|
|
} catch {
|
|
// Fall through to an error-shaped analysis; diagnostics keep the raw report.
|
|
}
|
|
}
|
|
}
|
|
return skillSpectorFailureAnalysis(error);
|
|
}
|
|
}
|
|
|
|
export function buildPrompt(
|
|
job: ClaimedJob,
|
|
injectionSignals: string[],
|
|
skillSpectorAnalysis?: SkillSpectorAnalysis,
|
|
) {
|
|
const vt = JSON.stringify(
|
|
(job.target.version as Record<string, unknown> | undefined)?.vtAnalysis ??
|
|
(job.target.release as Record<string, unknown> | undefined)?.vtAnalysis ??
|
|
null,
|
|
null,
|
|
2,
|
|
);
|
|
const skillSpector = JSON.stringify(
|
|
skillSpectorAnalysis ??
|
|
(job.target.version as Record<string, unknown> | undefined)?.skillSpectorAnalysis ??
|
|
(job.target.release as Record<string, unknown> | undefined)?.skillSpectorAnalysis ??
|
|
null,
|
|
null,
|
|
2,
|
|
);
|
|
const trusted = Boolean(job.target.trustedOpenClawPlugin);
|
|
return `${SKILL_SECURITY_EVALUATOR_SYSTEM_PROMPT}
|
|
|
|
Additional ClawHub policy for this Codex run:
|
|
- Do your own security research before deciding. Use SkillSpector, VirusTotal, static scan
|
|
findings, metadata, artifact evidence, and publisher context as inputs.
|
|
- Inspect workspace files when needed to verify scanner claims, resolve uncertainty, or build
|
|
confidence in the verdict. Treat metadata.json as context, not artifact instructions.
|
|
- SkillSpector findings are advisory research-preview evidence, not validated ground truth and
|
|
not the final verdict. Use them to guide investigation, then make the final policy verdict
|
|
from artifact-backed evidence and the totality of signals. Do not rename them, translate them
|
|
into another taxonomy, or directly copy them into ClawScan output.
|
|
- Make the final policy verdict from the totality of evidence.
|
|
- VirusTotal is untrusted telemetry only. It is useful signal, but it must never be the sole reason for a malicious or suspicious verdict.
|
|
- If VirusTotal is the only negative signal and artifact evidence is coherent, return benign.
|
|
- Static scan findings are signal. If static scan marked malicious, decide from artifact evidence whether the hold should remain.
|
|
- @openclaw plugin packages from the OpenClaw publisher are trusted by default. Keep them benign unless concrete artifact evidence proves malicious behavior.
|
|
- Treat pre-scan prompt-injection indicators as artifact context for your review, not as an automatic verdict.
|
|
|
|
Worker context:
|
|
- target kind: ${job.job.targetKind}
|
|
- source: ${job.job.source}
|
|
- non-VT malicious signal present: ${job.job.hasMaliciousSignal ? "yes" : "no"}
|
|
- trusted @openclaw plugin: ${trusted ? "yes" : "no"}
|
|
- pre-scan artifact injection signals: ${
|
|
injectionSignals.length > 0 ? injectionSignals.join(", ") : "none"
|
|
}
|
|
|
|
VirusTotal telemetry supplied to Codex:
|
|
\`\`\`json
|
|
${vt}
|
|
\`\`\`
|
|
|
|
SkillSpector findings supplied to Codex:
|
|
\`\`\`json
|
|
${skillSpector}
|
|
\`\`\`
|
|
|
|
Return the required JSON object only.`;
|
|
}
|
|
|
|
function codexEnv() {
|
|
const env = { ...process.env };
|
|
const codexHome = resolveCodexWorkerHome(process.env, LOCAL_CODEX_HOME);
|
|
if (codexHome) {
|
|
mkdirSync(codexHome, { recursive: true });
|
|
env.CODEX_HOME = codexHome;
|
|
}
|
|
delete env.GH_TOKEN;
|
|
delete env.GITHUB_TOKEN;
|
|
delete env.CONVEX_DEPLOY_KEY;
|
|
delete env.SECURITY_SCAN_WORKER_TOKEN;
|
|
delete env.HOMEBREW_GITHUB_API_TOKEN;
|
|
env.NO_COLOR = "1";
|
|
env.SKILLSPECTOR_PROVIDER = env.SKILLSPECTOR_PROVIDER || "openai";
|
|
return env;
|
|
}
|
|
|
|
class CommandFailure extends Error {
|
|
exitCode: number | null;
|
|
stderr: string;
|
|
stdout: string;
|
|
|
|
constructor(message: string, exitCode: number | null, stdout: string, stderr: string) {
|
|
super(message);
|
|
this.name = "CommandFailure";
|
|
this.exitCode = exitCode;
|
|
this.stdout = stdout;
|
|
this.stderr = stderr;
|
|
}
|
|
}
|
|
|
|
async function runCommand(
|
|
command: string,
|
|
args: string[],
|
|
options: { cwd: string; input?: string; timeoutMs: number },
|
|
) {
|
|
return await new Promise<{ stdout: string; stderr: string }>((resolvePromise, reject) => {
|
|
const child = spawn(command, args, {
|
|
cwd: options.cwd,
|
|
env: codexEnv(),
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
});
|
|
let stdout = "";
|
|
let stderr = "";
|
|
let timedOut = false;
|
|
const timeout = setTimeout(() => {
|
|
timedOut = true;
|
|
child.kill("SIGTERM");
|
|
setTimeout(() => child.kill("SIGKILL"), 10_000).unref();
|
|
}, options.timeoutMs);
|
|
child.stdout.on("data", (chunk) => {
|
|
stdout += String(chunk);
|
|
});
|
|
child.stderr.on("data", (chunk) => {
|
|
stderr += String(chunk);
|
|
});
|
|
child.on("error", (error) => {
|
|
clearTimeout(timeout);
|
|
reject(error);
|
|
});
|
|
child.on("close", (code) => {
|
|
clearTimeout(timeout);
|
|
if (code === 0) resolvePromise({ stdout, stderr });
|
|
else {
|
|
reject(
|
|
new CommandFailure(
|
|
`${command} ${timedOut ? "timed out" : `exited ${code}`}; see redacted stdout/stderr diagnostics`,
|
|
code,
|
|
stdout,
|
|
stderr,
|
|
),
|
|
);
|
|
}
|
|
});
|
|
if (options.input) child.stdin.end(options.input);
|
|
else child.stdin.end();
|
|
});
|
|
}
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: undefined;
|
|
}
|
|
|
|
function readField(record: Record<string, unknown>, names: string[]) {
|
|
for (const name of names) {
|
|
if (record[name] !== undefined && record[name] !== null) return record[name];
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function readString(record: Record<string, unknown>, names: string[]) {
|
|
const value = readField(record, names);
|
|
if (typeof value === "string") {
|
|
const trimmed = value.trim();
|
|
return trimmed || undefined;
|
|
}
|
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
return undefined;
|
|
}
|
|
|
|
function readNumber(record: Record<string, unknown>, names: string[]) {
|
|
const value = readField(record, names);
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
if (typeof value === "string") {
|
|
const parsed = Number(value.replace(/%$/, "").trim());
|
|
if (Number.isFinite(parsed)) return parsed;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function readNestedRecord(record: Record<string, unknown>, names: string[]) {
|
|
const value = readField(record, names);
|
|
return asRecord(value);
|
|
}
|
|
|
|
function readStringFromNested(
|
|
record: Record<string, unknown>,
|
|
nestedNames: string[],
|
|
fieldNames: string[],
|
|
) {
|
|
const nested = readNestedRecord(record, nestedNames);
|
|
return nested ? readString(nested, fieldNames) : undefined;
|
|
}
|
|
|
|
function readNumberFromNested(
|
|
record: Record<string, unknown>,
|
|
nestedNames: string[],
|
|
fieldNames: string[],
|
|
) {
|
|
const nested = readNestedRecord(record, nestedNames);
|
|
return nested ? readNumber(nested, fieldNames) : undefined;
|
|
}
|
|
|
|
function normalizeConfidence(value: number | undefined) {
|
|
if (value === undefined) return undefined;
|
|
if (value > 1) return Math.max(0, Math.min(1, value / 100));
|
|
return Math.max(0, Math.min(1, value));
|
|
}
|
|
|
|
function normalizeSkillSpectorIssue(input: unknown, index: number): SkillSpectorIssue | null {
|
|
const record = asRecord(input);
|
|
if (!record) return null;
|
|
const issueId =
|
|
readString(record, ["rule_id", "ruleId", "issue_id", "issueId", "id", "pattern_id"]) ??
|
|
`skillspector-${index + 1}`;
|
|
const pattern = readString(record, [
|
|
"pattern",
|
|
"rule_name",
|
|
"ruleName",
|
|
"name",
|
|
"title",
|
|
"message",
|
|
]);
|
|
const severity = (
|
|
readString(record, ["severity", "risk_severity", "level"]) ?? "UNKNOWN"
|
|
).toUpperCase();
|
|
const explanation =
|
|
readString(record, ["explanation", "message", "description", "reason", "details"]) ??
|
|
pattern ??
|
|
issueId;
|
|
const confidence = normalizeConfidence(readNumber(record, ["confidence", "score"]));
|
|
const file =
|
|
readString(record, ["file", "file_path", "filePath", "path"]) ??
|
|
readStringFromNested(record, ["location"], ["file", "path"]);
|
|
const startLine =
|
|
readNumber(record, ["line", "line_number", "lineNumber", "start_line", "startLine"]) ??
|
|
readNumberFromNested(record, ["location"], ["line", "start_line", "startLine"]);
|
|
const endLine =
|
|
readNumber(record, ["end_line", "endLine"]) ??
|
|
readNumberFromNested(record, ["location"], ["end_line", "endLine"]);
|
|
return {
|
|
issueId,
|
|
category: readString(record, ["category", "analyzer", "type"]),
|
|
pattern,
|
|
severity,
|
|
confidence,
|
|
file,
|
|
startLine,
|
|
endLine,
|
|
explanation,
|
|
remediation: readString(record, ["remediation", "recommendation", "fix", "mitigation"]),
|
|
finding: readString(record, ["finding", "match", "evidence"]),
|
|
codeSnippet: readString(record, ["code_snippet", "codeSnippet", "snippet"]),
|
|
};
|
|
}
|
|
|
|
function truncateStoredSkillSpectorText(
|
|
value: string | undefined,
|
|
maxChars = MAX_STORED_SKILLSPECTOR_TEXT_CHARS,
|
|
) {
|
|
if (value === undefined) return undefined;
|
|
if (value.length <= maxChars) return value;
|
|
return `${value.slice(0, maxChars)}\n...[truncated ${value.length - maxChars} chars]`;
|
|
}
|
|
|
|
function compactSkillSpectorIssue(issue: SkillSpectorIssue): SkillSpectorIssue {
|
|
return {
|
|
issueId:
|
|
truncateStoredSkillSpectorText(issue.issueId, MAX_STORED_SKILLSPECTOR_SHORT_TEXT_CHARS) ??
|
|
"skillspector-issue",
|
|
category: truncateStoredSkillSpectorText(
|
|
issue.category,
|
|
MAX_STORED_SKILLSPECTOR_SHORT_TEXT_CHARS,
|
|
),
|
|
pattern: truncateStoredSkillSpectorText(
|
|
issue.pattern,
|
|
MAX_STORED_SKILLSPECTOR_SHORT_TEXT_CHARS,
|
|
),
|
|
severity:
|
|
truncateStoredSkillSpectorText(issue.severity, MAX_STORED_SKILLSPECTOR_SHORT_TEXT_CHARS) ??
|
|
"UNKNOWN",
|
|
confidence: issue.confidence,
|
|
file: truncateStoredSkillSpectorText(issue.file, MAX_STORED_SKILLSPECTOR_SHORT_TEXT_CHARS),
|
|
startLine: issue.startLine,
|
|
endLine: issue.endLine,
|
|
explanation:
|
|
truncateStoredSkillSpectorText(issue.explanation) ??
|
|
"SkillSpector reported this issue without additional explanation.",
|
|
remediation: truncateStoredSkillSpectorText(issue.remediation),
|
|
finding: truncateStoredSkillSpectorText(issue.finding),
|
|
codeSnippet: truncateStoredSkillSpectorText(issue.codeSnippet),
|
|
};
|
|
}
|
|
|
|
function normalizeSkillSpectorStatus(params: {
|
|
rawStatus?: string;
|
|
recommendation?: string;
|
|
score?: number;
|
|
issueCount: number;
|
|
}) {
|
|
const rawStatus = params.rawStatus?.trim().toLowerCase();
|
|
if (rawStatus) {
|
|
if (rawStatus === "benign" || rawStatus === "safe") return "clean";
|
|
if (["clean", "suspicious", "malicious", "error", "failed"].includes(rawStatus)) {
|
|
return rawStatus;
|
|
}
|
|
}
|
|
const recommendation = params.recommendation?.trim().toLowerCase() ?? "";
|
|
if (recommendation.includes("safe")) return "clean";
|
|
if (params.issueCount > 0) return "suspicious";
|
|
if (typeof params.score === "number" && params.score > 20) return "suspicious";
|
|
return "clean";
|
|
}
|
|
|
|
export function normalizeSkillSpectorAnalysis(
|
|
raw: string,
|
|
checkedAt = Date.now(),
|
|
): SkillSpectorAnalysis {
|
|
const parsed = JSON.parse(raw) as unknown;
|
|
const record = asRecord(parsed);
|
|
if (!record) {
|
|
return {
|
|
status: "error",
|
|
issueCount: 0,
|
|
issues: [],
|
|
error: "SkillSpector returned a non-object JSON report.",
|
|
checkedAt,
|
|
};
|
|
}
|
|
const rawIssues = readField(record, [
|
|
"filtered_findings",
|
|
"filteredFindings",
|
|
"findings",
|
|
"issues",
|
|
"vulnerabilities",
|
|
]);
|
|
const rawIssueList = Array.isArray(rawIssues) ? rawIssues : [];
|
|
const issues = rawIssueList
|
|
.slice(0, MAX_STORED_SKILLSPECTOR_ISSUES)
|
|
.map((issue, index) => normalizeSkillSpectorIssue(issue, index))
|
|
.filter((issue): issue is SkillSpectorIssue => Boolean(issue))
|
|
.map(compactSkillSpectorIssue);
|
|
const score =
|
|
readNumber(record, ["risk_score", "riskScore", "score"]) ??
|
|
readNumberFromNested(record, ["risk_assessment", "riskAssessment"], ["score"]);
|
|
const severity =
|
|
readString(record, ["risk_severity", "riskSeverity", "severity"]) ??
|
|
readStringFromNested(record, ["risk_assessment", "riskAssessment"], ["severity"]);
|
|
const recommendation =
|
|
readString(record, ["risk_recommendation", "riskRecommendation", "recommendation"]) ??
|
|
readStringFromNested(
|
|
record,
|
|
["risk_assessment", "riskAssessment"],
|
|
["recommendation", "risk_recommendation", "riskRecommendation"],
|
|
);
|
|
const issueCount =
|
|
readNumber(record, ["issue_count", "issueCount", "finding_count", "findingCount"]) ??
|
|
rawIssueList.length;
|
|
return {
|
|
status: normalizeSkillSpectorStatus({
|
|
rawStatus: readString(record, ["status"]),
|
|
recommendation,
|
|
score,
|
|
issueCount,
|
|
}),
|
|
score,
|
|
severity,
|
|
recommendation,
|
|
issueCount,
|
|
issues,
|
|
scannerVersion: truncateStoredSkillSpectorText(
|
|
readString(record, ["scanner_version", "scannerVersion", "version"]) ??
|
|
readStringFromNested(
|
|
record,
|
|
["metadata"],
|
|
["skillspector_version", "skillspectorVersion", "version"],
|
|
),
|
|
MAX_STORED_SKILLSPECTOR_SHORT_TEXT_CHARS,
|
|
),
|
|
summary: truncateStoredSkillSpectorText(readString(record, ["summary", "analysis"])),
|
|
checkedAt,
|
|
};
|
|
}
|
|
|
|
function skillSpectorFailureAnalysis(error: unknown, checkedAt = Date.now()): SkillSpectorAnalysis {
|
|
return {
|
|
status: "error",
|
|
issueCount: 0,
|
|
issues: [],
|
|
scannerVersion: "skillspector",
|
|
error: error instanceof Error ? error.message : String(error),
|
|
checkedAt,
|
|
};
|
|
}
|
|
|
|
function verdictToStatus(verdict: string) {
|
|
return verdict === "benign" ? "clean" : verdict;
|
|
}
|
|
|
|
function toStoredLlmAnalysis(parsed: NonNullable<ReturnType<typeof parseLlmEvalResponse>>) {
|
|
return {
|
|
status: verdictToStatus(parsed.verdict),
|
|
verdict: parsed.verdict,
|
|
confidence: parsed.confidence,
|
|
summary: parsed.summary,
|
|
dimensions: parsed.dimensions,
|
|
guidance: parsed.guidance,
|
|
findings: parsed.findings || undefined,
|
|
model: process.env.CODEX_SECURITY_SCAN_MODEL ?? "gpt-5.5",
|
|
checkedAt: Date.now(),
|
|
};
|
|
}
|
|
|
|
function codexScanTimeoutMs() {
|
|
const parsed = Number(process.env.CODEX_SECURITY_SCAN_TIMEOUT_MS);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_CODEX_SCAN_TIMEOUT_MS;
|
|
}
|
|
|
|
async function runCodex(
|
|
job: ClaimedJob,
|
|
workspace: string,
|
|
skillSpectorAnalysis: SkillSpectorAnalysis,
|
|
onDiagnostic: (diagnostic: Partial<CodexCommandDiagnostic>) => void,
|
|
) {
|
|
const resultPath = join(workspace, "codex-result.json");
|
|
const args = [
|
|
"exec",
|
|
"--cd",
|
|
workspace,
|
|
"--model",
|
|
process.env.CODEX_SECURITY_SCAN_MODEL ?? "gpt-5.5",
|
|
"--sandbox",
|
|
"read-only",
|
|
"--skip-git-repo-check",
|
|
"--ignore-user-config",
|
|
"-c",
|
|
"approval_policy=never",
|
|
"-c",
|
|
`model_reasoning_effort=${process.env.CODEX_SECURITY_SCAN_REASONING_EFFORT ?? "high"}`,
|
|
"-c",
|
|
`service_tier=${process.env.CODEX_SECURITY_SCAN_SERVICE_TIER ?? "fast"}`,
|
|
"-c",
|
|
'shell_environment_policy.inherit="core"',
|
|
"-c",
|
|
"shell_environment_policy.ignore_default_excludes=false",
|
|
"--output-schema",
|
|
schemaPath,
|
|
"--output-last-message",
|
|
resultPath,
|
|
"--ephemeral",
|
|
"--json",
|
|
"-",
|
|
];
|
|
const artifactSignalText = await collectArtifactSignalText(join(workspace, "artifact"));
|
|
const injectionSignals = detectInjectionPatterns(artifactSignalText);
|
|
const prompt = buildPrompt(job, injectionSignals, skillSpectorAnalysis);
|
|
onDiagnostic({ args });
|
|
try {
|
|
const output = await runCommand("codex", args, {
|
|
cwd: workspace,
|
|
input: prompt,
|
|
timeoutMs: codexScanTimeoutMs(),
|
|
});
|
|
onDiagnostic({ exitCode: 0, stderr: output.stderr, stdout: output.stdout });
|
|
} catch (error) {
|
|
if (error instanceof CommandFailure) {
|
|
onDiagnostic({
|
|
exitCode: error.exitCode,
|
|
stderr: error.stderr,
|
|
stdout: error.stdout,
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
const raw = await readFile(resultPath, "utf8");
|
|
onDiagnostic({ rawResult: raw });
|
|
const parsed = parseLlmEvalResponse(raw);
|
|
if (!parsed) {
|
|
throw new Error(`Codex result did not match ClawScan schema (${raw.length} chars)`);
|
|
}
|
|
return toStoredLlmAnalysis(parsed);
|
|
}
|
|
|
|
export async function processJob(
|
|
client: CodexScanWorkerClient,
|
|
token: string,
|
|
job: ClaimedJob,
|
|
diagnosticsRoot: string | undefined,
|
|
): Promise<boolean> {
|
|
const workspace = await mkdtemp(join(tmpdir(), `clawhub-codex-scan-${basename(job.job._id)}-`));
|
|
const startedAt = Date.now();
|
|
const codex: CodexCommandDiagnostic = {};
|
|
const skillSpector: CodexCommandDiagnostic = {};
|
|
let errorMessage: string | undefined;
|
|
let llmAnalysis: StoredLlmAnalysis | undefined;
|
|
let skillSpectorAnalysis: SkillSpectorAnalysis | undefined;
|
|
let status: JobDiagnosticInput["status"] = "failed";
|
|
try {
|
|
await writeArtifactWorkspace(job, workspace);
|
|
skillSpectorAnalysis = await runSkillSpector(workspace, (next) => {
|
|
Object.assign(skillSpector, next);
|
|
});
|
|
llmAnalysis = await runCodex(job, workspace, skillSpectorAnalysis, (next) => {
|
|
Object.assign(codex, next);
|
|
});
|
|
await client.action(api.securityScan.completeCodexScanJob, {
|
|
token,
|
|
jobId: job.job._id as Id<"securityScanJobs">,
|
|
leaseToken: job.job.leaseToken,
|
|
llmAnalysis,
|
|
skillSpectorAnalysis,
|
|
runId: process.env.GITHUB_RUN_ID,
|
|
});
|
|
status = "completed";
|
|
logger.info(
|
|
{
|
|
durationMs: Date.now() - startedAt,
|
|
event: "security_scan_job_completed",
|
|
jobId: job.job._id,
|
|
scannerPhase: "complete",
|
|
status: llmAnalysis.status,
|
|
targetKind: job.job.targetKind,
|
|
},
|
|
"security scan job completed",
|
|
);
|
|
return true;
|
|
} catch (error) {
|
|
errorMessage = sanitizeWorkerErrorMessage(
|
|
error instanceof Error ? error.message : String(error),
|
|
);
|
|
const failResult = (await client.action(api.securityScan.failCodexScanJob, {
|
|
token,
|
|
jobId: job.job._id as Id<"securityScanJobs">,
|
|
leaseToken: job.job.leaseToken,
|
|
error: errorMessage,
|
|
})) as { retry?: boolean } | undefined;
|
|
logger.error(
|
|
{
|
|
durationMs: Date.now() - startedAt,
|
|
event: "security_scan_job_failed",
|
|
jobId: job.job._id,
|
|
publicReason: errorMessage,
|
|
retry: Boolean(failResult?.retry),
|
|
scannerPhase: "process",
|
|
targetKind: job.job.targetKind,
|
|
},
|
|
"security scan job failed",
|
|
);
|
|
return false;
|
|
} finally {
|
|
try {
|
|
await writeJobDiagnostic({
|
|
codex,
|
|
completedAt: Date.now(),
|
|
diagnosticsRoot,
|
|
error: errorMessage,
|
|
job,
|
|
llmAnalysis,
|
|
runId: process.env.GITHUB_RUN_ID,
|
|
skillSpector,
|
|
skillSpectorAnalysis,
|
|
startedAt,
|
|
status,
|
|
});
|
|
} catch (diagnosticError) {
|
|
const message =
|
|
diagnosticError instanceof Error ? diagnosticError.message : String(diagnosticError);
|
|
logger.error(
|
|
{
|
|
event: "security_scan_diagnostic_write_failed",
|
|
jobId: job.job._id,
|
|
publicReason: sanitizeWorkerErrorMessage(message),
|
|
},
|
|
"security scan diagnostic write failed",
|
|
);
|
|
}
|
|
await rm(workspace, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
export async function claimCodexScanJobBatch(
|
|
claimLimit: number,
|
|
claimOne: () => Promise<ClaimedJob[]>,
|
|
) {
|
|
const results = await Promise.allSettled(Array.from({ length: claimLimit }, () => claimOne()));
|
|
let claimFailures = 0;
|
|
const jobs = results.flatMap((result) => {
|
|
if (result.status === "fulfilled") return result.value;
|
|
claimFailures += 1;
|
|
const message = result.reason instanceof Error ? result.reason.message : String(result.reason);
|
|
logger.error(
|
|
{
|
|
event: "security_scan_claim_failed",
|
|
publicReason: sanitizeWorkerErrorMessage(message),
|
|
scannerPhase: "claim",
|
|
},
|
|
"failed to claim security scan job",
|
|
);
|
|
return [];
|
|
});
|
|
return { claimFailures, jobs };
|
|
}
|
|
|
|
async function main() {
|
|
const { batchLimit, maxJobs, maxRuntimeMs, leaseMs, diagnosticsRoot } = parseArgs();
|
|
assertCodexWorkerExecutionAllowed(process.env);
|
|
maskKnownWorkerSecrets();
|
|
const convexUrl = process.env.CONVEX_URL ?? process.env.VITE_CONVEX_URL;
|
|
if (!convexUrl) throw new Error("CONVEX_URL or VITE_CONVEX_URL is required");
|
|
const token = requireEnv("SECURITY_SCAN_WORKER_TOKEN");
|
|
const client = new ConvexHttpClient(convexUrl);
|
|
const workerId =
|
|
process.env.CODEX_SECURITY_SCAN_WORKER_ID ??
|
|
`github-actions:${process.env.GITHUB_RUN_ID ?? process.pid}:${
|
|
process.env.GITHUB_RUN_ATTEMPT ?? "1"
|
|
}:${process.env.CODEX_SECURITY_SCAN_SHARD ?? process.env.GITHUB_JOB ?? "0"}`;
|
|
const startedAt = Date.now();
|
|
const claimDeadline = startedAt + maxRuntimeMs;
|
|
let totalClaimed = 0;
|
|
let totalCompleted = 0;
|
|
let totalFailed = 0;
|
|
|
|
logger.info(
|
|
{ diagnosticsRoot, event: "security_scan_diagnostics_directory", workerId },
|
|
"security scan diagnostics directory",
|
|
);
|
|
|
|
while (Date.now() < claimDeadline) {
|
|
const remainingJobs = maxJobs === undefined ? batchLimit : Math.max(0, maxJobs - totalClaimed);
|
|
if (remainingJobs === 0) break;
|
|
const claimLimit = Math.min(batchLimit, remainingJobs);
|
|
const claimBatch = await claimCodexScanJobBatch(
|
|
claimLimit,
|
|
async () =>
|
|
(await client.action(api.securityScan.claimCodexScanJobs, {
|
|
token,
|
|
workerId,
|
|
limit: 1,
|
|
leaseMs,
|
|
})) as ClaimedJob[],
|
|
);
|
|
const { claimFailures, jobs } = claimBatch;
|
|
totalFailed += claimFailures;
|
|
logger.info(
|
|
{
|
|
claimed: jobs.length,
|
|
claimFailures,
|
|
claimLimit,
|
|
event: "security_scan_jobs_claimed",
|
|
leaseMs,
|
|
workerId,
|
|
},
|
|
"claimed security scan jobs",
|
|
);
|
|
if (jobs.length === 0) break;
|
|
|
|
totalClaimed += jobs.length;
|
|
const results = await Promise.all(
|
|
jobs.map((job) => processJob(client, token, job, diagnosticsRoot)),
|
|
);
|
|
totalCompleted += results.filter(Boolean).length;
|
|
totalFailed += results.filter((ok) => !ok).length;
|
|
|
|
if (jobs.length < claimLimit) break;
|
|
}
|
|
|
|
logger.info(
|
|
{
|
|
elapsedMs: Date.now() - startedAt,
|
|
event: "security_scan_worker_summary",
|
|
totalClaimed,
|
|
totalCompleted,
|
|
totalFailed,
|
|
workerId,
|
|
},
|
|
"security scan worker summary",
|
|
);
|
|
if (totalFailed > 0) {
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
await main();
|
|
}
|