import { useState, useCallback } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useFinding } from '../api/queries/findings'; import { useBulkTriage } from '../api/mutations/triage'; import { truncPath } from '../utils/truncPath'; import { escapeHtml, highlightSyntax } from '../utils/syntaxHighlight'; import { parseNoteText } from '../utils/parseNote'; import { findingToMarkdown } from '../utils/findingMarkdown'; import { CopyMarkdownButton } from '../components/CopyMarkdownButton'; import { Dropdown, DropdownItem } from '../components/ui/Dropdown'; import { CodeViewerModal } from '../modals/CodeViewerModal'; import type { FindingView, Evidence, FlowStep, SpanEvidence, RelatedFindingView, } from '../api/types'; // ── Helpers ───────────────────────────────────────────────────────────────── function formatTriageState(state: string): string { return (state || 'open').replace(/_/g, ' '); } interface StatusOption { value: string; label: string; } const STATUS_GROUPS: { heading: string; options: StatusOption[] }[] = [ { heading: 'Active', options: [ { value: 'open', label: 'Open' }, { value: 'investigating', label: 'Investigating' }, ], }, { heading: 'Resolved', options: [ { value: 'fixed', label: 'Fixed' }, { value: 'false_positive', label: 'False Positive' }, { value: 'accepted_risk', label: 'Accepted Risk' }, { value: 'suppressed', label: 'Suppressed' }, ], }, ]; function isStateFinding(f: FindingView): boolean { return f.rule_id.startsWith('state-'); } const STATE_REMEDIATION_HINTS: Record = { 'state-use-after-close': [ 'Do not access the resource after calling close/free.', 'Restructure so every use happens before release.', 'Consider a language-native cleanup pattern (defer, with, try-with-resources, RAII).', ], 'state-double-close': [ 'Remove the duplicate close call, or guard with a null/closed check.', 'Centralize cleanup in a single code path to avoid repeats.', ], 'state-resource-leak': [ 'Add a close/free call before every function exit.', 'Prefer a language-native cleanup pattern (defer, with, try-with-resources, RAII).', ], 'state-resource-leak-possible': [ 'Ensure the resource is closed on all code paths — including error and early-return paths.', 'Put cleanup in a finally/defer block rather than after the happy path.', ], 'state-unauthed-access': [ 'Add an authentication check before the sensitive operation.', 'Move this handler behind an auth middleware or guard.', ], }; const STATE_RULE_DESCRIPTIONS: Record = { 'state-use-after-close': 'Variable used after its resource handle was closed', 'state-double-close': 'Resource handle closed more than once', 'state-resource-leak': 'Resource acquired but never closed', 'state-resource-leak-possible': 'Resource may not be closed on all paths', 'state-unauthed-access': 'Sensitive operation reached without authentication', }; // ── Collapsible Section ───────────────────────────────────────────────────── interface CollapsibleSectionProps { title: string; defaultOpen?: boolean; children: React.ReactNode; } function CollapsibleSection({ title, defaultOpen = true, children, }: CollapsibleSectionProps) { const [open, setOpen] = useState(defaultOpen); return (
setOpen((v) => !v)}> {' '} {title}
{children}
); } // ── Evidence Cards ────────────────────────────────────────────────────────── function EvidenceCard({ kind, color, span, }: { kind: string; color: string; span: SpanEvidence; }) { return (
{kind}
{span.path}:{span.line}:{span.col}
{span.snippet &&
{span.snippet}
}
); } function StateTransitionCard({ evidence, ruleId, }: { evidence: Evidence; ruleId: string; }) { const st = evidence.state; if (!st) return null; const isAuth = st.machine === 'auth'; const machineLabel = isAuth ? 'Authentication State' : 'Resource Lifecycle'; const acquireLocation = ruleId.includes('leak') && evidence.sink ? `${evidence.sink.path}:${evidence.sink.line}:${evidence.sink.col}` : null; return (
{machineLabel}
{st.subject && (
Variable: {st.subject}
)}
{st.from_state} {st.to_state}
{acquireLocation && (
Acquired at: {acquireLocation}
)}
); } function EvidenceSection({ evidence, skipStateCard, }: { evidence: Evidence; skipStateCard?: boolean; }) { const cards: React.ReactNode[] = []; if (evidence.source) { cards.push( , ); } if (evidence.sink) { cards.push( , ); } for (let i = 0; i < (evidence.guards?.length ?? 0); i++) { cards.push( , ); } for (let i = 0; i < (evidence.sanitizers?.length ?? 0); i++) { cards.push( , ); } if (evidence.state && !skipStateCard) { const st = evidence.state; cards.push(
State: {st.machine}
{st.subject ? `${st.subject}: ` : ''} {st.from_state} → {st.to_state}
, ); } if (cards.length === 0) return null; return <>{cards}; } // ── Notes Section ─────────────────────────────────────────────────────────── function NotesSection({ evidence }: { evidence: Evidence }) { if (!evidence.notes || evidence.notes.length === 0) return null; return (
    {evidence.notes.map((note, i) => (
  • {parseNoteText(note)}
  • ))}
); } // ── Confidence Section ────────────────────────────────────────────────────── function ConfidenceSection({ finding }: { finding: FindingView }) { if (!finding.confidence) return null; const limiters = finding.evidence?.confidence_limiters; const showLimiters = limiters && limiters.length > 0 && finding.confidence !== 'High'; return ( <> {finding.confidence} {finding.rank_score != null && ( Score: {finding.rank_score.toFixed(1)} )} {finding.rank_reason && finding.rank_reason.length > 0 && (
{finding.rank_reason.map(([k, v], i) => (
{k}: {v}
))}
)} {showLimiters && (
Why not higher confidence?
    {limiters!.map((l, i) => (
  • {l}
  • ))}
)} ); } // ── Structured Explanation ────────────────────────────────────────────────── function describeSpan(span: SpanEvidence): string { const name = span.snippet?.trim() || span.kind || span.path.split('/').pop() || span.path; return `${name} (line ${span.line})`; } function StructuredExplanation({ finding, evidence, }: { finding: FindingView; evidence: Evidence; }) { const rows: { label: string; value: React.ReactNode }[] = []; if (evidence.source) { rows.push({ label: 'From', value: ( {describeSpan(evidence.source)} ), }); } if (evidence.sink) { rows.push({ label: 'Into', value: ( {describeSpan(evidence.sink)} ), }); } rows.push({ label: 'Risk', value: riskSummary(finding, evidence), }); const contextNote = buildContextNote(finding, evidence); if (contextNote) { rows.push({ label: 'Notes', value: contextNote }); } if (rows.length === 0) return null; return (
{rows.map((r, i) => (
{r.label}
{r.value}
))}
); } function riskSummary(finding: FindingView, evidence: Evidence): string { if (evidence.explanation) return evidence.explanation; if (finding.message) return finding.message; const category = finding.category?.toLowerCase() || ''; if (category.includes('security')) { return 'Potential injection or unsafe-operation vulnerability.'; } return `${finding.category} issue.`; } function buildContextNote( finding: FindingView, evidence: Evidence, ): React.ReactNode { const parts: string[] = []; const hasCrossFile = evidence.flow_steps?.some((s) => s.is_cross_file); if (hasCrossFile) { parts.push('Crosses function boundaries via summary resolution.'); } if (finding.sanitizer_status === 'none') { parts.push('No sanitizer was applied to this flow.'); } else if (finding.sanitizer_status === 'bypassed') { parts.push('A sanitizer was present but was bypassed.'); } if (finding.guard_kind) { parts.push(`Guard: ${finding.guard_kind}.`); } return parts.length ? parts.join(' ') : null; } // ── Taint Flow Timeline ───────────────────────────────────────────────────── const FLOW_KIND_COLORS: Record = { source: 'var(--success)', assignment: 'var(--accent)', call: 'var(--sev-medium)', phi: 'var(--text-tertiary)', sink: 'var(--sev-high)', }; const FLOW_KIND_LABELS: Record = { source: 'Source', assignment: 'Assign', call: 'Call', phi: 'Phi', sink: 'Sink', }; const FLOW_COLLAPSE_THRESHOLD = 5; function FlowTimeline({ steps }: { steps: FlowStep[] }) { const [expanded, setExpanded] = useState( steps.length <= FLOW_COLLAPSE_THRESHOLD, ); if (steps.length === 0) return null; const isLong = steps.length > FLOW_COLLAPSE_THRESHOLD; const visibleSteps: FlowStep[] = (() => { if (!isLong || expanded) return steps; const firstIdx = steps.findIndex((s) => s.kind === 'source'); const lastSinkIdx = [...steps] .map((s, i) => ({ s, i })) .reverse() .find(({ s }) => s.kind === 'sink')?.i; const picked = new Set(); if (firstIdx >= 0) picked.add(firstIdx); if (lastSinkIdx != null) picked.add(lastSinkIdx); picked.add(0); picked.add(steps.length - 1); return [...picked].sort((a, b) => a - b).map((i) => steps[i]); })(); return (
{visibleSteps.map((s, i) => { const color = FLOW_KIND_COLORS[s.kind] || 'var(--text-secondary)'; const label = FLOW_KIND_LABELS[s.kind] || s.kind; const isLast = i === visibleSteps.length - 1; const isEndpoint = s.kind === 'source' || s.kind === 'sink'; return (
{!isLast &&
}
{label} #{s.step} {s.variable && ( {s.variable} )} {s.callee && ( {s.callee} )}
{s.file}:{s.line}:{s.col} {s.function ? ` in ${s.function}` : ''}
{s.snippet && (
{s.snippet}
)}
); })} {isLong && ( )}
); } // ── Related Findings ──────────────────────────────────────────────────────── function RelatedFindings({ findings }: { findings: RelatedFindingView[] }) { const navigate = useNavigate(); if (findings.length === 0) return null; return ( <> {findings.map((r) => (
navigate(`/findings/${r.index}`)} > {r.severity.charAt(0)} {r.rule_id} {truncPath(r.path, 30)}:{r.line}
))} ); } // ── Code Preview ──────────────────────────────────────────────────────────── function CodePreview({ lines, startLine, highlightLine, language, }: { lines: string[]; startLine: number; highlightLine: number; language?: string; }) { const lang = (language || '').toLowerCase(); return (
{lines.map((line, i) => { const lineNum = startLine + i; const isHighlight = lineNum === highlightLine; return (
{lineNum}
); })}
); } // ── How to Fix ────────────────────────────────────────────────────────────── function sinkCapKey(finding: FindingView): string | null { const snippet = (finding.evidence?.sink?.snippet || '').toLowerCase(); const rule = finding.rule_id.toLowerCase(); if ( /innerhtml|outerhtml|document\.write|dangerouslysetinnerhtml/.test(snippet) ) return 'xss'; if (/\beval\b|new function|settimeout\s*\(\s*["'`]/.test(snippet)) return 'code-exec'; if ( /\bexec\b|\bspawn\b|\bsystem\b|\bpopen\b|shell_exec|subprocess/.test( snippet, ) ) return 'cmd-inject'; if ( /query|execute|raw|prepare.*%|select\s|insert\s|update\s|delete\s/i.test( snippet, ) ) return 'sql'; if (/readfile|fs\.|open\s*\(|path\.join/.test(snippet)) return 'path'; if (/\bfetch\b|\baxios\b|http\.|request\.|urlopen|curl/.test(snippet)) return 'ssrf'; if (rule.includes('xss')) return 'xss'; if (rule.includes('sql')) return 'sql'; if (rule.includes('cmd') || rule.includes('command')) return 'cmd-inject'; if (rule.includes('ssrf')) return 'ssrf'; if (rule.includes('path') || rule.includes('traversal')) return 'path'; if (rule.includes('deserial')) return 'deserialize'; if (rule.includes('eval') || rule.includes('codeexec')) return 'code-exec'; return null; } const TAINT_REMEDIATION: Record = { xss: [ 'Avoid writing user input into innerHTML / outerHTML / document.write.', 'Use textContent, or framework-native binding (React props, Vue {{ }}, etc.).', 'If HTML is unavoidable, run input through a well-maintained sanitizer (DOMPurify, Bleach).', ], sql: [ 'Use parameterized queries or a prepared statement — never concatenate user input into SQL.', 'Prefer an ORM or query builder that escapes parameters automatically.', 'Validate input type (integer, enum, allowlist) before the query.', ], 'cmd-inject': [ 'Avoid passing user input to shell/exec APIs.', 'Use the argv-array form of exec (no shell interpretation).', 'Validate against a strict allowlist of commands and arguments.', ], ssrf: [ 'Validate and allowlist outbound hostnames before making the request.', 'Resolve and check the target IP is not internal / metadata (169.254.169.254, 127.0.0.0/8, 10.0.0.0/8, RFC1918).', 'Use a dedicated HTTP client that disables redirects to private addresses.', ], path: [ 'Normalize the path and verify it stays within an expected root directory.', 'Reject inputs containing "..", null bytes, or absolute paths.', 'Use a safe-join helper rather than string concatenation.', ], deserialize: [ 'Do not deserialize untrusted input with dangerous formats (pickle, ObjectInputStream).', 'Use a schema-constrained format (JSON with a validator, Protobuf).', 'If unavoidable, run deserialization in a locked-down process and validate types post-hoc.', ], 'code-exec': [ 'Do not pass user input to eval / new Function / exec.', 'Replace dynamic code generation with a parser over an allowlisted grammar.', 'If scripting is required, sandbox it (VM / Web Worker with no DOM, seccomp).', ], }; const DEFAULT_TAINT_REMEDIATION: string[] = [ 'Validate user input against an allowlist (length, character set, format).', 'Encode or escape data appropriately for the target sink.', 'Prefer parameterized / structured APIs over string concatenation.', ]; function HowToFix({ finding }: { finding: FindingView }) { const isState = isStateFinding(finding); const bullets: string[] = (() => { if (isState) { return STATE_REMEDIATION_HINTS[finding.rule_id] || []; } const key = sinkCapKey(finding); if (key && TAINT_REMEDIATION[key]) return TAINT_REMEDIATION[key]; return DEFAULT_TAINT_REMEDIATION; })(); if (bullets.length === 0) return null; return (
    {bullets.map((b, i) => (
  • {b}
  • ))}
); } // ── Status Control ────────────────────────────────────────────────────────── function StatusControl({ finding, onTriage, isPending, }: { finding: FindingView; onTriage: (state: string, note: string) => void; isPending: boolean; }) { const [noteDraft, setNoteDraft] = useState(''); const [noteOpen, setNoteOpen] = useState(false); const currentState = finding.triage_state || 'open'; const chooseStatus = (state: string, close: () => void) => { if (state === currentState) { close(); return; } onTriage(state, noteDraft.trim()); setNoteDraft(''); setNoteOpen(false); close(); }; return (
( )} > {({ close }) => ( <> {STATUS_GROUPS.map((group) => (
{group.heading}
{group.options.map((opt) => ( chooseStatus(opt.value, close)} > {opt.label} ))}
))} )}
{!noteOpen && ( )}
{finding.triage_note && !noteOpen && (
Note: {finding.triage_note}
)} {noteOpen && (