Module: StillActive::VulnerabilityHelper

Extended by:
VulnerabilityHelper
Included in:
VulnerabilityHelper
Defined in:
lib/helpers/vulnerability_helper.rb

Constant Summary collapse

SEVERITY_ORDER =
["low", "medium", "high", "critical"].freeze
OSV_LABELS =

OSV's GHSA severity labels, mapped to our order. deps.dev stores only CVSS 3.x, so a CVSS-4-only advisory returns cvss3Score 0 (unscored); OSV's database_specific.severity reads correctly for it and rescues a real HIGH from fail-closed noise. GitHub uses MODERATE where we use medium.

{ "critical" => "critical", "high" => "high", "moderate" => "medium", "medium" => "medium", "low" => "low" }.freeze

Instance Method Summary collapse

Instance Method Details

#advisory_severity(vulnerability) ⇒ Object

One advisory's severity label ("low".."critical"), or nil when genuinely unscored. The authoritative GHSA/OSV label is a FLOOR: a real CVSS number sharpens precision and can RAISE the band, but never lowers it below the label. This matters because the OSV-derived score is cvss-suite's overall_score, which folds in any threat/environmental metrics a vector carries (and can diverge from GHSA at a band edge) -- on a fail-closed, no-false-positive tool a computed number must not silently demote a GHSA-HIGH finding out of a severity gate. With only one of the two present, that one stands.



31
32
33
34
35
36
37
# File 'lib/helpers/vulnerability_helper.rb', line 31

def advisory_severity(vulnerability)
  from_score = (score = effective_score(vulnerability)) ? severity_label(score) : nil
  from_label = OSV_LABELS[vulnerability[:osv_severity].to_s.downcase]
  return from_score || from_label if from_score.nil? || from_label.nil?

  [from_score, from_label].max_by { |label| SEVERITY_ORDER.index(label) }
end

#effective_score(vulnerability) ⇒ Object

The best real CVSS score for an advisory, or nil when none is usable. Prefers deps.dev's v3/v2, then the score OSV enrichment computed from a v4 (or v3) vector. A score of 0 is treated as absent: deps.dev only stores CVSS 3.x, so a CVSS-4-only advisory (e.g. two HIGH protobuf GHSAs) comes back with cvss3Score 0 -- its "no 3.x score" sentinel, not a genuine severity of zero (which published advisories never carry) -- and the OSV v4 score fills that gap. Every consumer of a numeric score (severity labels, SARIF level, CycloneDX rating) routes through here so a 0 can't masquerade as "low" and silently clear a gate.



65
66
67
# File 'lib/helpers/vulnerability_helper.rb', line 65

def effective_score(vulnerability)
  [vulnerability[:cvss3_score], vulnerability[:cvss2_score], vulnerability[:osv_cvss_score]].find { |score| score&.positive? }
end

#highest_severity(vulnerabilities) ⇒ Object



15
16
17
18
19
20
21
# File 'lib/helpers/vulnerability_helper.rb', line 15

def highest_severity(vulnerabilities)
  return if vulnerabilities.nil? || vulnerabilities.empty?

  vulnerabilities
    .filter_map { |v| advisory_severity(v) }
    .max_by { |label| SEVERITY_ORDER.index(label) }
end

#merge_advisories(deps_dev:, ruby_advisory_db:) ⇒ Object

Combines advisories from deps.dev and ruby-advisory-db (via bundler-audit), deduplicating on shared identifiers. deps.dev is preferred for CVSS/title/url (it carries the vector string); ruby-advisory-db fills gaps. Advisories present in both sources are tagged source: "merged"; otherwise the per-source tag is kept.



86
87
88
89
90
91
92
93
94
95
96
97
98
99
# File 'lib/helpers/vulnerability_helper.rb', line 86

def merge_advisories(deps_dev:, ruby_advisory_db:)
  merged = deps_dev.map(&:dup)

  ruby_advisory_db.each do |advisory|
    existing = merged.find { |m| identifiers(m).intersect?(identifiers(advisory)) }
    if existing
      combine!(existing, advisory)
    else
      merged << advisory
    end
  end

  merged
end

#no_fix_available?(vulnerabilities) ⇒ Boolean

True when at least one advisory has no fixed version available -- the gem can't be upgraded out of the vulnerability. Only ruby-advisory-db sets this today; a deps.dev-only advisory leaves it nil (unknown), which reads false.

Returns:

  • (Boolean)


53
54
55
# File 'lib/helpers/vulnerability_helper.rb', line 53

def no_fix_available?(vulnerabilities)
  Array(vulnerabilities).any? { |vulnerability| vulnerability[:no_fix_available] }
end

#severity_at_or_above?(vulnerabilities, threshold) ⇒ Boolean

Returns:

  • (Boolean)


69
70
71
72
73
74
75
76
77
78
79
80
# File 'lib/helpers/vulnerability_helper.rb', line 69

def severity_at_or_above?(vulnerabilities, threshold)
  return false if vulnerabilities.nil? || vulnerabilities.empty?

  # Fail closed on an unscored advisory: a confirmed advisory we can't score
  # could be anything up to critical (and fresh CVEs commonly lack a score),
  # so passing it as "below threshold" would silently clear a severity gate on
  # a real finding. The user accepts a specific advisory via a suppression if
  # they've reviewed it. Only when every advisory is scored do we compare.
  return true if vulnerabilities.any? { |vulnerability| unknown_severity?(vulnerability) }

  SEVERITY_ORDER.index(highest_severity(vulnerabilities)) >= SEVERITY_ORDER.index(threshold)
end

#unknown_severity?(vulnerability) ⇒ Boolean

Unknown = no CVSS score AND no OSV label. A freshly disclosed CVE often has neither yet, and the cross-ecosystem lens emits a minimal advisory when detail-fetch fails. The CVSS-4-only case (deps.dev returns cvss3Score 0, its "no 3.x score" sentinel, for two HIGH protobuf GHSAs scored CVSS 4.0) is no longer unknown once OSV enrichment attaches the GHSA severity label -- that's the whole point of the enrichment: a real HIGH stops failing closed as unscored. A truly unknown advisory still fails closed on a gate.

Returns:

  • (Boolean)


46
47
48
# File 'lib/helpers/vulnerability_helper.rb', line 46

def unknown_severity?(vulnerability)
  advisory_severity(vulnerability).nil?
end