Static Analysis False Negatives: The Bugs SAST Misses

Static Analysis False Negatives: The Bugs SAST Misses

A false positive wastes an hour. A false negative can cost you a breach, because the tool told you a piece of code was clean when it was not. This post is about static analysis false negatives, the real bugs a scanner stays silent on, why silence is more dangerous than noise, and what it takes to actually prove a bug is not there rather than just fail to find it.

Why static analysis false negatives are the worse failure

Its twin, the false positive, is loud. It shows up in your backlog, annoys a developer, and gets closed. A false negative makes no sound at all. The scan goes green, the pull request merges, and everyone moves on believing the code was checked. That green check is the danger. It replaces “we do not know” with “we are safe,” and the team ships on false confidence. If you want the noisy side of the story, we cover it in why SAST has false positives. Here we walk the quiet side: five reasons a static tool misses a real bug, each with a short example.

Cause one: the sink is not in the tool’s list

A static scanner only flags a dangerous call if that call is on a list it carries. Injection detection works by knowing that db.execute or os.system is a place tainted input must never land. If your dangerous call is a wrapper the tool has never seen, there is no rule to fire.

def run_shell(cmd):
    # thin wrapper the team wrote around subprocess
    return _spawn(cmd, shell=True)

def handler(request):
    host = request.args.get("host")
    run_shell("ping -c 1 " + host)

The user controls host, it flows straight into a shell, and this is a command injection. But the sink is run_shell, an internal helper, not the built in call the scanner watches for. The tool sees a function call it has no opinion about and moves on. The bug is real and the report is empty. Every codebase grows its own wrappers, and each one the catalog does not know is a blind spot.

Cause two: dynamic dispatch hides the real call target

Static analysis has to connect the place a value comes from to the place it is used. When the actual function called is chosen at run time, through reflection, a function pointer, or a lookup table, the tool often cannot tell which function runs, so it never draws the edge that would carry the taint.

ACTIONS = {"save": save_note, "export": export_note}

def dispatch(request):
    name = request.args.get("action")
    data = request.args.get("data")
    ACTIONS[name](data)   # which function is this?

If export_note writes data into a file path or a query without cleaning it, that is the bug. But the call goes through a dictionary keyed by a string, so the analysis cannot always prove which function is on the other end. To avoid a wrong guess it connects nothing, and the tainted flow into export_note is never traced. Reflection by name, virtual methods, and function pointers all produce the same broken link.

Cause three: framework and config behavior the parser never sees

Modern apps put a lot of behavior outside the code the parser reads line by line. Routes are wired by decorators or a config file. Input arrives through a callback the framework invokes. An ORM turns a method call into SQL somewhere the tool cannot follow.

# the framework calls this by name from a route table
@route("/upload/")
def upload(user_id):
    path = STORAGE + "/" + user_id
    open(path, "wb").write(request.data)

Here user_id comes from the URL and lands in a file path, so a value like ../../etc/cron.d/x writes outside the storage folder. That is a path traversal. But the scanner may never register that upload is an entry point at all, because the route is bound by a decorator string it does not model. If the function looks like dead code that nobody calls, its input never counts as attacker controlled, and the flaw stays invisible.

Cause four: the value crosses a boundary the analysis does not follow

Most static tools reason inside one process and often inside one function at a time. The moment a value leaves through a queue, a cache, a file, or a call to another service, the thread of the analysis is cut. What comes back out the other side looks brand new and untainted.

# service A
queue.push(request.args.get("payload"))

# service B, a different file or process
job = queue.pop()
os.system("convert " + job)

The user controls payload in service A. It rides a queue to service B and gets concatenated into a shell command. End to end this is a clean command injection, but no single function holds both halves. The tool analyzing service B sees job arrive from queue.pop() with no visible source, treats it as trusted, and says nothing. Values that round trip through Redis, a database column, or a call to a sibling service disappear from the taint graph the same way.

A false positive tells you a lie you can check. A false negative tells you a lie you will only discover when someone exploits it.

Cause five: logic and access control bugs have no sink to match

The four causes above are all missed connections in a flow the tool would flag if it could see it. This last one is different, and it is the hardest. Some of the worst bugs have no dangerous call anywhere, nothing malformed, no pattern to grab.

@route("/api/invoices/")
def get_invoice(invoice_id):
    inv = Invoice.query.get(invoice_id)
    return inv.to_json()   # never checks who owns it

A user who owns invoice 41 requests GET /api/invoices/42 and reads someone else’s invoice. The query is safe, the input is a clean integer, and the response is a normal 200. This is broken access control, one of the most common serious bugs in real apps, and pattern matching has nothing to catch on. There is no sink, no tainted string, no known bad shape. The bug is the missing check, and you cannot pattern match an absence. We go deeper on this in why SAST misses business logic.

The coverage versus noise tradeoff, honestly

Here is the part vendors do not say out loud. False positives and false negatives pull against each other. Tune a scanner to report every possible sink and you catch more real bugs while you drown the team in noise. Tune it to stay quiet and the backlog gets clean while the miss rate climbs. Cutting false positives too aggressively is exactly how you buy more false negatives. A tool tuned to look calm on a dashboard is often one that has been told to stay silent.

So the goal is not a quieter tool or a louder one. It is a tool that reports fewer maybes because it can prove more. Two things move that line:

  • Reachability, done for real. Instead of matching a sink shape, follow the specific value through the specific branches and prove attacker input arrives at a real dangerous call. That both drops false positives and, done properly across wrappers and call boundaries, finds the flows a shallow scan cut short. We explain the mechanics in reachability analysis for security.
  • Reasoning about intent for the logic bugs. The invoice case is invisible to any pattern engine. Catching it means understanding what the app is for, that an invoice belongs to an owner, that a request should be checked against that owner, and then testing whether the check exists. That is reasoning, not matching.

What this means for how you read a green scan

Treat a clean static result as “no known pattern fired,” not “no bug here.” The wrapper it did not know, the dispatch it could not resolve, the route bound in config, the value that crossed a queue, and the access check that was never written are all still on the table after the scan goes green. A finding you can defend points at a source, a sink, and the path between them. Silence proves none of that.

This is the gap we build for at UnboundCompute. It learns how an app is meant to work, forms ideas about where that logic breaks, and proves a finding with real evidence before reporting it, the same discipline that separates a missed bug from a caught one. More of this thinking lives under scanners vs research, and you can read how we approach it on our about page.

Frequently asked questions

What is a false negative in static analysis?

A false negative is a real bug that a static scanner does not report. The scan looks clean, so the code appears checked when it was not. This is more dangerous than a false positive because it replaces honest uncertainty with false confidence, and the team ships believing the code is safe.

Why are false negatives worse than false positives?

A false positive is loud. It lands in your backlog, wastes a developer an hour, and gets closed. A false negative makes no sound at all. Nobody knows the bug is there until someone exploits it. The quiet green check is exactly why a missed bug does more damage than a noisy one, which we cover in why SAST has false positives.

Why does static analysis miss real vulnerabilities?

Common reasons include a dangerous call the tool does not know about, a call target hidden behind dynamic dispatch or reflection, routes and behavior bound by framework config the parser never sees, values that cross a queue or another service and break the taint trail, and logic bugs like broken access control that have no dangerous sink to match.

How do you reduce static analysis false negatives?

Prove reachability instead of matching sink shapes, so you follow the specific value through the specific branches across wrappers and call boundaries. For logic bugs like broken access control, you need reasoning about what the app should allow, not pattern matching. See reachability analysis for security for the mechanics.


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.