Skip to content

Commit 45f38f0

Browse files
splintersfuryclaude
andcommitted
Add researcher workflow features: exploit tracker, variant search, batch triage, VT/WBI links
- Exploit stage tracker per finding (not_started → recon → poc → tested → working) - Cross-driver variant search finds similar findings across all analyses - Batch triage with checkbox selection and bulk state update - VirusTotal and WinBIndex links on all SHA256 hashes - Diff empty state with VT download links for manual binary diffing - Deep-link to specific findings via ?fn= query parameter - Fix FastAPI route ordering for /bulk before /{function} - Fix corpus page Set iteration TypeScript error Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5f3ea70 commit 45f38f0

11 files changed

Lines changed: 504 additions & 28 deletions

File tree

services/dashboard/backend/main.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
AlertsResponse,
2222
Analysis,
2323
AnalysisListResponse,
24+
BulkTriageUpdate,
2425
CorpusOverview,
2526
CVECorpusEntry,
2627
DriverSummary,
@@ -34,6 +35,7 @@
3435
TriageSummary,
3536
TriageUpdate,
3637
VariantAlertEntry,
38+
VariantMatch,
3739
)
3840
from .storage import FileStorage, MWDBStorage
3941
from .triage import TriageStore
@@ -435,6 +437,12 @@ async def get_triage_states(analysis_id: str):
435437
return triage_store.get_for_analysis(analysis_id)
436438

437439

440+
@app.put("/api/triage/{analysis_id}/bulk", response_model=list[TriageEntry])
441+
async def bulk_triage(analysis_id: str, body: BulkTriageUpdate, _auth: None = Depends(require_api_key)):
442+
"""Bulk update triage state for multiple findings."""
443+
return triage_store.set_bulk(analysis_id, body.functions, body.state, body.note)
444+
445+
438446
@app.get("/api/triage/{analysis_id}/{function}", response_model=TriageEntry)
439447
async def get_triage_state(analysis_id: str, function: str):
440448
"""Get triage state for a specific finding."""
@@ -444,7 +452,49 @@ async def get_triage_state(analysis_id: str, function: str):
444452
@app.put("/api/triage/{analysis_id}/{function}", response_model=TriageEntry)
445453
async def set_triage_state(analysis_id: str, function: str, body: TriageUpdate, _auth: None = Depends(require_api_key)):
446454
"""Update triage state for a specific finding."""
447-
return triage_store.set(analysis_id, function, body.state, body.note)
455+
return triage_store.set(analysis_id, function, body.state, body.note, body.exploit_stage)
456+
457+
458+
# ==========================================================================
459+
# Variant Search (cross-driver)
460+
# ==========================================================================
461+
462+
463+
@app.get("/api/variants/{analysis_id}/{function}", response_model=list[VariantMatch])
464+
async def find_variants(analysis_id: str, function: str):
465+
"""Find similar findings (same rule_id or category) in other analyses."""
466+
source = file_storage.get_analysis(analysis_id)
467+
if source is None:
468+
raise HTTPException(status_code=404, detail="Analysis not found")
469+
source_finding = next((f for f in source.findings if f.function == function), None)
470+
if source_finding is None:
471+
raise HTTPException(status_code=404, detail="Finding not found")
472+
473+
matches: list[VariantMatch] = []
474+
for item in file_storage.list_analyses():
475+
if item.id == analysis_id:
476+
continue
477+
other = file_storage.get_analysis(item.id)
478+
if other is None:
479+
continue
480+
for f in other.findings:
481+
if f.rule_id == source_finding.rule_id or (
482+
f.category == source_finding.category
483+
and abs(f.final_score - source_finding.final_score) < 3.0
484+
):
485+
matches.append(VariantMatch(
486+
analysis_id=item.id,
487+
driver_name=item.driver_name or item.id,
488+
function=f.function,
489+
rule_id=f.rule_id,
490+
category=f.category.value if hasattr(f.category, 'value') else str(f.category),
491+
final_score=f.final_score,
492+
confidence=f.confidence,
493+
reachability_class=f.reachability_class.value if hasattr(f.reachability_class, 'value') else str(f.reachability_class),
494+
created_at=item.created_at.isoformat() if hasattr(item.created_at, 'isoformat') else str(item.created_at),
495+
))
496+
matches.sort(key=lambda m: m.final_score, reverse=True)
497+
return matches[:30]
448498

449499

450500
# ==========================================================================

services/dashboard/backend/models.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,18 +234,35 @@ class TriageState(str, Enum):
234234
resolved = "resolved"
235235

236236

237+
class ExploitStage(str, Enum):
238+
not_started = "not_started"
239+
recon = "recon"
240+
poc = "poc"
241+
tested = "tested"
242+
working = "working"
243+
244+
237245
class TriageEntry(BaseModel):
238246
"""Triage state for a single finding (keyed by analysis_id + function)."""
239247
analysis_id: str
240248
function: str
241249
state: TriageState = TriageState.untriaged
250+
exploit_stage: ExploitStage = ExploitStage.not_started
242251
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
243252
note: str = ""
244253

245254

246255
class TriageUpdate(BaseModel):
247256
"""Request body for updating triage state."""
248257
state: TriageState
258+
exploit_stage: Optional[ExploitStage] = None
259+
note: str = ""
260+
261+
262+
class BulkTriageUpdate(BaseModel):
263+
"""Bulk triage request for multiple findings."""
264+
functions: list[str]
265+
state: TriageState
249266
note: str = ""
250267

251268

@@ -326,6 +343,19 @@ class AlertsResponse(BaseModel):
326343
variants: list[VariantAlertEntry] = []
327344

328345

346+
class VariantMatch(BaseModel):
347+
"""A finding in another analysis with the same rule/category."""
348+
analysis_id: str
349+
driver_name: str
350+
function: str
351+
rule_id: str
352+
category: str
353+
final_score: float
354+
confidence: float
355+
reachability_class: str
356+
created_at: str
357+
358+
329359
# --- Search ---
330360

331361

services/dashboard/backend/triage.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from pathlib import Path
1313
from typing import Optional
1414

15-
from .models import TriageEntry, TriageState, TriageSummary
15+
from .models import ExploitStage, TriageEntry, TriageState, TriageSummary
1616

1717
logger = logging.getLogger(__name__)
1818

@@ -70,19 +70,52 @@ def set(
7070
function: str,
7171
state: TriageState,
7272
note: str = "",
73+
exploit_stage: Optional[ExploitStage] = None,
7374
) -> TriageEntry:
7475
key = self._key(analysis_id, function)
76+
# Preserve existing exploit_stage if not explicitly set
77+
existing_exploit = ExploitStage.not_started
78+
if key in self._data:
79+
existing_exploit = ExploitStage(self._data[key].get("exploit_stage", "not_started"))
7580
entry = TriageEntry(
7681
analysis_id=analysis_id,
7782
function=function,
7883
state=state,
84+
exploit_stage=exploit_stage if exploit_stage is not None else existing_exploit,
7985
updated_at=datetime.now(),
8086
note=note,
8187
)
8288
self._data[key] = entry.model_dump()
8389
self._save()
8490
return entry
8591

92+
def set_bulk(
93+
self,
94+
analysis_id: str,
95+
functions: list[str],
96+
state: TriageState,
97+
note: str = "",
98+
) -> list[TriageEntry]:
99+
"""Set triage state for multiple findings at once."""
100+
entries = []
101+
for func in functions:
102+
key = self._key(analysis_id, func)
103+
existing_exploit = ExploitStage.not_started
104+
if key in self._data:
105+
existing_exploit = ExploitStage(self._data[key].get("exploit_stage", "not_started"))
106+
entry = TriageEntry(
107+
analysis_id=analysis_id,
108+
function=func,
109+
state=state,
110+
exploit_stage=existing_exploit,
111+
updated_at=datetime.now(),
112+
note=note,
113+
)
114+
self._data[key] = entry.model_dump()
115+
entries.append(entry)
116+
self._save()
117+
return entries
118+
86119
def summary(self) -> TriageSummary:
87120
counts = {s.value: 0 for s in TriageState}
88121
for data in self._data.values():

services/dashboard/frontend/src/app/analysis/[id]/page.tsx

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22

33
import { useEffect, useState } from "react";
4-
import { useParams } from "next/navigation";
4+
import { useParams, useSearchParams } from "next/navigation";
55
import type { Analysis, Finding, TriageEntry } from "@/types";
66
import { OverviewCards, TrustIndicators } from "@/components/overview-cards";
77
import { FindingsTable } from "@/components/findings-table";
@@ -10,7 +10,9 @@ import { formatDate, truncateSha, cn, triageBadge, triageLabel } from "@/lib/uti
1010

1111
export default function AnalysisPage() {
1212
const params = useParams();
13+
const searchParams = useSearchParams();
1314
const id = params.id as string;
15+
const fnParam = searchParams.get("fn");
1416
const [analysis, setAnalysis] = useState<Analysis | null>(null);
1517
const [selectedFinding, setSelectedFinding] = useState<Finding | null>(null);
1618
const [triageStates, setTriageStates] = useState<Record<string, TriageEntry>>({});
@@ -31,16 +33,18 @@ export default function AnalysisPage() {
3133
.then(([data, triage]: [Analysis, Record<string, TriageEntry>]) => {
3234
setAnalysis(data);
3335
setTriageStates(triage || {});
34-
if (data.findings.length > 0) {
35-
setSelectedFinding(data.findings[0]);
36-
}
36+
// Deep-link to specific finding if ?fn= is present
37+
const target = fnParam
38+
? data.findings.find((f: Finding) => f.function === fnParam)
39+
: null;
40+
setSelectedFinding(target || data.findings[0] || null);
3741
setLoading(false);
3842
})
3943
.catch((e) => {
4044
setError(e.message);
4145
setLoading(false);
4246
});
43-
}, [id]);
47+
}, [id, fnParam]);
4448

4549
if (loading) {
4650
return (
@@ -201,6 +205,13 @@ export default function AnalysisPage() {
201205
onSelect={setSelectedFinding}
202206
selectedFunction={selectedFinding?.function}
203207
triageStates={triageStates}
208+
analysisId={id}
209+
onTriageUpdate={() => {
210+
// Refresh triage states after bulk update
211+
fetch(`/api/triage/${id}`)
212+
.then((res) => (res.ok ? res.json() : {}))
213+
.then((data) => setTriageStates(data || {}));
214+
}}
204215
/>
205216
</div>
206217
</div>

services/dashboard/frontend/src/app/corpus/page.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -875,9 +875,9 @@ export default function CorpusPage() {
875875
}
876876

877877
// Unique categories from CVEs
878-
const allCategories = [
879-
...new Set(data.cves.map((c) => c.expected_category_primary).filter(Boolean)),
880-
].sort();
878+
const allCategories = Array.from(
879+
new Set(data.cves.map((c) => c.expected_category_primary).filter(Boolean))
880+
).sort();
881881

882882
// Apply filters
883883
const filteredCves = data.cves.filter((cve) => {

services/dashboard/frontend/src/components/diff-viewer.tsx

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
interface DiffViewerProps {
22
snippet: string;
3+
shaNew?: string;
4+
shaOld?: string;
35
}
46

57
function classifyLine(line: string): string {
@@ -10,11 +12,36 @@ function classifyLine(line: string): string {
1012
return "diff-line-context";
1113
}
1214

13-
export function DiffViewer({ snippet }: DiffViewerProps) {
15+
export function DiffViewer({ snippet, shaNew, shaOld }: DiffViewerProps) {
1416
if (!snippet) {
1517
return (
16-
<div className="rounded-lg border bg-muted/50 p-4 text-sm text-muted-foreground">
17-
No diff available
18+
<div className="rounded-lg border bg-muted/50 p-4 text-center">
19+
<p className="text-sm text-muted-foreground">No diff snippet available for this finding.</p>
20+
{(shaNew || shaOld) && (
21+
<div className="mt-2 flex items-center justify-center gap-3">
22+
<span className="text-xs text-muted-foreground">Download binaries to diff manually:</span>
23+
{shaOld && (
24+
<a
25+
href={`https://www.virustotal.com/gui/file/${shaOld}`}
26+
target="_blank"
27+
rel="noopener noreferrer"
28+
className="rounded px-2 py-1 text-xs font-medium bg-blue-500/10 text-blue-600 hover:bg-blue-500/20 dark:text-blue-400 transition-colors"
29+
>
30+
Old (VT)
31+
</a>
32+
)}
33+
{shaNew && (
34+
<a
35+
href={`https://www.virustotal.com/gui/file/${shaNew}`}
36+
target="_blank"
37+
rel="noopener noreferrer"
38+
className="rounded px-2 py-1 text-xs font-medium bg-blue-500/10 text-blue-600 hover:bg-blue-500/20 dark:text-blue-400 transition-colors"
39+
>
40+
New (VT)
41+
</a>
42+
)}
43+
</div>
44+
)}
1845
</div>
1946
);
2047
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import { setTriageState } from "@/lib/api";
5+
import { cn } from "@/lib/utils";
6+
import type { ExploitStage, TriageState } from "@/types";
7+
8+
const STAGES: { value: ExploitStage; label: string; color: string }[] = [
9+
{ value: "not_started", label: "Not Started", color: "bg-gray-400" },
10+
{ value: "recon", label: "Recon", color: "bg-blue-500" },
11+
{ value: "poc", label: "PoC", color: "bg-yellow-500" },
12+
{ value: "tested", label: "Tested", color: "bg-orange-500" },
13+
{ value: "working", label: "Working", color: "bg-red-500" },
14+
];
15+
16+
interface ExploitTrackerProps {
17+
analysisId: string;
18+
functionName: string;
19+
currentStage: ExploitStage;
20+
}
21+
22+
export function ExploitTracker({
23+
analysisId,
24+
functionName,
25+
currentStage,
26+
}: ExploitTrackerProps) {
27+
const [stage, setStage] = useState<ExploitStage>(currentStage);
28+
const [saving, setSaving] = useState(false);
29+
30+
const currentIndex = STAGES.findIndex((s) => s.value === stage);
31+
32+
const handleClick = async (newStage: ExploitStage) => {
33+
setSaving(true);
34+
try {
35+
// We need a non-untriaged state for exploit tracking to make sense
36+
const triageState: TriageState = "investigating";
37+
await setTriageState(analysisId, functionName, triageState, "", newStage);
38+
setStage(newStage);
39+
} catch (e) {
40+
console.error("Failed to update exploit stage:", e);
41+
} finally {
42+
setSaving(false);
43+
}
44+
};
45+
46+
return (
47+
<div className="space-y-1.5">
48+
<span className="text-xs font-medium text-muted-foreground">
49+
Exploit Progress:
50+
</span>
51+
<div className="flex items-center gap-1">
52+
{STAGES.map((s, i) => (
53+
<button
54+
key={s.value}
55+
onClick={() => handleClick(s.value)}
56+
disabled={saving}
57+
title={s.label}
58+
className={cn(
59+
"flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-medium transition-colors",
60+
i <= currentIndex
61+
? cn(s.color, "text-white")
62+
: "bg-muted text-muted-foreground hover:bg-accent",
63+
saving && "opacity-50 cursor-not-allowed"
64+
)}
65+
>
66+
{s.label}
67+
</button>
68+
))}
69+
</div>
70+
</div>
71+
);
72+
}

0 commit comments

Comments
 (0)