← All articles

The ignore file is where security programs go to die

August 7, 2026 · Wilson Santos · updated October 30, 2026
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:

Three realities, one line A report line reading pass with no findings is produced when the scan genuinely found nothing, when it found something that an ignore entry deleted, and when the scan never ran at all. Nothing in the output distinguishes them. WHAT HAPPENED The scan found nothing It found something, and an entry deleted it The scan never ran WHAT THE REPORT SAYS secrets pass no findings The same line in all three cases. The reader cannot tell which one they are looking at, and neither can you, six months later.
A deleted finding does not leave a gap shaped like itself. It leaves a report that reads exactly like one with nothing to report.

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 namesExpiryReasonOwner
.trivyignorea vulnerability id, everywhere it appearsexp:2026-01-01a commentno
.trivyignore.yamlan id, narrowed by path or packageexpired_at:statement:no
.semgrepignorefiles and paths, gitignore-style. Not findings at allnonea commentno
.gitleaksignorea fingerprint, commit:path:rule:linenonea commentno

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 carriesWhat its absence leaves behind
Narrow scope, a rule and a pathan 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 owneraccountability that belongs to everybody belongs to nobody. A name also tells the next person who to ask, which is often all they need
An expiryrisk 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:

AskIf 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
One decision in each format, and in one entry .trivyignore (Trivy): # throwaway test key, never valid / private-key exp:2026-12-31; excuses every private key in the repository. .trivyignore.yaml (Trivy): secrets: / - id: private-key / paths: ["test/fixtures/key.pem"] / statement: throwaway test key / expired_at: 2026-12-31; read only when named with --ignorefile. .semgrepignore (Semgrep): test/fixtures/; every rule, for every file beneath it. .gitleaksignore (Gitleaks): test/fixtures/key.pem:private-key:1; a fingerprint, which stops matching when the line moves. Cannot be written in # nosemgrep, gitleaks:allow, #nosec or a Grype rule. The config.exclude entry: paths test/fixtures/, rules private-key, a reason, expires 2026-12-31. draugr scan --evidence prints its git history: last changed in 9a70069be00c by A. Engineer, 2026-10-30. None of the four tools reports who accepted it. ONE DECISION, IN EACH TOOL THAT CAN REPORT IT ONE ENTRY, ANY SCANNER .trivyignore Trivy # throwaway test key, never valid private-key exp:2026-12-31 excuses every private key in the repository .trivyignore.yaml Trivy secrets: - id: private-key paths: ["test/fixtures/key.pem"] statement: throwaway test key expired_at: 2026-12-31 read only when named with --ignorefile .semgrepignore Semgrep test/fixtures/ every rule, for every file beneath it .gitleaksignore Gitleaks test/fixtures/key.pem:private-key:1 a fingerprint, which stops matching when the line moves # nosemgrep, gitleaks:allow and #nosec cannot express it, because each sits on a line of code and a .pem file takes no comments; a Grype rule matches packages, not secrets. draugr.saga.yaml - paths: ["test/fixtures/"] rules: ["private-key"] reason: >- The test writes a throwaway key. It was never valid. expires: 2026-12-31 draugr scan --evidence last changed in 9a70069be00c by A. Engineer, 2026-10-30 none of the four tools reports this
One decision, the throwaway key in test/fixtures, in each format that can express it and as one config.exclude entry, the only one whose scan says who accepted it.

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.

Where each exclusion was written In the code, origin tool, ask whoever committed the line: # nosemgrep hid subprocess-shell-true, P1, app.py line 13, no reason carried; #nosec G204 hid G204, P2, api/main.go line 12, reason argument checked against an allow list; gitleaks:allow hid github-pat, P1, api/config.go line 4, no reason carried. In the scanner's configuration, origin scanner, ask whoever owns the file: a .trivyignore line hid CVE-2019-10906 in Jinja2 2.10, P1, no reason carried. In the descriptor, origin saga: config.exclude hid private-key in test/fixtures/key.pem, P1, with a reason, expiring 2026-12-31, its line last changed by A. Engineer according to its git history. The console counts them as config.exclude 1 finding suppressed and scanner exclusions 4 findings suppressed. IN THE CODE IN THE SCANNER'S CONFIG IN THE DESCRIPTOR origin: tool · whoever committed the line origin: scanner · whoever owns the file origin: saga · reason, expiry, commit # nosemgrep // #nosec G204 // gitleaks:allow .trivyignore config.exclude P1 subprocess-shell-true P2 G204 P1 github-pat P1 CVE-2019-10906 P1 private-key Semgrep · app.py:13 no reason carried gosec · api/main.go:12 “argument checked against an allow list” Gitleaks · api/config.go:4 no reason carried Trivy · Jinja2 2.10 no reason carried test/fixtures/key.pem A. Engineer · expires 2026-12-31 THE CONSOLE ACCEPTED config.exclude 1 finding suppressed scanner exclusions 4 findings suppressed
Every exclusion in one repository, grouped by where it was written, which decides who to ask about it. Only the one in the descriptor says who decided and until when.

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.