Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions packages/processor/src/__tests__/triage.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
20 changes: 18 additions & 2 deletions packages/processor/src/triage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
const TRIAGE_BATCH_SIZE = 30;

interface TriageVerdict {
id?: number;
title: string;
priority: TriagePriority;
exploitability: "trivial" | "moderate" | "difficult";
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down