From cc2b4ecaa3bbebee09dc3a1f5e00b5095bd33997 Mon Sep 17 00:00:00 2001 From: Ville Laitila Date: Mon, 3 Aug 2026 15:49:08 +0300 Subject: [PATCH 1/4] SBOM: include version-managed and parent-pom dependencies A dependency whose version is governed by an imported BOM or an external parent has no version attribute anywhere in the model, and valid_for_bom dropped it entirely. Modern Maven centralizes versions in parents and BOMs, so the newer the project, the emptier its SBOM: a Spring Boot application lost its whole starter stack while a 2010-era pom came out complete. Three changes, one per missing shape: - An element carrying maven coordinates and at least one incoming reference is now a component with a versionless purl. The incoming requirement keeps out the husks that version-management redirection leaves behind, whose references were re-pointed at versioned elements. - extract_version falls back to parent_version, the attribute a block produces: the parent's exact version was already in the model and was discarded. - maven_purl treats an empty version like an unresolved expression. Splicing it in would emit a trailing '@' - not a canonical versionless purl but a malformed versioned one. --- .../converters/sbom_cyclonedx_generator.py | 14 ++++- ...lfile_for_sbom_maven_coordinates_tests.xml | 28 +++++++++ .../sbom_cyclonedx_generator_test.py | 60 +++++++++++++++++-- 3 files changed, 95 insertions(+), 7 deletions(-) diff --git a/src/sgraph/converters/sbom_cyclonedx_generator.py b/src/sgraph/converters/sbom_cyclonedx_generator.py index eaee68c..68204e8 100644 --- a/src/sgraph/converters/sbom_cyclonedx_generator.py +++ b/src/sgraph/converters/sbom_cyclonedx_generator.py @@ -27,8 +27,13 @@ def slugify_bom_ref(name: str) -> str: def valid_for_bom(elem): + # The coordinate branch demands an incoming reference where the version branches do not: + # version-management redirection leaves versionless elements behind after re-pointing their + # references at versioned ones, and coordinates alone cannot tell those husks apart from a + # BOM-managed dependency something still uses. return 'version' in elem.attrs or ' of version ' in elem.name or ' of tag ' in elem.name \ - or 'license' in elem.attrs + or 'license' in elem.attrs or 'parent_version' in elem.attrs \ + or ('groupId' in elem.attrs and 'artifactId' in elem.attrs and bool(elem.incoming)) def extract_version(elem): @@ -49,6 +54,8 @@ def extract_version(elem): version = elem.name.split(' of version ')[-1].strip() elif ' of tag ' in elem.name: version = elem.name.split(' of tag ')[-1].strip() + if 'parent_version' in elem.attrs: + version = elem.attrs['parent_version'] if version is None: return None return version.replace(VERSION_PATH_SEPARATOR_ENCODING, '/') @@ -325,13 +332,14 @@ def maven_purl(elem, version): An unresolved version yields a versionless purl rather than one carrying the expression. A purl version must be percent-encoded, so the expression would be either non-canonical raw or canonical-but-unmatchable encoded; omitting it yields a purl that is canonical and still - matches at package level. + matches at package level. An empty version takes the same branch: appending it would leave a + trailing '@', which is not a canonical versionless purl but a malformed versioned one. """ group_id = elem.attrs.get('groupId', '') artifact_id = elem.attrs.get('artifactId', '') if not (is_maven_coordinate(group_id) and is_maven_coordinate(artifact_id)): return None - if is_unresolved_version(version): + if not version or is_unresolved_version(version): return f'pkg:maven/{group_id}/{artifact_id}' return f'pkg:maven/{group_id}/{artifact_id}@{version}' diff --git a/tests/converters/modelfile_for_sbom_maven_coordinates_tests.xml b/tests/converters/modelfile_for_sbom_maven_coordinates_tests.xml index a17424d..89b77da 100644 --- a/tests/converters/modelfile_for_sbom_maven_coordinates_tests.xml +++ b/tests/converters/modelfile_for_sbom_maven_coordinates_tests.xml @@ -11,6 +11,14 @@ + + + + + + + + + + diff --git a/tests/converters/sbom_cyclonedx_generator_test.py b/tests/converters/sbom_cyclonedx_generator_test.py index 7d93a3e..08cf121 100644 --- a/tests/converters/sbom_cyclonedx_generator_test.py +++ b/tests/converters/sbom_cyclonedx_generator_test.py @@ -452,16 +452,68 @@ def test_maven_coordinate_guard_does_more_than_reject_whitespace(): assert len(whitespace_bearing) == 3 -def test_maven_coordinates_fixture_yields_five_distinct_components(): +def test_maven_coordinates_fixture_yields_seven_distinct_components(): """Anti-vacuity for the fixture, and the collapse guard for version omission. A lookup above raises when an element vanishes, but an element added, or two bom-refs - collapsed into one when a version is omitted, would otherwise go unnoticed. + collapsed into one when a version is omitted, would otherwise go unnoticed. Seven, not + eight: husk-lib exists in the fixture and must not be counted here. """ model, _ = get_model_and_model_api(MAVEN_COORDINATES_MODEL) sbom = sbom_cyclonedx_generator.generate_from_sgraph(model) - assert len(sbom['components']) == 5 - assert len({component['bom-ref'] for component in sbom['components']}) == 5 + assert len(sbom['components']) == 7 + assert len({component['bom-ref'] for component in sbom['components']}) == 7 + + +# --- version-managed dependency tests --- + + +def test_a_referenced_versionless_dependency_is_still_a_component(): + """A dependency version-managed by an imported BOM has no version anywhere in the model. + + The version is real but lives inside an artifact the analyzer never parses, so requiring a + version for inclusion drops exactly the dependencies modern Maven declares: the more a + project centralizes versions in parents and BOMs, the emptier its SBOM. A versionless maven + purl is canonical and still matches at package level — the same trade the + unresolved-expression case above already accepted. + """ + component = get_maven_coordinate_components()['org.example.managed managed-lib'] + assert component['purl'] == 'pkg:maven/org.example.managed/managed-lib' + assert component['version'] == '' + assert purl_type_resolution(component) is None + + +def test_an_unreferenced_versionless_element_is_not_swept_in(): + """The inclusion rule for versionless elements is incoming references, not existence. + + Version-management redirection re-points references at versioned elements and leaves the + versionless originals behind with none. husk-lib is the control for managed-lib: identical + shape, no incoming reference. Without it the rule could decay into plain coordinate + presence and every other assertion would stay green. + """ + assert 'org.example.husk husk-lib' not in get_maven_coordinate_components() + + +def test_parent_version_supplies_the_version_of_an_external_parent_pom(): + """A parent pom reference records its exact version under parent_version, not version. + + The analyzer read that version out of the block it parsed, so dropping the + component, or emitting it versionless, discards information the model already holds. + """ + component = get_maven_coordinate_components()['org.example.parentpom parent-pom'] + assert component['purl'] == 'pkg:maven/org.example.parentpom/parent-pom@7.1' + assert component['version'] == '7.1' + + +def test_an_explicit_version_outranks_parent_version(): + """An element that is both a parent and an ordinary dependency keeps its own version. + + No fixture element carries both attributes, deliberately: this ordering is a property of + extract_version alone, and a fixture pinning it would couple two orthogonal guards. + """ + elem = SElement(None, 'org.example both') + elem.attrs.update(version='1.0', parent_version='2.0') + assert sbom_cyclonedx_generator.extract_version(elem) == '1.0' def test_a_partly_resolved_version_keeps_its_version(): From 88ed69bb31b86bb63a593ea926058175177e831e Mon Sep 17 00:00:00 2001 From: Ville Laitila Date: Mon, 3 Aug 2026 16:04:28 +0300 Subject: [PATCH 2/4] SBOM: harden versionless inclusion against stored-model shapes Review against real stored models found four holes in the previous commit, all in shapes the fresh-model happy path never meets: - valid_for_bom admitted parent_version-only elements. Every model persisted before the analyzer wrote coordinates onto parents has that shape, and SBOMs are generated on demand from stored models, so the clause spliced space-bearing element names into generic purls for all existing deployments. Dropped: on coordinate-carrying models the coordinate branch already admits every parent. - The coordinate branch accepted charset-rejected coordinates such as a ${} groupId resolved only in an external parent. Versionless, such an element builds no maven purl and the fallback splice emits the raw space-bearing name. The branch now requires is_maven_coordinate on both attributes. - Two poms naming one parent at different versions collide on one versionless element and the attribute transfer joins the versions with a semicolon. maven_purl now omits such a version; the raw value stays disclosed in the component's version field. - The generic fallback splice had no empty-version guard, emitting a trailing '@' for versionless elements that fall through maven_purl. A cross-shape invariant test asserts no emitted purl or bom-ref in any fixture carries a space, a semicolon, or a trailing '@'. --- .../converters/sbom_cyclonedx_generator.py | 27 +++++- ...lfile_for_sbom_maven_coordinates_tests.xml | 29 ++++++- .../sbom_cyclonedx_generator_test.py | 84 +++++++++++++++++-- 3 files changed, 130 insertions(+), 10 deletions(-) diff --git a/src/sgraph/converters/sbom_cyclonedx_generator.py b/src/sgraph/converters/sbom_cyclonedx_generator.py index 68204e8..93a5b23 100644 --- a/src/sgraph/converters/sbom_cyclonedx_generator.py +++ b/src/sgraph/converters/sbom_cyclonedx_generator.py @@ -31,9 +31,22 @@ def valid_for_bom(elem): # version-management redirection leaves versionless elements behind after re-pointing their # references at versioned ones, and coordinates alone cannot tell those husks apart from a # BOM-managed dependency something still uses. + # + # parent_version alone deliberately does NOT qualify. Stored models predating + # coordinate-carrying parents hold parent_version-only elements, and admitting them splices + # the space-bearing element name into a generic purl. On models that do carry coordinates, + # the coordinate branch already admits every parent, so a parent_version clause would add + # nothing there and regress everything before. + # + # The coordinates must also be usable, not merely present: charset-rejected ones (a ${} + # groupId resolved only in an external parent) build no maven purl, and versionless there + # is nothing spec-clean left to emit — the fallback would splice the space-bearing element + # name into a generic purl. return 'version' in elem.attrs or ' of version ' in elem.name or ' of tag ' in elem.name \ - or 'license' in elem.attrs or 'parent_version' in elem.attrs \ - or ('groupId' in elem.attrs and 'artifactId' in elem.attrs and bool(elem.incoming)) + or 'license' in elem.attrs \ + or (is_maven_coordinate(elem.attrs.get('groupId', '')) + and is_maven_coordinate(elem.attrs.get('artifactId', '')) + and bool(elem.incoming)) def extract_version(elem): @@ -333,13 +346,16 @@ def maven_purl(elem, version): purl version must be percent-encoded, so the expression would be either non-canonical raw or canonical-but-unmatchable encoded; omitting it yields a purl that is canonical and still matches at package level. An empty version takes the same branch: appending it would leave a - trailing '@', which is not a canonical versionless purl but a malformed versioned one. + trailing '@', which is not a canonical versionless purl but a malformed versioned one. So + does a semicolon-joined multi-value, the shape attribute transfer produces when two poms + name one parent at different versions: it asserts a version that exists nowhere, while the + raw value stays disclosed in the component's version field. """ group_id = elem.attrs.get('groupId', '') artifact_id = elem.attrs.get('artifactId', '') if not (is_maven_coordinate(group_id) and is_maven_coordinate(artifact_id)): return None - if not version or is_unresolved_version(version): + if not version or ';' in version or is_unresolved_version(version): return f'pkg:maven/{group_id}/{artifact_id}' return f'pkg:maven/{group_id}/{artifact_id}@{version}' @@ -437,6 +453,9 @@ def purl_for(elem, v): # Disclosed rather than dropped: the raw value stays in the component's version field, # and this property records why the purl carries no version. properties.append({'name': VERSION_SOURCE_PROPERTY, 'value': v}) + # The versionless-inclusion rule made an empty version reachable here, not only in + # maven_purl: charset-rejected coordinates drop a versionless element to this splice. + if not v: return f'pkg:{pkgtype}/{pkgid}', properties return f'pkg:{pkgtype}/{pkgid}@{v}', properties diff --git a/tests/converters/modelfile_for_sbom_maven_coordinates_tests.xml b/tests/converters/modelfile_for_sbom_maven_coordinates_tests.xml index 89b77da..ea3abda 100644 --- a/tests/converters/modelfile_for_sbom_maven_coordinates_tests.xml +++ b/tests/converters/modelfile_for_sbom_maven_coordinates_tests.xml @@ -14,10 +14,15 @@ + pair into indistinguishable elements and the rule's control is gone. legacy-parent + is referenced too, deliberately: its exclusion must rest on its missing coordinates + alone, or the test would pass for the wrong reason on a missing reference. --> + + + @@ -76,6 +81,28 @@ + + + + + + diff --git a/tests/converters/sbom_cyclonedx_generator_test.py b/tests/converters/sbom_cyclonedx_generator_test.py index 08cf121..711ac06 100644 --- a/tests/converters/sbom_cyclonedx_generator_test.py +++ b/tests/converters/sbom_cyclonedx_generator_test.py @@ -452,17 +452,17 @@ def test_maven_coordinate_guard_does_more_than_reject_whitespace(): assert len(whitespace_bearing) == 3 -def test_maven_coordinates_fixture_yields_seven_distinct_components(): +def test_maven_coordinates_fixture_yields_eight_distinct_components(): """Anti-vacuity for the fixture, and the collapse guard for version omission. A lookup above raises when an element vanishes, but an element added, or two bom-refs - collapsed into one when a version is omitted, would otherwise go unnoticed. Seven, not - eight: husk-lib exists in the fixture and must not be counted here. + collapsed into one when a version is omitted, would otherwise go unnoticed. Eight, not + ten: husk-lib and legacy-parent exist in the fixture and must not be counted here. """ model, _ = get_model_and_model_api(MAVEN_COORDINATES_MODEL) sbom = sbom_cyclonedx_generator.generate_from_sgraph(model) - assert len(sbom['components']) == 7 - assert len({component['bom-ref'] for component in sbom['components']}) == 7 + assert len(sbom['components']) == 8 + assert len({component['bom-ref'] for component in sbom['components']}) == 8 # --- version-managed dependency tests --- @@ -516,6 +516,80 @@ def test_an_explicit_version_outranks_parent_version(): assert sbom_cyclonedx_generator.extract_version(elem) == '1.0' +def test_a_legacy_parent_without_coordinates_is_not_emitted(): + """Models persisted before the analyzer wrote coordinates onto parents must stay excluded. + + SBOMs are generated on demand from stored models with a multi-month lifetime, so the + generator meets old shapes long after the analyzer moved on. A parent_version-only element + has no coordinates to build a maven purl from; emitting it would splice the space-bearing + element name into a generic purl — the exact class the maven-purl work eliminated. The + fixture element is referenced, deliberately: exclusion must rest on the missing coordinates, + not on a missing reference. + """ + assert 'org.example.legacyparent legacy-parent' not in get_maven_coordinate_components() + + +def test_an_ambiguous_parent_version_stays_out_of_the_purl(): + """Two poms naming one parent at different versions collide on one versionless element. + + The attribute transfer joins their versions with a semicolon. A purl carrying the joined + value asserts a version that exists nowhere and matches nothing; omitting it keeps the purl + canonical and package-level matchable, while the raw value stays disclosed in the version + field — the same split the unresolved-expression case established. + """ + component = get_maven_coordinate_components()['org.example.multiparent multi-parent'] + assert component['purl'] == 'pkg:maven/org.example.multiparent/multi-parent' + assert component['version'] == '4.1.0;3.2.0' + + +def test_versionless_inclusion_requires_usable_coordinates(): + """Charset-rejected coordinates plus no version leave nothing spec-clean to emit. + + A ${} groupId whose property lives in an external parent fails the coordinate guard, so no + maven purl can be built; versionless, the element predates this feature in no BOM at all, + and admitting it now would splice the space-bearing element name into a generic purl. A + versioned element with the same broken coordinates still takes the generic residual as + before — this rule is about what the versionless-inclusion branch may admit, not about + tightening the residual. + """ + assert 'org.example.propgroup prop-group-lib' not in get_maven_coordinate_components() + + +def test_the_generic_fallback_never_splices_an_empty_version(): + """The versionless-inclusion rule made an empty version reachable on the fallback path. + + Coordinates that fail the charset guard, such as an unresolved ${project.groupId}, drop the + element to the generic branch, and a versionless element then reaches the final splice with + an empty version. Appending it would emit a trailing '@' — not a canonical versionless purl + but a malformed versioned one, the same shape maven_purl already refuses. + """ + maven_bucket = SElement(None, 'Maven') + elem = SElement(maven_bucket, 'caffeine') + elem.attrs.update(groupId='${project.groupId}', artifactId='caffeine') + purl, properties = sbom_cyclonedx_generator.purl_for(elem, '') + assert purl == 'pkg:generic/caffeine' + assert properties == [{'name': 'purlTypeResolution', 'value': 'maven coordinates unavailable'}] + + +def test_no_fixture_purl_carries_a_space_a_semicolon_or_a_trailing_at(): + """The cross-shape invariant the individual guards above defend, stated once directly. + + Findings against stored models all took one of these three shapes; asserting the invariant + over every fixture generation catches a regression in any of them even if the targeted + test for that shape is later weakened. + """ + for model_file in (MAVEN_COORDINATES_MODEL, BINARY_REFS_MODEL, + 'converters/modelfile_for_sbom_tests.xml', + 'converters/modelfile_for_sbom_multi_tests.xml'): + model, _ = get_model_and_model_api(model_file) + sbom = sbom_cyclonedx_generator.generate_from_sgraph(model) + for component in sbom['components']: + for ref in (component['purl'], component['bom-ref']): + assert ' ' not in ref, ref + assert ';' not in ref, ref + assert not ref.endswith('@'), ref + + def test_a_partly_resolved_version_keeps_its_version(): """Only a whole build-property expression is dropped, not a version that merely contains one. From cb9e829c01c8bf6b9d3d0d87a1d60d6b38f012f9 Mon Sep 17 00:00:00 2001 From: Ville Laitila Date: Wed, 5 Aug 2026 08:42:47 +0300 Subject: [PATCH 3/4] SBOM: repair two semantic conflicts from rebasing onto the #169 fixes Rebasing this branch over the five-defect commit resolved both overlap sites textually but broke each side's semantics at one line: - extract_version: the parent_version fallback landed as a plain 'if' after the restructured elif chain, overriding an explicit version instead of remaining the last resort. Restore it as the final elif; it now also flows through the __slash__ decode, which is the correct combined behavior. - purl_for: the URL-shaped-version branch lost its versionless return when the empty-version guard was woven in, so a URL version recorded its versionSource property but still spliced '@https://...' into the purl. Restore the return. Both regressions were caught by the existing tests of the respective sides (test_an_explicit_version_outranks_parent_version, test_a_url_shaped_version_yields_a_versionless_purl_and_records_its_source). --- src/sgraph/converters/sbom_cyclonedx_generator.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/sgraph/converters/sbom_cyclonedx_generator.py b/src/sgraph/converters/sbom_cyclonedx_generator.py index 93a5b23..1ad9dbc 100644 --- a/src/sgraph/converters/sbom_cyclonedx_generator.py +++ b/src/sgraph/converters/sbom_cyclonedx_generator.py @@ -67,7 +67,7 @@ def extract_version(elem): version = elem.name.split(' of version ')[-1].strip() elif ' of tag ' in elem.name: version = elem.name.split(' of tag ')[-1].strip() - if 'parent_version' in elem.attrs: + elif 'parent_version' in elem.attrs: version = elem.attrs['parent_version'] if version is None: return None @@ -453,6 +453,7 @@ def purl_for(elem, v): # Disclosed rather than dropped: the raw value stays in the component's version field, # and this property records why the purl carries no version. properties.append({'name': VERSION_SOURCE_PROPERTY, 'value': v}) + return f'pkg:{pkgtype}/{pkgid}', properties # The versionless-inclusion rule made an empty version reachable here, not only in # maven_purl: charset-rejected coordinates drop a versionless element to this splice. if not v: From 3f3c131b8b3f345b51a44344ecc380fa305964af Mon Sep 17 00:00:00 2001 From: Ville Laitila Date: Tue, 4 Aug 2026 14:51:17 +0300 Subject: [PATCH 4/4] SBOM: add transitive mode inlining the internal exposure chain Dependency-Track resolves dependency refs only within one uploaded BOM; the BOM-Link URNs of the default multi-SBOM mode are never followed across projects, so the chain repo -> internal library -> vulnerable 3rd-party component was invisible there. generate_multi_from_sgraph(..., transitive=True) inlines each element's reachable internal elements as components (softagram:internal, with a BOM-Link back to their standalone SBOM as an externalReference) plus the 3rd-party components of the whole chain (softagram:via provenance), and emits a multi-entry dependencies graph in which every ref resolves within the BOM. CLI: --transitive (requires --level). --- .../converters/sbom_cyclonedx_generator.py | 157 +++++++++++--- .../sbom_cyclonedx_generator_test.py | 202 +++++++++++++++++- 2 files changed, 331 insertions(+), 28 deletions(-) diff --git a/src/sgraph/converters/sbom_cyclonedx_generator.py b/src/sgraph/converters/sbom_cyclonedx_generator.py index 1ad9dbc..445966f 100644 --- a/src/sgraph/converters/sbom_cyclonedx_generator.py +++ b/src/sgraph/converters/sbom_cyclonedx_generator.py @@ -873,7 +873,91 @@ def _collect_3rdparty_for_subtree(subtree_root, external_root, other_externals_b return components -def generate_multi_from_sgraph(sgraph: SGraph, level: int = 3) -> list[dict]: +def _transitive_components_and_dependencies(root_path, gen_elem_by_path, orig_elem_by_path, + elem_serials, elem_bom_refs, orig_external_root, + other_externals_by_name): + """Inline everything reachable from root_path into one self-contained BOM. + + Dependency-Track resolves dependency refs only within a single uploaded BOM: the BOM-Link + URNs of the default mode are never followed into other projects, so the exposure chain + root -> internal element -> vulnerable 3rd-party component stays invisible there. Inlining + the reachable internal elements as components, together with the 3rd-party components of + the whole chain, makes the chain resolvable inside one project. Each inlined internal + component still points to its own standalone SBOM via an externalReference of type 'bom'. + + :return: (components, dependencies) for the SBOM of the element at root_path + """ + # Breadth-first walk over the generalized cross-element graph + order = [root_path] + direct_internal = {} + queue = [root_path] + while queue: + path = queue.pop(0) + targets = [] + gen_elem = gen_elem_by_path.get(path) + if gen_elem is not None: + for assoc in gen_elem.outgoing: + target_path = assoc.toElement.getPath() + if target_path in elem_bom_refs and target_path != path \ + and target_path not in targets: + targets.append(target_path) + direct_internal[path] = targets + for target_path in targets: + if target_path not in order: + order.append(target_path) + queue.append(target_path) + + # 3rd-party components of every element in the chain, deduplicated on the same case-folded + # key the per-subtree walk uses — a case-variant spelling arriving from another element of + # the chain is the same package, not a second one. dependsOn refs are canonicalized to the + # surviving spelling so no entry references a folded-away component. Components pulled in + # through an internal element are annotated with the element that routed them here. + components = [] + surviving_ref_by_key = {} + external_refs_of = {} + for path in order: + elem = orig_elem_by_path[path] + ext_components = _collect_3rdparty_for_subtree(elem, orig_external_root, + other_externals_by_name) + refs = [] + for component in ext_components: + key = dedup_key(component['bom-ref']) + if key not in surviving_ref_by_key: + surviving_ref_by_key[key] = component['bom-ref'] + if path != root_path: + component.setdefault('properties', []).append({ + 'name': 'softagram:via', + 'value': elem.name + }) + components.append(component) + refs.append(surviving_ref_by_key[key]) + external_refs_of[path] = refs + + # Reachable internal elements become components of this BOM + for path in order[1:]: + serial_uuid = elem_serials[path].replace('urn:uuid:', '') + components.append({ + 'bom-ref': elem_bom_refs[path], + 'type': 'library', + 'name': orig_elem_by_path[path].name, + 'version': '', + 'purl': '', + 'properties': [{'name': 'softagram:internal', 'value': 'true'}], + 'externalReferences': [{'url': f'urn:cdx:{serial_uuid}/1', 'type': 'bom'}], + }) + + # Multi-entry dependency graph: every ref resolves within this BOM + dependencies = [] + for path in order: + depends_on = list(external_refs_of[path]) + depends_on += [elem_bom_refs[target] for target in direct_internal[path]] + dependencies.append({'ref': elem_bom_refs[path], 'dependsOn': depends_on}) + + return components, dependencies + + +def generate_multi_from_sgraph(sgraph: SGraph, level: int = 3, + transitive: bool = False) -> list[dict]: """Generate one CycloneDX 1.7 SBOM per element at the given level. Uses the ORIGINAL model for 3rd-party component collection (preserves version info), @@ -881,6 +965,9 @@ def generate_multi_from_sgraph(sgraph: SGraph, level: int = 3) -> list[dict]: :param sgraph: The loaded SGraph model :param level: Tree depth at which to split into separate SBOMs + :param transitive: Inline the transitive closure of internal dependencies into each SBOM + so consumers that cannot follow BOM-Links across uploads (e.g. Dependency-Track) see + the full exposure chain within one BOM :return: List of CycloneDX SBOM dicts """ from sgraph.algorithms.generalizer import generalize_model @@ -937,10 +1024,12 @@ def collect_gen(elem, current_level): # Build path -> serial/ref mappings for all content elements (using original paths) elem_serials = {} elem_bom_refs = {} + orig_elem_by_path = {} for elem in orig_content_elements: path = elem.getPath() elem_serials[path] = deterministic_serial(path) elem_bom_refs[path] = slugify_bom_ref(elem.name) + orig_elem_by_path[path] = elem # Build generalized element lookup by path for cross-repo deps gen_elem_by_path = {elem.getPath(): elem for elem in gen_content_elements} @@ -962,31 +1051,37 @@ def collect_gen(elem, current_level): 'externalReferences': [] } - # 3rd party components from original model (preserves version info) - sbom.components = _collect_3rdparty_for_subtree( - orig_elem, orig_external_root, other_externals_by_name - ) - - # Dependencies section - depends_on = [] - - # 3rd party purl refs - for component in sbom.components: - depends_on.append(component['bom-ref']) - - # Internal cross-repo dependencies from generalized model (BOM-Link URNs) - gen_elem = gen_elem_by_path.get(path) - if gen_elem is not None: - for assoc in gen_elem.outgoing: - target_path = assoc.toElement.getPath() - if target_path in elem_serials and target_path != path: - target_serial_uuid = elem_serials[target_path].replace('urn:uuid:', '') - target_ref = elem_bom_refs[target_path] - bom_link = f"urn:cdx:{target_serial_uuid}/1#{target_ref}" - if bom_link not in depends_on: - depends_on.append(bom_link) - - dependencies = [{'ref': ref, 'dependsOn': depends_on}] + if transitive: + sbom.components, dependencies = _transitive_components_and_dependencies( + path, gen_elem_by_path, orig_elem_by_path, elem_serials, elem_bom_refs, + orig_external_root, other_externals_by_name + ) + else: + # 3rd party components from original model (preserves version info) + sbom.components = _collect_3rdparty_for_subtree( + orig_elem, orig_external_root, other_externals_by_name + ) + + # Dependencies section + depends_on = [] + + # 3rd party purl refs + for component in sbom.components: + depends_on.append(component['bom-ref']) + + # Internal cross-repo dependencies from generalized model (BOM-Link URNs) + gen_elem = gen_elem_by_path.get(path) + if gen_elem is not None: + for assoc in gen_elem.outgoing: + target_path = assoc.toElement.getPath() + if target_path in elem_serials and target_path != path: + target_serial_uuid = elem_serials[target_path].replace('urn:uuid:', '') + target_ref = elem_bom_refs[target_path] + bom_link = f"urn:cdx:{target_serial_uuid}/1#{target_ref}" + if bom_link not in depends_on: + depends_on.append(bom_link) + + dependencies = [{'ref': ref, 'dependsOn': depends_on}] # Serialize data = sbom.as_cyclonedx_json() @@ -1006,12 +1101,20 @@ def collect_gen(elem, current_level): parser.add_argument('--level', type=int, default=None, help='Generate one SBOM per element at this tree depth. ' 'Without this flag, generates a single SBOM (legacy behavior).') + parser.add_argument('--transitive', action='store_true', + help='Inline the transitive closure of internal dependencies into ' + 'each SBOM so the full exposure chain resolves within one BOM ' + '(for consumers like Dependency-Track that do not follow ' + 'BOM-Links across uploads). Requires --level.') args = parser.parse_args() + if args.transitive and args.level is None: + parser.error('--transitive requires --level') + g = SGraph.parse_xml_or_zipped_xml(args.model) if args.level is not None: - result = generate_multi_from_sgraph(g, level=args.level) + result = generate_multi_from_sgraph(g, level=args.level, transitive=args.transitive) else: result = generate_from_sgraph(g) diff --git a/tests/converters/sbom_cyclonedx_generator_test.py b/tests/converters/sbom_cyclonedx_generator_test.py index 711ac06..8fe835e 100644 --- a/tests/converters/sbom_cyclonedx_generator_test.py +++ b/tests/converters/sbom_cyclonedx_generator_test.py @@ -1,6 +1,6 @@ import re -from sgraph import SElement, SGraph +from sgraph import SElement, SElementAssociation, SGraph from sgraph.converters import sbom_cyclonedx_generator from sgraph.converters.sbom_cyclonedx_generator import ( deterministic_serial, slugify_bom_ref, generate_multi_from_sgraph, @@ -111,6 +111,206 @@ def test_multi_sbom_internal_dependencies(): assert any(repo_b_serial in link for link in bom_link_deps) +# --- Transitive multi-SBOM tests --- +# +# Dependency-Track resolves dependency graphs strictly within one uploaded BOM: BOM-Link URNs +# in dependsOn are never followed into other projects, so the exposure chain +# repoA -> repoB -> vulnerable-component is invisible when each repo uploads its own SBOM. +# Transitive mode inlines reachable internal elements and their 3rd-party components into each +# SBOM so the whole chain resolves inside a single BOM. + +MULTI_MODEL = 'converters/modelfile_for_sbom_multi_tests.xml' + + +# find_property is defined with the emission helpers further down; module-level resolution at +# call time lets the earlier tests here use it. + + +def sbom_of(result, name): + return next(s for s in result if s['metadata']['component']['name'] == name) + + +def test_transitive_mode_includes_internal_elements_as_components(): + """repoA's SBOM carries repoB inline as an internal library component.""" + model, _ = get_model_and_model_api(MULTI_MODEL) + result = generate_multi_from_sgraph(model, level=3, transitive=True) + + repo_a_sbom = sbom_of(result, 'repoA') + internal = [c for c in repo_a_sbom['components'] + if find_property(c, 'softagram:internal') == 'true'] + assert [c['name'] for c in internal] == ['repoB'] + assert internal[0]['type'] == 'library' + assert internal[0]['bom-ref'] == slugify_bom_ref('repoB') + + +def test_transitive_internal_component_links_to_its_standalone_sbom(): + """The inlined internal component carries a BOM-Link to repoB's own SBOM serial.""" + model, _ = get_model_and_model_api(MULTI_MODEL) + result = generate_multi_from_sgraph(model, level=3, transitive=True) + + repo_a_sbom = sbom_of(result, 'repoA') + repo_b_component = next(c for c in repo_a_sbom['components'] if c['name'] == 'repoB') + bom_refs = [r for r in repo_b_component['externalReferences'] if r['type'] == 'bom'] + + repo_b_serial = sbom_of(result, 'repoB')['serialNumber'].replace('urn:uuid:', '') + assert [r['url'] for r in bom_refs] == [f'urn:cdx:{repo_b_serial}/1'] + + +def test_transitive_mode_pulls_indirect_externals_with_provenance(): + """repoB's 3rd-party dependency lands in repoA's SBOM, annotated with its route.""" + model, _ = get_model_and_model_api(MULTI_MODEL) + result = generate_multi_from_sgraph(model, level=3, transitive=True) + + repo_a_sbom = sbom_of(result, 'repoA') + components_by_name = {c['name']: c for c in repo_a_sbom['components']} + + commons_lang = next(c for name, c in components_by_name.items() if 'commons-lang3' in name) + assert find_property(commons_lang, 'softagram:via') == 'repoB' + + # repoA's own direct dependency is not annotated as indirect + assert find_property(components_by_name['Newtonsoft.Json'], 'softagram:via') is None + + +def test_transitive_mode_emits_the_exposure_chain_in_dependencies(): + """dependencies expresses repoA -> repoB -> commons-lang3 as a multi-entry graph.""" + model, _ = get_model_and_model_api(MULTI_MODEL) + result = generate_multi_from_sgraph(model, level=3, transitive=True) + + repo_a_sbom = sbom_of(result, 'repoA') + entries = {d['ref']: d['dependsOn'] for d in repo_a_sbom['dependencies']} + + root_ref = repo_a_sbom['metadata']['component']['bom-ref'] + repo_b_ref = slugify_bom_ref('repoB') + + assert repo_b_ref in entries[root_ref] + assert any('Newtonsoft.Json' in ref for ref in entries[root_ref]) + assert any('commons-lang3' in ref for ref in entries[repo_b_ref]) + + # No BOM-Link URNs in dependsOn: Dependency-Track drops them as dangling refs + for depends_on in entries.values(): + assert not any(ref.startswith('urn:cdx:') for ref in depends_on) + + +def test_transitive_mode_dependson_refs_all_resolve_within_the_bom(): + """Every ref and dependsOn entry points at a component of the same BOM (DT-import safety).""" + model, _ = get_model_and_model_api(MULTI_MODEL) + result = generate_multi_from_sgraph(model, level=3, transitive=True) + + for sbom in result: + known_refs = {c['bom-ref'] for c in sbom['components']} + known_refs.add(sbom['metadata']['component']['bom-ref']) + for entry in sbom['dependencies']: + assert entry['ref'] in known_refs + for ref in entry['dependsOn']: + assert ref in known_refs + + +def test_default_mode_is_unchanged_by_the_transitive_feature(): + """Without transitive=True there are no inlined internal components and BOM-Links remain.""" + model, _ = get_model_and_model_api(MULTI_MODEL) + result = generate_multi_from_sgraph(model, level=3) + + repo_a_sbom = sbom_of(result, 'repoA') + assert all(find_property(c, 'softagram:internal') is None + for c in repo_a_sbom['components']) + assert len(repo_a_sbom['dependencies']) == 1 + assert any(ref.startswith('urn:cdx:') + for ref in repo_a_sbom['dependencies'][0]['dependsOn']) + + +def test_transitive_mode_terminates_on_dependency_cycles(): + """Mutually dependent repos inline each other once and both externals appear in both SBOMs.""" + model = SGraph(SElement(None, '')) + a_file = model.createOrGetElementFromPath('/Org/repoA/src/a.cs') + b_file = model.createOrGetElementFromPath('/Org/repoB/src/b.cs') + ext_a = model.createOrGetElementFromPath('/Org/External/NuGet/LibA') + ext_a.attrs['version'] = '1.0.0' + ext_b = model.createOrGetElementFromPath('/Org/External/NuGet/LibB') + ext_b.attrs['version'] = '2.0.0' + SElementAssociation(a_file, b_file, 'use').initElems() + SElementAssociation(b_file, a_file, 'use').initElems() + SElementAssociation(a_file, ext_a, 'use').initElems() + SElementAssociation(b_file, ext_b, 'use').initElems() + + result = generate_multi_from_sgraph(model, level=2, transitive=True) + assert len(result) == 2 + + for name, other in (('repoA', 'repoB'), ('repoB', 'repoA')): + sbom = sbom_of(result, name) + component_names = [c['name'] for c in sbom['components']] + assert component_names.count(other) == 1 + assert 'LibA' in component_names + assert 'LibB' in component_names + + +def test_transitive_dedup_folds_case_like_the_per_subtree_walk(): + """A case-variant NuGet id met through an internal element is the same package, not a second. + + The per-subtree collector folds nuget/pypi keys (G5); the cross-subtree merge of transitive + mode must apply the same key, or the duplicate G5 removed comes back whenever the two + spellings arrive from different repos. + """ + model = SGraph(SElement(None, '')) + a_file = model.createOrGetElementFromPath('/Org/repoA/src/a.cs') + b_file = model.createOrGetElementFromPath('/Org/repoB/src/b.cs') + upper = model.createOrGetElementFromPath('/Org/External/Assemblies/NLog') + upper.attrs['version'] = '5.0.0' + lower = model.createOrGetElementFromPath('/Org/External/Assemblies/nlog') + lower.attrs['version'] = '5.0.0' + SElementAssociation(a_file, b_file, 'use').initElems() + SElementAssociation(a_file, upper, 'use').initElems() + SElementAssociation(b_file, lower, 'use').initElems() + + result = generate_multi_from_sgraph(model, level=2, transitive=True) + repo_a_sbom = sbom_of(result, 'repoA') + + nlog_components = [c for c in repo_a_sbom['components'] if c['name'].lower() == 'nlog'] + assert len(nlog_components) == 1 + # Document order: repoA's own spelling arrives first and survives + assert nlog_components[0]['name'] == 'NLog' + + # repoB's dependency entry must reference the SURVIVING spelling, not the dropped one — + # otherwise the fold reintroduces a dangling ref inside the BOM + entries = {d['ref']: d['dependsOn'] for d in repo_a_sbom['dependencies']} + assert entries[slugify_bom_ref('repoB')] == ['pkg:nuget/NLog@5.0.0'] + + +def test_cli_supports_the_transitive_flag(tmp_path): + """python -m ...sbom_cyclonedx_generator model out.json --level 3 --transitive works.""" + import json + import subprocess + import sys + import os + + model_path = os.path.join(os.path.dirname(__file__), 'modelfile_for_sbom_multi_tests.xml') + out_path = tmp_path / 'sboms.json' + proc = subprocess.run( + [sys.executable, '-m', 'sgraph.converters.sbom_cyclonedx_generator', + model_path, str(out_path), '--level', '3', '--transitive'], + capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr + + result = json.loads(out_path.read_text()) + repo_a_sbom = sbom_of(result, 'repoA') + assert any(find_property(c, 'softagram:internal') == 'true' + for c in repo_a_sbom['components']) + + +def test_cli_rejects_transitive_without_level(tmp_path): + """--transitive is only meaningful with --level; without it the CLI refuses.""" + import subprocess + import sys + import os + + model_path = os.path.join(os.path.dirname(__file__), 'modelfile_for_sbom_multi_tests.xml') + proc = subprocess.run( + [sys.executable, '-m', 'sgraph.converters.sbom_cyclonedx_generator', + model_path, str(tmp_path / 'out.json'), '--transitive'], + capture_output=True, text=True) + assert proc.returncode != 0 + assert '--transitive requires --level' in proc.stderr + + # --- purl type inference tests --- BINARY_REFS_MODEL = 'converters/modelfile_for_sbom_binary_refs_tests.xml'