still_active 1.6.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -0
  3. data/README.md +131 -17
  4. data/lib/helpers/activity_helper.rb +41 -8
  5. data/lib/helpers/bundler_helper.rb +87 -26
  6. data/lib/helpers/catalog_index.rb +4 -1
  7. data/lib/helpers/constraint_helper.rb +208 -0
  8. data/lib/helpers/cvss_helper.rb +63 -0
  9. data/lib/helpers/cyclonedx_helper.rb +44 -12
  10. data/lib/helpers/diff_markdown_helper.rb +36 -17
  11. data/lib/helpers/endoflife_helper.rb +114 -0
  12. data/lib/helpers/http_helper.rb +117 -15
  13. data/lib/helpers/lockfile_dependency_parser.rb +99 -0
  14. data/lib/helpers/lockfile_indexer.rb +3 -0
  15. data/lib/helpers/markdown_escape.rb +58 -0
  16. data/lib/helpers/markdown_helper.rb +145 -6
  17. data/lib/helpers/pep440_helper.rb +100 -0
  18. data/lib/helpers/python_helper.rb +19 -0
  19. data/lib/helpers/ruby_advisory_db.rb +23 -0
  20. data/lib/helpers/ruby_helper.rb +18 -28
  21. data/lib/helpers/runtime_ceiling_helper.rb +121 -0
  22. data/lib/helpers/sarif_helper.rb +157 -28
  23. data/lib/helpers/semver_satisfaction.rb +68 -0
  24. data/lib/helpers/status_helper.rb +68 -0
  25. data/lib/helpers/summary_helper.rb +52 -0
  26. data/lib/helpers/terminal_helper.rb +167 -19
  27. data/lib/helpers/version_helper.rb +16 -1
  28. data/lib/helpers/vulnerability_helper.rb +73 -7
  29. data/lib/still_active/artifactory_client.rb +166 -0
  30. data/lib/still_active/ceiling_reconciler.rb +43 -0
  31. data/lib/still_active/cli.rb +292 -20
  32. data/lib/still_active/config.rb +59 -2
  33. data/lib/still_active/config_file.rb +207 -0
  34. data/lib/still_active/deps_dev_client.rb +140 -9
  35. data/lib/still_active/diff.rb +81 -2
  36. data/lib/still_active/ecosystem_lens.rb +284 -0
  37. data/lib/still_active/ecosystems_client.rb +123 -0
  38. data/lib/still_active/forgejo_client.rb +50 -0
  39. data/lib/still_active/github_client.rb +126 -0
  40. data/lib/still_active/gitlab_client.rb +15 -20
  41. data/lib/still_active/options.rb +58 -6
  42. data/lib/still_active/osv_client.rb +147 -0
  43. data/lib/still_active/poison_security_correlator.rb +232 -0
  44. data/lib/still_active/pypi_client.rb +56 -0
  45. data/lib/still_active/repository.rb +12 -4
  46. data/lib/still_active/sarif/rules.rb +35 -11
  47. data/lib/still_active/sbom_reader.rb +191 -0
  48. data/lib/still_active/sbom_workflow.rb +96 -0
  49. data/lib/still_active/suppressions.rb +143 -0
  50. data/lib/still_active/version.rb +1 -1
  51. data/lib/still_active/workflow.rb +312 -61
  52. data/lib/still_active.rb +3 -0
  53. data/still_active.gemspec +34 -9
  54. metadata +72 -11
@@ -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
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module StillActive
6
+ # Granular, auditable suppression of individual findings, loaded from the
7
+ # `ignore:` block of a committed .still_active.yml. Each entry silences one
8
+ # signal (activity / vulnerability / libyear / poison) or one advisory for one
9
+ # gem, optionally until an expiry date, replacing the all-or-nothing whole-gem
10
+ # --ignore. A bare gem
11
+ # name (or a gem-only mapping) keeps the old whole-gem behaviour so --ignore
12
+ # can union into the same list.
13
+ #
14
+ # Two guardrails keep suppression from hiding live risk: a lapsed entry stops
15
+ # applying so the finding re-surfaces, and a vulnerability suppression must
16
+ # name an explicit advisory id, so a newly disclosed CVE on the same gem is
17
+ # never pre-silenced.
18
+ class Suppressions
19
+ GATEABLE_SIGNALS = [:activity, :vulnerability, :libyear, :poison, :language_ceiling].freeze
20
+
21
+ Entry = Struct.new(:gem, :advisory, :signal, :reason, :expires, keyword_init: true) do
22
+ def whole_gem?
23
+ signal.nil? && advisory.nil?
24
+ end
25
+
26
+ def expired?(today)
27
+ !expires.nil? && expires < today
28
+ end
29
+
30
+ def covers?(signal:, advisory:, aliases:)
31
+ return true if whole_gem?
32
+
33
+ if self.signal == :vulnerability
34
+ [advisory, *aliases].compact.include?(self.advisory)
35
+ else
36
+ self.signal == signal
37
+ end
38
+ end
39
+ end
40
+
41
+ class << self
42
+ def from(raw, today: Date.today)
43
+ warnings = []
44
+ entries = Array(raw).filter_map { |item| parse_entry(item, warnings) }
45
+ new(entries, warnings, today)
46
+ end
47
+
48
+ private
49
+
50
+ def parse_entry(item, warnings)
51
+ return Entry.new(gem: item, advisory: nil, signal: nil, reason: nil, expires: nil) if item.is_a?(String)
52
+
53
+ unless item.is_a?(Hash)
54
+ warnings << "ignoring suppression entry #{item.inspect}: expected a gem name or a mapping"
55
+ return
56
+ end
57
+
58
+ gem = item["gem"]
59
+ advisory = item["advisory"]
60
+ signal = item["signal"]&.to_sym
61
+ signal ||= :vulnerability if advisory
62
+ expires = parse_expires(item["expires"], item, warnings)
63
+ return if expires == :invalid
64
+
65
+ label = gem || advisory || "entry"
66
+ return unless valid?(gem:, advisory:, signal:, label:, warnings:)
67
+
68
+ Entry.new(gem:, advisory:, signal:, reason: item["reason"], expires:)
69
+ end
70
+
71
+ # Returns true when the entry is well-formed, otherwise records a warning
72
+ # and returns false so the caller skips (does not apply) the entry.
73
+ def valid?(gem:, advisory:, signal:, label:, warnings:)
74
+ if gem.nil? && signal.nil? && advisory.nil?
75
+ warnings << "ignoring suppression entry: needs a gem, signal, or advisory"
76
+ return false
77
+ end
78
+ if signal && !GATEABLE_SIGNALS.include?(signal)
79
+ warnings << "ignoring suppression for #{label}: unknown signal #{signal.inspect} (expected activity, vulnerability, libyear, or poison)"
80
+ return false
81
+ end
82
+ if signal == :vulnerability && advisory.nil?
83
+ warnings << "ignoring vulnerability suppression for #{label}: must name an advisory id so newly disclosed advisories still surface"
84
+ return false
85
+ end
86
+ if [:activity, :libyear, :poison].include?(signal) && gem.nil?
87
+ warnings << "ignoring #{signal} suppression: must name a gem"
88
+ return false
89
+ end
90
+
91
+ true
92
+ end
93
+
94
+ def parse_expires(value, item, warnings)
95
+ return if value.nil?
96
+ return value if value.is_a?(Date)
97
+
98
+ Date.parse(value.to_s)
99
+ rescue ArgumentError, TypeError
100
+ warnings << "ignoring suppression #{item.inspect}: unparseable expires #{value.inspect}"
101
+ :invalid
102
+ end
103
+ end
104
+
105
+ attr_reader :warnings
106
+
107
+ def initialize(entries, warnings, today)
108
+ @entries = entries
109
+ @warnings = warnings
110
+ @today = today
111
+ end
112
+
113
+ def suppressed?(gem:, signal:, advisory: nil, aliases: [])
114
+ !match(gem:, signal:, advisory:, aliases:).nil?
115
+ end
116
+
117
+ # Warnings for live entries that name a gem absent from the audited set: they
118
+ # can never match, so they are dead config (a typo, or a gem removed since
119
+ # the suppression was written). This is the presence axis of suppression rot;
120
+ # `expired?` already covers the time axis, so an expired entry isn't
121
+ # re-reported here, and a gem-agnostic advisory entry (gem nil) is skipped
122
+ # since it applies across the whole graph.
123
+ def stale_gem_warnings(present_gems)
124
+ @entries.filter_map do |entry|
125
+ next if entry.expired?(@today)
126
+ next unless entry.gem && !present_gems.include?(entry.gem)
127
+
128
+ "suppression for #{entry.gem} never applies: it is not in the audited dependencies (typo, or removed since it was suppressed?)"
129
+ end
130
+ end
131
+
132
+ # The first live entry covering this finding, or nil. Used by SARIF to carry
133
+ # the suppression's reason as the native suppressions[] justification.
134
+ def match(gem:, signal:, advisory: nil, aliases: [])
135
+ @entries.find do |entry|
136
+ next false if entry.expired?(@today)
137
+ next false unless entry.gem.nil? || entry.gem == gem
138
+
139
+ entry.covers?(signal:, advisory:, aliases:)
140
+ end
141
+ end
142
+ end
143
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StillActive
4
- VERSION = "1.6.0"
4
+ VERSION = "3.0.0.rc1"
5
5
  end