Skip to main content

Why did the scanner not flag this?

· 8 min read
Vulkro
Security research

A penetration test report arrives with an injection at src/routes/users.js:42. The security scanner has run on every pull request for a year and never mentioned that line. The first question in the room is the obvious one: why did it not flag this?

Usually nobody can answer. Maybe the scanner never read the file. Maybe it read it and decided the input was sanitised. Maybe it found the bug, rated it below the default view, and it has been sitting one flag away the whole time. Maybe someone suppressed it eight months ago. Each of these calls for a different response, and without an answer the team guesses, loses some trust in the tool, and moves on.

Vulkro has a command for that question. vulkro why-not <path[:line]> explains why nothing was reported at a location. This post covers why silence needs explaining, what the command can tell you, and where its answers stop.

Silence is the hardest output to trust​

A finding can be checked. You open the file, follow the trace and decide. Silence cannot be checked the same way, because there is nothing to follow.

That matters because every scanner has false negatives. OWASP's page on source code analysis tools is direct about it: current tools can automatically identify "only a relatively small percentage of application security flaws," and authentication and access control problems are among the hardest to automate. Nobody serious claims a scanner finds everything.

The useful question is not whether a scanner misses things. It is whether it can tell you why it missed a particular thing. The engineers who built static analysis at Google list "not trustworthy" among the reasons developers stop using a tool, in their 2018 Communications of the ACM article. A miss with no explanation is a fast way to get there. A miss with a specific reason is a lead: either a gap to close, or a decision to review.

The command​

vulkro why-not src/routes/users.js:42
vulkro why-not src/routes/users.js:42 --family sqli
vulkro why-not app/views.py:88 --rule py-taint-sql-001 --format json

The target is a path with an optional line. --family narrows the question to one vulnerability family (for example sqli, cmdi, path-traversal, idor or missing-auth), and --rule asks about one rule id. --path sets the project root if the default (the nearest folder with .git or a manifest) is wrong. --format json produces a vulkro-why-not/1 document for tooling.

It runs the normal deterministic scan once and uses it as the oracle, so the answer always matches what vulkro scan would do. The exit code tells a script which case it hit: 0 means nothing exists at the location and the reasons explain the silence, 1 means a finding does exist there (reported, hidden or suppressed), and 2 is an error.

First answer: maybe it did report it​

The first thing the command checks is whether the premise is true. If a finding was reported at that location, it prints it. Sometimes the honest answer to "why did it not flag this?" is "it did", and the report went somewhere nobody was reading.

Second answer: it found it, but the default view hides it​

The next case is a finding that exists but is not in the default output. why-not prints the hidden finding and the switch that shows it. The reasons it can give:

  • Below the confidence floor. The finding is real but low-confidence, so it is hidden unless you pass --all-confidence. See proven, unproven, not checked for how that floor is set.
  • In code no entry point reaches. The scan found the pattern in a file the call graph showed as unreachable.
  • Suppressed. A decision in .vulkro/triage.toml, a path in .vulkroignore, or a vulkro:disable comment in the source. This is the case worth reviewing most carefully: a suppression that made sense for one version of the code may not fit the current one.
  • Folded into another issue. When several analyses report the same root cause, the scan shows it once and lists the others under it. The line you asked about may be one of the folded reports.

Third answer: why the scanner stayed silent​

If there is genuinely no finding, the command says which step of the analysis produced the silence:

  • Not read. The file was never analysed: a language the build does not cover, or a file skipped for a recorded reason such as its size or encoding. This comes from the same coverage data described in a clean scan that did not read your code.
  • Sink not modelled. The dangerous call on that line is not one the scanner recognises.
  • No entry point. No route, handler or other entry point reaches the code, so there is no attacker to start a path from.
  • Guard. A check on the path (authentication, an ownership check, a role check) answers the question the finding would raise.
  • Neutraliser. Something between the source and the sink makes the value safe: a bound parameter, an allowlist, a type conversion.
  • Source not recognised. The input arrives in a way the scanner does not treat as attacker-controlled.
  • No flow. The source and the sink are both there, but no data flow connects them.

Here is a case that gets the neutraliser answer, and is a correct silence:

app.get('/api/users', async (req, res) => {
const rows = await db.query('SELECT * FROM users WHERE team = $1', [req.query.team]);
res.json(rows);
});

And here is one where the answer points at a gap rather than a decision. If an internal wrapper runs raw SQL and the scanner does not know it is a query sink, why-not says the sink is not modelled:

app.get('/api/users', async (req, res) => {
const rows = await legacyDb.runRaw(`SELECT * FROM users WHERE team = '${req.query.team}'`);
res.json(rows);
});

That answer is actionable. The scanner needs to be told about legacyDb.runRaw, and custom rules are how you do it.

Where its answers stop​

The command is a best-effort explainer, and its help text says so. The checks for neutralisers, sinks and sources are lexical, and the output labels them as such. It does not re-run the analysis engines in a special mode that would emit every near-miss. When it cannot determine a reason, it does not invent one.

We think that is the right trade. An explainer that sounds certain and is sometimes wrong would recreate the trust problem it exists to solve.

The same question for Salesforce​

vulkro-sf why-not <path[:line]> asks the same question about Apex taint findings. Given a .cls file and an optional line, it runs the scan as an oracle and, if nothing was reported, checks whether the line is a SOQL, DML or callout sink inside an entry-point method. If it is, it reports the gates that would have suppressed a finding, drawn from the same gates the detectors use:

  • no attacker-controlled source reaches the line;
  • a sanitiser applies, such as String.escapeSingleQuotes, Id.valueOf, a bind variable or Security.stripInaccessible;
  • the query runs WITH SECURITY_ENFORCED or WITH USER_MODE;
  • the method is an @AuraEnabled(cacheable=true) getter;
  • the method is not exposed to guest users;
  • the path is longer than the number of method calls the analysis follows.

It is also best effort, and its output says so. Its exit codes differ slightly from the core command: 0 means a clean answer with no reported finding or near miss, 1 means a finding or a near miss with a suppression reason was shown.

What to do with the answer​

Each reason points to a different next step:

  • It did report it: find out where the report went and why nobody saw it.
  • Hidden by confidence: look at the row with --all-confidence and decide whether your default view is right for this repository.
  • Suppressed: re-check the triage decision against the current code.
  • Not read: scan that language with the right tool, or fix whatever stopped the file being parsed.
  • Sink or source not modelled: write a custom rule, and tell us, because a gap that bit you will bite someone else.
  • Guard or neutraliser: check that the guard really does what the scanner assumed. It is often right. When it is not, that is worth knowing too.

Pair it with severity that reflects who can reach the code, and silence becomes something you can question instead of something you have to accept.

Where to see it​

Run vulkro why-not --help for the full option list, then point it at a line you expected a finding on. The suppressions guide explains .vulkro/triage.toml and the inline directives, and the custom rules guide shows how to teach the scanner a sink it does not know. To get set up, start here.