Module: StillActive::SbomGraph

Extended by:
SbomGraph
Included in:
SbomGraph
Defined in:
lib/still_active/helpers/sbom_graph.rb

Overview

Direct-vs-transitive reasoning over a CycloneDX dependencies graph.

The native Bundler audit knows which gems you declared and, for the rest, the shortest path back to the declared gem that pulls them in, which is what turns an un-actionable transitive finding into "replace your direct dep A". The SBOM path had no equivalent: every package read the same, whether you chose it or it arrived six levels down. CycloneDX carries the graph to answer this; still_active simply never read it.

This works purely in bom-refs. Turning a ref into an ecosystem/name identity is the reader's job, so this stays a graph problem with no purl knowledge.

Two real generator shapes have to work, both captured from actual output rather than assumed:

Trivy: `metadata.component` is an application, whose children are one
     application per manifest found (Gemfile.lock, package-lock.json), whose
     children are the libraries that manifest declares. bom-refs are UUIDs.
Syft:  `metadata.component` is a `file` that is not a graph node at all. The
     scanned project appears instead as an ordinary library component with
     no incoming edge. bom-refs are purls.

So "direct" can be neither "child of metadata.component" nor "one hop from the root". What holds for both: start at the graph's entry points, descend through any non-library scaffolding, and the FIRST library reached on each path is a declared dependency.

Instance Method Summary collapse

Instance Method Details

#project_refs(dependencies:, library_refs:) ⇒ Object

The library components that are the scanned project(s) rather than dependencies of one. A Syft SBOM lists the project as an ordinary library with a purl, indistinguishable from a dependency by shape alone, so still_active audited the user's own code and reported it as critically stale (it has no registry entry, so it looks abandoned).

The graph tells them apart: the project is what nothing depends on. The rule is deliberately conservative on both halves. It requires an OUTGOING edge, so a dependency whose parent edge the generator simply failed to record stays a dependency rather than vanishing from the audit, and it only ever considers libraries, so Trivy's application-typed root and manifest nodes (never dependencies to begin with) are not reported here. A merged SBOM covering several projects yields all of them.



67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
# File 'lib/still_active/helpers/sbom_graph.rb', line 67

def project_refs(dependencies:, library_refs:)
  edges = edges_from(dependencies)
  return Set.new if edges.empty?

  pointed_at = Set.new
  edges.each_value { |children| pointed_at.merge(children) }

  edges.each_with_object(Set.new) do |(ref, children), roots|
    next unless library_refs.include?(ref)
    next if pointed_at.include?(ref)
    next if children.empty?

    roots << ref
  end
end

#resolve(dependencies:, root_ref:, library_refs:) ⇒ Object

=> { ref => bool, path: [ref, ...] | nil }, or nil when this SBOM cannot answer the question. nil is not "nothing is direct": callers must omit the fields entirely, because emitting direct: false everywhere would be the positive claim "none of these are yours" about a document that never said.



37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# File 'lib/still_active/helpers/sbom_graph.rb', line 37

def resolve(dependencies:, root_ref:, library_refs:)
  edges = edges_from(dependencies)
  return if edges.empty?

  entries = entry_points(edges, root_ref)
  return if entries.empty?

  direct = direct_libraries(edges, entries, library_refs)
  return if direct.empty?

  paths = shortest_paths(edges, direct, library_refs)
  resolved = {}
  direct.each { |ref| resolved[ref] = {direct: true, path: nil} }
  paths.each { |ref, path| resolved[ref] ||= {direct: false, path: path} }
  resolved
end