/** * CVE Recall Benchmark — measures Assay's miss rate against ground truth. * * Precision asks "when we fire, are we right?" This asks the other question: * "when a real vulnerability exists, do we fire at all?" * * For each security advisory with a parseable fix commit, fetch BOTH sides of * the fix: the vulnerable (parent commit) and patched (fix commit) versions of * each changed file. Run the deterministic detectors (claimless checks + * promoted learned rules) over both. * * Three hit levels, strictest first: * - discriminative: a finding inside the fix-changed region, from a detector * that fires FEWER times post-fix — i.e. the fix removed what it matches. * This is the headline metric; the others are diagnostics. * - strict: any finding inside the fix-changed region (± slack), regardless * of whether it still fires post-fix. * - lenient: any finding anywhere in a changed file. * * Two guards keep the number honest: * - Noise cap: a detector firing > maxFiresPerFile times in one file has no * localization signal (its region "hits" are chance); its findings are * dropped for that file and the detector is reported as noise-capped. * First live run without this: single files with 400k+ findings and a * meaningless 100% recall. * - In-sample split: advisories that already fed the learned catalog are * reported separately from holdout — a rule trivially re-detects its origin. * * Deterministic-only: zero LLM cost, fully repeatable. Learned rules run via * executeRule directly — never runLearnedRules — so the benchmark cannot * mutate catalog fireCount stats. */ import type { DiffHunk, LearnedRule } from '../learned-rules/types.js'; import type { CVEAdvisory } from './cve-discovery.js'; /** Above this many fires in a single file, a detector is noise, not signal. */ export declare const DEFAULT_MAX_FIRES_PER_FILE = 20; export interface RecallFinding { /** Which detector tier produced this finding. */ readonly tier: 'claimless' | 'learned'; /** checkType (claimless) or rule ID (learned). */ readonly id: string; readonly severity: string; /** 1-indexed line in the scanned file; 0 when the line could not be located. */ readonly line: number; readonly evidence: string; } /** A detector whose findings were dropped in a file for firing too often. */ export interface NoiseCappedDetector { readonly tier: 'claimless' | 'learned'; readonly id: string; readonly fires: number; } export interface DetectorRun { readonly findings: RecallFinding[]; readonly noiseCapped: NoiseCappedDetector[]; } export interface FileRecallResult { readonly file: string; /** False when the pre-fix version could not be fetched (e.g. file added by the fix). */ readonly fetched: boolean; readonly findings: RecallFinding[]; readonly noiseCapped: NoiseCappedDetector[]; /** Old-file line regions the fix touched: [start, end] inclusive, 1-indexed. */ readonly hunkRegions: Array<[number, number]>; /** A discriminative detector's finding overlaps a hunk region (± slack). */ readonly discriminativeHit: boolean; /** Any finding overlaps a hunk region (± slack). */ readonly strictHit: boolean; /** Any finding anywhere in the file. */ readonly lenientHit: boolean; } export interface CVERecallResult { readonly ghsaId: string; readonly cveId: string | null; readonly severity: string; readonly cwes: string[]; readonly repo: string; readonly commitSha: string; /** True when this advisory already fed the learned catalog (self-match risk). */ readonly inSample: boolean; readonly files: FileRecallResult[]; readonly discriminativeHit: boolean; readonly strictHit: boolean; readonly lenientHit: boolean; /** Detector tiers that contributed a discriminative hit. */ readonly hitTiers: string[]; } /** Get the parent SHA of a fix commit (the last vulnerable state). */ export declare function fetchParentSha(repo: string, sha: string): string | null; /** Fetch a file's content at a specific ref. Null when missing at that ref. */ export declare function fetchFileAtRef(repo: string, ref: string, path: string): string | null; export interface FixPair { readonly parentSha: string | null; /** Pre-fix (vulnerable) content per path. */ readonly files: Record; /** Post-fix (patched) content per path. */ readonly postFiles: Record; } /** * Fetch pre-fix and post-fix content of every changed file, cached per * advisory so re-runs cost zero API calls. */ export declare function fetchFixPair(ghsaId: string, repo: string, commitSha: string, paths: string[], cacheDir: string, refetch?: boolean): Promise; export interface CorpusFile { readonly path: string; readonly content: string; } /** * Load a multi-repo code corpus from the fix-pair cache: the POST-fix * (patched) side of every cached advisory. Patched files are the closest * thing to a known-good corpus available here — a candidate rule that * fires heavily across them matches ubiquitous code, not a vulnerability. * * Deterministic: cache entries are read in sorted filename order. * Returns an empty corpus when the cache directory is missing or empty, * so callers can treat "no corpus" as "gate inactive" rather than an error. */ export declare function loadFixPairCorpus(cacheDir: string, maxFiles?: number): Promise; export declare function detectLanguage(path: string): string; /** * Locate the 1-indexed line of each match string in the source, walking * forward so repeated identical matches map to successive occurrences. */ export declare function computeMatchLines(code: string, matches: string[]): number[]; export declare function detectorKey(tier: string, id: string): string; /** * Run both deterministic tiers over code, dropping detectors that exceed the * per-file noise cap. Read-only: catalog stats are never touched. */ export declare function runRecallDetectors(code: string, language: string, promotedRules: LearnedRule[], maxFiresPerFile?: number): DetectorRun; /** * Detectors that fire FEWER times post-fix than pre-fix in this file — the fix * removed something they match, so their pre-fix findings discriminate * vulnerable from patched code. A detector firing equally on both sides is * background noise for this file. */ export declare function computeDiscriminativeDetectors(preFindings: RecallFinding[], postFindings: RecallFinding[]): Set; /** * Old-file line region a hunk touched: [start, end] inclusive. startLine is * the old-file side of the @@ header; the old-side extent is context lines * plus removed lines. */ export declare function hunkOldRegion(hunk: DiffHunk): [number, number]; export declare function scoreFile(file: string, fetched: boolean, detectorRun: DetectorRun, hunks: DiffHunk[], slack: number, discriminativeDetectors: Set): FileRecallResult; /** * An advisory is in-sample when the learned catalog already contains a rule * sourced from it — that rule would trivially re-detect its own origin. */ export declare function isInSample(rules: LearnedRule[], ghsaId: string): boolean; export declare function scoreCVE(advisory: CVEAdvisory, repo: string, commitSha: string, inSample: boolean, files: FileRecallResult[], discriminativeByFile: Map>): CVERecallResult; export interface RecallSlice { readonly n: number; readonly discriminativeHits: number; readonly strictHits: number; readonly lenientHits: number; readonly discriminativeRecall: number; readonly strictRecall: number; readonly lenientRecall: number; } export declare function computeSlice(results: CVERecallResult[]): RecallSlice; export interface RecallSummary { readonly overall: RecallSlice; readonly holdout: RecallSlice; readonly inSample: RecallSlice; readonly bySeverity: Record; /** CWE → miss count among holdout discriminative misses. The roadmap for new rules. */ readonly missedCwes: Record; } export declare function summarize(results: CVERecallResult[]): RecallSummary;