still_active 2.0.0 → 3.0.0.rc1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -0
  3. data/README.md +57 -10
  4. data/lib/helpers/activity_helper.rb +8 -0
  5. data/lib/helpers/constraint_helper.rb +208 -0
  6. data/lib/helpers/cvss_helper.rb +63 -0
  7. data/lib/helpers/cyclonedx_helper.rb +20 -4
  8. data/lib/helpers/diff_markdown_helper.rb +21 -5
  9. data/lib/helpers/endoflife_helper.rb +114 -0
  10. data/lib/helpers/http_helper.rb +17 -2
  11. data/lib/helpers/markdown_helper.rb +118 -1
  12. data/lib/helpers/pep440_helper.rb +100 -0
  13. data/lib/helpers/python_helper.rb +19 -0
  14. data/lib/helpers/ruby_advisory_db.rb +23 -0
  15. data/lib/helpers/ruby_helper.rb +13 -24
  16. data/lib/helpers/runtime_ceiling_helper.rb +121 -0
  17. data/lib/helpers/sarif_helper.rb +105 -3
  18. data/lib/helpers/semver_satisfaction.rb +68 -0
  19. data/lib/helpers/status_helper.rb +68 -0
  20. data/lib/helpers/summary_helper.rb +4 -0
  21. data/lib/helpers/terminal_helper.rb +144 -6
  22. data/lib/helpers/version_helper.rb +9 -0
  23. data/lib/helpers/vulnerability_helper.rb +73 -7
  24. data/lib/still_active/ceiling_reconciler.rb +43 -0
  25. data/lib/still_active/cli.rb +212 -9
  26. data/lib/still_active/config.rb +28 -1
  27. data/lib/still_active/config_file.rb +27 -0
  28. data/lib/still_active/deps_dev_client.rb +115 -5
  29. data/lib/still_active/diff.rb +22 -0
  30. data/lib/still_active/ecosystem_lens.rb +284 -0
  31. data/lib/still_active/ecosystems_client.rb +123 -0
  32. data/lib/still_active/options.rb +44 -1
  33. data/lib/still_active/osv_client.rb +147 -0
  34. data/lib/still_active/poison_security_correlator.rb +232 -0
  35. data/lib/still_active/pypi_client.rb +56 -0
  36. data/lib/still_active/sarif/rules.rb +33 -9
  37. data/lib/still_active/sbom_reader.rb +191 -0
  38. data/lib/still_active/sbom_workflow.rb +96 -0
  39. data/lib/still_active/suppressions.rb +6 -5
  40. data/lib/still_active/version.rb +1 -1
  41. data/lib/still_active/workflow.rb +201 -8
  42. data/lib/still_active.rb +3 -0
  43. data/still_active.gemspec +28 -5
  44. metadata +61 -11
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../helpers/vulnerability_helper"
4
+ require_relative "../helpers/constraint_helper"
5
+ require_relative "../helpers/semver_satisfaction"
6
+
7
+ module StillActive
8
+ # Whole-tree correlation, run once after the fan-out: a poison cap is far more
9
+ # urgent when the dependency it pins is ITSELF vulnerable in the same tree --
10
+ # "a dormant package is holding you on a known-vulnerable dependency, below the
11
+ # version that fixes it." Both facts are already assembled (the poison
12
+ # constraints and every dependency's vulnerability count), so this is one pass,
13
+ # no extra fetches. It marks the security-relevant caps so the report can lead
14
+ # with them and demote the FYI caps on healthy dependencies.
15
+ #
16
+ # The moat's strongest case (verified on real repos): several archived Google
17
+ # client libraries pin a vulnerable `protobuf` three majors below the fix.
18
+ module PoisonSecurityCorrelator
19
+ extend self
20
+
21
+ # Only a HIGH-or-above advisory on the capped dep makes the cap security-
22
+ # relevant. Most advisories are low-threat noise (research: ~95% of vulnerable
23
+ # deps are unreachable/low-impact), so a low/medium CVE on the pinned dep isn't
24
+ # the "you're stuck below the fix" story. Unscored advisories fail CLOSED (a
25
+ # confirmed advisory we can't score could be severe), matching --fail-if-
26
+ # vulnerable. Reachability/exploitability is beyond static, metadata-only sight
27
+ # and deliberately out of scope -- this gates on severity, not exploitability.
28
+ SECURITY_THRESHOLD = "high"
29
+
30
+ # The severity labels that let an advisory establish "below the fix". Only a
31
+ # confirmed HIGH+ advisory with a known fix can: an unscored advisory (which the
32
+ # capped_dep_vulnerable gate accepts fail-closed) has no reliable fix analysis.
33
+ BELOW_FIX_LABELS = ["high", "critical"].freeze
34
+
35
+ def correlate(result_object)
36
+ advisories = flat_advisories(result_object)
37
+ copies = copy_index(result_object)
38
+
39
+ result_object.each_value do |data|
40
+ # FLAT (rubygems/pypi): mark below-fix on the poison caps already attached.
41
+ mark_flat_constraints(data, advisories)
42
+ # NESTED (npm/cargo): promote a genuine below-fix candidate into :constraints.
43
+ promote_nested_below_fix(data, copies) if data[:capped_deps]
44
+
45
+ constraints = hashes(data[:constraints])
46
+ next if constraints.empty?
47
+
48
+ data[:poison_security_relevant] = true if constraints.any? { |c| c[:capped_dep_vulnerable] }
49
+ data[:poison_below_fix] = true if constraints.any? { |c| c[:capped_below_fix] }
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ # This runs OUTSIDE the per-gem rescue (a whole-tree pass after the barrier),
56
+ # so a malformed shape must degrade that one entry's enrichment, never raise and
57
+ # crash the audit after every fetch is paid for. The pipeline always assigns
58
+ # arrays of hashes here, so these guards are unreachable today, but they match the
59
+ # defensive Array()-wrapping the sibling consumers (markdown/sarif/terminal) apply.
60
+ def hashes(value)
61
+ Array(value).grep(Hash)
62
+ end
63
+
64
+ # Ecosystem-qualified map of each tree package's advisories AND the version it's
65
+ # pinned at, for the FLAT path (one resolved version per name). A capped dep
66
+ # resolves in its capper's ecosystem, so match "ecosystem/name" on both sides;
67
+ # native results carry no :ecosystem (nil on both) and still match. The used
68
+ # version is kept so "below the fix" ignores fixes that land BELOW it: an OSV
69
+ # advisory lists a fix per affected range, and a fix for an older line is a
70
+ # downgrade, not a patch for the version in hand.
71
+ def flat_advisories(result_object)
72
+ result_object.each_with_object({}) do |(key, data), map|
73
+ next unless data[:vulnerability_count].to_i.positive?
74
+
75
+ name = data[:name] || key
76
+ map["#{data[:ecosystem]}/#{name}"] = { vulns: hashes(data[:vulnerabilities]), used_version: data[:version_used] }
77
+ end
78
+ end
79
+
80
+ # Every RESOLVED copy of each package, keyed "ecosystem/name", for the NESTED
81
+ # path: npm nests versions and cargo coexists majors, so one name maps to several
82
+ # copies. The full set (vulnerable AND not) is what the condition-5 soundness
83
+ # guard needs -- "is there a SAFE copy within the cap" can't be answered from the
84
+ # vulnerable copies alone.
85
+ def copy_index(result_object)
86
+ result_object.each_with_object({}) do |(key, data), map|
87
+ name = data[:name] || key
88
+ (map["#{data[:ecosystem]}/#{name}"] ||= []) << { version: data[:version_used], vulns: hashes(data[:vulnerabilities]) }
89
+ end
90
+ end
91
+
92
+ def mark_flat_constraints(data, advisories)
93
+ constraints = hashes(data[:constraints])
94
+ return if constraints.empty?
95
+
96
+ eco = data[:ecosystem]
97
+ constraints.each do |constraint|
98
+ entry = advisories["#{eco}/#{constraint[:dependency]}"]
99
+ next if entry.nil?
100
+
101
+ vulns = entry[:vulns]
102
+ next unless VulnerabilityHelper.severity_at_or_above?(vulns, SECURITY_THRESHOLD)
103
+
104
+ constraint[:capped_dep_vulnerable] = true
105
+ mark_below_fix(constraint, vulns, entry[:used_version])
106
+ end
107
+ end
108
+
109
+ # NESTED below-the-fix (npm/cargo). The pure poison cap is suppressed for these
110
+ # ecosystems (caret default + nested copies over-claim), so a candidate is
111
+ # promoted ONLY when it genuinely holds a vulnerable copy below its fix, checked
112
+ # at PATCH precision. When one is, it takes the same shape the flat path renders,
113
+ # and :poison is set here (the only npm/cargo poison there is is a below-fix).
114
+ def promote_nested_below_fix(data, copies)
115
+ eco = data[:ecosystem]
116
+ # Consume the candidates: they are an internal work-list, never serialized.
117
+ promoted = hashes(data.delete(:capped_deps)).filter_map do |candidate|
118
+ dep_copies = copies["#{eco}/#{candidate[:dependency]}"] || []
119
+ nested_below_fix(eco, candidate[:dependency], candidate[:requirement], dep_copies)
120
+ end
121
+ return if promoted.empty?
122
+
123
+ data[:constraints] = promoted
124
+ data[:poison] = true
125
+ data[:poison_severity] = :critical
126
+ end
127
+
128
+ # A below-fix finding for one capped dep, or nil. The claim holds only when
129
+ # EVERY resolved copy the constraint governs is vulnerable to the same HIGH+
130
+ # advisory (condition 5: no safe in-constraint copy to resolve to), AND no fix
131
+ # of that advisory satisfies the constraint (the wall, at patch precision). A
132
+ # patched copy sitting OUTSIDE the constraint elsewhere in the tree is irrelevant
133
+ # -- it can't lift the copy this capper pins -- so it never enters this view.
134
+ def nested_below_fix(ecosystem, dependency, requirement, dep_copies)
135
+ in_constraint = dep_copies.select { |copy| satisfies?(requirement, copy[:version], ecosystem) }
136
+ return if in_constraint.empty?
137
+
138
+ oldest = in_constraint.map { |copy| copy[:version] }.min_by { |version| gem_version(version) }
139
+ stuck = candidate_advisories(in_constraint).select do |advisory|
140
+ walls?(requirement, advisory, ecosystem, oldest) && every_copy_affected?(in_constraint, advisory)
141
+ end
142
+ return if stuck.empty?
143
+
144
+ receipt = stuck.min_by { |advisory| gem_version(nearest_fix(advisory, oldest)) }
145
+ {
146
+ dependency: dependency,
147
+ requirement: requirement,
148
+ capped_dep_vulnerable: true,
149
+ capped_below_fix: true,
150
+ below_fix_advisory: receipt[:id],
151
+ below_fix_fixed_in: nearest_fix(receipt, oldest),
152
+ }
153
+ end
154
+
155
+ def satisfies?(requirement, version, ecosystem)
156
+ SemverSatisfaction.evaluate(requirement: requirement, version: version, ecosystem: ecosystem) == true
157
+ end
158
+
159
+ # HIGH+ advisories with a known fix, deduped by id, across the in-constraint copies.
160
+ def candidate_advisories(copies)
161
+ copies.flat_map { |copy| copy[:vulns] }
162
+ .select { |vuln| BELOW_FIX_LABELS.include?(VulnerabilityHelper.advisory_severity(vuln)) && Array(vuln[:fixed_versions]).any? }
163
+ .uniq { |vuln| vuln[:id] }
164
+ end
165
+
166
+ # The wall: NO applicable fix satisfies the constraint at patch precision. Every
167
+ # fix must be a DEFINITE non-match (evaluate == false); an undecidable fix (nil,
168
+ # unparseable) blocks the claim rather than counting as unreachable, so we never
169
+ # fabricate a wall from input we couldn't parse.
170
+ def walls?(requirement, advisory, ecosystem, oldest_affected)
171
+ fixes = applicable_fixes(advisory, oldest_affected)
172
+ fixes.any? && fixes.all? { |fix| SemverSatisfaction.evaluate(requirement: requirement, version: fix, ecosystem: ecosystem) == false }
173
+ end
174
+
175
+ # Condition 5: every governed copy is vulnerable to THIS advisory (none is a safe
176
+ # version you could resolve to within the cap).
177
+ def every_copy_affected?(copies, advisory)
178
+ copies.all? { |copy| copy[:vulns].any? { |vuln| vuln[:id] == advisory[:id] } }
179
+ end
180
+
181
+ # The sharper claim on a security-relevant cap: does it hold you BELOW THE FIX?
182
+ # A HIGH+ advisory with a known fix establishes it only when EVERY fixed version
183
+ # lands outside the cap (a major it forbids) -- you cannot patch the CVE without
184
+ # replacing the dormant capper. The receipt names the advisory and its nearest
185
+ # fix (the lowest fixed version, all of which are outside the cap).
186
+ def mark_below_fix(constraint, vulns, used_version)
187
+ stuck = vulns.select do |vuln|
188
+ fixes = applicable_fixes(vuln, used_version)
189
+ next false if fixes.empty?
190
+ next false unless BELOW_FIX_LABELS.include?(VulnerabilityHelper.advisory_severity(vuln))
191
+
192
+ fixes.none? { |fix| ConstraintHelper.reachable_within_cap?(constraint, fix) }
193
+ end
194
+ return if stuck.empty?
195
+
196
+ receipt = stuck.min_by { |vuln| gem_version(nearest_fix(vuln, used_version)) }
197
+ constraint[:capped_below_fix] = true
198
+ constraint[:below_fix_advisory] = receipt[:id]
199
+ constraint[:below_fix_fixed_in] = nearest_fix(receipt, used_version)
200
+ end
201
+
202
+ # An advisory lists a fixed version per affected range; only fixes ABOVE the
203
+ # version in hand are real remediation. A fix at or below it belongs to an older
204
+ # release line (a downgrade), so it neither patches this version nor counts as
205
+ # "reachable within the cap". With no known used version (older data), all fixes
206
+ # are kept -- the pre-existing behaviour, never fewer findings than before.
207
+ def applicable_fixes(vuln, used_version)
208
+ fixes = Array(vuln[:fixed_versions])
209
+ return fixes if used_version.nil? || !Gem::Version.correct?(used_version.to_s)
210
+
211
+ used = gem_version(used_version)
212
+ fixes.select { |fix| !Gem::Version.correct?(fix.to_s) || gem_version(fix) > used }
213
+ end
214
+
215
+ # The lowest applicable fixed version, for the "fixed in X" receipt. A parseable
216
+ # version always beats an unparseable one (PyPI epoch `1!2.3.4`, or garbage), so
217
+ # the receipt never names an odd string when a clean fix is present; only when
218
+ # EVERY fix is unparseable does it fall back to one of those.
219
+ def nearest_fix(vuln, used_version)
220
+ applicable_fixes(vuln, used_version).min_by { |fix| [Gem::Version.correct?(fix.to_s) ? 0 : 1, gem_version(fix)] }
221
+ end
222
+
223
+ # A version key that sorts fix strings without raising; an unparseable version
224
+ # collapses to 0 (only reached as a tiebreak among all-unparseable fixes, since
225
+ # nearest_fix prefers parseable ones).
226
+ def gem_version(version)
227
+ Gem::Version.new(version.to_s)
228
+ rescue ArgumentError
229
+ Gem::Version.new("0")
230
+ end
231
+ end
232
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../helpers/http_helper"
4
+ require_relative "version"
5
+
6
+ module StillActive
7
+ # The authoritative source for a PyPI release's declared Python constraint,
8
+ # used by the cross-ecosystem language-runtime ceiling. `requires_python` is
9
+ # set once per release and enforced by pip as a hard install wall (a real
10
+ # resolver refuses an incompatible interpreter), so still_active reads it from
11
+ # PyPI's release-level `info.requires_python` rather than a per-file wheel value
12
+ # or deps.dev (which does not carry the field). pypi.org is a public source,
13
+ # queried with no credentials.
14
+ #
15
+ # Best-effort like every other network entry point: an unindexed version,
16
+ # schema drift, or a transport failure degrades to nil (no ceiling), never a
17
+ # raise that could abort the per-package audit.
18
+ module PypiClient
19
+ extend self
20
+
21
+ BASE_URI = URI("https://pypi.org/")
22
+ # Polite identification, matching EcosystemsClient's convention.
23
+ USER_AGENT = "still_active/#{StillActive::VERSION} (+https://github.com/SeanLF/still_active)"
24
+
25
+ # The PEP 440 `requires_python` specifier a release declares (e.g.
26
+ # ">=3.8,<3.13"), or nil when the version is unreadable or declares none.
27
+ # PyPI renders an unset constraint as an empty string; normalize it to nil so
28
+ # callers get a single "no constraint" signal.
29
+ def requires_python(name:, version:)
30
+ return if name.nil? || version.nil?
31
+
32
+ path = "/pypi/#{encode(name)}/#{encode(version)}/json"
33
+ body = HttpHelper.get_json(BASE_URI, path, headers: { "User-Agent" => USER_AGENT })
34
+ return unless body.is_a?(Hash)
35
+
36
+ # Guard the nested read: a non-Hash `info` (schema drift) would make
37
+ # Hash#dig raise TypeError, which -- since this is the LAST enrichment in
38
+ # EcosystemLens#assess -- would propagate to the per-package rescue and
39
+ # discard the package's already-computed vuln/poison/staleness findings.
40
+ # Best-effort must never demote the core audit; degrade to nil.
41
+ info = body["info"]
42
+ return unless info.is_a?(Hash)
43
+
44
+ value = info["requires_python"]
45
+ return unless value.is_a?(String) && !value.strip.empty?
46
+
47
+ value
48
+ end
49
+
50
+ private
51
+
52
+ def encode(segment)
53
+ URI.encode_www_form_component(segment)
54
+ end
55
+ end
56
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module StillActive
4
4
  module Sarif
5
- # Catalog of SARIF rules emitted by still_active. Rule IDs (SA001-SA007)
5
+ # Catalog of SARIF rules emitted by still_active. Rule IDs (SA001-SA009)
6
6
  # are stable across versions; renames or removals are breaking changes.
7
7
  #
8
8
  # Each rule maps a still_active finding into a SARIF result. Security
@@ -17,14 +17,18 @@ module StillActive
17
17
  "#{DOCS_BASE}##{rule_id.downcase}"
18
18
  end
19
19
 
20
- # CVSS 0-10 -> SARIF level. Per GitHub Code Scanning convention,
21
- # scores >= 7.0 map to error, 4.0-6.9 to warning, below 4.0 to note.
22
- def cvss_to_level(score)
23
- return "note" if score.nil?
24
- return "error" if score >= 7.0
25
- return "warning" if score >= 4.0
26
-
27
- "note"
20
+ def severity_to_level(label)
21
+ # Maps a resolved advisory severity label (VulnerabilityHelper.advisory_severity:
22
+ # a real CVSS score OR the OSV/GHSA label) to a SARIF level. A nil label is a
23
+ # confirmed-but-unscored advisory (no CVSS score and no OSV label, e.g. a fresh
24
+ # CVE) -- elevated to warning, not an informational note, so a gate can't read
25
+ # it as clean. This is the SARIF analogue of the CLI gate's fail-closed.
26
+ case label
27
+ when "critical", "high" then "error"
28
+ when "medium" then "warning"
29
+ when "low" then "note"
30
+ else "warning"
31
+ end
28
32
  end
29
33
 
30
34
  # SARIF security-severity must be a string with one decimal place.
@@ -105,6 +109,26 @@ module StillActive
105
109
  security_severity: "8.0",
106
110
  tags: ["security", "supply-chain", "external/cwe/cwe-1104"],
107
111
  },
112
+ {
113
+ id: "SA008",
114
+ name: "PoisonPill",
115
+ short: "Dormant gem caps a dependency below its latest major",
116
+ full: "A dormant (abandoned or archived) gem declares a runtime constraint that caps one of its dependencies below that dependency's current latest major. Because nobody is shipping the gem, the cap will never lift, and it grows more constraining as the capped dependency releases new majors: the tree is held below a ceiling no upstream release will raise.",
117
+ help_text: "Replace or fork the dormant gem, or vendor a version that relaxes the constraint. For a transitive pill, target the direct dependency that pulls it in.",
118
+ level: "warning",
119
+ security_severity: nil, # maintenance/resolvability, not a CVE (as SA002/SA004/SA005)
120
+ tags: ["maintenance", "supply-chain", "external/cwe/cwe-1104"],
121
+ },
122
+ {
123
+ id: "SA009",
124
+ name: "RuntimeCeiling",
125
+ short: "Resolved package version caps its language runtime",
126
+ full: "A resolved package version's declared runtime constraint (Ruby `ruby_version`, Python `requires_python`) caps the language runtime you can run: either below every still-supported release (stranding you on an end-of-life runtime with no security patches) or below the latest stable (a compatibility ceiling to plan around). The default level is note; an EOL-forcing cap is raised to error per result.",
127
+ help_text: "If a newer release of the package lifts the cap, upgrade it. Otherwise replace or fork the package, or contribute support for the newer runtime upstream.",
128
+ level: "note",
129
+ security_severity: nil, # maintenance/compatibility, not a CVE (as SA002/SA004/SA005/SA008)
130
+ tags: ["maintenance", "runtime", "external/cwe/cwe-1104"],
131
+ },
108
132
  ].freeze
109
133
 
110
134
  CATALOG = RAW_RULES.map do |r|
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+ require "package_url"
6
+
7
+ module StillActive
8
+ # Reads a CycloneDX SBOM (e.g. produced by Syft) into a clean, normalized
9
+ # dependency set -- the breadth input for non-Ruby ecosystems, where the
10
+ # maintenance lens runs over deps.dev/ecosyste.ms.
11
+ #
12
+ # `parse` returns both the assessable dependencies AND the components it could
13
+ # NOT assess (a real dependency in an unsupported ecosystem, or a library with
14
+ # no PURL -- typically a git/path/local source). Surfacing those is the point:
15
+ # silently dropping them would let an audit report "all clear" while ignoring
16
+ # the deps that are often the riskiest. Genuine non-package noise (GitHub
17
+ # Actions `pkg:github`, opaque binaries `pkg:generic`, and file/application
18
+ # components) is excluded, not surfaced.
19
+ #
20
+ # `read` keeps the simple "just the dependencies" shape. Never raises -- a
21
+ # missing/malformed SBOM or a bad PURL degrades to empty/skip.
22
+ module SbomReader
23
+ extend self
24
+
25
+ Result = Data.define(:dependencies, :unassessable)
26
+
27
+ # PURL type -> still_active ecosystem (also the deps.dev `system`, lowercased).
28
+ ECOSYSTEMS = {
29
+ "gem" => :rubygems,
30
+ "npm" => :npm,
31
+ "pypi" => :pypi,
32
+ "cargo" => :cargo,
33
+ "maven" => :maven,
34
+ "golang" => :go,
35
+ "nuget" => :nuget,
36
+ }.freeze
37
+
38
+ # PURL types that are not package dependencies: CI actions and opaque
39
+ # binaries. Excluded from both lists (not "unassessed deps").
40
+ NOISE_TYPES = ["github", "generic"].freeze
41
+
42
+ def read(path) = parse(path).dependencies
43
+ def read_string(body) = parse_string(body).dependencies
44
+
45
+ def parse(path)
46
+ parse_string(File.read(path))
47
+ rescue SystemCallError, IOError
48
+ Result.new(dependencies: [], unassessable: [])
49
+ end
50
+
51
+ def parse_string(body)
52
+ doc = JSON.parse(body)
53
+ components = doc.is_a?(Hash) ? doc["components"] : nil
54
+ return Result.new(dependencies: [], unassessable: []) unless components.is_a?(Array)
55
+
56
+ # Only trust the prod/dev split when the generator actually marks it
57
+ # somewhere (scope, or a dev property). syft-style SBOMs mark nothing, so an
58
+ # unmarked component is genuinely unknown -- never assume it's production.
59
+ # Scoped to library components: scope/properties are legal on any component
60
+ # type (application/file/...), and a dev signal on one of those is unrelated
61
+ # to whether the dependency library components are marked.
62
+ marks_dev = components.any? { |c| c.is_a?(Hash) && c["type"] == "library" && dev_signal?(c) }
63
+
64
+ deps = []
65
+ unassessable = []
66
+ components.each do |component|
67
+ kind, entry = classify(component)
68
+ entry[:production] = !dev_signal?(component) if kind == :dependency && marks_dev
69
+ deps << entry if kind == :dependency
70
+ unassessable << entry if kind == :unassessable
71
+ end
72
+ # uniq collapses a package that a generator lists more than once (e.g. Syft's
73
+ # per-location entries); `production` is derived from each component's own
74
+ # signal, so duplicates of the same name+version dedup cleanly unless a
75
+ # generator marks the copies inconsistently (malformed; not observed).
76
+ Result.new(dependencies: deps.uniq, unassessable: unassessable.uniq)
77
+ rescue JSON::ParserError
78
+ Result.new(dependencies: [], unassessable: [])
79
+ end
80
+
81
+ private
82
+
83
+ # Whether a component is marked dev/test-only. CycloneDX `scope` (excluded =
84
+ # not part of the product, optional = not needed at runtime) is the standard;
85
+ # tools that don't set scope may instead emit a property (cyclonedx-npm's
86
+ # `cdx:npm:package:development=true`). Absent both, this is false -- the caller
87
+ # only trusts a false when the document marks dev somewhere.
88
+ def dev_signal?(component)
89
+ scope = component["scope"]
90
+ return true if scope == "excluded" || scope == "optional"
91
+
92
+ Array(component["properties"]).any? do |p|
93
+ p.is_a?(Hash) && p["value"] == "true" && p["name"].to_s.match?(/(\A|:)dev(elopment)?\z/)
94
+ end
95
+ end
96
+
97
+ # [:dependency, {...}] | [:unassessable, {...}] | nil (non-package noise).
98
+ def classify(component)
99
+ return unless component.is_a?(Hash) && component["type"] == "library"
100
+
101
+ name = component["name"]
102
+ purl = component["purl"]
103
+ return [:unassessable, { ecosystem: nil, name: name, reason: :no_purl }] unless purl.is_a?(String) && !purl.empty?
104
+
105
+ classify_purl(purl, name)
106
+ end
107
+
108
+ # Public registry hosts per ecosystem. A purl `repository_url` qualifier means
109
+ # a non-default (private/alternative) registry per the purl spec, EXCEPT when a
110
+ # generator redundantly names the public one -- so we still assess those.
111
+ PUBLIC_REGISTRY_HOSTS = [
112
+ "rubygems.org",
113
+ "registry.npmjs.org",
114
+ "npmjs.org",
115
+ "registry.yarnpkg.com",
116
+ "pypi.org",
117
+ "files.pythonhosted.org",
118
+ "crates.io",
119
+ "static.crates.io",
120
+ "index.crates.io",
121
+ "repo1.maven.org",
122
+ "repo.maven.apache.org",
123
+ "api.nuget.org",
124
+ "proxy.golang.org",
125
+ ].freeze
126
+
127
+ def classify_purl(purl, name)
128
+ parsed = PackageURL.parse(purl)
129
+ type = parsed.type&.downcase
130
+ ecosystem = ECOSYSTEMS[type]
131
+
132
+ if ecosystem
133
+ return [:unassessable, { ecosystem: type, name: name, reason: :no_version }] if parsed.version.to_s.empty?
134
+
135
+ full_name = build_name(ecosystem, parsed.namespace, parsed.name)
136
+ repository_url = parsed.qualifiers&.dig("repository_url")
137
+ # A private/alternative registry: never look the name up on the public
138
+ # registry (a same-named public package's data is not this one's -- the #43
139
+ # dependency-confusion guard, cross-ecosystem). We never dial the URL; its
140
+ # presence alone is the signal. Limit: this can only fire when the SBOM
141
+ # carries repository_url; a generator that omits it (Syft often can't
142
+ # determine the source registry) leaves a private package indistinguishable
143
+ # from a public one, so it is still assessed by name. Best-effort, not a
144
+ # guarantee that every private package is caught.
145
+ if repository_url && !public_registry?(repository_url)
146
+ return [:unassessable, { ecosystem: ecosystem, name: full_name, version: parsed.version, reason: :private_registry, repository_url: repository_url }]
147
+ end
148
+
149
+ [:dependency, { ecosystem: ecosystem, name: full_name, version: parsed.version }]
150
+ elsif NOISE_TYPES.include?(type)
151
+ nil # CI actions / opaque binaries: not a package dependency
152
+ else
153
+ [:unassessable, { ecosystem: type, name: name, reason: :unsupported_ecosystem }]
154
+ end
155
+ rescue ArgumentError
156
+ # InvalidPackageURL is a subclass of ArgumentError, but PackageURL.parse also
157
+ # raises a BARE ArgumentError on an empty name-after-namespace (`pkg:npm/@1.0.0`)
158
+ # or bad percent-encoding, both common in real Syft/Trivy output. Catch both so
159
+ # one malformed component degrades to unassessable, never a backtrace that drops
160
+ # every other dependency's verdict (SbomReader's documented "never raises").
161
+ [:unassessable, { ecosystem: nil, name: name, reason: :malformed_purl }]
162
+ end
163
+
164
+ # Whether a repository_url points at a known public registry. We only read the
165
+ # host; we never connect to it (a lockfile/SBOM-derived URL is untrusted). An
166
+ # unparseable or non-public host reads as private, failing safe.
167
+ def public_registry?(repository_url)
168
+ host = registry_host(repository_url)
169
+ !host.nil? && PUBLIC_REGISTRY_HOSTS.include?(host)
170
+ end
171
+
172
+ def registry_host(url)
173
+ URI.parse(url.include?("//") ? url : "//#{url}").host&.downcase
174
+ rescue URI::InvalidURIError
175
+ nil
176
+ end
177
+
178
+ # Reconstruct the lookup name from the PURL's namespace + name: npm
179
+ # `@scope/name`, maven `group:artifact`, Go module path `github.com/owner/name`;
180
+ # pypi/cargo/gem/nuget have no namespace and use the bare name.
181
+ def build_name(ecosystem, namespace, name)
182
+ return name if namespace.to_s.empty?
183
+
184
+ case ecosystem
185
+ when :npm, :go then "#{namespace}/#{name}"
186
+ when :maven then "#{namespace}:#{name}"
187
+ else name
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ecosystem_lens"
4
+ require_relative "ceiling_reconciler"
5
+ require_relative "poison_security_correlator"
6
+ require_relative "../helpers/python_helper"
7
+ require "async"
8
+ require "async/barrier"
9
+ require "async/semaphore"
10
+
11
+ module StillActive
12
+ # The SBOM audit's fan-out, parallel to Workflow but for a cross-ecosystem
13
+ # SBOM rather than a Bundler lockfile: it runs EcosystemLens over each
14
+ # dependency concurrently and returns a result hash the same output path can
15
+ # render. Keyed by "ecosystem/name@version" (not bare name) so nothing collides
16
+ # and overwrites: not an npm `foo` against a pypi `foo`, and -- the case a merged
17
+ # monorepo SBOM actually produces -- not two versions of the same package pinned
18
+ # by different subprojects (a lockfile resolves one version per name, an SBOM
19
+ # doesn't). Each is a distinct assessable unit with its own version-specific
20
+ # advisory set, so collapsing them would silently drop one dep's verdict.
21
+ module SbomWorkflow
22
+ extend self
23
+
24
+ # `assessed` is the "ecosystem/name@version" => gem_data hash; `failures` is
25
+ # the deps whose lens call raised (a rate-limited/flaky deps.dev, or a bug
26
+ # hitting every dep). The two are returned separately so run_sbom can surface
27
+ # the failures instead of dropping them: a raised dep that just vanished would
28
+ # be absent from the JSON, uncounted, and past the exit gate, letting a
29
+ # rate-limited run read "all clear" while silently skipping deps.
30
+ Outcome = Data.define(:assessed, :failures)
31
+
32
+ def call(sbom_result, &on_progress)
33
+ dependencies = sbom_result.dependencies
34
+ Async do
35
+ # The Python runtime support window, fetched once for the whole SBOM (the
36
+ # language-ceiling input for pypi deps). Guarded like the native Ruby path:
37
+ # a feed failure degrades to "no ceiling checks", never aborts the audit.
38
+ python_range =
39
+ begin
40
+ PythonHelper.supported_python_range
41
+ rescue StandardError => e
42
+ $stderr.puts("warning: Python support window lookup failed: #{e.class} (#{e.message}); skipping language-ceiling checks")
43
+ nil
44
+ end
45
+ barrier = Async::Barrier.new
46
+ semaphore = Async::Semaphore.new(StillActive.config.parallelism, parent: barrier)
47
+ result = {}
48
+ failures = []
49
+ # Memoizes each capped dep's RESOLVED latest version across the whole SBOM
50
+ # (keyed by ecosystem+name inside the lens), so a dep pinned by several
51
+ # dormant packages is fetched once. Unresolved lookups aren't cached (a
52
+ # transient nil must not suppress a later package's pill), so concurrent
53
+ # first-misses on the same dep may briefly double-fetch; harmless.
54
+ constraint_cache = {}
55
+ total = dependencies.size
56
+ completed = 0
57
+ dependencies.each do |dep|
58
+ semaphore.async do
59
+ # Carry the SBOM's prod-vs-dev/test verdict onto the assessment so the
60
+ # output can separate prod risk from test debt. `slice` copies it only
61
+ # when the SBOM actually marked it (SbomReader leaves the key absent
62
+ # otherwise): absent stays "unknown", never a false that reads as test-only.
63
+ result["#{dep[:ecosystem]}/#{dep[:name]}@#{dep[:version]}"] =
64
+ EcosystemLens.assess(ecosystem: dep[:ecosystem], name: dep[:name], version: dep[:version], constraint_cache: constraint_cache, python_range: python_range)
65
+ .merge(dep.slice(:production))
66
+ rescue StandardError => e
67
+ # One dependency's failure must not abort the audit, but it must not
68
+ # disappear either: record it as an unassessable entry (same shape as
69
+ # the reader's, with a distinct reason) so it's counted and reported.
70
+ # production rides along (when known, via slice) so a failed prod dep
71
+ # stays distinguishable from a failed dev one.
72
+ failures << { ecosystem: dep[:ecosystem], name: dep[:name], version: dep[:version], reason: :assessment_error, error: "#{e.class}: #{e.message}" }
73
+ .merge(dep.slice(:production))
74
+ $stderr.puts("error assessing #{dep[:ecosystem]}/#{dep[:name]}@#{dep[:version]}: #{e.class}\n\t#{e.message}")
75
+ ensure
76
+ completed += 1
77
+ on_progress&.call(completed, total)
78
+ end
79
+ end
80
+ barrier.wait
81
+ # Whole-tree correlation once every package's signals are in: a Python
82
+ # ceiling's "upgrade to lift it" must not contradict a poison finding that
83
+ # caps the same package below that upgrade (same guarantee as the native path).
84
+ CeilingReconciler.reconcile_ceiling_with_poison(result)
85
+ # Flag poison caps that pin a vulnerable dependency (the moat's strongest
86
+ # case: an archived package holding you on a known-vulnerable dep).
87
+ PoisonSecurityCorrelator.correlate(result)
88
+ # Stable, diffable order regardless of async completion order.
89
+ Outcome.new(
90
+ assessed: result.sort_by { |key, _| key }.to_h,
91
+ failures: failures.sort_by { |failure| "#{failure[:ecosystem]}/#{failure[:name]}@#{failure[:version]}" },
92
+ )
93
+ end.wait
94
+ end
95
+ end
96
+ end