CVEs & SBOM
A Zyxel NAS326 firmware image — 521AAZF4C0.bin, 59,389,563 bytes — unpacks to 6,789 files. The most serious fact about it is in none of them:
"firmware_version": "V5.21(AAZF.4)",
"firmware_cves": ["CVE-2020-9054"],
That is the container header's own version string, matched by substring against a table of eight vendor firmware entries. CVE-2020-9054 is a pre-authentication OS command injection in weblogin.cgi, patched in V5.21(AAZF.7)C0; AAZF.4, .5 and .6 are vulnerable. No binary in the image had to be opened to establish it.
The 6,789 files answer a different question: which upstream components were bundled, at which versions, and which of those versions fall inside a published affected range. The mechanism throughout is version comparison, not code-shape analysis. Does the firmware's own HTTP daemon have a stack overflow? is Findings. Does the firmware ship an OpenSSL that someone else already found a bug in? is this.
| Question | Answer |
|---|---|
| What is inventoried | Upstream components bundled in a binary or firmware image — name, version, the files evidencing each one, and the CVEs whose affected ranges contain that version |
| How versions are recovered | Two producers. Five routes over an extracted firmware tree — path patterns, version banners, firmware header strings, package databases and lockfiles, web-library banners — plus a per-binary producer with four evidence kinds, the only route for a standalone binary |
| Where the CVE data comes from | A local index built from CVEProject cvelistV5, OSV.dev, CISA KEV and FIRST EPSS — never queried live at request time. The 24-hour refresh task is off unless an operator enables it |
| How a component reaches a CVE | Registry identity goes straight to an OSV ecosystem key; a native library goes through a curated 78-row mapping table |
| What the posture is | Over-report. The pipeline is advisory, not gating, and every finding carries the reason it fired |
| What it exports | A pinned openbinary.package-sbom-v1 document, rendered on demand to CycloneDX 1.6 or SPDX 3.0, and diffable against a vendor's claimed SBOM |
The component inventory
Eighteen components came out of that image, seven of them carrying CVEs, eleven distinct CVE ids in total. All eighteen, with the five jQuery rows folded onto one line:
| Component | Version | Evidence | Files | CVEs |
|---|---|---|---|---|
| openssl | 1.0.2h | binary_version | 2 | CVE-2019-1559, CVE-2021-3711, CVE-2021-3712, CVE-2022-0778 |
| busybox | 1.19.4 | binary_version | 4 | CVE-2018-1000517, CVE-2021-42378, CVE-2022-30065 |
| openssh | 2.3.0 | binary_version | 2 | CVE-2020-14145, CVE-2021-41617 |
| openssh | 6.7p1 | binary_version | 1 | CVE-2020-14145, CVE-2021-41617 |
| zysh | — | path_pattern | 8 | CVE-2022-30525 |
| firmware:CVE-2020-9054 | V5.21(AAZF.4) | firmware_version | 1 | CVE-2020-9054 |
| weblogin.cgi | — | path_pattern | 1 | CVE-2020-9054 |
| apache-httpd | — | path_pattern | 3250 | |
| cups | — | path_pattern | 41 | |
| samba | — | path_pattern | 10 | |
| bootstrap | 3.1.1 | web_lib_banner | 2 | |
| linux-kernel | 3.10.39 | binary_version | 2 | |
| jquery | 1.3.2, 1.5.1, 1.7.0, 1.10.2, 1.11.1 | web_lib_banner | 1 each | |
| setuptools | 7.0 | package_manifest | 1 |
Every row has the same shape:
| Field | What it holds |
|---|---|
| Component | The canonical component name, or a synthetic firmware:<CVE> name where the firmware-version route has no file to point at |
| Version | The recovered version, or empty where the route asserts presence only |
| Evidence | Which route produced the row: binary_version, path_pattern, firmware_version, web_lib_banner, package_manifest |
| Files | Count of matching extracted entries, not distinct installed copies |
| Paths | Up to three example paths — usr/bin/openssl and usr/lib/libssl.so.1.0.0 for the OpenSSL row, usr/local/apache/cgi-bin/weblogin.cgi for the CGI |
| CVEs | Curated CVE ids for that component, from the unpacker's own tables |
Rows are ordered by CVE count descending, then file count, then name. firmware:CVE-2020-9054 is the synthetic case: the firmware-version layer has no file to point at, so the CVE it matched becomes the component name and the header string becomes the version.
The Files count and the 4 MiB scan window are both visible in this image's own rows. linux-kernel 3.10.39 reads 2 and quotes linux_kernel.bin twice. The banner scan runs over every extracted entry, executable or not, so one of the two files behind openssh 2.3.0 is not a program at all but the 149,946,368-byte decompressed rootfs image, which survives as its own entry and carries the string OpenSSH_2.3.0* at offset 2,582,571 — inside the window, since only the first 4 MiB of each entry gets string-scanned.
That same image carries the bytes OpenSSL 6 KB earlier at offset 2,576,873, well inside the window, and still produces no OpenSSL row: only printable runs that begin with a rule's prefix are tested, and that occurrence sits mid-run inside licence boilerplate. The OpenSSL row counts two real libraries and not three because of where the string sits in its run, not because the window ended.
Coverage counters
Counts at the end of the SBOM report how much of the image was actually opened, which is the only thing separating a partially covered image from a fully covered one:
"stats": {
"files_extracted": 6789,
"components_total": 18,
"components_versioned": 13,
"components_with_cves": 7,
"components_vendor": 3,
"unique_cve_ids": 11,
"known_unknowns": {
"cycle": 861, "no_extractor": 30, "depth_exceeded": 3,
"encrypted": 0, "unsupported_compression": 0, "budget_truncated": 0,
"total": 894, "bytes_skipped": 0
}
}
components_vendor: 3—zysh,weblogin.cgiand the synthetic firmware row are vendor-specific rather than upstream. This is the count that says how much of this bill of materials no public advisory feed will ever key on.known_unknowns.total: 894— formats the unpacker recognised and did not descend into, broken out by reason. Without it a partially covered image is indistinguishable from a fully covered one.bytes_skipped: 0— a reserved field, not a measurement. The skip records carry no per-entry size, so the zero does not mean nothing was skipped.
The same counters read very differently on a second Zyxel image. V550ABOM8.1C0.bin — a VMG8825-T50K, less than half the NAS326's size — produces 17× the components and a worse coverage ratio:
| Counter | NAS326 521AAZF4C0.bin | VMG8825 V550ABOM8.1C0.bin |
|---|---|---|
| Image size, bytes | 59,389,563 | 26,758,973 |
files_extracted | 6,789 | 3,391 |
components_total | 18 | 300 |
components_versioned | 13 | 298 |
components_with_cves | 7 | 5 |
unique_cve_ids | 11 | 10 |
known_unknowns.total | 894 | 2,266 |
The 300 is not 300 answers. 286 of those rows are package_manifest rows with supplier opkg, read out of one OpenWRT package database, and opkg has no OSV ecosystem, so each falls back to name-based matching through the curated 78-row native table, where almost none of them appear; five rows carry a CVE. The 2,266 known unknowns against 3,391 extracted files is the other half of the same picture — two thirds as many recognised-and-not-descended entries as files actually written, against 894 of 6,789 for the NAS326.
Empty and absent CVE lists
An empty cves array in the SBOM means the curated tables inside the unpacker carried nothing for that component — the unpacker runs with no vulnerability database at all.
- Answered later. setuptools 7.0 gets its answer from the matcher, because the row carries the PyPI ecosystem the advisory feed keys on.
- Never answered. The five jQuery rows and the Bootstrap row never do. A banner-scanned web library has no registry behind it, its supplier reads
cdn, and the rollup that hands components to the matcher does not recognise that supplier. Those six rows are inventory with no vulnerability answer attached to them anywhere in the pipeline. - Dropped rather than empty. Where an empty array would be a lie — the rollup for a package whose members are analysed individually, computed on a server with no database loaded — the
cvesfield is dropped from the row entirely rather than emitted empty. Absent is honest;[]claims "checked, none found".
Component and version evidence
Two producers name components. Five routes run over an extracted firmware tree, with no vulnerability database loaded at all:
| Route | Keys on | Yields | Catalog size |
|---|---|---|---|
| Path patterns | Path substrings in the extracted tree | Presence only | 6 substrings, 3 of them CVE-bearing |
| Version banners | A printable-run prefix per component | Name and version | 7 rules over 6 components |
| Firmware version | The version string in the container header | Firmware-level CVEs | 8 substrings |
| Package databases and lockfiles | Installed-package databases and language lockfiles | Exact (ecosystem, package, version) | 11 ecosystems |
| Web-library banners | Banner wording in .js, .mjs, .cjs and .css files | Name and dotted version | 15 libraries |
None of those five sees a standalone Mach-O, ELF or PE upload. A second producer runs inside a single binary — the only inventory route for an uploaded binary and for a curated package's members:
| Evidence | Keys on | Yields |
|---|---|---|
| Install-name | Mach-O LC_LOAD_DYLIB and its weak/lazy variants | Linked library name, version where the install-name carries one |
| Section banner | A string literal in a named section, quoted with its section and file offset | Name and version. Two reserved section markers carry the registry routes: a Go binary's build-info graph yields full module paths, a Rust binary's crate graph yields bare crates.io names |
| Soname | An ELF DT_NEEDED entry matched against the curated Linux identity table | Verbatim soname plus canonical library name |
| Signature match | Function-signature matches, Tier-1 high-confidence only | Name, the mined build's version, and the count of functions matched |
Path patterns
Path patterns confirm a component exists and never say which version. Six substrings run against the extracted tree:
| Substring | Component | CVEs |
|---|---|---|
weblogin.cgi | weblogin.cgi (Zyxel) | CVE-2020-9054 |
/zysh | zysh (Zyxel) | CVE-2022-30525 |
/pluto | ipsec / pluto, supplier strongSwan / vendor | CVE-2023-28771 |
/samba | samba | Presence only |
/apache | apache-httpd | Presence only |
/cups | cups | Presence only |
The key is coarse: /apache matched 3,250 paths in the NAS326 image — the entire web UI under usr/local/apache, not 3,250 copies of httpd. A patched build ships the same filename as an unpatched one, so a hit here is a prompt to look, not a conclusion.
Version banners
Version banners recover a version string from a file's own printable runs and compare it against a per-rule maximum. Seven rules cover six components:
| Component | Rules | Detail |
|---|---|---|
| BusyBox | 1 | Maximum vulnerable version 1.34.0 |
| OpenSSL | 1 | — |
| dnsmasq | 1 | — |
| OpenSSH | 1 | — |
| Linux kernel | 1 | Version only, no CVEs attached |
| Dropbear | 2 | The banner is spelled Dropbear v in some builds and Dropbear_v in others |
Each rule is a prefix plus a maximum vulnerable version. The scan walks the printable runs of every extracted file, executable or not, takes the first run beginning with the prefix, keeps the alphanumerics and dots that follow — a hyphen and everything after it is dropped — and requires the result to contain a dot and be at least three characters long. Comparison is dotted-numeric with a trailing release letter folded in as one more component, so 1.0.2h compares as (1, 0, 2, 8) and sorts below 1.0.2k at (1, 0, 2, 11).
Matching the banner is not the same as being vulnerable. A bill of materials is not a vulnerable-only list: BusyBox v1.35.0 — past the 1.34.0 cut — still emits a component row, with an empty CVE list.
Firmware version strings
Firmware version matching is the most decisive route and the coarsest key: a device running NAS326 V5.21(AAZF.4) has CVE-2020-9054 whatever any individual binary looks like. Eight substrings run against the version string in the container header:
| Substring | Device family | CVEs |
|---|---|---|
AAZF.4, AAZF.5, AAZF.6 | NAS326 | CVE-2020-9054 |
7.3.245.300 | VMG8825 | CVE-2024-40891, CVE-2025-0890 |
V4.6, V4.70, V4.71, V4.72 | ZyWALL and USG | CVE-2023-28771 |
The key is a substring test, not a range: nothing parses the version or orders it against a boundary, so V4.6 fires on a V4.65 as readily as on the V4.60–V4.69 line it was written for, and "up to but not including" cannot be expressed at all. The VMG8825 fixture's extracted stamp is 7.3.245.300_v007, where the 7.3.245.300 entry matches as a prefix and containment does work only on the trailing _v007. Every entry needs a published vendor advisory naming a version range, which is why this layer grows one vendor at a time.
Package databases and lockfiles
Package databases and lockfiles bypass curation entirely, because they already name the exact (ecosystem, package, version) triple the advisory feed keys on. Eleven ecosystems are parsed from the extracted tree: dpkg status, Alpine apk installed, opkg status, rpmdb.sqlite, and the language lockfiles for npm, PyPI, Maven, RubyGems, Packagist, Go and Cargo.
An Alpine row keeps its -rN revision, a dpkg row keeps its epoch, a pom.properties gives the Maven group-and-artifact coordinate the Maven feed indexes — so the row goes straight to the feed with no mapping table in between and no coverage ceiling. Which of the eleven actually reach a feed is in Supplier and ecosystem coverage below.
Web-library banners
Web-library banners read pinned JS/CSS distributions out of .js, .mjs, .cjs and .css files in firmware web UIs. The scan is not a header window — it covers the same first 4 MiB every other extracted file gets. The catalog has fifteen entries — jQuery, jQuery UI, Bootstrap, AngularJS, Vue, React, Lodash, Underscore, Moment, Handlebars, Backbone, Knockout, Ember (emitted under its npm name, ember-source), Mustache, Prototype — each keying on that library's own banner wording plus a three-part dotted version:
- Bootstrap — the word
Bootstrap, then whitespace, then an optionalv; the NAS326 hit isBootstrap v3.1.1, butBootstrap 5.3.2matches on the same terms. - Knockout — the phrase
Knockout JavaScript library. - AngularJS — the 1.x line only. Angular 2+ ships as
@angular/coreand is recognised by the package-manifest parsers, never by banner. - Lodash — a
VERSIONassignment up to 4,000 characters past the wordlodash, since a minified bundle keeps its licence header and nothing else legible.
The NAS326 image reports five jQuery versions — 1.3.2, 1.5.1, 1.7.0, 1.10.2, 1.11.1 — plus a Bootstrap 3.1.1. Nothing in the firmware declares any of them; they are dropped files with no registry behind them, which is why their supplier reads cdn. Four of the five jQuery hits are real files named for the version they carry: jquery-1.3.2.min.js, jquery-1.5.1.min.js, jquery-1.10.2.min.js, and a bare jquery.js at 1.11.1.
Signature identity
Signature identity names a statically linked library that carries no banner at all, through function-signature matches. The version then reflects the build the signature was mined from, which can differ from the build actually linked, so this evidence ranks below anything read directly out of the bytes. See Fingerprinting.
Known false positives
Four failure shapes are measured in the two Zyxel images. The first two are the same shape: a string naming another program's version, read as this file's own. The last two are version comparison producing a CVE list nothing supports.
Phantom openssh 2.3.0 — a pattern table read as a banner. sbin/ssh-keygen in this image contains no self-identifying banner; its first OpenSSH_-prefixed printable run is an entry in OpenSSH's own bug-compatibility pattern table:
$ strings -a sbin/ssh-keygen | grep OpenSSH_ | head -3
OpenSSH-2.0*,OpenSSH-2.1*,OpenSSH_2.1*,OpenSSH_2.2*
OpenSSH_2.3.0*
OpenSSH_2.3.*
The token stops at *, 2.3.0 parses, and a component that was never installed appears next to the real openssh 6.7p1 recovered from sbin/ssh. First-match-wins on a printable-run scan has no way to prefer a real banner over a pattern string that a program carries in order to recognise other programs.
Phantom jquery 1.7.0 — a dependency line read as a component. The 1.7.0 row is not jQuery at all. It comes from jPlayer's playlist plugin, where the banner is a dependency line:
$ sed -n '10,16p' usr/local/dmsf/binary/resources/webbrowse/jPlayer/jplayer.playlist.min.js
* Author: Mark J Panaghiston
* Version: 2.3.0
* Date: 20th April 2013
*
* Requires:
* - jQuery 1.7.0+
* - jPlayer 2.3.0+
The v is optional in the jQuery entry, so jQuery 1.7.0+ matches on the same terms as jQuery v1.7.0, and a stated requirement becomes a claimed component. The five entries that do require the v — AngularJS, Vue, React, Ember, Knockout — are narrower, and pay for it in banners that omit it.
CVE-2021-3711 on OpenSSL 1.0.2h — an upper bound with no floor. A banner rule carries an upper bound and no lower bound, so every CVE it lists attaches to every version at or below the cut. OpenSSL 1.0.2h therefore collects CVE-2021-3711, a buffer overflow in SM2 decryption — code that only exists in the 1.1.1 series. Three of the four CVEs on that row are correct for 1.0.2h; the fourth describes a function the binary does not contain.
openssl X9.42 — four CVEs against no version at all. The VMG8825 image emits this row, measured:
openssl X9.42 binary_version CVE-2019-1559, CVE-2021-3711, CVE-2021-3712, CVE-2022-0778
$ grep -ao 'OpenSSL X9.42[ -~]*' lib/libcrypto.so.1.1
OpenSSL X9.42 DH method
X9.42 is a Diffie-Hellman key-agreement standard, and the run is a method name inside the real OpenSSL library. X9.42 clears the emit gate because it contains a dot and is at least three characters, then clears the vulnerability gate for the opposite reason: the dotted-numeric parser reads X9, finds no leading digit, and returns an empty version — which compares below every rule maximum, so the rule's whole CVE list attaches. The same image's accurate OpenSSL row, 1.1.1n, sits past the 1.1.1k cut and carries none. The fabricated version is the one that scores, and nothing in it looks wrong: a component name, a version field, four real CVE ids.
Supplier and ecosystem coverage
A parsed component only becomes a finding if its supplier is one the rollup recognises: four OS-package suppliers and five language-registry suppliers, and nothing else.
| Supplier | Parsed from | Reaches the advisory feed | If not |
|---|---|---|---|
| Debian | dpkg status, and a standalone .deb's control | Yes — OSV ecosystem key, epoch preserved, keyed on the source package: a Source: field remaps libssl1.1 to openssl, which is what OSV keys Debian advisories on | — |
| Alpine | apk installed | Yes — OSV ecosystem key, -rN revision preserved | — |
| Ubuntu | Nothing — no parser ever emits an ubuntu supplier | Unreachable, though the feed is ingested and the rollup would route to it | A dpkg status is tagged Debian whatever distro it came from, so an Ubuntu-derived tree is matched against Debian advisories and the Ubuntu-specific ones are never consulted |
| opkg (OpenWRT) | opkg status | No — OpenWRT has no OSV ecosystem | Falls back to name-based matching through the curated native table, keyed on the binary package name — the source remap is off here, since opkg advisories key on it |
| rpm | rpmdb.sqlite | No — the distro/release qualifier the OSV rpm feeds require is not recoverable from an rpmdb alone | Inventoried, never matched |
| npm, PyPI, Maven, RubyGems, Packagist | Lockfiles and manifests (pom.properties for the Maven coordinate) | Yes — that registry's ecosystem key | — |
| Go, Cargo — from a firmware tree | Lockfiles | No — dropped by the firmware rollup | Inventoried, never matched |
| Go, Cargo — from a binary | The binary's own embedded build graph | Yes — Go module path, crates.io name | — |
| cdn | Web-library banners in .js / .mjs / .cjs / .css | No — a dropped file has no registry behind it | Inventoried, never matched |
| Native library | Soname, banner or signature | Via the curated 78-row table | No table row and no registry produces no finding at all |
| Apple | System frameworks | No — Apple publishes no mappable CVE/CPE applicability | Inventoried, excluded from matching |
The vulnerability database
The vulnerability corpus is not bundled and is never queried live from an external service at request time. An operator fetches it once — about five minutes, dominated by the initial cvelistV5 git clone — and can enable an in-server task that refreshes it every 24 hours plus 0 to 10 minutes of jitter, single-flighted behind an on-disk lock, with a live-reload hook so a rebuilt index swaps in without a restart. That task is off by default: on a stock configuration the index is as fresh as the last hand-run ingest and no fresher.
Every source can be pointed at an internal mirror instead of its upstream, which is what makes an air-gapped deployment possible: a mirror base replaces the canonical host, with the per-ecosystem paths unchanged.
| Feed | What it supplies | Transport |
|---|---|---|
CVEProject cvelistV5 | The CVE records — descriptions, CVSS metrics, CWE ids, CPE applicability | Shallow git clone; subsequent pulls diff only the changed CVE JSON files |
| OSV.dev | Affected-version ranges per ecosystem — Linux, Debian, Ubuntu, Alpine, Go, crates.io, npm, PyPI, Maven, RubyGems, Packagist, NuGet, Pub | One zip per ecosystem, spooled to disk, never buffered in RAM |
| CISA KEV | Known-exploited catalog membership, due dates, ransomware association | JSON, atomic-rename on update |
| FIRST EPSS | Exploit-probability score and percentile per CVE | Daily csv.gz |
Every feed URL is refused unless it is HTTPS; setting OPENBINARY_INSECURE_VULNDB=1 logs a warning and accepts plain HTTP, for an internal mirror that is not TLS-terminated. Three of the four fetches are size-capped:
| Fetch | Cap |
|---|---|
| OSV ecosystem zip | 2 GiB |
| CISA KEV | 50 MiB |
| EPSS archive | 100 MiB compressed, 200 MiB decompressed |
cvelistV5 git clone | None — and it is the largest transfer of the four. Bounded only by a stall guard: git aborts a transfer that stays below 1 KB/s for 60 seconds |
Those caps are load-bearing. The OSV cap was 500 MiB; Ubuntu's archive grew past it to roughly 600 MB, the fetch aborted mid-stream on every cycle, and Ubuntu CVE coverage silently froze — a size limit that fires looks exactly like an ecosystem with no new advisories.
That is the reason every response carrying vulnerability data also carries the database's age in hours and an ok flag. The flag goes false if the last refresh failed or the index is more than 168 hours old, so a consumer can tell "no findings" from "nothing has been checked in a week".
Component-to-CVE matching
The posture is over-report: the pipeline is advisory, not gating, so a false positive is dismissable and a false negative is a breach. Every finding carries the reason it fired, and that reason is a query filter — as are minimum severity and known-exploited-only, for an operator who wants the short list:
| Confidence | Meaning |
|---|---|
range_matched | An affected range explicitly contains the component version |
exact | The version equals an enumerated affected version in the record |
range_unparseable | A range was present and failed to parse — surfaced for audit rather than dropped |
floor_only | The version is not exact, so no range can be confirmed against it. Two weaknesses land here: a floor (>= V) from an ELF symbol-version requirement or a .note.ABI-tag minimum, where the real release is that or newer; and an ABI major — a bare-major soname like libssl.so.3, or a signature-mined build number reflecting the build the signature came from rather than the one linked — which is not a floor in any useful sense. Both demote identically: the record names the component, and the CVE surfaces unconfirmed rather than as a fabricated range hit |
name_only | The component has no version at all; the match is on name |
The two matching routes
Registry identity — direct. Anything with an exact registry identity — a distro package, a lockfile entry, a Go module path from an embedded build graph, a crates.io name from a Rust binary's dependency graph — goes directly to its ecosystem key.
Native libraries — a curated table. Native libraries have no registry, so they go through a curated 78-row table: canonical name, aliases, CPE (vendor, product) pairs, optional OSV ecosystem, PURL template, SPDX license id, and an upstream citation per row. The OpenSSL row is the shape of all of them — aliases libssl, libcrypto, libssl3, libcrypto3 so a soname read from a DT_NEEDED entry lands on the project name, and two CPE pairs, (openssl, openssl) and (openssl, openssl-fips), because NIST splits the vendor across both.
Version comparison respects the ecosystem's scheme: PEP 440 for PyPI, Maven's ordering for Maven, dotted-numeric for the rest, and a demotion to range_unparseable when the comparison cannot be modeled.
Finding fields
Findings are deduplicated by canonical CVE id — DEBIAN-CVE-2024-6197 and CVE-2024-6197 collapse into one row, and the canonical record's metadata wins, because distro mirrors usually omit CVSS, KEV and EPSS entirely.
| Field | Rule |
|---|---|
| Id | Always the canonical form, since a distro-prefixed id resolves to no NVD page |
| Severity | Bucketed from CVSS: v3 preferred, v4 promoted when its base score is higher |
| Vector | The CVSS vector string |
| EPSS | Score and percentile |
| KEV | Catalog hit with its ransomware flag |
| Components | Every component that matched |
| Description | Capped at 4,096 characters |
| CWE | The id, when the record has one |
| Confidence | Why it fired — the table above |
Sort order is severity, then KEV, then EPSS, then id.
Distro-range suppression
When a canonical record and a distro mirror describe the same CVE, the distro range is dropped and only the canonical one is consulted. The OSV Debian feed reports curl as still-unfixed in Debian 11 for CVEs that upstream curl patched years ago: the range arrives as introduced=0 with no fixed event, which reads as "every version is affected". Apple ships libcurl 8.7.1, built from upstream. Matching that against the Debian range produced a confirmed false positive on every macOS binary linking libcurl:
index: CVE-2023-28320 (cvelist) curl, fixed in 8.1.0
DEBIAN-CVE-2023-28320 (osv) curl, introduced 0, no fix
component: curl 8.7.1
result: no finding
The suppression is per-CVE and does not cross CVE boundaries: an unrelated DEBIAN-CVE-… still emits, and a distro CVE with no canonical sibling still emits — silence there would be wrong, because there is nothing better to check against.
Two channels do the suppressing, because half the distro advisories do not carry a canonical id in their own name. DEBIAN-CVE-2023-28320 reduces to CVE-2023-28320 by stripping a prefix; a vendor advisory wrapper — DLA-, DSA-, RHSA-, USN-, SUSE-SU-, ALAS- — cannot, since DLA-2085-1 names no CVE and the link exists only in the record's own alias list. The second channel reads those aliases and drops the wrapper when a canonical alias on it is also a candidate against the same component. A wrapper whose canonical CVE is not a candidate survives under its own id.
The wire schema
One pinned schema, openbinary.package-sbom-v1, carries the component data: field additions inside v1 are non-breaking, and a change to what an existing field means bumps to v2. A component appears once per name-and-version pair however many files carry it — the OpenSSL row's count of 2 is the number of matching files, and at most three of their paths are quoted, with the rest left to the package's file listing.
Name-and-version is the whole key, so one installed component reached by two evidence routes becomes two rows whenever the routes spell its version differently, and nothing merges them. The VMG8825 image, measured:
busybox 1.20.1 binary_version 3 CVEs
busybox 1_20_1-5 package_manifest 0 CVEs
openssl X9.42 binary_version 4 CVEs
openssl 1.1.1n binary_version 0 CVEs
zysh — path_pattern 1 CVE
zysh 2.0-7 package_manifest 0 CVEs
Three installed components, six rows. In every pair the row carrying the CVEs and the row carrying the accurate version are different rows, and a consumer counting components counts each twice.
On the vulnerability-finding side of the wire, a reachability hint — whether the vulnerable function is reachable from this binary's entry points — has a fully defined type that nothing ever fills in, so the field is omitted from every response the engine emits today.
SBOM exports and vendor-SBOM diffing
The same component data renders on demand to CycloneDX 1.6 or SPDX 3.0 JSON-LD, deterministically: the same components at the same timestamp produce byte-equal output, and the CycloneDX serialNumber is a UUIDv5 derived from the binary's SHA-256, so re-rendering an SBOM does not produce a document that diffs against its predecessor. PURL, CPE and SPDX license id are materialized from the curated table at render time and never persisted on the component, so a correction to a mapping row fixes every future export at once.
The reverse direction is the supply-chain primitive the format converters do not have: a vendor's claimed SBOM — CycloneDX 1.4 through 1.6, or SPDX 2.x/3.0 — can be diffed against what was derived from the bytes, sorting every component into confirmed, version-mismatch, undeclared, or unverifiable. Undeclared is the interesting bucket: components in the shipped binary that the vendor's own bill of materials does not mention.
Downstream consumers
Six consumers read the same component and finding data off that schema:
- Per binary — a vulnerability list at
/binary/{sha}/vulnerabilities, in two shapes: Pro gets per-CVE findings sorted most-severe-first, Free gets per-severity counts with the ids withheld. Both carry the same database-age and ok fields. - Per package — a rollup that unions the unpack-time curated CVEs with live distro-package, language-package and per-child matches.
- Per fleet, per node —
/fleet/cvesand/nodes/{id}/cves, answering which CVEs affect which hosts and which single enrolled machine. - Version simulator — read-only: if component X were at version Y, which CVEs would clear across the fleet.
- Search facets — severity and known-exploited status, so "critical and known-exploited" is one filter.
- SARIF 2.1.0 export — where the CVE family sits alongside CWE findings, secrets, capabilities and the threat ledger.
Limits
- No request-time external dependency. CVSS, EPSS and KEV are attached, but joined from the local index. Correctness never depends on NVD or OSV being reachable; the cost is that freshness becomes something the response has to report rather than something guaranteed.
- No algorithmic CPE guessing. Only the
(vendor, product)pairs enumerated in the curated table are used; there is no free-text derivation of a CPE string from a component name. Registry-keyed components skip CPE altogether. A component name that is in neither the table nor a registry produces no finding at all — silence, not a guess. - No exploitability scoring of its own. EPSS and KEV are upstream facts, republished unchanged.
- No remediation guidance. Upstream fields only: id, severity, vector, EPSS, KEV, description. No "upgrade to version X". The fleet simulator answers a version hypothetical the operator poses; findings never volunteer one.
- No Apple CVE matching. Apple-supplied components are inventoried and excluded from matching. Apple does not publish CVE/CPE applicability for system frameworks in a mappable form, and a name-only match against
libSystemis noise. - No VEX ingest. The claim-reconciliation path is where a CycloneDX or CSAF VEX document would fold
not_affectedstatements over derived findings. Not built. - No SPDX tag-value reading. A vendor SBOM has to arrive as JSON — CycloneDX or SPDX. The tag-value
.spdxtext form is not parsed, so a vendor who ships only that cannot be diffed at all. - No license detection. SPDX ids appear in exports, but only from the curated table. Licenses are not read from the bytes.
Related briefs
The other three security surfaces run on the binary's own code; this one runs on the inventory of what the binary bundles. A firmware audit needs all four. A bundled OpenSSL with a published CVE, the vendor's own HTTP daemon with a stack overflow, a binary whose recovered capabilities include process injection, and a dropped payload that scores Malicious are four different problems, and knowing one of them says nothing about the other three.
See Findings for the code-shape CWE catalog, Indicators for capability tagging, Malware for the verdict composer, Fingerprinting for the function-signature identity layer, Platform for the fleet rollups, and Architectures for what the unpacker can and cannot open.