mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
feat: export ClawScan findings sidecar (#2201)
* feat: export clawscan findings sidecar * fix: canonicalize clawscan sidecar fields
This commit is contained in:
@@ -345,6 +345,7 @@ function normalizeLlmAnalysis(analysis: StoredLlmAnalysis) {
|
||||
dimensions: analysis.dimensions ?? null,
|
||||
guidance: analysis.guidance ?? null,
|
||||
findings: analysis.findings ?? null,
|
||||
agenticRiskFindings: analysis.agenticRiskFindings ?? [],
|
||||
model: analysis.model ?? null,
|
||||
checkedAt: analysis.checkedAt,
|
||||
};
|
||||
|
||||
@@ -46,6 +46,23 @@ describe("Convex export dataset ingestion", () => {
|
||||
verdict: "suspicious",
|
||||
confidence: "high",
|
||||
summary: "asks for secrets",
|
||||
agenticRiskFindings: [
|
||||
{
|
||||
categoryId: "ASI04",
|
||||
categoryLabel: "Tool and permission overreach",
|
||||
riskBucket: "permission_boundary",
|
||||
status: "concern",
|
||||
severity: "high",
|
||||
confidence: "high",
|
||||
evidence: {
|
||||
path: "SKILL.md",
|
||||
snippet: "Use token=supersecret123",
|
||||
explanation: "References sensitive token handling.",
|
||||
},
|
||||
userImpact: "Could expose credentials.",
|
||||
recommendation: "Require least-privilege credentials.",
|
||||
},
|
||||
],
|
||||
model: "gpt-test",
|
||||
checkedAt: 3,
|
||||
},
|
||||
@@ -119,6 +136,17 @@ describe("Convex export dataset ingestion", () => {
|
||||
llmAnalysis: { model: "gpt-test" },
|
||||
skillMdContentRedacted: "Use this skill safely. [REDACTED_SECRET]",
|
||||
});
|
||||
expect(rows[1]?.llmAnalysis?.agenticRiskFindings).toMatchObject([
|
||||
{
|
||||
categoryId: "ASI04",
|
||||
riskBucket: "permission_boundary",
|
||||
status: "concern",
|
||||
severity: "high",
|
||||
evidence: {
|
||||
path: "SKILL.md",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads table JSONL files from a Convex export zip", async () => {
|
||||
|
||||
@@ -257,11 +257,55 @@ function llmAnalysisFromExport(value: unknown): LlmAnalysisInput | null {
|
||||
dimensions: llmDimensionsFromExport(value.dimensions),
|
||||
guidance: stringOrNull(value.guidance),
|
||||
findings: stringOrNull(value.findings),
|
||||
agenticRiskFindings: llmAgenticRiskFindingsFromExport(value.agenticRiskFindings),
|
||||
model: stringOrNull(value.model),
|
||||
checkedAt: numberValue(value.checkedAt, "llmAnalysis.checkedAt"),
|
||||
};
|
||||
}
|
||||
|
||||
function llmAgenticRiskFindingsFromExport(value: unknown): LlmAnalysisInput["agenticRiskFindings"] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.flatMap((finding) => {
|
||||
if (!isRecord(finding)) return [];
|
||||
const riskBucket = finding.riskBucket;
|
||||
if (
|
||||
riskBucket !== "abnormal_behavior_control" &&
|
||||
riskBucket !== "permission_boundary" &&
|
||||
riskBucket !== "sensitive_data_protection"
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const status = finding.status;
|
||||
if (status !== "none" && status !== "note" && status !== "concern") return [];
|
||||
const confidence = finding.confidence;
|
||||
if (confidence !== "high" && confidence !== "medium" && confidence !== "low") return [];
|
||||
return [
|
||||
{
|
||||
categoryId: stringOrNull(finding.categoryId) ?? "",
|
||||
categoryLabel: stringOrNull(finding.categoryLabel) ?? "",
|
||||
riskBucket,
|
||||
status,
|
||||
severity: stringOrNull(finding.severity) ?? "none",
|
||||
confidence,
|
||||
evidence: llmRiskEvidenceFromExport(finding.evidence),
|
||||
userImpact: stringOrNull(finding.userImpact) ?? "",
|
||||
recommendation: stringOrNull(finding.recommendation) ?? "",
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function llmRiskEvidenceFromExport(
|
||||
value: unknown,
|
||||
): LlmAnalysisInput["agenticRiskFindings"][number]["evidence"] {
|
||||
if (!isRecord(value)) return null;
|
||||
return {
|
||||
path: stringOrNull(value.path) ?? "",
|
||||
snippet: stringOrNull(value.snippet) ?? "",
|
||||
explanation: stringOrNull(value.explanation) ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function llmDimensionsFromExport(value: unknown): LlmAnalysisInput["dimensions"] {
|
||||
if (!Array.isArray(value)) return null;
|
||||
return value.flatMap((dimension) => {
|
||||
|
||||
@@ -73,6 +73,7 @@ type SnapshotState = {
|
||||
artifacts: number;
|
||||
scanResults: number;
|
||||
staticFindings: number;
|
||||
clawScanFindings: number;
|
||||
labels: number;
|
||||
splits: number;
|
||||
};
|
||||
@@ -84,6 +85,7 @@ type SnapshotWriters = {
|
||||
artifacts: WriteStream;
|
||||
scanResults: WriteStream;
|
||||
staticFindings: WriteStream;
|
||||
clawScanFindings: WriteStream;
|
||||
labels: WriteStream;
|
||||
splits: WriteStream;
|
||||
};
|
||||
@@ -368,12 +370,13 @@ function buildManifest(input: {
|
||||
artifacts: state.rowCounts.artifacts,
|
||||
scanResults: state.rowCounts.scanResults,
|
||||
staticFindings: state.rowCounts.staticFindings,
|
||||
clawScanFindings: state.rowCounts.clawScanFindings,
|
||||
labels: state.rowCounts.labels,
|
||||
splits: state.rowCounts.splits,
|
||||
},
|
||||
scannerVersions: Array.from(state.scannerVersions).sort(),
|
||||
modelNames: Array.from(state.modelNames).sort(),
|
||||
redactionPolicyVersion: "public-signals-v1",
|
||||
redactionPolicyVersion: "public-signals-v2",
|
||||
sourceTables: ["skillVersions", "packageReleases"],
|
||||
timeWindow: options.timeWindow,
|
||||
});
|
||||
@@ -396,6 +399,7 @@ function createSnapshotState(): SnapshotState {
|
||||
artifacts: 0,
|
||||
scanResults: 0,
|
||||
staticFindings: 0,
|
||||
clawScanFindings: 0,
|
||||
labels: 0,
|
||||
splits: 0,
|
||||
},
|
||||
@@ -414,6 +418,7 @@ async function processArtifactInputs(input: {
|
||||
state.rowCounts.artifacts += rows.artifacts.length;
|
||||
state.rowCounts.scanResults += rows.scanResults.length;
|
||||
state.rowCounts.staticFindings += rows.staticFindings.length;
|
||||
state.rowCounts.clawScanFindings += rows.clawScanFindings.length;
|
||||
state.rowCounts.labels += rows.labels.length;
|
||||
state.rowCounts.splits += rows.splits.length;
|
||||
for (const row of rows.scanResults) {
|
||||
@@ -433,6 +438,9 @@ async function openSnapshotWriters(snapshotDir: string): Promise<SnapshotWriters
|
||||
staticFindings: createWriteStream(join(snapshotDir, "static_findings.jsonl"), {
|
||||
encoding: "utf8",
|
||||
}),
|
||||
clawScanFindings: createWriteStream(join(snapshotDir, "clawscan_findings.jsonl"), {
|
||||
encoding: "utf8",
|
||||
}),
|
||||
labels: createWriteStream(join(snapshotDir, "labels.jsonl"), { encoding: "utf8" }),
|
||||
splits: createWriteStream(join(snapshotDir, "splits.jsonl"), { encoding: "utf8" }),
|
||||
};
|
||||
@@ -451,6 +459,7 @@ async function writeNormalizedRows(writers: SnapshotWriters, rows: NormalizedDat
|
||||
await writeJsonlRows(writers.artifacts, rows.artifacts);
|
||||
await writeJsonlRows(writers.scanResults, rows.scanResults);
|
||||
await writeJsonlRows(writers.staticFindings, rows.staticFindings);
|
||||
await writeJsonlRows(writers.clawScanFindings, rows.clawScanFindings);
|
||||
await writeJsonlRows(writers.labels, rows.labels);
|
||||
await writeJsonlRows(writers.splits, rows.splits);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ describe("security dataset manifest", () => {
|
||||
artifacts: 1,
|
||||
scanResults: 2,
|
||||
staticFindings: 3,
|
||||
clawScanFindings: 5,
|
||||
labels: 4,
|
||||
splits: 1,
|
||||
},
|
||||
@@ -37,6 +38,9 @@ describe("security dataset manifest", () => {
|
||||
created_at_gte: 1777507200000,
|
||||
created_at_lt: 1780185600000,
|
||||
},
|
||||
row_counts: {
|
||||
clawscan_findings: 5,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ export type SnapshotManifestInput = {
|
||||
artifacts: number;
|
||||
scanResults: number;
|
||||
staticFindings: number;
|
||||
clawScanFindings: number;
|
||||
labels: number;
|
||||
splits: number;
|
||||
};
|
||||
@@ -43,6 +44,7 @@ export function buildSecurityDatasetManifest(input: SnapshotManifestInput) {
|
||||
artifacts: input.rowCounts.artifacts,
|
||||
scan_results: input.rowCounts.scanResults,
|
||||
static_findings: input.rowCounts.staticFindings,
|
||||
clawscan_findings: input.rowCounts.clawScanFindings,
|
||||
labels: input.rowCounts.labels,
|
||||
splits: input.rowCounts.splits,
|
||||
},
|
||||
|
||||
@@ -72,6 +72,49 @@ const baseArtifact: ArtifactExportInput = {
|
||||
dimensions: null,
|
||||
guidance: null,
|
||||
findings: null,
|
||||
agenticRiskFindings: [
|
||||
{
|
||||
categoryId: "ASI04",
|
||||
categoryLabel: "Ignore this label token=supersecret123",
|
||||
riskBucket: "permission_boundary",
|
||||
status: "note",
|
||||
severity: "MEDIUM",
|
||||
confidence: "high",
|
||||
evidence: {
|
||||
path: "SKILL.md",
|
||||
snippet: "Use TOKEN=ghp_abcdefghijklmnopqrstuvwxyz1234567890 for setup",
|
||||
explanation: "The skill documents token use.",
|
||||
},
|
||||
userImpact: "Users should understand the token scope before install.",
|
||||
recommendation: "Use a narrowly scoped token.",
|
||||
},
|
||||
{
|
||||
categoryId: "UNKNOWN token=supersecret123",
|
||||
categoryLabel: "Unknown category token=supersecret123",
|
||||
riskBucket: "permission_boundary",
|
||||
status: "note",
|
||||
severity: "critical",
|
||||
confidence: "high",
|
||||
evidence: {
|
||||
path: "SKILL.md",
|
||||
snippet: "Unknown category should not export.",
|
||||
explanation: "Unknown categories are not part of the public sidecar contract.",
|
||||
},
|
||||
userImpact: "Should not export.",
|
||||
recommendation: "Should not export.",
|
||||
},
|
||||
{
|
||||
categoryId: "ASI05",
|
||||
categoryLabel: "Sensitive data protection",
|
||||
riskBucket: "sensitive_data_protection",
|
||||
status: "none",
|
||||
severity: "none",
|
||||
confidence: "high",
|
||||
evidence: null,
|
||||
userImpact: "",
|
||||
recommendation: "",
|
||||
},
|
||||
],
|
||||
model: "test-model",
|
||||
checkedAt: Date.UTC(2026, 3, 29),
|
||||
},
|
||||
@@ -108,6 +151,18 @@ describe("security dataset normalizer", () => {
|
||||
line_bucket: "21-50",
|
||||
});
|
||||
expect(rows.staticFindings[0]?.evidence_redacted).toContain("[REDACTED_SECRET]");
|
||||
expect(rows.clawScanFindings).toHaveLength(1);
|
||||
expect(rows.clawScanFindings[0]).toMatchObject({
|
||||
category_id: "ASI04",
|
||||
category_label: "Agentic Supply Chain Vulnerabilities",
|
||||
risk_bucket: "permission_boundary",
|
||||
status: "note",
|
||||
severity: "medium",
|
||||
confidence: "high",
|
||||
evidence_path_hash: hashString("SKILL.md"),
|
||||
evidence_file_ext: ".md",
|
||||
});
|
||||
expect(rows.clawScanFindings[0]?.evidence_snippet_redacted).toContain("[REDACTED_SECRET]");
|
||||
expect(rows.labels.find((row) => row.label_source === "moderation_consensus")).toMatchObject({
|
||||
label: "malicious",
|
||||
label_confidence: "derived_consensus",
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { AGENTIC_RISK_CATEGORIES } from "../../convex/lib/securityPrompt.ts";
|
||||
|
||||
export type SourceKind = "skill" | "package";
|
||||
export type DatasetLabel = "clean" | "suspicious" | "malicious" | "unknown";
|
||||
export type DatasetSplit = "train" | "validation" | "test" | "eval_holdout";
|
||||
export type ScannerName = "static" | "virustotal" | "llm" | "moderation_consensus";
|
||||
export type ClawScanRiskBucket =
|
||||
| "abnormal_behavior_control"
|
||||
| "permission_boundary"
|
||||
| "sensitive_data_protection";
|
||||
export type ClawScanFindingStatus = "none" | "note" | "concern";
|
||||
export type ClawScanSeverity = "none" | "info" | "low" | "medium" | "high" | "critical";
|
||||
|
||||
export type ExportFileInput = {
|
||||
path: string;
|
||||
@@ -56,6 +63,21 @@ export type LlmAnalysisInput = {
|
||||
}> | null;
|
||||
guidance: string | null;
|
||||
findings: string | null;
|
||||
agenticRiskFindings: Array<{
|
||||
categoryId: string;
|
||||
categoryLabel: string;
|
||||
riskBucket: ClawScanRiskBucket;
|
||||
status: ClawScanFindingStatus;
|
||||
severity: string;
|
||||
confidence: "high" | "medium" | "low";
|
||||
evidence: {
|
||||
path: string;
|
||||
snippet: string;
|
||||
explanation: string;
|
||||
} | null;
|
||||
userImpact: string;
|
||||
recommendation: string;
|
||||
}>;
|
||||
model: string | null;
|
||||
checkedAt: number;
|
||||
};
|
||||
@@ -146,6 +168,23 @@ export type StaticFindingRow = {
|
||||
evidence_redacted: string;
|
||||
};
|
||||
|
||||
export type ClawScanFindingRow = {
|
||||
artifact_id: string;
|
||||
finding_id: string;
|
||||
category_id: string;
|
||||
category_label: string;
|
||||
risk_bucket: ClawScanRiskBucket;
|
||||
status: "note" | "concern";
|
||||
severity: ClawScanSeverity;
|
||||
confidence: "high" | "medium" | "low";
|
||||
evidence_path_hash: string | null;
|
||||
evidence_file_ext: string | null;
|
||||
evidence_snippet_redacted: string | null;
|
||||
evidence_explanation_redacted: string | null;
|
||||
user_impact_redacted: string;
|
||||
recommendation_redacted: string;
|
||||
};
|
||||
|
||||
export type LabelRow = {
|
||||
artifact_id: string;
|
||||
label: DatasetLabel;
|
||||
@@ -167,6 +206,7 @@ export type NormalizedDatasetRows = {
|
||||
artifacts: ArtifactRow[];
|
||||
scanResults: ScanResultRow[];
|
||||
staticFindings: StaticFindingRow[];
|
||||
clawScanFindings: ClawScanFindingRow[];
|
||||
labels: LabelRow[];
|
||||
splits: SplitRow[];
|
||||
};
|
||||
@@ -174,6 +214,17 @@ export type NormalizedDatasetRows = {
|
||||
const SPLIT_VERSION = "sha256-v1";
|
||||
const MAX_REDACTED_TEXT_LENGTH = 240;
|
||||
const MAX_REDACTED_SKILL_CONTENT_LENGTH = 120_000;
|
||||
const CLAWSCAN_SEVERITIES = new Set<ClawScanSeverity>([
|
||||
"none",
|
||||
"info",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"critical",
|
||||
]);
|
||||
const AGENTIC_RISK_CATEGORY_LABEL_BY_ID: ReadonlyMap<string, string> = new Map(
|
||||
AGENTIC_RISK_CATEGORIES.map((category) => [category.id, category.label] as const),
|
||||
);
|
||||
|
||||
const SECRET_PATTERNS: RegExp[] = [
|
||||
/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi,
|
||||
@@ -214,6 +265,7 @@ export function normalizeArtifactExport(inputs: ArtifactExportInput[]): Normaliz
|
||||
const artifacts: ArtifactRow[] = [];
|
||||
const scanResults: ScanResultRow[] = [];
|
||||
const staticFindings: StaticFindingRow[] = [];
|
||||
const clawScanFindings: ClawScanFindingRow[] = [];
|
||||
const labels: LabelRow[] = [];
|
||||
const splits: SplitRow[] = [];
|
||||
|
||||
@@ -223,11 +275,12 @@ export function normalizeArtifactExport(inputs: ArtifactExportInput[]): Normaliz
|
||||
artifacts.push(artifact);
|
||||
scanResults.push(...buildScanResultRows(input, artifactId));
|
||||
staticFindings.push(...buildStaticFindingRows(input, artifactId));
|
||||
clawScanFindings.push(...buildClawScanFindingRows(input, artifactId));
|
||||
labels.push(...buildLabelRows(input, artifactId));
|
||||
splits.push(buildSplitRow(input, artifactId));
|
||||
}
|
||||
|
||||
return { artifacts, scanResults, staticFindings, labels, splits };
|
||||
return { artifacts, scanResults, staticFindings, clawScanFindings, labels, splits };
|
||||
}
|
||||
|
||||
export function buildArtifactId(input: ArtifactExportInput) {
|
||||
@@ -367,6 +420,45 @@ function buildStaticFindingRows(
|
||||
}));
|
||||
}
|
||||
|
||||
function buildClawScanFindingRows(
|
||||
input: ArtifactExportInput,
|
||||
artifactId: string,
|
||||
): ClawScanFindingRow[] {
|
||||
return (input.llmAnalysis?.agenticRiskFindings ?? [])
|
||||
.filter(
|
||||
(finding): finding is typeof finding & { status: "note" | "concern" } =>
|
||||
finding.status === "note" || finding.status === "concern",
|
||||
)
|
||||
.flatMap((finding, index) => {
|
||||
const evidence = finding.evidence;
|
||||
const categoryLabel = AGENTIC_RISK_CATEGORY_LABEL_BY_ID.get(finding.categoryId);
|
||||
if (!categoryLabel) return [];
|
||||
const severity = normalizeClawScanSeverity(finding.severity);
|
||||
return [
|
||||
{
|
||||
artifact_id: artifactId,
|
||||
finding_id: `${artifactId}:clawscan:${index}:${hashString(
|
||||
`${finding.categoryId}:${finding.riskBucket}:${finding.status}:${severity}:${
|
||||
evidence?.path ?? ""
|
||||
}:${evidence?.snippet ?? ""}:${finding.userImpact}`,
|
||||
).slice(0, 12)}`,
|
||||
category_id: finding.categoryId,
|
||||
category_label: categoryLabel,
|
||||
risk_bucket: finding.riskBucket,
|
||||
status: finding.status,
|
||||
severity,
|
||||
confidence: finding.confidence,
|
||||
evidence_path_hash: evidence ? hashString(evidence.path) : null,
|
||||
evidence_file_ext: evidence ? fileExtension(evidence.path) : null,
|
||||
evidence_snippet_redacted: redactText(evidence?.snippet),
|
||||
evidence_explanation_redacted: redactText(evidence?.explanation),
|
||||
user_impact_redacted: redactText(finding.userImpact) ?? "",
|
||||
recommendation_redacted: redactText(finding.recommendation) ?? "",
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function buildLabelRows(input: ArtifactExportInput, artifactId: string): LabelRow[] {
|
||||
const scannerLabels: DatasetLabel[] = [];
|
||||
const rows: LabelRow[] = [];
|
||||
@@ -488,6 +580,13 @@ function normalizeLabel(value: string): DatasetLabel {
|
||||
return labelFromText(value);
|
||||
}
|
||||
|
||||
function normalizeClawScanSeverity(value: string): ClawScanSeverity {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return CLAWSCAN_SEVERITIES.has(normalized as ClawScanSeverity)
|
||||
? (normalized as ClawScanSeverity)
|
||||
: "none";
|
||||
}
|
||||
|
||||
function countFileExtensions(files: ExportFileInput[]) {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const file of files) {
|
||||
|
||||
Reference in New Issue
Block a user