Skip to content

feat(osv): read OSV vulnerabilities from the database instead of the HTTP API - #37

Merged
scanoss-qg merged 5 commits into
mainfrom
feat/osv-from-database
Aug 17, 2026
Merged

feat(osv): read OSV vulnerabilities from the database instead of the HTTP API#37
scanoss-qg merged 5 commits into
mainfrom
feat/osv-from-database

Conversation

@scanoss-qg

Copy link
Copy Markdown
Contributor

What this does

OSV vulnerabilities are read from the osv and osv_severity tables instead of api.osv.dev. The response is unchanged: same fields, same source, same URL construction, and the cvss array still carries every vector.

Verified against production data: for pkg:maven/org.apache.logging.log4j/log4j-core@2.0.0 this returns the same 8 advisories the API returns, in 0.70 s instead of 1.60 s, and with identical results on PostgreSQL and SQLite (18 → 8 after the ecosystem filter below; the 18 were byte-identical across both engines before it).

Removed with the HTTP client

  • getRepoURL and the GIT-ecosystem fallback. The table stores pkg:github purls directly (118,211 rows), so a component is looked up by its purl with no translation to a repository URL and no retry
  • OSVRequest / OSVPackageRequest, which only shaped the API payload
  • packageurl-go is no longer a direct dependency
  • VULN_OSV_API_BASE_URL is gone from the config. It was still validated — an empty value prevented startup for a setting nothing read, which is what someone would try in order to stop the service calling the API

VULN_OSV_VULNERABILITY_INFO_BASE_URL stays, since it builds each vulnerability's URL. VULN_OSV_API_WORKERS keeps its meaning as the concurrency limit, now over database lookups.

The parts that took the most care

Version matching. The API decided which advisories applied to a version; now we do. OSV uses two mechanisms and both are honoured: the explicit affected_versions list and the introduced/fixed/last_affected range. Which one a row uses varies wildly by ecosystem — pypi is almost all lists (19,047 vs 1,395), golang almost all ranges (12,510 vs 165). Comparison reuses naturalSortKey, already ported and tested.

Collapsing rows. The table is keyed per affected range and averages 15.6 rows per vulnerability; the API returns one entry. Which row supplies the vulnerability's fields is not arbitrary: 137,302 (id, purl) pairs disagree across their rows, almost always on modified, and the most recently modified row is the one that agrees with osv_json and the API. That row wins even when a different one matched the version.

Parsing the list columns. affected_versions and aliases are PostgreSQL arrays rendered as {a,b} text. 1,082 production rows have a quoted element and some contain a comma ({"v1,1",v1.1}), so splitting on commas alone would invent versions that do not exist.

Excluding repackager ecosystems. OSV publishes advisories under Vendor:LanguageEcosystem names (TuxCare:Maven, Echo:npm). They share the purl of the upstream package, so a lookup by purl picked them up while the API, filtering by ecosystem, does not return them — 10 extra advisories on log4j-core alone. They are excluded by vendor prefix. Worth recording why not by "contains a colon": legitimate distro ecosystems are versioned that way (Ubuntu:22.04:LTS, Debian:12) and that pattern covers 3,770,300 of 5,480,673 rows — it would have silently dropped every Debian and Ubuntu advisory. The repackagers are 31,093 rows, 0.57%.

Tests

The OSV use case tests used to call api.osv.dev and assert only that something came back, so they needed internet and exercised whatever OSV served that day. They now run on SQLite against a fixture covering both matching mechanisms, several rows per vulnerability, rows disagreeing on modified, a two-vector advisory, a non-CVSS Ubuntu score that must be skipped, and one repackager plus one distro row to pin the ecosystem filter in both directions.

TestOSVParity compares the model against the live API and is skipped unless PG_DSN and OSV_PARITY are set, so CI never reaches the network.

Deployment requirements

  • The osv and osv_severity tables must be present and populated. osv_severity holds the CVSS vectors, which osv cannot: a vulnerability can carry up to five
  • osv must carry every OSV affected entry, including those whose ranges are type ECOSYSTEM (93% of all ranges). An earlier load kept only SEMVER ranges and silently dropped advisories — pkg:gem/rack@0.4.0 returned 6 instead of 32. Already fixed on the production database
  • Both tables are in map_schema.json for the SQLite export

Known limitation

OSV marks retracted advisories with withdrawn and the table has no such column, so 43,739 retracted advisories across 192,159 rows are reported as live. This is the one remaining parity gap and causes the single false positive in TestOSVParity. A data ticket for it is pending.

Verification

go build, go vet, the full suite, and golangci-lint v2.10.1 with a clean cache all pass. Behaviour confirmed end to end against both the production PostgreSQL database and the exported SQLite file.

First half of replacing the OSV HTTP API with database access. The model and its
version matching are in; the use case still calls the API and is untouched.

- pkg/models/osv.go queries the osv table by purl and collapses the rows. The CAST on
  the array columns keeps one query working on both engines, the same mechanism already
  proven for cves.match_criteria_ids. CVSS vectors come from osv_severity in a second
  query rather than a join, to avoid multiplying rows that are already duplicated
- pkg/models/osv_array.go parses PostgreSQL array literals. Not incidental: 1,082
  production rows have a quoted element and some contain a comma ({"v1,1",v1.1}), so
  splitting on commas alone would invent versions that do not exist
- version matching reuses naturalSortKey, honouring both mechanisms OSV uses: the
  explicit affected_versions list and the introduced/fixed/last_affected range
- osv and osv_severity added to testSchemaDDL

Which row supplies a vulnerability's fields is deliberate. They are supposed to agree
across the rows of one id and do not: 137,302 (id, purl) pairs disagree, almost always
on modified. The most recently modified row is the one that matches osv_json and the
API, so that row wins even when a different one matched the version.

Parity against the live API (TestOSVParity, opt-in via PG_DSN + OSV_PARITY) currently
fails on 2 of 5 sampled pairs, and the cause is upstream of this code: the osv table
does not carry every affected entry OSV publishes. Kept as a failing test rather than a
lowered bar.
Replaces the api.osv.dev calls with lookups against the osv tables. The response shape
is unchanged: same fields, same source value, same URL construction, and the cvss array
still carries every vector, now from osv_severity.

Removed along with the HTTP client:

- getRepoURL and the GIT-ecosystem fallback. The table stores pkg:github purls directly
  (118,211 rows), so a component is looked up by its purl with no translation to a
  repository URL and no retry. packageurl-go is no longer a direct dependency
- OSVRequest / OSVPackageRequest, which only existed to shape the API payload

Config: VULN_OSV_API_BASE_URL is now unused. VULN_OSV_VULNERABILITY_INFO_BASE_URL still
builds each vulnerability's URL, and VULN_OSV_API_WORKERS keeps its meaning as the
concurrency limit, now over database lookups.

One deliberate behaviour change: a failed lookup is reported as "Failed to query OSV
data" rather than "No vulnerabilities found". The API path collapsed both into the
latter, so a broken query was indistinguishable from a clean component - the same
silent-failure pattern already noted in vulnerabilityWorker.

Tests no longer need the network. They used to call api.osv.dev and assert only that
something came back, which meant they exercised whatever OSV served that day. They now
run on SQLite against osv_scenario.sql, which covers both matching mechanisms, several
rows per vulnerability, rows disagreeing on modified, a two-vector vulnerability, and a
non-CVSS Ubuntu score that must be skipped. TestGetRepoURL is gone with the function.

Still pending the osv table reload: TestOSVParity fails on 2 of 5 sampled pairs because
the table drops affected entries whose ranges are of type ECOSYSTEM. Nothing in this
commit changes when that lands.
…ables

Also drops the last remnant of the HTTP path in config.

VULN_OSV_API_BASE_URL is gone from the struct, its default, and IsValidConfig. Nothing
read it any more, yet the validation still refused to start when it was empty - so
clearing it, which is what someone would try in order to stop the service calling the
API, prevented startup instead. Its test case now covers VULN_OSV_INFO_BASE_URL, which
is still required because it builds each vulnerability's URL.

The README documented OSV_ENABLED, OSV_API_BASE_URL and
OSV_VULNERABILITY_INFO_BASE_URL. None of those are the variables the service reads: the
tags are VULN_OSV_SOURCE_ENABLED and VULN_OSV_INFO_BASE_URL. The APP_* and DB_* entries
were correct, only the vulnerability-source block was wrong. Corrected, with
VULN_OSV_API_WORKERS and VULN_SCANOSS_WORKERS added since both are read, plus a note
that OSV now needs the osv and osv_severity tables present and populated.

Filed as 0.15.0 rather than Unreleased, matching how 0.14.0 was handled. The entry
records the withdrawn limitation and the requirement that the osv load keep ECOSYSTEM
ranges, since both matter to whoever deploys this.
Comparing against production surfaced this: for
pkg:maven/org.apache.logging.log4j/log4j-core@2.0.0 the service returned 18
vulnerabilities where the OSV API returns 8. The 8 matched exactly; the 10 extras were
all TuxCare advisories.

OSV publishes advisories for vendors that rebuild libraries under their own ecosystem
name, following Vendor:LanguageEcosystem - TuxCare:Maven, Echo:npm. Those rows carry the
same purl as the upstream package, so a lookup by purl alone picks them up, while the API
filters by ecosystem and does not return them. They are dropped here because their fixed
versions are unreachable from the upstream registry ("2.22.1-tuxcare.2" is not in Maven
Central), they carry no aliases tying them to a CVE, and their introduced_version is 0,
so they would attach to every version of every affected component.

Now returns the same 8 as the API, verified against the production database.

The obvious filter would have been "ecosystem contains a colon", and it would have been a
serious mistake: legitimate distro ecosystems are versioned that way (Ubuntu:22.04:LTS,
Debian:12) and that pattern covers 3,770,300 of 5,480,673 rows. It would have silently
dropped every Debian and Ubuntu advisory. The repackagers are 31,093 rows, 0.57%, and are
excluded by vendor prefix instead.

The list is a single var with a comment explaining how to restore them, since whether
these advisories are noise depends on whether anyone consumes those rebuilt packages. The
fixture now carries one repackager row and one distro row, and a test pins that the first
is excluded and the second is not - so restoring them is a deliberate change, not an
accident.

Why the parity test did not catch it: its sample had no component with repackager
advisories. That is a gap in the sample, not in the check.
The filter landed after the changelog entry was written, and it changes what the service
returns, so it belongs there. Also moves the release date to the 17th.
@scanoss-qg
scanoss-qg requested a review from agustingroh August 17, 2026 13:53
@scanoss-qg
scanoss-qg merged commit cae59b3 into main Aug 17, 2026
2 checks passed
@scanoss-qg
scanoss-qg deleted the feat/osv-from-database branch August 17, 2026 15:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant