Skip to content

SBOM: reach transitive dependencies, and give internal packages an identity - #176

Merged
villelaitila merged 1 commit into
softagram:mainfrom
villelaitila:feature/sbom-npm-transitive
Aug 21, 2026
Merged

SBOM: reach transitive dependencies, and give internal packages an identity#176
villelaitila merged 1 commit into
softagram:mainfrom
villelaitila:feature/sbom-npm-transitive

Conversation

@villelaitila

Copy link
Copy Markdown
Contributor

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_subtree followed associations from the analysed code into the External subtree, but never followed External→External associations. Verified on a minimal model: with owner → express and express → qs, the document contained only express, 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:

deptype edges, per model
packagejson 1259, 1586, 1186, 567, 261, 219, 195, 186, 3
package_reference 749, 8

That 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 3 export 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 carry dependencyDepth, and the closure emits real dependencies graph 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

/External is 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 carrying typescript@4.9.4 while its own lockfile said 4.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:

model level components internal, all without a purl
a real estate 3 3168 2656 (84 %)
another 3 308 153

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):

  • 32 of 32 cases: the whole default document is identical — components and dependency sections.
  • Legacy single-SBOM mode and --transitive alone: identical.
  • The closure is inert without its flag, and inert with it on a model holding no package-to-package edges.

Contract

docs/data-formats.md is 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.

…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.
@villelaitila
villelaitila merged commit 7661d6b into softagram:main Aug 21, 2026
1 check passed
@softagram-bot

Copy link
Copy Markdown

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

Showing the changed files, dependency changes and the impact - click for full size
(Open in Softagram Desktop for full details)

⭐ Details of Dependency Changes (diagram)

details of dependency changes - click for full size
(Open in Softagram Desktop for full details)

🤖 AGENTS - machine-readable impact data (2 files changed, 1 impacted, +195/-4 deps)

Change overview

Head 831132b7ce97 vs base 359ec8dc017f. 2 code files changed. 1 unchanged files directly depend on the changed files (see Impacted files). Dependencies: 195 added, 4 removed. New external components: 1. Removed external components: 0.

Added dependencies (194, showing 50)

from to type roles signal
sgraph/tests/converters/sbom_cyclonedx_generator_test.py sgraph/src> sgraph/converters/sbom_cyclonedx_generator.py/deptype_base import test→prod expands test coverage
sgraph/tests/converters/sbom_cyclonedx_generator_test.py sgraph/src> sgraph/converters/sbom_cyclonedx_generator.py/DECLARING_SCOPE_ATTRIBUTE import test→prod expands test coverage
sgraph/tests/converters/sbom_cyclonedx_generator_test.py sgraph/src> sgraph/converters/sbom_cyclonedx_generator.py/DECLARING_SCOPE_SEPARATOR import test→prod expands test coverage
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/declare sgraph/src> sgraph/sgraph.py/SElementAssociation import test→prod expands test coverage
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/npm_chain_model sgraph/src> sgraph/sgraph.py/SGraph import test→prod expands test coverage
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/published_package_model sgraph/src> sgraph/sgraph.py/SGraph import test→prod expands test coverage
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_a_finding_under_a_versioned_external_is_never_a_component sgraph/src> sgraph/sgraph.py/SElement import test→prod expands test coverage
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_a_hop_from_an_undescribed_package_still_leaves_its_target_in_the_graph sgraph/src> sgraph/sgraph.py/SGraph import test→prod expands test coverage
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_a_model_predating_the_identity_triple_is_untouched sgraph/src> sgraph/sgraph.py/SGraph import test→prod expands test coverage
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_a_package_an_inlined_element_declares_reports_the_shorter_depth sgraph/src> sgraph/sgraph.py/SGraph import test→prod expands test coverage
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_a_package_both_declared_and_pulled_in_hangs_off_both sgraph/src> sgraph/sgraph.py/SGraph import test→prod expands test coverage
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_a_repeated_internal_edge_produces_one_component_and_one_link sgraph/src> sgraph/sgraph.py/SGraph import test→prod expands test coverage
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_an_ambiguous_multi_package_repository_falls_back_to_the_element_name sgraph/src> sgraph/sgraph.py/SGraph import test→prod expands test coverage
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_an_element_that_publishes_no_package_keeps_the_element_name sgraph/src> sgraph/sgraph.py/SGraph import test→prod expands test coverage
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_deptype_base_strips_only_the_reserved_development_prefix External/Python/sgraph/converters> sbom_cyclonedx_generator/deptype_base import test→external new external component
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_the_closure_composes_with_the_internal_transitive_mode sgraph/src> sgraph/sgraph.py/SGraph import test→prod expands test coverage
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_the_closure_follows_a_nuget_package_reference_edge sgraph/src> sgraph/sgraph.py/SGraph import test→prod expands test coverage
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_the_closure_graph_survives_the_internal_transitive_merge sgraph/src> sgraph/sgraph.py/SGraph import test→prod expands test coverage
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_the_closure_is_ecosystem_independent_and_follows_pip_edges sgraph/src> sgraph/sgraph.py/SGraph import test→prod expands test coverage
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_the_default_document_gains_the_direct_internal_dependencies_and_no_more sgraph/src> sgraph/sgraph.py/SGraph import test→prod expands test coverage
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_the_package_actually_depended_upon_resolves_the_ambiguity sgraph/src> sgraph/sgraph.py/SGraph import test→prod expands test coverage
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_the_shortest_of_two_routes_is_the_depth_a_package_reports sgraph/src> sgraph/sgraph.py/SGraph import test→prod expands test coverage
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_transitive_externals_terminates_on_an_external_dependency_cycle sgraph/src> sgraph/sgraph.py/SGraph import test→prod expands test coverage
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/two_repository_model sgraph/src> sgraph/sgraph.py/SGraph import test→prod expands test coverage
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_a_closure_that_followed_an_edge_reports_nothing External/Python/sgraph/converters> sbom_cyclonedx_generator/generate_multi_from_sgraph import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_a_declaring_scope_changes_nothing_without_the_closure_flag External/Python/sgraph/converters> sbom_cyclonedx_generator/generate_multi_from_sgraph import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_a_default_mode_document_reports_no_skipped_deptype External/Python/sgraph/converters> sbom_cyclonedx_generator/generate_multi_from_sgraph import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_a_dev_prefixed_deptype_traverses_exactly_like_its_base External/Python/sgraph/converters> sbom_cyclonedx_generator/generate_multi_from_sgraph import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_a_directly_used_internal_dependency_with_identity_is_a_component_and_keeps_its_link External/Python/Usual dependencies import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_a_directly_used_internal_dependency_with_identity_is_a_component_and_keeps_its_link External/Python/sgraph/converters> sbom_cyclonedx_generator/generate_multi_from_sgraph import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_a_directly_used_internal_dependency_without_identity_is_only_a_bom_link External/Python/Usual dependencies import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_a_directly_used_internal_dependency_without_identity_is_only_a_bom_link External/Python/sgraph/converters> sbom_cyclonedx_generator/generate_multi_from_sgraph import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_a_directory_level_export_still_excludes_a_sibling_repositorys_edges External/Python/sgraph/converters> sbom_cyclonedx_generator/generate_multi_from_sgraph import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_a_directory_level_export_still_follows_its_repositorys_edges External/Python/sgraph/converters> sbom_cyclonedx_generator/generate_multi_from_sgraph import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_a_finding_under_a_versioned_external_is_never_a_component External/Python/sgraph/converters> sbom_cyclonedx_generator/generate_multi_from_sgraph import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_a_hop_from_an_undescribed_package_still_leaves_its_target_in_the_graph External/Python/Usual dependencies import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_a_hop_from_an_undescribed_package_still_leaves_its_target_in_the_graph External/Python/sgraph/converters> sbom_cyclonedx_generator/generate_multi_from_sgraph import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_a_package_an_inlined_element_declares_reports_the_shorter_depth External/Python/Usual dependencies import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_a_package_both_declared_and_pulled_in_hangs_off_both External/Python/Usual dependencies import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_a_package_both_declared_and_pulled_in_hangs_off_both External/Python/sgraph/converters> sbom_cyclonedx_generator/generate_multi_from_sgraph import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_a_scope_below_the_subtree_is_followed External/Python/sgraph/converters> sbom_cyclonedx_generator/generate_multi_from_sgraph import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_a_selected_directory_element_follows_its_repositorys_edges_too External/Python/Usual dependencies import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_a_selected_element_document_inlines_its_internal_dependencies_too External/Python/Usual dependencies import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_a_sibling_directory_scope_is_not_followed External/Python/sgraph/converters> sbom_cyclonedx_generator/generate_multi_from_sgraph import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_an_audit_declared_edge_takes_the_same_rule External/Python/sgraph/converters> sbom_cyclonedx_generator/generate_multi_from_sgraph import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_an_edge_declared_by_two_repositories_is_followed_from_both External/Python/sgraph/converters> sbom_cyclonedx_generator/generate_multi_from_sgraph import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_an_edge_declared_only_by_another_repository_is_not_followed External/Python/sgraph/converters> sbom_cyclonedx_generator/generate_multi_from_sgraph import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_an_edge_that_is_not_a_package_relation_is_not_followed External/Python/sgraph/converters> sbom_cyclonedx_generator/generate_multi_from_sgraph import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_an_edge_with_no_declaring_scope_is_followed External/Python/sgraph/converters> sbom_cyclonedx_generator/generate_multi_from_sgraph import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_an_empty_closure_names_the_deptypes_it_skipped External/Python/sgraph/converters> sbom_cyclonedx_generator/generate_multi_from_sgraph import test→external external dependency

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)

from to type roles signal
sgraph/src> sgraph/converters/sbom_cyclonedx_generator.py/_collect_3rdparty_for_subtree sgraph/src> sgraph/converters/sbom_cyclonedx_generator.py/dedup_key func_ref prod→prod regular
sgraph/src> sgraph/converters/sbom_cyclonedx_generator.py/_collect_3rdparty_for_subtree sgraph/src> sgraph/converters/sbom_cyclonedx_generator.py/elem_as_bom_data func_ref prod→prod regular
sgraph/src> sgraph/converters/sbom_cyclonedx_generator.py/_transitive_components_and_dependencies sgraph/src> sgraph/converters/sbom_cyclonedx_generator.py/_add_element_location func_ref prod→prod regular
sgraph/src> sgraph/converters/sbom_cyclonedx_generator.py/_transitive_components_and_dependencies sgraph/src> sgraph/converters/sbom_cyclonedx_generator.py/_add_vcs_reference func_ref prod→prod regular

Impacted files (1)

Unchanged files that directly depend on files changed in this PR - check them for behavioral impact. Grouped by changed file:

changed file directly impacted dependents
sgraph/src/sgraph/converters/sbom_cyclonedx_generator.py 1: sgraph/src/sgraph/graphdataservice.py

Complete data

[]

📄 Full report

Impact Report explained. Give feedback on this report to support@softagram.com

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants