A local LLM given the finding, code context, and call-graph trace classifies structural false positives that reachability alone cannot filter - without sending source code outside your environment.
SAST finds the sink. It does not know whether any real request handler can reach it, and it does not know whether a sanitizer two frames upstream already neutralizes the taint. Reachability filtering removes findings with no live call path from an HTTP entry point to the vulnerable function - that cut is significant. What it does not remove is the structural tail: sinks reachable on paper through a parameterized wrapper, an internal-only middleware, or a framework guard the taint tracker did not model. A local LLM, given the finding, the surrounding code, and the call-graph trace, can classify that tail - running entirely on your infrastructure, with no source code leaving your environment.
Reachability answers one binary question: does any entry point call the vulnerable function? What it cannot encode are structural guards the call graph edges do not carry: a parameter sanitized at the service boundary before the sink is called, a middleware that enforces a content-type constraint on every route above the sink, or a boolean flag that gates the code path in every real caller but is invisible to edge traversal. These findings look identical to real vulnerabilities from a pure taint-tracking view - same source, same sink, different context above it.
The canonical case is a SQL sink reached only through a parameterized wrapper that normalizes every caller's input before the query assembles. The taint flow the scanner traces is real. The exploit is not available - the wrapper is always in the path. A human reviewer resolves this in thirty seconds by reading the wrapper. A local LLM given the sink, the five-frame call trace, and the wrapper source can reach the same classification without a ticket queue and at scan speed.
The triage prompt is structured, not open-ended. For each finding that survived reachability the engine assembles a context package: the finding class and CWE, the call-graph path from the nearest HTTP handler to the sink trimmed to five relevant frames, the sanitizer and guard annotations on that path, and the source lines at the sink and at the nearest user-controlled input. That context is enough for a binary classification with a stated reason and a model confidence score.
A second field the LLM always receives is the confidence floor instruction: classify as false positive only when certain, and default to true positive when uncertain. A model that hedges toward FP when unsure suppresses real findings. The correct conservative default is to pass the borderline finding to a human reviewer with the LLM's reasoning attached, not to drop it silently.
{
"finding": {
"class": "SQL_INJECTION",
"cwe": "CWE-89",
"sink": "UserRepository.findByUsername",
"file": "src/users/repository.py:47"
},
"call_path": [
"GET /api/users -> UserController.search",
" -> UserService.search",
" -> ParameterizedQueryWrapper.execute <guard>",
" -> UserRepository.findByUsername <sink>"
],
"guards_on_path": [
"ParameterizedQueryWrapper normalizes all input before query assembly"
],
"instruction": "Classify FP only when certain. Default TP when uncertain."
}The LLM's output is a signal, not a decision. A FP classification is accepted only when two conditions hold: the stated reason matches a known structural pattern (sanitizer present, guard on every call path, test fixture only, unreachable branch) and the model confidence score exceeds a configurable threshold - default 0.90. Anything below that threshold, or any reason that does not match a known pattern, is forwarded to a human reviewer with the full reasoning attached. The finding is never silently dropped.
A finding reclassified as FP is archived with the triage verdict, the model version that produced it, and the guard that justified the classification. When a future scan detects that the cited guard is no longer present - the wrapper is refactored, the middleware is removed, the branch condition is deleted - the finding reactivates from the archive automatically and re-enters the queue with its original severity. Triage verdicts age with the code, not with a reviewer's calendar.
| Triage method | Context used | Structural FP catch rate | Air-gap safe |
|---|---|---|---|
| Severity sort only | CVSS score | Very low - structural guards invisible | Yes |
| Reachability filter | Call graph edges | Medium - guarded-but-reachable sinks pass through | Yes |
| Human code review | Full code and context | High | Yes |
| Local LLM triage | Call path, guards, source context | High - at GPU hours, not human hours | Yes |
| Cloud LLM triage | Call path, guards, source context | High | No - source leaves the environment |
The LLM runs on the same node as the scanners. No source code, no call-graph fragment, and no finding detail leaves the environment. For air-gapped deployments where sending source to any external service is a compliance requirement, a cloud-hosted triage LLM is not an option - local inference is the only viable path. The model is a quantized GGUF artifact served by a local inference runtime; the same curated-cache mechanism that keeps CVE detection current keeps the model pinned to a versioned, checksum-verified artifact that can be updated in the same offline bundle without a network connection.
Model verdicts are stored per finding in the posture database. Audit trails show the model version, the stated reason, and the human disposition for every finding that was reviewed or overridden. A reviewer who disagrees with a verdict flips it in the UI and the correction is recorded - those overrides feed back into threshold calibration across the team without retraining the model. The goal is a shorter human review queue, not an autonomous decision system.