DedSec Security Research ·
Two scanners run over the same lockfile on the same day. One reports a critical CVE in a dependency; the other reports nothing for that package. A third tool reports the same vulnerability twice, under two different identifiers. Nobody has made a mistake in the ordinary sense. Each tool answered a slightly different question, using a different database, with a different idea of what the package is called and of what "earlier than 2.3.1" means.
Advisory matching looks like a lookup, but it is three joins, and each one can disagree. The first is identity: does the thing the advisory names match the thing you installed? The second is the version predicate: is your version inside the affected range, under the ordering rules of that ecosystem? The third is deduplication: how many distinct vulnerabilities do the matching records describe? Around those three sit three more problems: records that change state after you have cached them, distribution packages whose version string hides a backported fix, and copies of a library that no manifest declares.
Other posts here cover the next step, deciding what to fix first: CVSS, EPSS and KEV for urgency, reachability for whether the vulnerable code is called, malicious packages and MAL- identifiers for the threats that are not CVEs at all, and container image CVE counts for OS packages in images. This post stops before all of those, at the question of whether the match itself is right.
The sources are the OSV schema specification, SemVer 2.0.0, PEP 440 and PEP 503, the Version Order Specification in Maven's POM reference, the dpkg deb-version manual, NISTIR 7695 (the CPE 2.3 naming specification), the NVD CVE API 2.0 JSON schema, the CVE Record Format schema, the package-url type definitions, GitHub's Advisory Database documentation, the Debian security tracker documentation, the Ubuntu CVE tracker README, the node-semver README and Trivy's documentation. Real records quoted below were retrieved from the OSV.dev, NVD and CVE Services APIs on 23 October 2026 and can change after that date. Where a package name is hypothetical, it is called example-lib.
NVD describes affected software with CPE names. A CPE 2.3 name has eleven attributes (part, vendor, product, version, update, edition, language, sw_edition, target_sw, target_hw and other), and NISTIR 7695 says the product value should identify "the most common and recognizable title or name of the product". That is a name for a product as a person would know it. A package manager installs something else: an npm package name, a PyPI project, a Maven groupId and artifactId. OSV records identify the affected package by ecosystem and name, optionally with a package URL (purl), and the schema makes the point with an example: npm's zlib and PyPI's zlib are different libraries with different versions and different vulnerabilities.
Log4Shell shows the gap in real records. NVD's entry for CVE-2021-44228 matches cpe:2.3:a:apache:log4j, with affected ranges from 2.0.1 up to but excluding 2.3.1, from 2.4.0 up to but excluding 2.12.2, and from 2.13.0 up to but excluding 2.15.0, plus separate entries for version 2.0 with update values of beta9, rc1, rc2 and "-". GitHub's advisory for the same vulnerability, GHSA-jfh8-c2jp-5v3q, names the Maven artifact org.apache.logging.log4j:log4j-core. It also lists artifacts published under other groupIds, among them com.guicedee.services:log4j-core, org.xbib.elasticsearch:log4j and org.ops4j.pax.logging:pax-logging-log4j2, an OSGi bundle that packages Log4j 2. It does not list log4j-api, the separate artifact for Log4j's API.
A tool matching on the CPE has to turn apache:log4j into package coordinates, and every way of doing that fails in a predictable direction:
The CVE Record Format allows a numbering authority to identify an affected product either by vendor and product or by collectionURL and packageName, the package's name in a named repository. When a CVE record carries a packageName and collectionURL, the identity question is answered at the source. When it carries only a vendor and a marketing product title, which is common, someone downstream has to work out the mapping.
Once the package matches, the tool must decide whether your version is in the affected range. That needs a less-than operator, and ecosystems define it differently.
SemVer 2.0.0. Major, minor and patch are compared numerically. A pre-release has lower precedence than the release: 1.0.0-alpha < 1.0.0. Pre-release identifiers are compared one dot-separated field at a time, numerically if all digits and in ASCII order otherwise, so 1.0.0-beta.2 < 1.0.0-beta.11, and case matters. Build metadata after + is ignored, so 1.0.0+build5 and 1.0.0 have equal precedence.
PEP 440. Python's scheme has suffixes SemVer does not. Within a release they sort .devN, aN, bN, rcN, the release itself, then .postN. Normalisation turns 1.0-1 into 1.0.post1, a post-release that sorts after 1.0. An epoch, written 1!, outranks everything else, so 1!0.5 sorts after 2024.1; it exists for projects that changed numbering scheme. A local version label after +, as in 1.0+abc.5, sorts after the public version 1.0. PEP 440 reserves local labels for downstream rebuilds, such as a distribution backporting fixes, and says PyPI must not accept them.
Maven. The POM reference splits versions into tokens on dots, hyphens, underscores and digit-letter transitions, trims trailing "null" values, and ranks known qualifiers: alpha < beta < milestone < rc = cr < snapshot < (no qualifier) = final = ga = release < sp. Its own examples include 1-snapshot < 1 < 1-sp, 1.ga = 1.0 = 1, and 1-foo2 < 1-foo10. Qualifiers compare case-insensitively. The specification states that Maven's ordering is not compatible with SemVer 2.0.0, and that the plus sign gets no special treatment.
Debian. A Debian version is [epoch:]upstream-version[-debian-revision]. An epoch outranks the rest, so 1:1.0 sorts after 2.0, and a tilde sorts before anything, even the end of the string, so 1.0~rc1 sorts before 1.0. Both are easy to confirm with dpkg --compare-versions.
Take the string 1.0-1. Read as SemVer (1.0.0-1), it is a pre-release that comes before 1.0.0. Read as PEP 440, it is 1.0.post1, which comes after 1.0. Feed it to the wrong comparator and a range check against "fixed in 1.0" flips its answer, with no error raised.
The failures that follow are rarely loud:
OSV's schema makes the ordering explicit. Each range has a type:
A range is a timeline of events. introduced, with "0" meaning before every version, starts an affected span. fixed ends it at a version that is not affected. last_affected ends it at a version that is affected, and everything later is treated as unaffected. limit caps a GIT range, and "*" means infinity. A version is affected if it falls in any range or appears in the explicit versions list. Two warnings in the specification matter for triage. last_affected "opens up the possibility for false negatives", because it records the highest affected version known at publication, not a fix. In GIT ranges, every cherry-picked fix on other branches must be listed as its own fixed event to avoid false positives on those branches, while limit can produce false negatives. The schema also notes that version strings in a record may be normalised or have build metadata stripped, so they will not always match the registry's spelling exactly.
GitHub's Log4Shell record uses both kinds of boundary. The log4j-core ranges end in fixed events. The entry for com.guicedee.services:log4j-core is introduced 0, last_affected 1.2.1.2-jre17, so later releases of that artifact, which Maven Central lists, are treated as unaffected whether or not they contain the fix. The record cannot tell you which.
CVE records have the same shape. The CVE Record Format gives each version range a versionType that "defines the exact semantics of the comparison", with lessThan or lessThanOrEqual as the upper bound, and a defaultStatus that falls back to unknown if it is not set. A lessThanOrEqual bound with a defaultStatus of unaffected has the same false-negative shape as last_affected. The schema states the intended semantics; it does not guarantee that the data in each record follows them, so a comparator should reject a version string it cannot parse under the declared versionType rather than guess.
Querying OSV for jinja2 2.10 on PyPI returned twelve records. They describe six vulnerabilities, each once as a GHSA record and once as a PYSEC record, with each pair naming the other and the CVE in its aliases. A tool that counts records reports twelve findings for six problems. The ranges in each pair agreed, so here the only effect is double counting, but there is no guarantee that two sources agree. The identifiers also carry a trap: PYSEC-2026-1473 describes CVE-2024-22195, so the year in an identifier is not a reliable date for the vulnerability.
OSV defines three relationships, and they behave differently when you group records:
That matters in practice. Debian's OSV record for the requests CVE-2023-32681 is DEBIAN-CVE-2023-32681, and it carries the CVE in upstream, not in aliases. Group only by aliases and the Debian record stays separate from the PyPI ones. Group by aliases and upstream together and they merge. Either can be right, as long as it is a deliberate choice. The Debian record concerns the requests source package in Debian 12, 13 and 14 (still open in 12, fixed in 2.31.0+dfsg-1 in the later releases); the GHSA concerns the PyPI project. They are the same vulnerability in two different packages, and the fix for each comes from a different place.
Transitivity also cuts the other way. If one source wrongly lists an alias, taking the transitive closure merges two different vulnerabilities, and a decision recorded against one ("accepted, not exploitable here") silently covers the other. Keep a record of which source asserted each alias, so a bad merge can be traced and undone.
Then there is the unit of counting. NVD describes Log4j itself with one CPE product. GitHub's record lists several affected packages. OSV recommends one affected entry per package. A finding is best keyed by vulnerability (after alias grouping), package and location, so that one CVE in log4j-core in three services is three findings with one shared decision, not one finding or nine. The post on stale pull-request baselines and the post on suppression at scale cover how finding fingerprints and suppressions fail when that key changes.
Advisories are not immutable. OSV records carry a modified timestamp, and when two copies of the same id differ, the later modification wins. A record that is retracted gets a withdrawn timestamp, with the reason in its summary. GitHub's database lets you filter for withdrawn advisories. In the CVE Record Format a record can be REJECTED, with rejectedReasons and, where a duplicate was the cause, a replacedBy list pointing at the CVE IDs to use instead. Debian's tracker describes rejected entries as assignments that "turn out to be duplicates, mistakes or non-issues". NVD's API can tag a record as disputed, and Ubuntu's tracker uses ignored for, among other reasons, a CVE that has been withdrawn or disputed upstream.
Each of these causes a disagreement between tools whose data is different ages. A scanner reads a snapshot of its database: Trivy, for example, downloads and caches its vulnerability database and can be told not to refresh it with --skip-db-update. Until the snapshot is refreshed, a withdrawn advisory is still in it and still matches. A tool on a fresh snapshot stops reporting the finding. A tool on a stale one keeps reporting it.
What should happen to the finding is a lifecycle decision. A finding whose only supporting record was withdrawn should be closed, with withdrawal as the recorded reason, not left open and not allowed to disappear without explanation. A finding on a rejected CVE with a replacedBy should move to the replacement ID, carrying its triage history with it. Otherwise the replacement arrives as a new finding and someone triages it again. Monitor the age of the advisory database as a health signal, because a snapshot that has silently stopped updating looks exactly like a quiet week. The post on scans that pass without running covers that failure in general.
The container scanning post covers backports for OS packages. The same problem reaches language packages whenever a distribution ships them. Trivy's documentation gives an example: Red Hat Universal Base Image 8 ships Python requests through dnf, and the installed Python metadata says version 2.20.0. CVE-2023-32681 affects requests before 2.31.0, so the upstream advisory matches. But Red Hat backported the fix in RHSA-2023:4520, so the package, at rpm version 2.20.0-3, is not vulnerable. A tool that reads the Python metadata and applies the PyPI advisory reports a false positive. A tool that asks the rpm database, and Red Hat's data, does not.
PEP 440's local version labels exist for exactly this kind of patched downstream build, but the requests metadata in that image still says 2.20.0. The evidence of the fix is in the OS package manager's version string and the vendor's advisory, not in anything the Python ecosystem can see.
Distribution trackers also record outcomes that upstream ranges cannot express:
Trivy's documented approach is to trust the OS vendor's advisories, not upstream ones, for files installed by an OS package manager, including JARs and Python packages. It says plainly that this trades possible false negatives, if the vendor's advisory is late or missing, for fewer false positives, and offers --detection-priority comprehensive to consider upstream advisories as well. Neither setting is wrong. What matters is knowing which one produced your report, because it explains a whole class of disagreement between two tools scanning the same image.
A manifest lists what you asked for. It does not list what your dependencies carry inside themselves.
When two tools disagree about an image, check first whether they inventoried the same packages, before comparing their advisory data.
A CVE is published by the numbering authority that assigned it. Identifying what it affects, in a form a matcher can use, can happen later and in other places. In NVD, CPE configurations are added when a record is analysed. The NVD API exposes the state of each record in vulnStatus. At any moment a share of recently published records has not been analysed and carries no configurations block. A matcher that relies only on NVD CPE data finds nothing in those records, so "no match" means no data, not "not affected".
Other paths fill the gap at different speeds. The CNA's own CVE record may carry affected versions, but often against a vendor and product title, not a package. GitHub publishes unreviewed advisories automatically from the NVD feed. Its documentation says Dependabot does not create alerts for them, because they are not checked for validity or completion. Alerts come from reviewed advisories, which GitHub maps to packages in the ecosystems it supports. Some feeds delay by design: Trivy's data-source table lists the GitLab Advisories Community data used for C and C++ with an intentional one-month delay between disclosure and registration.
So the same CVE can be unmatched in one tool, matched in a second because that tool reads a reviewed package-level advisory, and matched with a different range in a third because it read a distribution's data. A report should show which source each match came from, and whether a "not affected" is a negative result or missing data.
When two tools disagree, or one tool's answer looks wrong, work through the joins in order:
A tool that can answer the last question makes every other disagreement debuggable.
AppSecD's software composition analysis runs Trivy and OSV across npm, PyPI, Go, Maven, RubyGems, Cargo, Packagist and NuGet. Advisories from 18 feeds are correlated against the versions, manifests and repositories actually present in your software estate, with CISA KEV flagged and a blast-radius view per CVE. CVE reachability answers whether the vulnerable code path can be reached at all. Findings from every engine land in one deduplicated finding model, deduplicated across scans and tracked through new, reopened and fixed states.
That is the whole claim. The identity, ordering, alias, withdrawal, backport and vendored-copy techniques in this post are general practice. They are not a description of how AppSecD implements advisory correlation. This post does not claim which comparator AppSecD uses for each ecosystem, how it groups aliases or upstream links, whether it prefers distribution or upstream advisories for OS-installed language packages, how it handles withdrawn or rejected records, or which embedded copies it detects. Put the questions above to us as you would to anyone else.
A vulnerability report that says "affected" or "not affected" has made three decisions for you: what the package is, where its version sorts, and which records describe the same flaw. When two tools disagree, one of those three decisions is usually where they differ, and it can be checked.