feat(osv): read OSV vulnerabilities from the database instead of the HTTP API - #37
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this does
OSV vulnerabilities are read from the
osvandosv_severitytables instead ofapi.osv.dev. The response is unchanged: same fields, samesource, same URL construction, and thecvssarray still carries every vector.Verified against production data: for
pkg:maven/org.apache.logging.log4j/log4j-core@2.0.0this 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
getRepoURLand the GIT-ecosystem fallback. The table storespkg:githubpurls directly (118,211 rows), so a component is looked up by its purl with no translation to a repository URL and no retryOSVRequest/OSVPackageRequest, which only shaped the API payloadpackageurl-gois no longer a direct dependencyVULN_OSV_API_BASE_URLis 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 APIVULN_OSV_VULNERABILITY_INFO_BASE_URLstays, since it builds each vulnerability's URL.VULN_OSV_API_WORKERSkeeps 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_versionslist and theintroduced/fixed/last_affectedrange. 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 reusesnaturalSortKey, 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 withosv_jsonand the API. That row wins even when a different one matched the version.Parsing the list columns.
affected_versionsandaliasesare 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:LanguageEcosystemnames (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.devand 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 onmodified, a two-vector advisory, a non-CVSSUbuntuscore that must be skipped, and one repackager plus one distro row to pin the ecosystem filter in both directions.TestOSVParitycompares the model against the live API and is skipped unlessPG_DSNandOSV_PARITYare set, so CI never reaches the network.Deployment requirements
osvandosv_severitytables must be present and populated.osv_severityholds the CVSS vectors, whichosvcannot: a vulnerability can carry up to fiveosvmust carry every OSVaffectedentry, including those whose ranges are typeECOSYSTEM(93% of all ranges). An earlier load kept onlySEMVERranges and silently dropped advisories —pkg:gem/rack@0.4.0returned 6 instead of 32. Already fixed on the production databasemap_schema.jsonfor the SQLite exportKnown limitation
OSV marks retracted advisories with
withdrawnand 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 inTestOSVParity. 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.