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
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "optparse"
4
+ require_relative "../helpers/constraint_helper"
4
5
  require_relative "../helpers/cyclonedx_helper"
5
6
  require_relative "../helpers/vulnerability_helper"
6
7
 
@@ -15,6 +16,7 @@ module StillActive
15
16
  add_tail_options(opts)
16
17
  add_gemfile_option(opts)
17
18
  add_gems_option(opts)
19
+ add_sbom_option(opts)
18
20
  add_ignore_option(opts)
19
21
  add_output_options(opts)
20
22
  add_token_options(opts)
@@ -34,7 +36,11 @@ module StillActive
34
36
  private
35
37
 
36
38
  def validate_options
37
- raise ArgumentError, "provide gemfile or gems, not both" if options[:provided_gemfile] && options[:provided_gems]
39
+ inputs = [options[:provided_gemfile], options[:provided_gems], options[:provided_sbom]].compact
40
+ raise ArgumentError, "provide only one of --gemfile, --gems, --sbom" if inputs.size > 1
41
+ if options[:provided_sbom] && !File.exist?(StillActive.config.sbom_path)
42
+ raise ArgumentError, "SBOM file not found: #{StillActive.config.sbom_path}"
43
+ end
38
44
  if options[:provided_baseline] && !File.exist?(StillActive.config.baseline_path)
39
45
  raise ArgumentError, "baseline file not found: #{StillActive.config.baseline_path}"
40
46
  end
@@ -50,7 +56,15 @@ module StillActive
50
56
  def add_gems_option(opts)
51
57
  opts.on("--gems=GEM,GEM2,...", Array, "Gem(s)") do |value|
52
58
  options[:provided_gems] = true
53
- StillActive.config { |config| config.gems = value.map { |g| { name: g } } }
59
+ # Explicitly named gems are direct by definition (the user chose them).
60
+ StillActive.config { |config| config.gems = value.map { |g| { name: g, direct: true } } }
61
+ end
62
+ end
63
+
64
+ def add_sbom_option(opts)
65
+ opts.on("--sbom=PATH", String, "audit a CycloneDX SBOM cross-ecosystem (npm/pypi/cargo/go/maven/nuget) instead of a Gemfile; JSON output") do |value|
66
+ options[:provided_sbom] = true
67
+ StillActive.config { |config| config.sbom_path = value }
54
68
  end
55
69
  end
56
70
 
@@ -65,6 +79,8 @@ module StillActive
65
79
  opts.on("--markdown", "Markdown table output") { StillActive.config { |config| config.output_format = :markdown } }
66
80
  opts.on("--json", "JSON output (default when piped)") { StillActive.config { |config| config.output_format = :json } }
67
81
  opts.on("--alternatives", "Suggest maintained alternatives (Ruby Toolbox leads) for archived/critical gems") { StillActive.config { |config| config.alternatives = true } }
82
+ opts.on("--unreleased-commits", "Count commits on the default branch since the latest release (GitHub only; opt-in, one extra API call per gem)") { StillActive.config { |config| config.unreleased_commits = true } }
83
+ opts.on("--direct-only", "Audit only direct (declared) dependencies, not the full transitive lockfile graph") { StillActive.config { |config| config.direct_only = true } }
68
84
  opts.on("--sarif[=PATH]", "SARIF 2.1.0 output for GitHub Code Scanning (default path: still_active.sarif.json; '-' for stdout). Overrides --terminal/--markdown/--json.") do |value|
69
85
  StillActive.config { |config| config.sarif_path = value || "still_active.sarif.json" }
70
86
  end
@@ -91,6 +107,17 @@ module StillActive
91
107
  opts.on("--gitlab-token=TOKEN", String, "GitLab personal access token for API calls") do |value|
92
108
  StillActive.config { |config| config.gitlab_token = value }
93
109
  end
110
+ opts.on("--ecosystems-email=EMAIL", String, "Contact email for the ecosyste.ms polite pool (used only when falling back to ecosyste.ms without a GitHub token)") do |value|
111
+ email = value.strip
112
+ warn("warning: --ecosystems-email=#{value.inspect} doesn't look like an email (no @); ecosyste.ms will keep you in the anonymous pool") unless email.include?("@")
113
+ StillActive.config { |config| config.ecosystems_email = email }
114
+ end
115
+ opts.on("--artifactory-token=TOKEN", String, "Artifactory token for private gem registry API calls") do |value|
116
+ StillActive.config { |config| config.artifactory_token = value }
117
+ end
118
+ opts.on("--artifactory-host=HOST", String, "Artifactory host that may receive the global token (e.g. my-org.jfrog.io)") do |value|
119
+ StillActive.config { |config| config.artifactory_host = value }
120
+ end
94
121
  end
95
122
 
96
123
  def add_parallelism_options(opts)
@@ -102,15 +129,15 @@ module StillActive
102
129
  def add_range_options(opts)
103
130
  opts.on(
104
131
  "--safe-range-end=YEARS",
105
- Integer,
106
- "maximum years since last activity considered safe (no warning)",
132
+ Float,
133
+ "maximum years since last release considered safe, no warning (default 1.5; fractional allowed)",
107
134
  ) do |value|
108
135
  StillActive.config { |config| config.no_warning_range_end = value }
109
136
  end
110
137
  opts.on(
111
138
  "--warning-range-end=YEARS",
112
- Integer,
113
- "maximum years since last activity that triggers a warning (beyond this is critical)",
139
+ Float,
140
+ "maximum years since last release that triggers a warning, beyond this is critical (default 3)",
114
141
  ) do |value|
115
142
  StillActive.config { |config| config.warning_range_end = value }
116
143
  end
@@ -136,6 +163,31 @@ module StillActive
136
163
  opts.on("--fail-if-outdated=LIBYEARS", Float, "Exit 1 if any gem exceeds LIBYEARS behind latest") do |value|
137
164
  StillActive.config { |config| config.fail_if_outdated = value }
138
165
  end
166
+ opts.on("--fail-if-poison[=TIER]", "Exit 1 on a poison-pill at or above TIER (note|warning|critical; default warning)") do |value|
167
+ if value
168
+ valid = StillActive::ConstraintHelper::SEVERITY.map(&:to_s)
169
+ raise ArgumentError, "--fail-if-poison tier must be one of: #{valid.join(", ")} (got #{value})" unless valid.include?(value)
170
+
171
+ StillActive.config { |config| config.fail_if_poison = value.to_sym }
172
+ else
173
+ StillActive.config { |config| config.fail_if_poison = true }
174
+ end
175
+ end
176
+ opts.on("--fail-if-language-ceiling[=TIER]", "Exit 1 on a language-runtime ceiling (Ruby/Python; default: EOL-forced only; =note also gates latest-not-yet)") do |value|
177
+ if value
178
+ valid = StillActive::ConstraintHelper::SEVERITY.map(&:to_s)
179
+ raise ArgumentError, "--fail-if-language-ceiling tier must be one of: #{valid.join(", ")} (got #{value})" unless valid.include?(value)
180
+
181
+ # Ceiling findings are only ever :critical or :note, so =warning can't
182
+ # match anything the bare (=critical) default doesn't already catch.
183
+ if value == "warning"
184
+ $stderr.puts("warning: --fail-if-language-ceiling=warning has no effect (runtime ceilings are only critical or note); behaves as =critical")
185
+ end
186
+ StillActive.config { |config| config.fail_if_language_ceiling = value.to_sym }
187
+ else
188
+ StillActive.config { |config| config.fail_if_language_ceiling = true }
189
+ end
190
+ end
139
191
  end
140
192
 
141
193
  def add_emoji_options(opts)
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../helpers/http_helper"
4
+ require_relative "../helpers/cvss_helper"
5
+
6
+ module StillActive
7
+ # OSV (api.osv.dev) enrichment for advisories deps.dev has already discovered.
8
+ # deps.dev is the discovery source -- it lists a package version's advisory ids --
9
+ # but it stores only CVSS 3.x (a CVSS-4-only advisory comes back with cvss3Score
10
+ # 0, which reads as unscored) and carries no fixed-version ranges at all. OSV
11
+ # supplies all three gaps: a GHSA severity LABEL (`database_specific.severity`)
12
+ # that reads correctly even for a v4-only advisory, the CVSS v4 VECTOR (turned into
13
+ # a real score, so the finding carries a security-severity number), and the
14
+ # per-package fixed versions the "capped below the fix" signal compares against a
15
+ # poison cap's ceiling.
16
+ #
17
+ # Enrichment is best-effort: any failure (missing record, transport error, odd
18
+ # shape) leaves the advisory exactly as deps.dev produced it. It must never drop
19
+ # an advisory the audit already found, so it only ever ADDS fields.
20
+ module OsvClient
21
+ extend self
22
+
23
+ BASE_URI = URI("https://api.osv.dev/")
24
+
25
+ # Every ecosystem SbomReader/deps.dev resolve, mapped to OSV's package-ecosystem
26
+ # casing (verified against live OSV records). One advisory can name the same
27
+ # package in several ecosystems, so `affected` is filtered to the one being
28
+ # audited; the native Bundler path carries no ecosystem and is always rubygems.
29
+ # Anything unmapped falls back to name-only fix filtering rather than dropping fixes.
30
+ ECOSYSTEM_NAMES = {
31
+ rubygems: "RubyGems",
32
+ pypi: "PyPI",
33
+ npm: "npm",
34
+ cargo: "crates.io",
35
+ maven: "Maven",
36
+ go: "Go",
37
+ nuget: "NuGet",
38
+ }.freeze
39
+
40
+ # Prefer the newest CVSS version a record carries (v4 is the whole point: it's the
41
+ # one deps.dev can't score). `severity[].score` is the vector STRING, oddly named.
42
+ CVSS_PRIORITY = { "CVSS_V4" => 3, "CVSS_V3" => 2, "CVSS_V2" => 1 }.freeze
43
+ # A v2 vector has no `CVSS:X.Y` prefix, so the version can't be read from the
44
+ # string; fall back to the entry type so the CycloneDX rating method labels it v2.
45
+ TYPE_VERSIONS = { "CVSS_V4" => "4.0", "CVSS_V3" => "3.1", "CVSS_V2" => "2.0" }.freeze
46
+
47
+ # Enrich each advisory in place with the OSV severity label and the fixed
48
+ # versions for the audited package. A missing/failed lookup is a no-op on that
49
+ # advisory (it keeps whatever deps.dev gave it), never a raise.
50
+ def enrich(advisories, ecosystem:, name:)
51
+ advisories.each do |advisory|
52
+ record = detail(advisory_id: advisory[:id])
53
+ next if record.nil?
54
+
55
+ advisory[:osv_severity] = record[:severity_label]
56
+ advisory[:osv_cvss_score] = record[:cvss_score]
57
+ advisory[:cvss_version] = record[:cvss_version]
58
+ advisory[:cvss_vector] = record[:cvss_vector]
59
+ advisory[:fixed_versions] = fixed_versions(record, ecosystem: ecosystem, name: name)
60
+ rescue StandardError => e
61
+ # Enrichment is additive and best-effort. An unexpected OSV shape must never
62
+ # raise out through the workflow's per-gem rescue, which would DROP the whole
63
+ # gem and read a known-vulnerable dependency as clean. Leave the advisory
64
+ # exactly as deps.dev produced it.
65
+ $stderr.puts("warning: OSV enrichment for #{advisory[:id]} failed: #{e.class} (#{e.message}); leaving advisory unchanged")
66
+ end
67
+ end
68
+
69
+ # Fetch and parse one OSV record by advisory id (GHSA/CVE). Returns
70
+ # { severity_label:, cvss_score:, cvss_version:, cvss_vector:, affected: [...] }
71
+ # or nil when the id is absent or OSV has no usable record for it. A non-object
72
+ # body (a CDN/error envelope that parses to an array or scalar) yields nil rather
73
+ # than raising on `dig`.
74
+ def detail(advisory_id:)
75
+ return if advisory_id.nil?
76
+
77
+ body = HttpHelper.get_json(BASE_URI, "/v1/vulns/#{encode(advisory_id)}")
78
+ return unless body.is_a?(Hash)
79
+
80
+ cvss = best_cvss(body)
81
+ {
82
+ severity_label: body.dig("database_specific", "severity"),
83
+ cvss_score: cvss[:score],
84
+ cvss_version: cvss[:version],
85
+ cvss_vector: cvss[:vector],
86
+ affected: Array(body["affected"]).filter_map { |entry| parse_affected(entry) },
87
+ }
88
+ end
89
+
90
+ private
91
+
92
+ # The highest-version CVSS vector the record carries, as { score:, version:,
93
+ # vector: } (a computed base score, the "3.1"/"4.0" version, and the raw string),
94
+ # or all-nil. deps.dev has no v4 score, so this is what gives a CVSS-4-only
95
+ # advisory a real number for the security-severity/rating.
96
+ def best_cvss(body)
97
+ entry = Array(body["severity"])
98
+ .select { |s| s.is_a?(Hash) && CVSS_PRIORITY.key?(s["type"]) }
99
+ .max_by { |s| CVSS_PRIORITY[s["type"]] }
100
+ return {} if entry.nil?
101
+
102
+ vector = entry["score"]
103
+ { score: CvssHelper.score(vector), version: cvss_version(vector) || TYPE_VERSIONS[entry["type"]], vector: vector }
104
+ end
105
+
106
+ # The X.Y version from a "CVSS:X.Y/..." vector prefix, or nil.
107
+ def cvss_version(vector)
108
+ vector.to_s[%r{\ACVSS:(\d+\.\d+)/}, 1]
109
+ end
110
+
111
+ # The fixed versions declared for `name` in `ecosystem` across the record's
112
+ # affected packages (a branch-structured advisory carries one fix per maintained
113
+ # line, so several can apply). Empty when the package is enumerated by
114
+ # `versions` with no fix boundary, or isn't present in this ecosystem. When the
115
+ # ecosystem symbol has no OSV mapping (an ecosystem we don't audit for fixes),
116
+ # match by name alone rather than silently dropping every fix.
117
+ def fixed_versions(record, ecosystem:, name:)
118
+ osv_ecosystem = ECOSYSTEM_NAMES.fetch(ecosystem || :rubygems, nil)
119
+ record[:affected]
120
+ .select { |a| a[:name] == name && (osv_ecosystem.nil? || a[:ecosystem] == osv_ecosystem) }
121
+ .flat_map { |a| a[:fixed] }
122
+ .uniq
123
+ end
124
+
125
+ # Shape guards throughout: a malformed entry (null/scalar where an object is
126
+ # expected, or an object where an array is) is skipped, not raised on, so one
127
+ # bad `affected`/`ranges`/`events` element can't discard the good fixed versions
128
+ # beside it in the same record.
129
+ def parse_affected(entry)
130
+ return unless entry.is_a?(Hash)
131
+
132
+ package = entry["package"]
133
+ return unless package.is_a?(Hash) && package["name"]
134
+
135
+ fixed = Array(entry["ranges"]).flat_map do |range|
136
+ next [] unless range.is_a?(Hash)
137
+
138
+ Array(range["events"]).filter_map { |event| event["fixed"] if event.is_a?(Hash) }
139
+ end
140
+ { ecosystem: package["ecosystem"], name: package["name"], fixed: fixed }
141
+ end
142
+
143
+ def encode(value)
144
+ URI.encode_www_form_component(value.to_s)
145
+ end
146
+ end
147
+ end
@@ -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,15 @@
2
2
 
3
3
  module StillActive
4
4
  module Repository
5
- REPO_REGEX = %r{(https?://(?:www\.)?(github|gitlab)\.com/([\w.\-]+)/([\w.\-]+))}i
5
+ # codeberg.org is the one Forgejo/Gitea host wired up today; it speaks the
6
+ # same Gitea API as any self-hosted instance, so its source is :forgejo (the
7
+ # forge software, what ForgejoClient handles), not :codeberg (the host).
8
+ SOURCE_BY_HOST = {
9
+ "github.com" => :github,
10
+ "gitlab.com" => :gitlab,
11
+ "codeberg.org" => :forgejo,
12
+ }.freeze
13
+ REPO_REGEX = %r{(?<url>https?://(?:www\.)?(?<host>github\.com|gitlab\.com|codeberg\.org)/(?<owner>[\w.\-]+)/(?<name>[\w.\-]+))}i
6
14
 
7
15
  extend self
8
16
 
@@ -16,10 +24,10 @@ module StillActive
16
24
  match = url&.match(REPO_REGEX)
17
25
  return { source: :unhandled, owner: nil, name: nil } unless match
18
26
 
19
- url = match[1].delete_suffix(".git")
20
- name = match[4].delete_suffix(".git")
27
+ clean_url = match[:url].delete_suffix(".git")
28
+ name = match[:name].delete_suffix(".git")
21
29
 
22
- { url: url, source: match[2].to_sym, owner: match[3], name: name }
30
+ { url: clean_url, source: SOURCE_BY_HOST.fetch(match[:host].downcase), owner: match[:owner], name: name }
23
31
  end
24
32
  end
25
33
  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.
@@ -48,8 +52,8 @@ module StillActive
48
52
  {
49
53
  id: "SA002",
50
54
  name: "AbandonedGem",
51
- short: "Gem has had no commits for over 2 years",
52
- full: "The gem's source repository shows no commit activity for over 2 years. Not formally archived, but a strong dormancy signal.",
55
+ short: "Gem has had no release for over 3 years",
56
+ full: "The gem's latest release is over 3 years old. Not formally archived, but a strong abandonment signal: a consumer cannot pull fixes that were never released. For a gem with no releases at all (e.g. git-sourced), the last commit date is used instead.",
53
57
  help_text: "Verify the gem still works on supported Ruby versions and consider a maintained alternative.",
54
58
  level: "warning",
55
59
  security_severity: nil,
@@ -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|