Module: StillActive::SbomWorkflow

Extended by:
SbomWorkflow
Included in:
SbomWorkflow
Defined in:
lib/still_active/sbom_workflow.rb

Overview

The SBOM audit's fan-out, parallel to Workflow but for a cross-ecosystem SBOM rather than a Bundler lockfile: it runs EcosystemLens over each dependency concurrently and returns a result hash the same output path can render. Keyed by "ecosystem/name@version" (not bare name) so nothing collides and overwrites: not an npm foo against a pypi foo, and -- the case a merged monorepo SBOM actually produces -- not two versions of the same package pinned by different subprojects (a lockfile resolves one version per name, an SBOM doesn't). Each is a distinct assessable unit with its own version-specific advisory set, so collapsing them would silently drop one dep's verdict.

Defined Under Namespace

Classes: Outcome

Instance Method Summary collapse

Instance Method Details

#call(sbom_result, &on_progress) ⇒ Object



32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# File 'lib/still_active/sbom_workflow.rb', line 32

def call(sbom_result, &on_progress)
  dependencies = sbom_result.dependencies
  Async do
    # The Python runtime support window, fetched once for the whole SBOM (the
    # language-ceiling input for pypi deps). Guarded like the native Ruby path:
    # a feed failure degrades to "no ceiling checks", never aborts the audit.
    python_range =
      begin
        PythonHelper.supported_python_range
      rescue StandardError => e
        $stderr.puts("warning: Python support window lookup failed: #{e.class} (#{e.message}); skipping language-ceiling checks")
        nil
      end
    barrier = Async::Barrier.new
    semaphore = Async::Semaphore.new(StillActive.config.parallelism, parent: barrier)
    result = {}
    failures = []
    # Memoizes each capped dep's RESOLVED latest version across the whole SBOM
    # (keyed by ecosystem+name inside the lens), so a dep pinned by several
    # dormant packages is fetched once. Unresolved lookups aren't cached (a
    # transient nil must not suppress a later package's pill), so concurrent
    # first-misses on the same dep may briefly double-fetch; harmless.
    constraint_cache = {}
    total = dependencies.size
    completed = 0
    dependencies.each do |dep|
      semaphore.async do
        # Carry the SBOM's prod-vs-dev/test verdict onto the assessment so the
        # output can separate prod risk from test debt. `slice` copies it only
        # when the SBOM actually marked it (SbomReader leaves the key absent
        # otherwise): absent stays "unknown", never a false that reads as test-only.
        result["#{dep[:ecosystem]}/#{dep[:name]}@#{dep[:version]}"] =
          EcosystemLens.assess(ecosystem: dep[:ecosystem], name: dep[:name], version: dep[:version], constraint_cache: constraint_cache, python_range: python_range)
            .merge(dep.slice(:production))
      rescue StandardError => e
        # One dependency's failure must not abort the audit, but it must not
        # disappear either: record it as an unassessable entry (same shape as
        # the reader's, with a distinct reason) so it's counted and reported.
        # production rides along (when known, via slice) so a failed prod dep
        # stays distinguishable from a failed dev one.
        failures << { ecosystem: dep[:ecosystem], name: dep[:name], version: dep[:version], reason: :assessment_error, error: "#{e.class}: #{e.message}" }
          .merge(dep.slice(:production))
        $stderr.puts("error assessing #{dep[:ecosystem]}/#{dep[:name]}@#{dep[:version]}: #{e.class}\n\t#{e.message}")
      ensure
        completed += 1
        on_progress&.call(completed, total)
      end
    end
    barrier.wait
    # Whole-tree correlation once every package's signals are in: a Python
    # ceiling's "upgrade to lift it" must not contradict a poison finding that
    # caps the same package below that upgrade (same guarantee as the native path).
    CeilingReconciler.reconcile_ceiling_with_poison(result)
    # Flag poison caps that pin a vulnerable dependency (the moat's strongest
    # case: an archived package holding you on a known-vulnerable dep).
    PoisonSecurityCorrelator.correlate(result)
    # Stable, diffable order regardless of async completion order.
    Outcome.new(
      assessed: result.sort_by { |key, _| key }.to_h,
      failures: failures.sort_by { |failure| "#{failure[:ecosystem]}/#{failure[:name]}@#{failure[:version]}" },
    )
  end.wait
end