A scan comes back with zero findings. The pipeline goes green, the gate passes, and everyone moves on.
On a codebase of any size, zero is usually not a result. It is the scanner telling you it did not find anything to analyse, in a way that is indistinguishable from finding nothing wrong. This post is the specific reasons that happens, and how to make a clean scan prove it actually scanned something.
Written for whoever owns the pipeline, and it applies to every scanner including ours.
Why zero is suspicious
Every scanner has two failure modes that produce identical output. It analysed your code and found nothing, or it failed to analyse your code and therefore found nothing. Most tools report both as success, because from the tool's perspective it ran to completion without error.
The asymmetry matters: a false positive is annoying and visible, so it gets fixed. A false negative caused by zero coverage is invisible and gets trusted. Over time the second one accumulates, because nothing in the system ever contradicts it.
The seven reasons a scan covers nothing
No lockfile. Most dependency scanners need a resolved dependency graph. Given only a manifest with version ranges, some tools resolve it themselves, some analyse what they can, and some quietly analyse nothing. A repository with package.json and no package-lock.json is the single most common cause of an empty dependency scan.
The language is not supported. Every scanner supports a list of ecosystems, and your codebase probably contains one that is not on it. The scan of your Go and TypeScript services is real; the Elixir service in the same monorepo produced nothing and said so in a log line nobody read.
The scanner ran in the wrong directory. In a monorepo, a tool pointed at the root may find no manifest at the root and stop. Or it finds one, analyses that, and never descends into the twelve service directories that hold the actual dependencies.
Authentication to a private registry failed. The tool cannot resolve your internal packages, so it skips them, and your internal packages are the ones nobody else is auditing.
The build did not run. For anything needing compiled output or a resolved classpath, a scan against a source tree with no build artifacts covers a fraction of what it would otherwise.
Everything was excluded. An ignore file written years ago to silence noise from vendor/ now excludes a directory that has since become the application.
The scan was killed. Timeout, out of memory, evicted. If the pipeline treats a killed scan as a passed scan, you have a gate that fails open under exactly the conditions where scans get slow, which correlate with the codebase growing.
Make coverage explicit
The fix is not a better scanner. It is refusing to accept a result that does not state what it covered.
Demand a coverage number and assert on it. Files scanned, dependencies resolved, packages analysed, whatever unit the tool reports. Then fail the build when it drops:
count=$(jq '.dependencies | length' scan-results.json)
if [ "$count" -lt 50 ]; then
echo "coverage collapse: $count dependencies analysed, expected >= 50" >&2
exit 1
fi
The threshold is not a quality bar, it is a smoke alarm. A repository that resolved 400 dependencies last week and 3 today has a broken scan, not a dramatically improved one.
Plant a canary. Add a dependency with a known, harmless, well-published vulnerability to a test fixture, and assert the scanner finds it. If the canary stops being reported, the scan stopped working, and you learn that on the build rather than six months later.
This is the single highest-value check in this post and almost nobody does it. It is the difference between trusting a tool and verifying it.
Fail closed on incomplete. Three outcomes, not two: found issues, found none, did not complete. Only the middle one is a pass. Any tool that cannot distinguish the first and third for you needs a wrapper that checks the exit code and the output shape.
Read the warnings once, deliberately. Most scanners do say what they skipped. The information is usually there, in a log nobody opens because the step was green. Read the full output of one scan per repository, by hand, once, and you will find at least one surprise.
The audit worth doing this week
Take your last successful scan of each repository and ask three questions.
How many units did it analyse, and does that resemble the size of the repository? Open package.json and count. If the tool reports fewer dependencies than your direct dependencies, it did not resolve transitives, and transitives are where the findings are.
Which languages does the repository contain, and which does the tool support? The gap is your uncovered surface, and it should be written down rather than discovered during an incident.
What happens if the scan step times out? Read the pipeline definition. If a killed scan leaves the build green, fix that first, because it is the failure that arrives under load.
The concession
Coverage assertions add friction, and some of it is genuine noise. A repository legitimately shrinks, a service is decommissioned, a directory is intentionally excluded, and each of those trips a threshold and produces a build failure somebody has to investigate and dismiss.
That cost is real. The way to keep it low is to set thresholds loosely, at roughly half the expected value rather than at the exact one, so they catch collapse rather than drift. A threshold that fires on normal variation gets raised until it never fires, which returns you to where you started.
The implication
A security tool that fails silently is worse than no tool, because it produces a green signal that stops anyone else from looking. The dangerous state is not a scanner that is wrong, it is a scanner that is absent while appearing present.
So treat zero findings as a claim requiring evidence. The canary dependency is the evidence, it takes an hour to set up, and after that a clean scan means something.