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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +88 -0
- data/README.md +131 -17
- data/lib/helpers/activity_helper.rb +41 -8
- data/lib/helpers/bundler_helper.rb +87 -26
- data/lib/helpers/catalog_index.rb +4 -1
- data/lib/helpers/constraint_helper.rb +208 -0
- data/lib/helpers/cvss_helper.rb +63 -0
- data/lib/helpers/cyclonedx_helper.rb +44 -12
- data/lib/helpers/diff_markdown_helper.rb +36 -17
- data/lib/helpers/endoflife_helper.rb +114 -0
- data/lib/helpers/http_helper.rb +117 -15
- data/lib/helpers/lockfile_dependency_parser.rb +99 -0
- data/lib/helpers/lockfile_indexer.rb +3 -0
- data/lib/helpers/markdown_escape.rb +58 -0
- data/lib/helpers/markdown_helper.rb +145 -6
- data/lib/helpers/pep440_helper.rb +100 -0
- data/lib/helpers/python_helper.rb +19 -0
- data/lib/helpers/ruby_advisory_db.rb +23 -0
- data/lib/helpers/ruby_helper.rb +18 -28
- data/lib/helpers/runtime_ceiling_helper.rb +121 -0
- data/lib/helpers/sarif_helper.rb +157 -28
- data/lib/helpers/semver_satisfaction.rb +68 -0
- data/lib/helpers/status_helper.rb +68 -0
- data/lib/helpers/summary_helper.rb +52 -0
- data/lib/helpers/terminal_helper.rb +167 -19
- data/lib/helpers/version_helper.rb +16 -1
- data/lib/helpers/vulnerability_helper.rb +73 -7
- data/lib/still_active/artifactory_client.rb +166 -0
- data/lib/still_active/ceiling_reconciler.rb +43 -0
- data/lib/still_active/cli.rb +292 -20
- data/lib/still_active/config.rb +59 -2
- data/lib/still_active/config_file.rb +207 -0
- data/lib/still_active/deps_dev_client.rb +140 -9
- data/lib/still_active/diff.rb +81 -2
- data/lib/still_active/ecosystem_lens.rb +284 -0
- data/lib/still_active/ecosystems_client.rb +123 -0
- data/lib/still_active/forgejo_client.rb +50 -0
- data/lib/still_active/github_client.rb +126 -0
- data/lib/still_active/gitlab_client.rb +15 -20
- data/lib/still_active/options.rb +58 -6
- data/lib/still_active/osv_client.rb +147 -0
- data/lib/still_active/poison_security_correlator.rb +232 -0
- data/lib/still_active/pypi_client.rb +56 -0
- data/lib/still_active/repository.rb +12 -4
- data/lib/still_active/sarif/rules.rb +35 -11
- data/lib/still_active/sbom_reader.rb +191 -0
- data/lib/still_active/sbom_workflow.rb +96 -0
- data/lib/still_active/suppressions.rb +143 -0
- data/lib/still_active/version.rb +1 -1
- data/lib/still_active/workflow.rb +312 -61
- data/lib/still_active.rb +3 -0
- data/still_active.gemspec +34 -9
- 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
|
data/lib/still_active/version.rb
CHANGED