Module: StillActive::OsvClient
Overview
OSV (api.osv.dev) enrichment for advisories deps.dev has already discovered.
deps.dev is the discovery source -- it lists a package version's advisory ids --
but it stores only CVSS 3.x (a CVSS-4-only advisory comes back with cvss3Score
0, which reads as unscored) and carries no fixed-version ranges at all. OSV
supplies all three gaps: a GHSA severity LABEL (database_specific.severity)
that reads correctly even for a v4-only advisory, the CVSS v4 VECTOR (turned into
a real score, so the finding carries a security-severity number), and the
per-package fixed versions the "capped below the fix" signal compares against a
poison cap's ceiling.
Enrichment is best-effort: any failure (missing record, transport error, odd shape) leaves the advisory exactly as deps.dev produced it.
OSV is also the ARBITER of whether a deps.dev-discovered advisory really applies to the audited version (see reject_unaffected), which is the one place this module removes rather than adds. That direction is the dangerous one -- a wrongly dropped advisory reads a vulnerable dependency as clean -- so every degrade path here must resolve to "keep", and only an unambiguous OSV contradiction may drop.
Constant Summary collapse
- BASE_URI =
URI("https://api.osv.dev/")
- ECOSYSTEM_NAMES =
Every ecosystem SbomReader/deps.dev resolve, mapped to OSV's package-ecosystem casing (verified against live OSV records). One advisory can name the same package in several ecosystems, so
affectedis filtered to the one being audited; the native Bundler path carries no ecosystem and is always rubygems. Anything unmapped falls back to name-only fix filtering rather than dropping fixes. { rubygems: "RubyGems", pypi: "PyPI", npm: "npm", cargo: "crates.io", maven: "Maven", go: "Go", nuget: "NuGet" }.freeze
- CVSS_PRIORITY =
Prefer the newest CVSS version a record carries (v4 is the whole point: it's the one deps.dev can't score).
severity[].scoreis the vector STRING, oddly named. {"CVSS_V4" => 3, "CVSS_V3" => 2, "CVSS_V2" => 1}.freeze
- TYPE_VERSIONS =
A v2 vector has no
CVSS:X.Yprefix, so the version can't be read from the string; fall back to the entry type so the CycloneDX rating method labels it v2. {"CVSS_V4" => "4.0", "CVSS_V3" => "3.1", "CVSS_V2" => "2.0"}.freeze
Instance Method Summary collapse
-
#detail(advisory_id:) ⇒ Object
Fetch and parse one OSV record by advisory id (GHSA/CVE).
-
#enrich(advisories, ecosystem:, name:, version: nil) ⇒ Object
Enrich each advisory in place with the OSV severity label and the fixed versions for the audited package, and return the advisories that survive version confirmation (see reject_unaffected).
Instance Method Details
#detail(advisory_id:) ⇒ Object
Fetch and parse one OSV record by advisory id (GHSA/CVE). Returns
{ severity_label:, cvss_score:, cvss_version:, cvss_vector:, affected: [...] }
or nil when the id is absent or OSV has no usable record for it. A non-object
body (a CDN/error envelope that parses to an array or scalar) yields nil rather
than raising on dig.
78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 |
# File 'lib/still_active/osv_client.rb', line 78 def detail(advisory_id:) return if advisory_id.nil? body = HttpHelper.get_json(BASE_URI, "/v1/vulns/#{encode(advisory_id)}") return unless body.is_a?(Hash) cvss = best_cvss(body) { # OSV's own identity for the advisory, unioned into the match against a query # result. Asking for a CVE returns the GHSA record (OSV resolves aliases), so # deps.dev's id alone can miss the id the query answers with, and reading a # listed advisory as absent would drop a real finding. identifiers: [body["id"], *Array(body["aliases"])].grep(String), severity_label: body.dig("database_specific", "severity"), cvss_score: cvss[:score], cvss_version: cvss[:version], cvss_vector: cvss[:vector], affected: Array(body["affected"]).filter_map { |entry| parse_affected(entry) } } end |
#enrich(advisories, ecosystem:, name:, version: nil) ⇒ Object
Enrich each advisory in place with the OSV severity label and the fixed versions for the audited package, and return the advisories that survive version confirmation (see reject_unaffected). A missing/failed lookup is a no-op on that advisory (it keeps whatever deps.dev gave it), never a raise.
56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
# File 'lib/still_active/osv_client.rb', line 56 def enrich(advisories, ecosystem:, name:, version: nil) # Each eligible advisory is paired with OSV's own identifiers for it, which the # confirmation match needs alongside deps.dev's (see detail). backed = advisories.filter_map do |advisory| osv_ids = apply_record(advisory, ecosystem: ecosystem, name: name) [advisory, osv_ids] if osv_ids end reject_unaffected(advisories, backed, ecosystem: ecosystem, name: name, version: version) rescue => e # The confirmation pass runs outside apply_record's per-advisory rescue, and an # escape from here would hit the workflow's per-gem rescue and strip the gem of # ALL its signals -- reading a known-vulnerable dependency as clean, the very # thing this module exists to prevent. Keep every advisory instead. warn("warning: OSV version confirmation for #{name} failed: #{e.class} (#{e.}); keeping every advisory") advisories end |