Why SAST tools produce false positives
A tour of the static analysis landscape and the structural reasons every tool in it over-reports — from Rice's theorem down to the sanitiser it has never seen. Plus what actually reduces the noise.
Everyone who has run a static analysis tool over a real codebase knows the moment. Four thousand findings. Six hundred marked critical. You open the first one, read the code, and it is fine — there is a guard four lines up that the tool did not see. You open the second. Also fine. By the fortieth you are not really reading them any more, and by the following week nobody on the team opens the report at all.
This is the central problem in application security tooling, and it is almost always described as a quality issue — as though some vendor could simply be more careful. It is not. It is structural, it has a proof behind it, and understanding the shape of it is what lets you tell the difference between noise you can eliminate and noise that is the price of the analysis.
First: "false positive" means at least five different things
Most of the confusion in this area comes from one word doing too much work. When a developer closes a finding as a false positive, they mean one of these, and they are not the same problem:
- Not a vulnerability. The tool misread the code. There is a sanitiser it does not recognise, or a guard it did not model. This is a genuine defect in the analysis.
- Real, but unreachable. The pattern is exactly what the tool says. No untrusted input can ever arrive there — it is a migration script, a test fixture, a build tool, a CLI that only runs from a developer's laptop.
- Real and reachable, but not exploitable. The taint arrives, but a downstream property blocks it: output is HTML-escaped by the template engine, the column is an integer, the WAF in front of it drops the payload.
- Real, but already accepted. It is a known and deliberate trade-off with a documented decision behind it. The tool has no memory of that conversation.
- Real, but not yours. It is inside a vendored dependency, generated code, or a build artefact that should never have been scanned.
Only the first is an analysis error. The rest are context errors — the tool is right about the code and wrong about the world around it. That distinction matters, because they have completely different fixes, and a vendor claiming a low "false positive rate" is usually only counting category one.
Published figures for SAST false positive rates run from roughly 45% to over 90%, and that spread is the most informative thing about them: the tools are not that different, the counting is. OX Security's benchmark across 250 organisations put the average enterprise at around 865,000 security alerts a year, of which 0.092% remained critical after exploitability analysis. Whatever you think of vendor research, the order of magnitude is the point.
The mathematical floor
Start with the part no tool can engineer its way past.
Determining whether an arbitrary program has any non-trivial semantic property is undecidable — Rice's theorem, and "does untrusted input reach this sink in a form that harms" is squarely a non-trivial semantic property. There is no analyser, however clever, that decides it correctly for every program. That is not a limit of current technology. It is a limit.
So every static analyser approximates, and it gets exactly two choices:
- Over-approximate — treat anything you cannot prove safe as unsafe. You miss nothing real (sound) and you report a great deal that is not (false positives).
- Under-approximate — report only what you can prove. Everything you report is real (complete) and you miss the rest (false negatives).
You may have one. Not both. Every tool on the market sits somewhere on that line, and the position is a product decision rather than a technical achievement. Commercial SAST has historically sat far toward the sound end, for reasons that are commercial rather than mathematical — see below.
The six practical causes
Underneath the theory, six mechanisms produce most of the noise you actually see.
1. Sanitisers the tool has never heard of
Every analyser ships a list of functions that neutralise taint: html.escape, parameterize, the framework's own escaping. Your codebase has its own.
ALLOWED_COLUMNS = {"created_at", "total", "status"}
def safe_ident(name: str) -> str:
if name not in ALLOWED_COLUMNS:
raise ValueError(name)
return name
# ...
cur.execute(f"SELECT * FROM orders ORDER BY {safe_ident(sort)}")
This is correct. sort cannot be anything but one of three literal strings by the time it reaches the query. The analyser sees a request parameter, an f-string and execute(), does not know what safe_ident means, and reports SQL injection.
It will report it every time this pattern appears — and in a mature codebase, a well-designed internal sanitiser is used in hundreds of places. One unrecognised helper function generates hundreds of findings. This is by a wide margin the largest practical source of false positives in codebases that are actually well written, which produces the perverse result that the better your abstractions, the noisier your scan.
2. Guards that are not modelled as guards
value = request.args.get("id", "")
if not value.isdigit():
abort(400)
cur.execute("SELECT * FROM orders WHERE id = " + value)
The concatenation is real. It is also unexploitable: after isdigit() returns true, the string contains nothing but digits. Recognising that requires the analyser to model str.isdigit, understand that abort() does not return, and propagate the refined type down the branch. Many do not do all three, and the ones that do have a hand-written model for isdigit specifically — which tells you what happens the moment you write your own equivalent.
3. Path explosion, and the merging that follows
A function with 30 independent branches has over a billion paths through it. No analyser explores them separately, so states get merged at join points — and merging loses information. Afterwards the tool knows a variable is tainted on some path reaching this point, not which path, and certainly not whether that path is the one where the guard ran.
Every precision improvement here costs analysis time super-linearly. This is why the deep tools are slow, and why they get less precise as the file gets bigger — exactly backwards from what you want.
4. Frameworks the analyser cannot see through
Modern code barely calls itself. A request arrives through a middleware chain, gets routed by a decorator, is injected into a handler by a container, and touches the database through an ORM that builds SQL at runtime from a class definition.
None of those edges are visible in the source as a call. The analyser either assumes the framework can do anything — and over-reports — or fails to connect the entry point to the handler at all and silently under-reports. Dependency injection, reflection, dynamic dispatch, decorators, event buses and annotation-driven routing are all this same problem.
5. No model of who can reach the code
# scripts/reset_local_db.py
import os, sys
os.system(f"dropdb {sys.argv[1]} && createdb {sys.argv[1]}")
Textbook command injection. Also completely fine: the only person who can pass an argument to it is someone who already has a shell on the machine, at which point they do not need it.
The analyser has no concept of deployment. It cannot tell a request handler from a developer script, a production entry point from a test fixture, an internet-facing service from a cron job on an isolated host. Everything in the repository is treated as equally reachable by an attacker, and most of a repository is not reachable by an attacker at all.
6. Dependency findings matched on version, not on use
Software composition analysis has its own version of this. A tool reads your lockfile, finds [email protected], matches it against an advisory, and reports the CVE.
But the advisory is about one function in that library, and your code never calls it. Whether that CVE matters to you depends on your call graph, and version-matching does not look at your call graph. This is why dependency queues are usually the largest and least actionable list a team owns.
The landscape, and what each approach trades away
Different tools sit in different places, and the noise you get is a direct consequence of where.
| Approach | Representative tools | What it is good at | Where its false positives come from |
|---|---|---|---|
| Lexical / regex | git-secrets, grep-based rules, older linters |
Fast, runs on anything, trivial to extend | No syntax awareness whatsoever — a match in a comment, a string, or dead code looks identical to a real one |
| AST pattern matching | Semgrep, Bandit, Brakeman, njsscan | Precise on the pattern it describes; readable rules; fast enough for a pre-commit hook | Largely intraprocedural by default: it cannot see that the value was validated in a function three frames up |
| Query over a code database | CodeQL | Real interprocedural dataflow; genuinely expressive; strong community queries | Requires a build; precision is bounded by the sanitiser model written into each query, and your custom helpers are not in it |
| Commercial taint engines | Fortify, Checkmarx, Coverity, Veracode | Deep interprocedural analysis, broad language coverage, compliance-grade reporting | Over-approximation by design, plus framework opacity; historically the highest raw volume in the industry |
| Composition analysis | Snyk, Dependabot, OWASP Dependency-Check | Cheap, near-instant coverage of known CVEs | Matches on version rather than reachability, so most findings are about code you never execute |
None of these is a bad tool. Semgrep is excellent at what it is: a fast, writable pattern engine, and it does not pretend to do whole-program taint. CodeQL genuinely does interprocedural analysis and its queries are as good as anyone's. The commercial engines earn their place in regulated environments where audit trail and language breadth matter more than triage cost.
The point is that each one's noise profile is a direct, predictable consequence of the trade it made — and no amount of rule tuning moves a tool off its own curve.
Why the incentives push toward over-reporting
Now the uncomfortable part, which is economic rather than technical.
A false negative is a catastrophe with a name on it. A breach happens, the post-mortem finds the tool did not flag it, and the vendor is in the story. A false positive is a diffuse cost paid in twenty-minute increments by developers who are not the buyer, do not fill in the renewal, and are usually not in the room during the evaluation.
Every incentive on a vendor points the same direction. Evaluations are run by counting what a tool finds on a seeded benchmark, which rewards recall and does not price triage cost at all. So tools are tuned to find more, and "more" is the metric that gets sold.
The result is a queue that nobody works. A 2026 industry survey found 71% of respondents rating alert fatigue a moderate-to-critical problem, and the mechanism is not subtle: once developers learn that most findings are noise, they treat all findings as noise, including the ones that are real.
The security value of a finding is roughly (probability it is real) × (probability someone acts on it).
The industry optimises the first term and destroys the second. A tool with excellent detection whose queue has been abandoned finds nothing, and it finds nothing in a way that looks like coverage on a dashboard — which is worse than finding nothing honestly.
And the pressure is rising, not falling. AI assistants have multiplied the volume of code arriving for review while security headcount stayed flat. Feeding more code into a tool with a 70% false positive rate does not produce more security; it produces a bigger queue that gets abandoned sooner. (We wrote about the code-volume side of this in the security risks of LLM-generated code.)
What actually reduces the noise
Roughly in order of effect.
Verify before reporting. Take each candidate finding and re-examine it with full context: is the flow real, is the sink reachable, is there a guard on the path. This is expensive per finding and it is the single highest-value thing an analyser can do, because it moves work from every developer on the team to the machine, once.
Filter by reachability from an untrusted entry point. Not "does this pattern exist" but "can data that an attacker controls arrive here". That one question eliminates most of categories 2 and 5 above — scripts, fixtures, tooling, internal utilities.
Demand a proof of concept. The strongest false-positive filter available: if the tool cannot produce a concrete input that reaches the sink, the finding is a hypothesis, not a vulnerability. Rank it accordingly. A finding that arrives with a request you can replay is one you can confirm in thirty seconds instead of thirty minutes.
Teach it your sanitisers. Every serious tool lets you declare custom sanitisers, sources and sinks. Almost nobody does it, and it is usually the highest-leverage hour available — one safe_ident declaration can retire several hundred findings.
Baseline, but with an expiry. Suppressing existing findings to get a clean signal on new code is sound. Suppressing them permanently means you have decided the old code is fine without checking. Baselines should age out and come back.
Scope the scan. Exclude vendored code, generated files, and build artefacts. Free, and it removes an entire category.
Rank by exploitability, not by CWE class. A CWE's average severity says nothing about your instance of it. An SQL injection behind an admin-only route protected by SSO is genuinely lower priority than a stored XSS on a public profile page, and any tool that sorts by class alone will tell you the opposite.
What CodeZero does differently
This entire article is the design document for the engine we build, so the summary is short: CodeZero treats false positives as the primary problem rather than an acceptable side effect.
There is a verification phase, and it is adversarial. After analysis, every critical and high finding goes back through a pass whose job is to disprove it — to check whether the flow really connects, whether a guard on the path defeats it, whether the sink is reachable from anywhere an attacker can reach. What survives is reported. What does not is dropped before you see it. This costs real analysis time, and we think that is the correct place to spend it: once, on our side, instead of twenty minutes per finding on yours.
Findings arrive with a proof of concept. A dedicated phase writes a concrete request that reproduces each confirmed finding. That is the honest test described above, applied by default — you never have to take the engine's word for anything.
Analysis follows data, not patterns. A dedicated input-tracing phase follows untrusted input from where it enters the application to where it is used, across function and file boundaries. That is what makes the safe_ident case in this article resolvable: the engine reads what the helper does rather than matching on the name it does not recognise.
Separate findings become one attack chain. An attack-chain phase links individually low-severity findings into the route an attacker would actually walk — the thing a per-finding severity score can never express, and the reason "three mediums" is sometimes a critical.
The rest of the pipeline is built the same way: 16 phases, 17 languages, 652 detection patterns, 86 CWEs, a dependency phase, and a report that opens with an executive summary rather than a table of 4,000 rows.