AI Security · Jul 31, 2026 · 7 min read

Local-LLM false-positive triage: cutting SAST noise without a cloud call

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.

01Why reachability alone does not clear the tail

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.

Raw SAST output 12 critical ──► reachability filter removes 4 (no call path from any HTTP entry point) ──► 8 reachable findings ──► local-LLM triage 5 confirmed TP (no guard on path, passed to DAST queue) 3 structural FP (parameterized wrapper / internal-only middleware) ──► human review queue SLA clock starts on 5 real findings
Two-stage funnel: reachability removes call-path gaps; local-LLM triage removes structural false positives before the human queue.

02What the local LLM receives

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.

triage-prompt.jsonjson
{
  "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."
}

03Calibrating trust - when to accept a verdict

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.

Reachable - HighSQL_INJECTION - UserRepository.findByUsername - GET /api/users
Reachability: call path confirmed from GET /api/users entry point ──► LLM verdict: FP (confidence 0.94) Reason: ParameterizedQueryWrapper.execute is present on every call frame above the sink; wrapper normalizes all caller input before query assembly. Disposition: archived with guard version pinned. Will reactivate if guard is removed or refactored.
Oracle: reachability confirmed call-path reach. LLM classified FP at confidence 0.94 with structural guard cited. Finding archived - not deleted. Guard hash recorded for drift detection.

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 methodContext usedStructural FP catch rateAir-gap safe
Severity sort onlyCVSS scoreVery low - structural guards invisibleYes
Reachability filterCall graph edgesMedium - guarded-but-reachable sinks pass throughYes
Human code reviewFull code and contextHighYes
Local LLM triageCall path, guards, source contextHigh - at GPU hours, not human hoursYes
Cloud LLM triageCall path, guards, source contextHighNo - source leaves the environment

04Air-gap and privacy properties

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.

0.90
default confidence floor for FP acceptance
5
call-path frames passed per finding
0
bytes of source code sent outside your environment
Evidence over heuristicsReachable beats foundMonitor → Block: rolling out a gate without slowing teamsIaC-grounded threat modelingAir-gapped AppSec with no phone-homeOne platform vs three tools
See it on your own app →