Module: StillActive::OsvClient

Extended by:
OsvClient
Included in:
OsvClient
Defined in:
lib/still_active/osv_client.rb

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. It must never drop an advisory the audit already found, so it only ever ADDS fields.

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 affected is 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[].score is the vector STRING, oddly named.

{ "CVSS_V4" => 3, "CVSS_V3" => 2, "CVSS_V2" => 1 }.freeze
TYPE_VERSIONS =

A v2 vector has no CVSS:X.Y prefix, 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

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.



74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/still_active/osv_client.rb', line 74

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)
  {
    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:) ⇒ Object

Enrich each advisory in place with the OSV severity label and the fixed versions for the audited package. A missing/failed lookup is a no-op on that advisory (it keeps whatever deps.dev gave it), never a raise.



50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# File 'lib/still_active/osv_client.rb', line 50

def enrich(advisories, ecosystem:, name:)
  advisories.each do |advisory|
    record = detail(advisory_id: advisory[:id])
    next if record.nil?

    advisory[:osv_severity] = record[:severity_label]
    advisory[:osv_cvss_score] = record[:cvss_score]
    advisory[:cvss_version] = record[:cvss_version]
    advisory[:cvss_vector] = record[:cvss_vector]
    advisory[:fixed_versions] = fixed_versions(record, ecosystem: ecosystem, name: name)
  rescue StandardError => e
    # Enrichment is additive and best-effort. An unexpected OSV shape must never
    # raise out through the workflow's per-gem rescue, which would DROP the whole
    # gem and read a known-vulnerable dependency as clean. Leave the advisory
    # exactly as deps.dev produced it.
    $stderr.puts("warning: OSV enrichment for #{advisory[:id]} failed: #{e.class} (#{e.message}); leaving advisory unchanged")
  end
end