diff --git a/packages/processor/src/__tests__/triage.test.ts b/packages/processor/src/__tests__/triage.test.ts new file mode 100644 index 0000000..486fd70 --- /dev/null +++ b/packages/processor/src/__tests__/triage.test.ts @@ -0,0 +1,133 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { FileRecord } from "@deepsec/core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// triage() drives the Claude Agent SDK's `query` directly (not via the plugin +// agent system), so stub the SDK to yield a canned verdict payload. +const sdk = vi.hoisted(() => ({ resultText: "" })); +vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ + query: () => + (async function* () { + yield { type: "result", subtype: "success", result: sdk.resultText }; + })(), +})); + +import { triage } from "../triage.js"; + +function setupProject(): { + projectId: string; + writeRecord: (rec: FileRecord) => void; + readRecord: (rel: string) => FileRecord; +} { + const projectId = "triage-test"; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "deepsec-triage-")); + const targetRoot = path.join(tmp, "target"); + const dataRoot = path.join(tmp, "data"); + fs.mkdirSync(targetRoot, { recursive: true }); + fs.mkdirSync(path.join(dataRoot, projectId, "files"), { recursive: true }); + fs.writeFileSync( + path.join(dataRoot, projectId, "project.json"), + JSON.stringify({ projectId, rootPath: targetRoot, createdAt: new Date().toISOString() }), + ); + process.env.DEEPSEC_DATA_ROOT = dataRoot; + + const recordPath = (rel: string) => path.join(dataRoot, projectId, "files", `${rel}.json`); + const writeRecord = (rec: FileRecord): void => { + const p = recordPath(rec.filePath); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, JSON.stringify(rec)); + }; + const readRecord = (rel: string): FileRecord => + JSON.parse(fs.readFileSync(recordPath(rel), "utf-8")); + return { projectId, writeRecord, readRecord }; +} + +function recordWithFinding(projectId: string, filePath: string, title: string): FileRecord { + return { + filePath, + projectId, + candidates: [ + { vulnSlug: "missing-authz", lineNumbers: [1], snippet: "// stub", matchedPattern: "p" }, + ], + lastScannedAt: new Date().toISOString(), + lastScannedRunId: "scan-fixture", + fileHash: "fixture-hash", + findings: [ + { + severity: "MEDIUM", + vulnSlug: "missing-authz", + title, + description: "x", + lineNumbers: [1], + recommendation: "x", + confidence: "high", + }, + ], + analysisHistory: [], + status: "analyzed", + }; +} + +describe("triage verdict correlation", () => { + let prevDataRoot: string | undefined; + + beforeEach(() => { + prevDataRoot = process.env.DEEPSEC_DATA_ROOT; + }); + + afterEach(() => { + if (prevDataRoot === undefined) delete process.env.DEEPSEC_DATA_ROOT; + else process.env.DEEPSEC_DATA_ROOT = prevDataRoot; + }); + + it("maps each verdict to its own finding by id when titles collide", async () => { + const fx = setupProject(); + fx.writeRecord(recordWithFinding(fx.projectId, "a.ts", "Missing authorization check")); + fx.writeRecord(recordWithFinding(fx.projectId, "b.ts", "Missing authorization check")); + + sdk.resultText = [ + "```json", + JSON.stringify([ + { + id: 1, + title: "Missing authorization check", + priority: "P0", + exploitability: "trivial", + impact: "high", + reasoning: "one", + }, + { + id: 2, + title: "Missing authorization check", + priority: "skip", + exploitability: "difficult", + impact: "low", + reasoning: "two", + }, + ]), + "```", + ].join("\n"); + + const result = await triage({ projectId: fx.projectId, concurrency: 1 }); + + const a = fx.readRecord("a.ts").findings[0]; + const b = fx.readRecord("b.ts").findings[0]; + + // Both same-titled findings must be triaged. Before the fix, title-matching + // collapsed both verdicts onto the first finding, leaving the other one + // untouched. + expect(a.triage).toBeDefined(); + expect(b.triage).toBeDefined(); + + // And each keeps its own distinct verdict (P0 vs skip), rather than one + // verdict overwriting the other. + const priorities = [a.triage?.priority, b.triage?.priority].sort(); + expect(priorities).toEqual(["P0", "skip"]); + + expect(result.triaged).toBe(2); + expect(result.p0).toBe(1); + expect(result.skip).toBe(1); + }); +}); diff --git a/packages/processor/src/triage.ts b/packages/processor/src/triage.ts index 5f07029..e25c0ae 100644 --- a/packages/processor/src/triage.ts +++ b/packages/processor/src/triage.ts @@ -16,6 +16,7 @@ import { const TRIAGE_BATCH_SIZE = 30; interface TriageVerdict { + id?: number; title: string; priority: TriagePriority; exploitability: "trivial" | "moderate" | "difficult"; @@ -123,6 +124,7 @@ export async function triage(params: { const findingsList = batch .map((item, idx) => { return `### ${idx + 1}. ${item.finding.title} +- **ID:** ${idx + 1} - **File:** \`${item.record.filePath}\` - **Severity:** ${item.finding.severity} - **Slug:** ${item.finding.vulnSlug} @@ -163,9 +165,13 @@ ${findingsList} ## Output +Return one object per finding above, echoing its **ID** so each verdict can be +matched back to the exact finding it judged (titles are not unique). + \`\`\`json [ { + "id": 1, "title": "exact title", "priority": "P0" | "P1" | "P2" | "skip", "exploitability": "trivial" | "moderate" | "difficult", @@ -200,9 +206,19 @@ ${findingsList} verdicts = JSON.parse(jsonStr); } catch {} + // Match verdicts back to findings by the 1-based ID emitted in the + // prompt, not by title: titles are free-text and recur across a batch, + // so title-matching mis-attributes verdicts among same-titled findings. + // Fall back to title only when the model omits the id. Guard against a + // single finding being assigned twice (duplicate id/title in the reply). + const assigned = new Set<(typeof batch)[number]>(); for (const verdict of verdicts) { - const item = batch.find((b) => b.finding.title === verdict.title); - if (!item) continue; + const item = + typeof verdict.id === "number" && verdict.id >= 1 && verdict.id <= batch.length + ? batch[verdict.id - 1] + : batch.find((b) => b.finding.title === verdict.title); + if (!item || assigned.has(item)) continue; + assigned.add(item); item.finding.triage = { priority: verdict.priority,