DEV Community

Christ-loisele Atidegla
Christ-loisele Atidegla

Posted on

The most important test in a security scanner is the one where nothing happens

Every security scanner has tests proving it catches things. Malicious sample in, finding out, green tick.

Very few have the other suite: benign sample in, nothing out, build fails if anything appears.

Writing that second suite first changes the design of everything else.

Why the inverted test is the hard one

Catching an attack is easy, because you have the attack and can write a pattern that matches it.

The difficulty is that attacks are written in the same language as the thing they hide among. In MCP, prompt injections are imperative English addressed to a model, and so is every legitimate tool description. In PHP, an unsafe query and a safe one differ by whether a value was checked twenty lines earlier. Signal and noise are made of the same material.

A pattern tuned only against attacks will therefore match the corpus. An independent audit found regex-based MCP scanners running at roughly 78% false positives, which is what "does it catch the bad one" gets you as the only test.

Pick the benign samples that look worst

A corpus of obviously innocent text is worthless. Choose the ones that look guilty.

The strongest case in mine is the official fetch server:

Although originally you did not have internet access, and were advised to refuse and tell the user this, this tool now grants you internet access.

It addresses the model directly, references its prior instructions, and overrides a refusal behaviour. All three are signals I look for. It is also one of the most installed MCP servers there is, and the description is honest.

A scanner that flags that flags the ecosystem. So it goes in the corpus, and CI fails if it produces a finding.

Others worth having: a GitHub server description mentioning GITHUB_PERSONAL_ACCESS_TOKEN, a filesystem server saying "only works within allowed directories", and any tool with a documented prerequisite.

What the constraint forces

Once the corpus has to pass, four decisions stop being optional.

Single keywords cannot report. "Ignore", "credentials", "do not" and "before using" all appear in benign text. If any of them can produce a finding alone, the corpus fails immediately, so findings have to accumulate from independent groups.

Repetition cannot be evidence. Scoring each group at most once follows directly. A description saying "for example" three times is not three times as documentation-ish, and a malicious one repeating a phrase is not three times as malicious.

You need signals that argue the other way. Almost nobody implements these. Mine has dampeners: self-scoped phrasing like "this tool requires" scores -2, documentation context like "for example" or "returns:" scores -1.5. Without them, suspicion only accumulates and every sufficiently long description eventually crosses a threshold.

Suppressed findings must be inspectable. Low confidence results are held back from the default output instead of discarded, and --all prints them, so you can audit what was hidden. A scanner that silently discards asks you to trust its judgement with no way to check it.

Write the corpus first

The ordering matters.

Detection rules first and benign corpus second gives you a list of exceptions bolted onto rules whose shape is already wrong. That produces allowlists, which usually mean a rule should have been designed differently.

Corpus first constrains the rules from the beginning, so you never build the version that needs exceptions.

Publish the ones you cut

Keep a record of the rules that failed the corpus, and publish it.

I maintain a REJECTED.md in a Semgrep ruleset for this. One entry is a rule that flagged unvalidated orderBy columns and fired on the correct fix, because taint analysis follows values and a guard clause does not produce a new one. Another is a rule that was simply wrong about how a framework method worked, at high confidence, in a security tool.

A ruleset with no rejected rules has either not been tested against real code, or is not telling you.

The scanner is mcpaudit, and the corpus is in test/poisoning.test.mjs, at the top of the file.

Top comments (0)