SBOM: reach transitive dependencies, and give internal packages an identity - #176
Merged
villelaitila merged 1 commit intoAug 21, 2026
Merged
Conversation
…entity Two generator-side defects from a report comparing our CycloneDX export against Syft on the same repositories. Transitive dependencies never reached the BOM. The subtree collector followed associations from the analysed code into the External subtree, but never followed External to External associations. This is not npm-specific: the pip and NuGet analyzers already store resolved dependency closures that way, and that data sits in every stored model without ever having reached a document. One real model goes from 203 components to 1171 with the closure enabled, on data already on disk. The closure is opt-in, --transitive-externals with an optional --max-depth. Making it the default would grow an existing --level 3 export by roughly an order of magnitude, which is a compatibility event for anyone running the generator from their own scripts rather than a bug fix. Components carry a dependencyDepth property, and the closure emits real dependency graph entries so a consumer can see which component pulled in each transitive one. An edge is followed only when the lockfile that declared it belongs to the same repository as the document being generated. Without that, two repositories sharing a package at different versions leak into each other's documents. Where no declaring scope is recorded - every model stored today, and the pip and NuGet closures - the edge is followed as before, so nothing existing changes. Internal dependencies lost their package identity. A dependency on another repository in the estate was emitted as a component named after the repository, with an empty version and an empty purl, and in the default export it did not appear at all - only as a BOM-Link, which Dependency-Track does not follow across uploads, so in practice the dependency was invisible. Internal packages are now a third component category, distinct from the repository as a model element and from a third-party package, and a directly used one appears in the default export too, keeping its BOM-Link so cross document federation is not lost. It is emitted only once the element carries package identity: without that guard, models produced before the analyzer half gain rows with a repository name, no version and no purl - measured at 84% of one real document - which is the shape the report complained about rather than the fix for it. Their purl type is generic. pkg:npm/name@version would assert an identity in the public registry: either the name is not there, in which case the type buys nothing, or it is and belongs to somebody else, in which case the component silently inherits a stranger's advisories. The identity is read from an ecosystem-neutral triple, so the same code serves an internal pip, NuGet or Maven package without an npm-shaped branch. Swept across 16 stored models at two levels and in legacy mode, with timestamps normalised: the whole default document is identical, components and dependency sections alike, in 32 of 32 cases. docs/data-formats.md is the published output contract and is updated with the same change.
Softagram Impact Report for pull/176 (head commit: 831132b)TL;DR Arch. Impact: -6 | Changed code files: 2 | Directly impacted code files: 1⭐ Change Overview
⭐ Details of Dependency Changes (diagram)
🤖 AGENTS - machine-readable impact data (2 files changed, 1 impacted, +195/-4 deps)Change overviewHead Added dependencies (194, showing 50)
144 more omitted. Complete data: https://opensource.softagram.com/cdn/impact/d20d45f2-ea4e-4ae4-a9ca-0f350bd8fac5_sgraph_176_impact_change_graph_wIkk40BYjBiF65ctQE3TlUJC19GY1k.png_change_info.json Removed dependencies (4)
Impacted files (1)Unchanged files that directly depend on files changed in this PR - check them for behavioral impact. Grouped by changed file:
Complete data
[] 📄 Full report
Impact Report explained. Give feedback on this report to support@softagram.com |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Two generator-side defects from a report comparing our CycloneDX export against Syft on the same repositories. The other two reported defects are analyzer-side and are filed as softagram/softagram-live#3978.
Transitive dependencies never reached the BOM
_collect_3rdparty_for_subtreefollowed associations from the analysed code into the External subtree, but never followed External→External associations. Verified on a minimal model: withowner → expressandexpress → qs, the document contained onlyexpress, in both modes.This is not npm-specific, and that is the interesting part. The pip and NuGet analyzers already store resolved dependency closures as External→External associations. A census across 16 real stored models:
packagejsonpackage_referenceThat data has been in every model for years and has never reached a document. One real model goes 203 → 1171 components with the closure enabled — on data already on disk, with no re-analysis and no analyzer upgrade.
The closure is opt-in (
--transitive-externals, with an optional--max-depth). Making it the default would grow an existing--level 3export by roughly an order of magnitude, which is a compatibility event for anyone driving the generator from their own scripts, not a bug fix. Components carrydependencyDepth, and the closure emits realdependenciesgraph entries so a consumer can see which component pulled in each transitive one rather than getting a flat list.Scoping, so one repository's BOM is not another's
/Externalis shared by the whole estate, so the walk followed edges declared by a different repository's lockfile. Measured on two real lockfiles in one estate: 6 % and 4 % of each document's components were packages that repository does not install — one carryingtypescript@4.9.4while its own lockfile said4.3.5, with the sibling showing the exact mirror image. For a vulnerability-scanning consumer that is a false-positive generator.An edge is now followed only when its declaring lockfile belongs to the same repository as the document. 83 → 0 and 76 → 0, with each repository's own coverage unchanged.
Where no declaring scope is recorded — every model stored today, and the pip and NuGet closures — the edge is followed exactly as before. Absence means unknown provenance, and unknown provenance keeps current behaviour.
Internal dependencies lost their package identity
A dependency on another repository in the estate was emitted as a component named after the repository, with an empty version and an empty purl — and in the default export it did not appear at all, only as a BOM-Link. Dependency-Track does not follow BOM-Links across uploads, so in practice the dependency was invisible.
Internal packages are now a third component category, distinct from the repository as a model element and from a third-party package. A directly used one appears in the default export too, keeping its BOM-Link so cross-document federation is not lost.
It is emitted only once the element carries package identity. Without that guard, models produced before the analyzer half gain rows with a repository name, no version and no purl:
84 % of one document would have been the exact shape the report complained about. With the guard, the default view is inert on stored models.
The purl type is
generic, deliberately.pkg:npm/<name>@<version>asserts an identity in the public npm registry: either the name is not there, in which case the npm type buys nothing, or it is and belongs to somebody else, in which case the component silently inherits a stranger's advisories. This is the same reasoning the module already applies to an in-house binary that would otherwise be mistyped as a public NuGet package.The identity is read from an ecosystem-neutral triple, so the same code serves an internal pip, NuGet or Maven package the day an analyzer stamps one — no npm-shaped branch.
Evidence
Swept across 16 stored models at two levels and in legacy mode, comparing against released 1.10.0 with timestamps normalised (the generator stamps
datetime.now(), so byte-identity is impossible between any two runs of any version):--transitivealone: identical.Contract
docs/data-formats.mdis the published output contract and is updated in the same change: the dependency-graph entries, the internal-package category, a correction to the previous "only third-party components carry a purl" statement, and a note that the internal-component count scales with the granularity the caller selects.Review
This change set went through an adversarial review that verified each claim from disk and used mutation testing — deliberately breaking the implementation to see whether any test noticed. It corrected two claims I would otherwise have carried into this description (the test baseline is 256, not 270, and default-mode output had changed until the identity guard above), and found that several tests pinned a deptype the product does not yet emit while nothing pinned the two carrying every real edge. Those test-quality fixes are being pushed as follow-up commits.
Limitation
Neither the closure's accuracy nor the identity feature takes effect on an instance until it upgrades and re-analyses. The 32/32 byte-identical result is exactly that statement, measured.