Module: StillActive::SbomGraph
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
-
#project_refs(dependencies:, library_refs:) ⇒ Object
The library components that are the scanned project(s) rather than dependencies of one.
-
#resolve(dependencies:, root_ref:, library_refs:) ⇒ Object
=> { ref => bool, path: [ref, ...] | nil }, or nil when this SBOM cannot answer the question.
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 |