DedSec Application Security · 2026-10-14
Picture a static analyser reporting SQL injection on line 412 of a billing module. The engineer opens the file and finds a call that translates an error message. They mark it false positive, and the next scan reports the same thing on line 518.
The engineer has learned that the tool is wrong, but not why, and without the why there is nothing to tell the rule author except "it's noisy". False positives are rarely random. They come from a small number of specific bugs inside the analyser, each with a recognisable signature in the finding it produces, and each fixable in the rule or the engine rather than in your code.
The reachability post on this site covers sources, sinks and sanitisers as concepts; this one is about what goes wrong when they are implemented.
Before diagnosing anything, sort the finding into one of three categories. Each needs a different response.
A false positive. The analyser's claim about the code is untrue. The function it called a sink is not that sink, the dataflow it reported does not exist, or the sanitiser it says is missing is present. The fix belongs in the rule or the engine. Nothing about your code should change.
A true positive that is not exploitable in context. The analyser is right: untrusted input really does reach a dangerous call unsanitised. But something outside the analysed code prevents exploitation, such as an internal-only caller or an upstream gateway that rejects the input shape. The rule is correct. The decision is a risk acceptance that depends on context that can change, so record it as one and revisit it.
A true positive nobody wants to fix. The analyser is right and the path is exploitable, but the fix is expensive, the code is owned by nobody, or the release is next week. This is a prioritisation decision, made by someone with the authority to make it.
Conflating them does specific damage. File the second and third categories as false positives and a correct rule's false-positive rate rises, and the natural response to that is to loosen or disable the rule: an accurate injection rule gets weakened because nobody wanted to fix what it found. File a genuine false positive as accepted risk and the rule author never hears about it, so the bug persists.
If your tooling has one "not a problem" button, add a reason field with these three values first.
The simplest analyser bug, and the one with the most recognisable signature.
A sink is defined by a pattern. A rule for raw SQL in SQLAlchemy wants to catch the text function, which builds a textual SQL clause from a string. If the rule author writes the pattern as the literal characters 'text(' and the engine, or a pre-filter in front of the engine, matches that against source as a substring, it also matches:
Each of these is now a SQL sink, and any argument that touched request data, which user-facing messages often do, becomes a SQL injection finding.
The same shape appears wherever a dangerous name is a suffix or a fragment of a harmless one. A pattern for 'eval(' matches retrieval( and medieval(. A pattern for 'system(' meant for os.system matches get_filesystem(. A pattern for 'execute(' meant for a database cursor matches the execute method of a job runner, a command-pattern object or a test harness, which is the same text with an entirely different receiver.
The signature: the reported sink is a call whose name contains the dangerous name but is not it, or is it but on an object that has nothing to do with the sink class. Reading the flagged line is usually enough to tell.
What the fix looks like: match on resolved symbols, not text. A sink should be "the text function imported from sqlalchemy" or "the execute method on an object whose type is a DB-API cursor", which requires the engine to resolve imports and, where it can, receiver types. Engines that match on syntax trees rather than raw text are not exposed to the pure substring version of the bug unless a rule falls back to regex matching, but the receiver-type version survives in any rule that matches a method name on an object of unknown type. Where full resolution is not available, a word-boundary anchor plus an import check removes most of the noise.
What to tell the rule author: the flagged call, the symbol it actually resolves to, and the pattern that matched it. Usually a one-line fix.
Taint analysis keeps a record of which values are currently tainted. How that record is keyed determines a whole family of false positives.
The correct key is, roughly, a variable within a specific scope at a specific point in the flow. A simpler implementation keys taint by variable name, and if that map lives for the whole file rather than being reset per function, names leak between unrelated scopes. Consider a module with two functions:
A file-scoped taint map sees query tainted at line 20 and query reaching a SQL sink at line 900, and reports injection. No call connects the functions, and the second query only ever receives a literal.
The signature: the source and the sink in the reported trace are in different functions, and the trace contains no call or return linking them. If the trace jumps from one function to another without a step that explains the jump, suspect scoping.
A related bug is flow insensitivity: taint that is never removed. Within a single function, a variable is assigned from the request, then reassigned to a constant, then passed to a sink. A flow-sensitive analysis kills the taint at the reassignment. A flow-insensitive one treats the variable as tainted everywhere because it was tainted somewhere. The signature is a trace whose last assignment to the sink argument, read in the source, is a literal.
A third variant is field insensitivity: a request object is tainted, so every attribute of it is tainted, including ones the framework populated itself, such as the matched route name.
What to tell the rule author: a minimal file with the two scopes, or the reassignment, that reproduces it. This class is usually an engine bug rather than a rule bug, so it goes to whoever maintains the analyser, and the reproduction is the whole report.
A sanitiser is only meaningful relative to a sink class. Getting that relationship wrong produces errors in both directions, and the two directions have very different costs.
Credited too broadly. If a sanitiser is modelled as removing taint, rather than removing taint for a particular sink class, any function on the sanitiser list cleans data for every sink. HTML escaping is the classic case. Escaping for HTML replaces angle brackets, ampersands and, in Python's html.escape with its default arguments, quote characters. Passing the result to a shell command leaves semicolons, pipes and newlines untouched. Passing it into an integer position in a SQL statement, such as an id compared without quotes, leaves a payload like 1 OR 1=1 intact, because that payload needs no quote at all. An analyser that credits the escape as a general sanitiser reports nothing. That is a false negative, not a false positive, and it is the more dangerous of the two failures here, because nobody triages a finding that was never raised.
Not credited at all. The converse produces false positives. Common safe patterns that a rule set may fail to model:
The signature: the trace passes through something you know makes the value safe for this sink, and the finding does not mention it. In the dangerous direction, an expected path is not reported, and a sanitiser for a different sink class sits on it.
What to tell the rule author: the sanitiser, the sink class, and whether it should be credited for that class. Then ask the converse: which sink classes does each listed sanitiser apply to? A rule set that cannot answer has the broad-credit bug somewhere.
Many sanitisers are not functions that return a clean value. They are conditions that stop execution when the value is bad:
A path-sensitive analysis understands that the sink is only reached when the condition held. A path-insensitive one does not refine the value's taint by the condition, so on the path that falls through to the sink the identifier is still tainted. The same applies to an early raise on a failed allow-list check or a validator that throws.
The signature: the trace is accurate as a sequence of statements but skips over a conditional return or raise between source and sink.
What to tell the rule author: the guard and its exact shape. Some engines let a rule declare a check as a validating guard rather than a transforming sanitiser; if yours does, this is a rule fix, and if not, an engine limitation.
Some sanitisation happens in the framework, and a rule that does not know about it reports every use. Template auto-escaping is the main case. Django templates escape variables by default. Flask configures Jinja2 to autoescape templates with extensions such as .html, .htm and .xml when they are rendered through its template functions. A bare Jinja2 Environment, by contrast, does not autoescape unless it is configured to. A rule that treats every variable rendered into an HTML template as cross-site scripting is wrong in the first two situations for ordinary HTML body and quoted-attribute contexts, and right in the third, and the configuration, plus the context the variable is rendered into, tells them apart. Escaping does not make a value safe inside a script block, an unquoted attribute or a javascript: URL, so a precise rule checks that context too.
The rule that matters is the one that looks for the places auto-escaping is turned off: the safe filter, Markup objects wrapping request data, autoescape disabled for a block, or an Environment built without it. Those are where real cross-site scripting lives in a framework that escapes by default. Template injection is a separate class: request data used as the template source itself, for example passed to Flask's render_template_string.
What to tell the rule author: the framework, its version, and where escaping is configured. A rule that cannot condition on configuration should at least be split into "escaping may be off" and "escaping explicitly disabled", which have very different precision.
Three smaller classes, each with an easy signature.
Literal-only arguments. A query concatenated from two string literals is ugly but not injectable. A rule that fires on concatenation into a sink without checking whether any operand is tainted is a pattern rule pretending to be a taint rule.
Dead code. A function with no callers, or a branch behind a constant false condition, sits on the boundary of the taxonomy: the analyser is right about the code, and the code is unreachable. Deleting it is usually the cheapest response. But "no callers" is exactly what static analysis gets wrong for reflection, dependency injection and route registration, so check before you act.
Tests, fixtures and vendored code. A test that sends a SQL payload to a handler is supposed to contain one, a fixture contains credential-shaped strings on purpose, and a finding in vendored code belongs upstream. Scanned as production, these are correct about the file and wrong about the risk. Exclude them by path, but scope the glob carefully: one written for a test directory can match a production directory with a similar name.
General practice, and most of it is about not losing information.
Fix the rule, not the instance. Every bug class above is systematic. A rule that matches gettext as a SQL sink does so on every call in every repository; suppressing one instance clears one row, and one rule fix removes the class. At large finding volumes that is the difference between triage that converges and triage that does not.
Give the rule author a reproduction. The smallest file that triggers it, the rule and engine versions, the reported source and sink lines, and which mechanism above you think it is. "Noisy" gets deprioritised. "File-scoped taint map: source and sink are in unrelated functions, here are twelve lines" gets fixed.
Measure false-positive rate per rule, not globally. One global number averages a precise injection rule with one that fires on every translation call, and says nothing about which to fix. Count only the first category above in it, or it measures reluctance rather than precision.
Make every suppression carry a reason, an owner and an expiry, and scope it as narrowly as possible. Reason: which of the three categories, and why. Owner: someone who can be asked about it in a year. Expiry: when it must be looked at again, because accepted-risk decisions depend on context that changes. Scope: one finding rather than a whole file or rule, so that a new, genuinely vulnerable instance does not inherit an old decision.
Watch suppression rate per rule as the health signal. Detection count says how much a rule fires, not whether anyone acts on it. A rule whose findings are mostly suppressed is either wrong, which is a rule fix, or finding things nobody will fix, which is a policy conversation. Both are worth knowing, and neither is visible in a detection count. A rule with a low suppression rate and a steady stream of fixes is the one doing the work, regardless of how many findings it produces.
AppSecD runs static analysis through a farm of more than fifteen analyzers with Semgrep as the primary engine, and its interprocedural taint analysis, in v1 and v2, traces source to sink across function boundaries. Findings from every engine land in one deduplicated finding model. Its AI layer labels likely false positives, and the label is itself tracked, so triage quality is measurable rather than asserted. And its lifecycle supports maker-checker approval, so a finding marked false positive can require a second person to confirm it. That last property matters for the taxonomy above, because a second reviewer is a natural point to ask which of the three categories a dismissal actually belongs to.
When a finding looks wrong, read the trace and ask, in order:
If one of these explains the finding, you have a false positive with a mechanism and a bug report a rule author can act on. If none does, the analyser may well be right, and the question is not whether to suppress it but which of the other two categories it belongs in.