Here's a scenario that anyone running a version-based vulnerability scanner will recognize.
A scan comes back with "Apache HTTP Server < 2.4.54" on forty hosts. You open the first one. It's a Debian box. Debian doesn't bump version numbers when it backports a security fix; their own FAQ answers the question "the version number for a package indicates that I am still running a vulnerable version!" with the line "we backport security fixes to the version that was shipped in the stable release." So apache2 -v says 2.4.52, the patch is in, and the scanner is wrong.
You click False positive.
Now the question this whole post is about: what just happened to the other 39?
Depending on the tool, one of three things. The finding is gone from all 40 rows. Or it's gone from this one row and still sitting on the others. Or it's gone from this row until the next scan, at which point it comes back and asks you again. All three are shipped in products people pay for. The difference between them is a data-modeling decision, and it usually gets made by whoever writes the first version of the issues table, long before anyone designs the button.
That decision is worth walking through slowly, because the two options produce genuinely different products, and the better-behaved one is the less comfortable to use.
The status has to hang off a noun. Which one?
Strip the UI away and a scanner's results are a join between two things:
- a vulnerability: a CVE, a CVSS vector, a title, a description. This is a fact about software in general.
- an occurrence: this host, this port, this protocol, this check, seen at this time. This is a fact about your infrastructure.
A "false positive" button has to write a status somewhere. There are only two candidates, and they produce completely different products.
-- Model A: the status lives on the vulnerability
CREATE TABLE vulnerability (
id serial PRIMARY KEY,
cve text,
title text,
status text -- open | false_positive
);
CREATE TABLE occurrence (
id serial PRIMARY KEY,
vulnerability_id int REFERENCES vulnerability(id),
host text,
port int
);
Click the button under Model A and you've updated one row. Forty occurrences inherit the change because they never had a status of their own. This is the "gone from all 40" behavior, and it feels great for about a week.
-- Model B: the status lives on the occurrence
CREATE TABLE vulnerability (
id serial PRIMARY KEY,
cve text,
title text
);
CREATE TABLE occurrence (
id serial PRIMARY KEY,
vulnerability_id int REFERENCES vulnerability(id),
host text,
port int,
proto text,
status text, -- open | snoozed | fixed | false_positive
status_set_by int,
status_set_at timestamptz
);
Under Model B the click updates one occurrence. The other 39 don't know anything happened. If you want the grouped view ("Apache < 2.4.54, 40 hosts") you compute it at read time, which is where it belongs, because a group is a way of looking at data and not a thing that can be true or false.
Same click, same UI. The difference is one foreign key
Why Model A keeps getting built
Nobody picks Model A out of carelessness. It gets picked because it matches what you see on screen. The list is grouped by vulnerability, the row you clicked on is the vulnerability, so the status goes on the vulnerability. Obvious.
And the false positives that hurt the most really are systemic. Version-based detection is wrong on every Debian, Ubuntu, RHEL or SUSE box in the fleet, in the same way, for the same reason. So "make this check shut up" is exactly the button anyone wants in that moment.
The trouble is that the Debian backport is true on the Debian box and false on the Ubuntu 18.04 box that nobody has touched since 2021. Both show up in the same group. Model A can't tell them apart because it threw that information away the moment you clicked. And the next time someone runs a compliance report, or an attacker runs nmap -sV, the group boundary you drew in the UI isn't going to mean anything to them.
A false positive is a claim about a host. Not about a check.
How three well-known tools handle it
You can watch this lesson get learned in public, because the fix tends to leave a scar in the config format.
Trivy started with .trivyignore: one CVE ID per line, applied to the whole scan. Global by design. The later .trivyignore.yaml format adds paths, purls, expired_at and a statement field for the reason. You can still ignore a CVE everywhere, but the docs now say plainly that without paths or purls the suppression stays global. Scope was bolted on after the flat file turned out to be a blunt instrument.
Greenbone / OpenVAS made scope a first-class thing from the start. An override is attached to a host or IP range, a port, a task, and optionally a single result; "False Positive" is just one of the values you can set. If several overrides match, the most specific one wins, and you can give an override a lifetime in days. It's more clicks. It's also impossible to hide a finding fleet-wide by accident.
DefectDojo has a setting called False Positive History: when a new finding comes in and a matching one in the same product was already marked false positive, the new one gets marked automatically. Which is useful, and which is also how you end up with issue #2525 in their tracker, where a user reports that deduplication reopened their false positives on reimport and argues that "the finding should stay inactive." Both sides of that thread are right. That's the tension every one of these tools is stuck with: a false positive that never comes back is a suppression rule with a nicer name, and a false positive that always comes back is a chore.
Where Topscan landed
Topscan.me stores the status on the occurrence. Model B.
"It's the part of the data model I'd defend hardest," says Aleksandr Melnichuk, Product Director at Topscan.me. "Everything else in that list is a view you're allowed to change your mind about. The key isn't."
An issue record is keyed on target + issue type + port + protocol, and the status (open, snoozed, fixed, or false positive) lives on that record. The CVE data is a separate classification object, one-to-many. The product's help center defines a false positive as "a CVE-based finding that is incorrect for this specific target," and that wording was chosen carefully: the word doing the work is this.
A CVE is a fact about a piece of software. Whether it applies is a fact about one host on one port, and that's the only level where "this is wrong" can honestly be said. Everything above that level is aggregation.
So the grouping is purely a view. The default issue list shows "Apache HTTP Server < 2.4.54 - 12 occurrences" as one row, because for a posture overview that's the right density. Flip a switch called Triage mode and you get 12 rows, each with its own host and port, with your filters preserved. Every issue's detail panel also lists its occurrences by hostname and port, so you see the full spread before you decide anything. The data underneath doesn't change between the two views. Only the query does.
Same data, two queries. The grouping is a view, not the storage
Marking an occurrence as false positive takes it out of the security score and out of the SLA clock for that occurrence. The other eleven keep their score penalty and their deadline. Each status change is recorded with who made it and when, so a false positive is a decision with an author rather than a row that quietly disappeared.
The part that needs defending
If the next scan detects the same vulnerability on the same target after you've marked it false positive, it comes back into the current list.
The docs describe this as intentional, and the reasoning fits in one sentence: if something changes on the target and the vulnerability becomes real, you should know.
The cost is real. For a genuinely persistent false positive, like the Debian backport that will trip a version check on every scan until the package moves to a new upstream release, this means re-triaging the same finding again. That is annoying.
"I've been on the receiving end of it in our own dashboard," Melnichuk says. "We decided we'd rather be asked twice than go quiet on a host that changed underneath us."
The alternative is a mute button, and over eighteen months a mute button does this to a fleet. Someone marks a finding false positive on a box in year one. In year two the box gets rebuilt from an old image, the finding is real now, and the scanner has been told not to mention it. The person who pressed mute has changed jobs. Nobody is wrong, and nobody knows.
So the false positive is a claim about a host at a point in time, and the scanner is allowed to ask again.
"If anyone knows a cleaner way to tell 'still the same backport' from 'actually vulnerable now' without a human looking at it, I'd like to hear it," Melnichuk says. "We'd ship it."
Questions to ask before you trust the button
Whether you're evaluating a scanner or building the issues table for one, these will tell you which model you're dealing with faster than any feature list.
Where does the status column actually live? Ask for the schema, or the API shape. If status sits next to cve, you have Model A.
What happens to a false positive on the next scan? "It stays hidden forever," "it comes back," and "it comes back only if the detection details change" are three different products.
Does marking one host change the score or the SLA for the others? It shouldn't, and in Model A it can't help it.
Can you switch between grouped and per-host views without losing your filters? If the per-host view is an export, then the grouping is the storage, whatever the UI calls it.
And who marked it, when? Without an author and a timestamp, a false positive is a decision nobody can revisit.
The button itself costs one click either way. What it's attached to decides whether you silenced one host or forty.
Quotes come from an interview with Aleksandr Melnichuk, Product Director at Topscan.me.
Top comments (0)