If you have ever turned on a static scanner and watched it dump hundreds of alerts on the first run, you have met the problem this post is about. Most of those alerts are wrong, and the reasons are not random. This post walks through why sast false positives happen, gives a short code example for each cause, and explains what actually cuts the noise so a real bug does not drown in it.
What SAST does well, and where sast false positives creep in
SAST reads your source code without running it. It builds a model of how data moves and flags a path when tainted input looks like it reaches a dangerous function. That is genuinely useful. It catches obvious injection sinks early, on every commit, before code ships, and it sees branches that live traffic rarely touches. The trouble is that a static reader has to guess about everything the program would only know at run time. Each guess it gets wrong becomes a false positive. Here are the five causes that produce most of them.
Cause one: no reachability
The scanner sees a sink and flags it even when no attacker input can get there, or when the code never runs at all. It matches the shape of a bug without proving the path.
def build_report(rows):
q = "SELECT * FROM audit WHERE id = " + rows[0].id
db.execute(q)
That string concatenation into a query looks like SQL injection. But rows comes from an internal report job, and id is an integer the database itself assigned. No user ever controls it. The value is not attacker reachable, so the finding is noise. A tool that only pattern matches the sink cannot tell the difference between this and a real one.
Cause two: unmodeled sanitizers
Teams write their own validation and escaping helpers. If the scanner does not know your function cleans the data, it treats the value as still tainted all the way to the sink.
def handler(request):
name = request.args.get("name")
safe = clean_identifier(name) # keeps only a to z and 0 to 9
db.execute("SELECT * FROM t WHERE u = '" + safe + "'")
Here clean_identifier keeps only letters and digits, so the value reaching the query is already safe. The scanner has a built in list of sanitizers it trusts. Your custom one is not on that list, so it reports tainted input flowing into a query. The code is fine. The model is incomplete.
Cause three: over approximation
Static analysis has to be conservative. When it cannot decide something, it assumes the worst so it does not miss a real bug. That safety costs precision at every branch and every dynamic call.
def render(kind, value):
fn = RENDERERS.get(kind, escape_html)
return fn(value)
The scanner cannot always tell which function fn will be at run time. To stay safe it assumes fn could be one that does not escape, so it flags a possible cross site scripting path. In practice every entry in RENDERERS escapes its input. The tool reports the worst case because it cannot resolve the call, and worst case reporting piles up fast in code that uses dictionaries of handlers, callbacks, or reflection.
Cause four: config and framework blindness
A lot of safety lives in a layer the parser does not model. A query behind a parameterized driver or an ORM looks like raw string handling to a tool reading text.
User.objects.raw(
"SELECT * FROM users WHERE email = %s", [email]
)
The %s here is a bound parameter. The database driver sends the query and the value separately, so email is never interpreted as SQL. To a scanner that does not understand this ORM method, it reads like a hand built query string with user input in it, and out comes an injection alert. Framework routing, template autoescaping, and middleware that validates input all cause the same blind spot. If you want the mechanics of the real bug this one imitates, see what is SQL injection.
Cause five: dead code and test fixtures
Scanners read every file, including code that never ships and test data written to be deliberately unsafe.
# tests/fixtures.py
BAD_PASSWORD = "hunter2"
def make_vulnerable_query(x):
return "SELECT * FROM t WHERE a = '" + x + "'"
This is a fixture. It exists to test the scanner or to seed a demo, and nothing in production calls it. The tool cannot tell a hardcoded secret in a test from one in a live config, or a deliberately unsafe helper from a shipping one, so it reports both. An old function no one calls anymore lands in the same bucket.
The real cost of a noisy backlog
Every wrong alert is developer time. Someone opens it, reads the code, proves it is safe, and closes it. Do that a few hundred times and the lesson the team learns is that the tool cries wolf. Once people stop reading the output, the one true positive sitting in the pile gets closed with the rest.
A scanner that reports maybes trains a team to ignore it, and the day it is finally right, no one is listening.
This is the quiet failure mode of static analysis. The tool did find the real bug. It also found four hundred fake ones around it, so the real one was never seen. Precision is not a nice to have. Below a certain signal level, a tool stops changing what anyone does.
What actually reduces sast false positives
The fixes all point the same way: stop reporting shapes and start proving paths.
- Reachability analysis. Before flagging a sink, check that attacker controlled input can actually reach it and that the code runs. The
build_reportcase above disappears the moment you ask whether any real input flows in. - Model the sanitizers and framework. Learn the custom cleaning functions and the ORM and template layers a codebase uses, so a parameterized query stops reading as raw string building.
- Confirm the value truly reaches the sink. Trace the specific value, through the specific branches, and only report when the path holds end to end. A maybe is not a finding.
- Separate test and dead code from live code. Fixtures and uncalled functions are not production risk and should not sit in the same queue.
The common thread is confirmation. A finding you can defend to a hostile expert is one where you can point at the source, the sink, and the path between them, and show input moving along it. Everything short of that is a guess dressed up as an alert.
This is the gap we care about at UnboundCompute: it confirms a real source to sink path and proves a finding before reporting it, so the output is signal instead of a list of maybes. That difference between pattern matching and proof is what our writing on scanners vs research keeps coming back to, and it is why we compare tools like SAST, DAST, and IAST by how well they prove a bug, not how many they claim. You can read how we think about it on our about page.
Frequently asked questions
Why does SAST produce so many false positives?
Because a static tool reads code without running it, so it has to guess about things only known at run time. It flags a sink even when no attacker input reaches it, treats custom cleaning functions as unsafe because it does not recognize them, assumes the worst at dynamic calls, misreads parameterized queries behind an ORM as raw strings, and reports test fixtures and dead code as live risk.
What is reachability analysis and why does it reduce sast false positives?
Reachability analysis checks that attacker controlled input can actually reach a sink and that the code runs, before reporting anything. Many false positives are sinks that look dangerous but no real input flows into, or code that is never called. Confirming the path first removes those maybes and leaves findings you can defend.
Are all SAST findings false positives?
No. SAST genuinely catches obvious injection sinks, hardcoded secrets, and unsafe patterns early on every commit, including on branches live traffic rarely touches. The problem is the ratio. A real finding is often buried under hundreds of wrong ones, so the useful signal gets ignored along with the noise.
How do I reduce false positives from my SAST tool?
Model the custom sanitizers and the ORM and template layers your codebase uses so safe patterns stop reading as raw, separate test and dead code from production code, and prefer tools that confirm a value truly reaches the sink before reporting instead of matching the shape of a bug. The common thread is proving the path rather than flagging a pattern.
Put an autonomous researcher on your own systems
UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.
Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.
