DedSec Application Security · 2026-10-14
Picture a static analysis programme a couple of years in. Every repository is scanned, the rule packs have been widened twice, and the headline number on the dashboard, open findings, is larger than the number of engineers multiplied by the working days left in the year. Nobody is going to read them all. Somebody proposes adding a rule pack that detects more.
That proposal answers the wrong question. Once a backlog is too large to read, detection is not the bottleneck. Attention is. What the programme actually produces is decisions, one per finding: fix it, or record why not. Suppression is how the second kind of decision is stored, and at scale its quality decides whether the backlog shrinks or grows forever.
This post covers what kinds of suppression there are, what they are keyed to, how they rot, and what to measure. It builds on what a SAST false positive actually is and how a pull-request gate blames the wrong change, and links to them rather than repeating them.
Raising detection count is cheap. Enable another rule pack, switch on an analyser for a language that was previously skipped, lower a severity threshold, or loosen a pattern so it matches more call shapes. Each of these raises the number on the dashboard without anyone learning anything new about the code. Mostly, detection count measures how broad the rules are and how much code they were pointed at.
It also counts the same thing many times. One unsafe query helper called from many places is many findings. One pattern flagged by two analysers is two findings unless something deduplicates them. A vendored library copied into several repositories is the same set of findings several times over.
What the count does not say is what any finding cost or bought. A finding costs attention when someone has to read it, follow the trace, and decide. It buys something only when that decision is acted on: a fix, or a recorded reason that still holds later. A finding nobody decides on costs no triage time today and buys nothing ever. If it is real, it is exposure that nobody has accepted, and it still sits in the count.
So the question for a large programme is not how many it found. It is: of what it found, how much was decided, how well, and does each decision stay correct as the code moves?
Every finding in a backlog is in one of three states:
Report them as three numbers and watch the third. Actioned and suppressed are both outcomes of work. Untouched is the absence of work, and its growth is the failure mode, for a reason that is arithmetic rather than effort.
Think of findings as a flow. New findings arrive at some rate: new code, new rules, new analysers, and old findings re-found because the suppression that covered them stopped matching. Decisions are made at some rate, set by how many people triage and how long each decision takes. If arrivals outpace decisions, and code churn does not happen to clear findings as fast, untouched grows for as long as that holds, however good each individual decision is.
That leaves three levers:
Suppression sits inside all three, which is why it matters more than detection count.
There is also a bad way to shrink untouched: the bulk suppression, usually done by generating a baseline file or rebaselining, that marks everything currently open as accepted so the programme can start clean. It converts untouched into suppressed in one step and records nothing: no reason, no category, no owner. The dashboard improves, and the record of which findings anyone actually looked at is gone. If you have to do it, record it as what it is, a deferred decision with a date, and keep those findings distinguishable from ones a person actually looked at.
The false-positive post argued for separating "the analyser is wrong" from "the analyser is right but we accept it". At scale the split needs to go further. There are at least five distinct reasons a finding is not fixed, and each has a different owner, a different audience for the feedback, and a different condition under which it should be looked at again.
False positive. The analyser's claim about the code is untrue. Feedback goes to the rule author or the engine maintainer. It is worth revisiting when the rule or the engine changes, because the bug that produced it may have been fixed, or a different one introduced.
Accepted risk. The finding is real, and someone with the authority to decide has chosen to tolerate it, usually because something outside the analysed code limits exploitation or because the fix costs more than the exposure. Feedback goes to whoever owns risk. It is worth revisiting on a date, and whenever the context it depends on changes: the internal-only endpoint gets exposed, the upstream gateway is replaced.
Not applicable. The pattern is real but the code is not production code: tests, fixtures, examples, generated code, vendored dependencies tracked upstream, dead code. Feedback goes to whoever owns the scan configuration, because the right fix is usually scope rather than suppression. It is worth revisiting when the path's status changes: the example directory becomes a deployed service, or the fixture gets imported by real code.
Duplicate. The same underlying problem is already tracked as another finding: two analysers flagging the same call, or one issue reported at both its source and its sink. Feedback goes to whoever owns deduplication. A duplicate has to name the finding it duplicates. One that does not is a suppression with no reason.
Fixed elsewhere. The finding is real and has been addressed by a change not visible at the flagged line: a fix in a shared helper, a framework upgrade, a gateway rule. If the analyser can see the fix, the next scan should make the finding disappear, and if it does not disappear the claim deserves a second look. If the analyser cannot see it, the suppression should link the change that fixed it, so the claim can be checked by a person.
Put all five behind one "ignore" button and four things are lost.
The rule author cannot see the rule's false-positive rate, because it is mixed with risk decisions about correct findings. An accurate rule looks noisy, and noisy rules get loosened.
Accepted risk has no revisit trigger, because nothing records which context it depended on.
Scope problems never reach the scan configuration, so the same test directory produces the same not-applicable findings with every new rule, and each one is suppressed by hand.
And "fixed elsewhere" is never verified, although it is the one category the next scan can often check for free.
If a tool has only one button, a mandatory category field is the first thing to add.
A suppression is a rule that says: when a finding matching this appears, do not show it. What "matching this" means decides how it fails. The PR-gate post covers how finding fingerprints are built and how each scheme breaks; the concern here is what happens to a suppression attached to each kind of key.
A finding fingerprint. As narrow as the fingerprint scheme is, which is usually what you want, and it rots in both directions. A coarse scheme, such as rule plus file plus function, absorbs a new instance of the same rule in the same function into the old decision. And the key stops matching when the fingerprint changes for reasons unrelated to risk: the function is renamed, the code is extracted into a helper, the file moves, or an engine upgrade changes how fingerprints are computed or renames a rule whose ID is part of the fingerprint. The finding reappears as new, someone triages it again, and the original reasoning is not attached.
A file and line. Rots on the next edit above it. It mostly survives as a fallback when a richer key cannot be computed.
A rule. Disabling a rule globally, or for one repository, suppresses every finding the rule will ever produce, including findings in code not yet written. A rule that is wrong for a codebase should be off, but as a configuration change that reviewers of the scanner's configuration can see, not as a suppression.
A path glob. Covers every finding of every rule under a path, now and later. It rots in the other direction: it does not stop matching, it starts matching more. The glob written for a test directory matches a new production directory with a similar name, and the vendored-code exclusion catches the directory a team quietly started editing by hand.
An inline comment. Travels with the code, which is the right property when code moves and the wrong one when it is copied. Copy the function and the ignore comes along into a context where the reasoning may not hold. A formatter that shifts the comment can detach it without a word: Semgrep's 'nosemgrep' comment, for example, only applies on the flagged line or the line before it, so a rewrap that pushes it further away turns the suppression off. And an inline ignore added in the same change as the code it covers is a decision the author made alone, which the PR-gate post discusses.
The two failure directions differ in cost and in visibility.
A suppression that stops matching produces noise. The finding is re-found, re-triaged, and usually re-suppressed by someone who does not know it was decided before. It is visible but expensive, and it teaches engineers that triage does not stick.
A suppression that matches too broadly produces silence. A new, real finding arrives and is hidden on arrival by a decision made about something else. Nobody sees it. That is the more dangerous failure, and the only way to find it is to go looking.
A cheap signal for both, as general practice, is to record how many findings each suppression matched on each scan. A suppression scoped to one finding should match exactly one. If it matches none, the code was fixed, the key rotted, or the file or rule was not analysed this time. A new finding appearing nearby in the same scan, and the scan's coverage record, usually tell you which; the post on silent scanner failure covers why that record matters. If a suppression matches more findings this scan than last, it has started covering things nobody decided about, and every newly covered finding should be surfaced for review rather than absorbed.
At small scale a suppression can be a checkbox. At large scale it is the main artefact the programme produces, and it should carry what any decision record carries:
There is a standard shape for part of this. SARIF 2.1.0, the interchange format many analysers emit, gives each result an optional list of suppression objects, each carrying a kind (inSource for a comment in the code, external for a decision stored elsewhere), a status (accepted, underReview or rejected) and a free-text justification. That is less than the list above, with no category, no revisit condition and no confirmer, but if your tools exchange SARIF, those fields are a reasonable place to start and worth not throwing away at ingest.
The revisit condition deserves more thought than it usually gets. A fixed expiry date is better than none and simple to implement, but it reopens every suppression on a calendar whether or not anything changed, which manufactures arrivals. The false-positive post recommended an expiry on every suppression, and that is still the minimum; a condition is the better version of it. Conditions follow the actual reason a decision might stop holding:
A second reviewer earns their time most on the decisions that say a finding is not real or no longer present: false positive and fixed. Duplicate and not-applicable decisions also close findings, and deserve at least a check that the duplicate names its target and the path really is out of scope. False positive and fixed are the decisions easiest to make for the wrong reason, because the finding is in the way or the release is today, and the hardest to discover were wrong, because the finding disappears. A second reviewer is also the natural place to ask whether the category itself is right, and misfiled categories are what corrupt the per-rule signal in the next section.
The false-positive post made the case for measuring false-positive rate per rule rather than globally. With categorised suppressions, each rule gets a profile instead of a single number, and each part of the profile says something different:
Cut the same data by who made each suppression, and two more patterns appear. One person behind most of a rule's false-positive marks is either the engineer who understands its failure mode or someone clearing a queue; both are worth knowing. And a suppression made moments after the finding was first shown is unlikely to reflect a reading of the trace. Time to decision is a crude signal, but it is free.
None of this works if categories are misfiled. Accepted risk filed as false positive makes a correct rule look broken, which is why the category matters more than any other field on the record.
Upgrading an analyser or a rule pack changes more than detection. It changes which findings exist, what their fingerprints are, and so what every stored suppression matches.
Picture an upgrade in which one rule is renamed, another is split into two narrower rules, the taint engine starts following dataflow through a kind of call it used to give up on, and the fingerprint scheme gains a field. After it:
The general practice is to treat an upgrade as a migration with a diff, not as a version bump:
The post on silent scanner failure covered how an upgrade can change output formats underneath an ingest parser. This is the same event seen from the decision side: even when ingest works perfectly, an upgrade quietly re-judges every suppression you hold.
Re-validating every suppression is the work suppression was meant to save. Sample instead, and design the sample with care.
Stratify by category. False positives, accepted risks and not-applicables fail differently and should be sampled separately. One random sample over everything is dominated by the largest category.
Stratify by rule and by suppressor. The rules with the highest suppression shares, and the people who make the most suppressions, are where systematic errors concentrate.
Review every broad key. Each rule-wide or glob suppression covers an open-ended set of future findings, so none of them should be left to chance.
Re-validate, do not re-read. For a false positive, ask whether the mechanism still holds: is the sink still not a sink, is the guard still there? For an accepted risk, whether the context it named still exists. For fixed elsewhere, whether a scan or a person confirmed it.
Plant known-real findings. The silent-failure post suggested canary fixtures to prove coverage. The same trick tests suppression breadth: put a known-vulnerable pattern in a path covered by a broad suppression and see whether it surfaces. If it does not, a real one would not either.
Read the sample's error rate as the output. A stratum in which the sampled suppressions still hold is healthy. One in which many have gone stale needs a different revisit condition, or a different reviewer.
AppSecD runs SAST, DAST, SCA, secrets, IaC, container, Kubernetes and API security against one deduplicated finding model, so a vulnerability found by two engines is tracked once. Findings are deduplicated across scans and move through new, reopened and fixed states, and they carry ageing and an SLA and are assigned to an owner. Its lifecycle supports maker-checker approval, so a finding marked fixed or false positive can require a second person to confirm it. And its AI triage layer labels likely false positives with the label itself tracked, so triage quality is measurable rather than asserted.
Everything else in this post, from the five categories and revisit conditions to match counts per suppression, per-rule suppression profiles and upgrade diffs, is general practice. The questions below are fair to put to any tool, ours included.
A programme that can answer the last question is running on decisions. One that can only report how many findings it detected is running on a number that rises every time someone enables a rule, whether or not anything is getting safer.