The ignore file is where security programs go to die
On this page
Open the ignore file of any security tool that has been in a codebase for two years.
You will find entries nobody can explain. A rule id with no comment. A path that no longer exists.
A wildcard somebody added during an incident. Possibly a whole control switched off, dated by
git blame to a Friday.
None of that happened because the team was careless. It happened because the tool offered exactly one way to deal with a finding it could not fix today, and that way was to make it disappear.
Accepting risk is not the problem
Every security program accepts risk. A vulnerability with no patch, a rule that is wrong about your code, a finding in a test fixture that is supposed to look like a secret. Refusing to accept any risk means never shipping, so the question was never whether. Only whether it is written down.
The problem is the shape of the mechanism. An ignore entry deletes the finding, and a deleted finding produces a report that is indistinguishable from three very different situations:
Every tool means something different by it
There is no shared idea of what an exclusion is. Three tools a normal pipeline runs suppress three different kinds of thing, in three files, with three syntaxes:
| What an entry actually names | Expiry | Reason | Owner | |
|---|---|---|---|---|
.trivyignore | a vulnerability id, everywhere it appears | exp:2026-01-01 | a comment | no |
.trivyignore.yaml | an id, narrowed by path or package | expired_at: | statement: | no |
.semgrepignore | files and paths, gitignore-style. Not findings at all | none | a comment | no |
.gitleaksignore | a fingerprint, commit:path:rule:line | none | a comment | no |
Trivy's YAML form is the closest any of them gets, with somewhere to write the reason and a date it lapses. It is also one tool's file, experimental enough that it is not read unless you name it on the command line, and it has nowhere to record who decided. The other two cannot express the decision at all: a Semgrep exclusion is a path glob, so "we accepted this finding" and "do not look in this directory" are written identically, and a Gitleaks entry is a hash of a finding at a commit, which stops matching when the file moves.
So a team running four scanners is not maintaining an exceptions policy so much as four lists, in four formats, that mean four different things and cannot be read together.
And the worst of it is not in a file at all. # nosemgrep and #trivy:ignore put the decision in
the source, on the line it excuses, where no review of accepted risk will ever find it. At best the
scanner notes the excluded finding in a machine format nobody reads, and a #trivy:ignore in an
IaC file leaves no record anywhere. You would have to grep the codebase to know your own policy.
The question you will be asked
The question is not "do you have vulnerabilities?", since everyone does. It is:
This was in the March report and not in June's. What happened to it?
Three possible answers. It was fixed is fine; the diff proves it. Somebody accepted it, here is who and why is fine, and is a functioning process. We don't know is the one that costs, and it is what an ignore file produces by default.
What a decision needs to survive
Four things. The absence of any one turns a decision into a hole:
| What it carries | What its absence leaves behind |
|---|---|
| Narrow scope, a rule and a path | an exception tied to a whole control is a switch somebody flipped, and it silently covers findings nobody has ever seen |
| A reason a stranger can evaluate, not "false positive" | a reader in two years has no context and cannot ask you, so they re-derive it badly or leave it alone forever |
| A named owner | accountability that belongs to everybody belongs to nobody. A name also tells the next person who to ask, which is often all they need |
| An expiry | risk accepted because no patch exists stops being acceptable the week one ships. Without a date a temporary decision becomes permanent by inaction |
The last is the one nearly every tool omits and nearly every program needs, and it is why an entry outlives the person who wrote it.
Suppress rather than delete
The alternative to an ignore file is not "fix everything" but keeping the finding in the report, marked, with the decision attached.
The report gets longer. That is the entire cost, and it buys something specific: a passing verdict that does not pretend, and a list of accepted risks that somebody can review. Because that is the other half. An exceptions list is a document to be read on a schedule, not a configuration file that grows.
If nobody has ever removed an entry from yours, it is sediment rather than a list of decisions.
What to check in your own tooling
Three questions, and you can answer them this afternoon:
| Ask | If the answer is no |
|---|---|
| Does a suppressed finding still appear anywhere? | your reports cannot distinguish clean from quiet |
| Can an exception carry a reason and an owner in a structured way? | you have a comment somebody might have written, which nothing can read or check |
| Can it expire, and does anything say so when it does? | every acceptance is permanent, and you will find out which ones mattered from somebody else |
Draugr's answer is config.exclude: one syntax for every scanner, written in the file that
already describes your scope, so excusing a Trivy finding and a Gitleaks one are the same edit. It
writes down three of the four things above, and the git history of its line records the fourth:
config:
exclude:
- paths: ["test/fixtures/"] # narrow scope, not a whole control
rules: ["private-key"]
reason: >-
The integration test writes a throwaway key so the secrets control has
something real to find. The material is fake and was never valid anywhere.
expires: 2026-12-31
An excluded finding stays in the report marked suppressed, carrying its reason and the file and
line of the exclusion. --evidence prints the git history of that line, the last commit to change
it with its author and date, and report.json carries the same for every exclusion. That record is
stronger than a name typed into the entry, which nothing checks and anyone editing the file can
write. It holds to whatever the repository enforces before a change merges, signed commits and
required review included.
The scan says how many findings were suppressed, an exclusion past its expires date stops
suppressing and reports that it lapsed, and one that matched nothing is reported too, because a
rule doing nothing is usually a typo, a rule id that moved, or a finding somebody already fixed and
forgot to stop excusing.
There is also nothing to learn per tool. A new scanner arrives with its own ignore format and its own semantics, and none of that reaches you, because the exclusion was never expressed in the scanner's terms. It was expressed in yours, against a rule id and a path, in the descriptor that already says what your software is.
The exclusions somebody already wrote in a scanner's own terms are kept as well, wherever the scanner
records them. A # nosemgrep, a #nosec or a gitleaks:allow beside the line, a .trivyignore
line and a Grype ignore: rule each leave the finding in the report, suppressed and still ranked
into a priority band, P1 to P4, from its severity and the
component's declared exposure and criticality. They are counted under scanner exclusions,
separately from config.exclude, because neither kind is a decision recorded in the descriptor.
Each one says where it was written: origin: tool for a directive in the scanned file, which sends
you to whoever committed the line,
and origin: scanner for the scanner's own configuration, named by file, which sends you to
whoever owns it. An exclusion the scanner records nowhere, such as a #trivy:ignore or a
.gitleaksignore entry, cannot be reported by anything, while one written in config.exclude
keeps the finding and its reason in every case.
None of that decides anything for you. It makes the decision legible, which is the part anyone asking is after.
The risk acceptance guide covers the discipline, and your CI logs are not evidence covers what the resulting report has to carry.