Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 156 additions & 30 deletions src/sgraph/converters/sbom_cyclonedx_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,26 @@ 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.
#
# 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 '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):
Expand All @@ -40,6 +58,8 @@ def extract_version(elem):
return elem.name.split(' of version ')[-1].strip()
if ' of tag ' in elem.name:
return elem.name.split(' of tag ')[-1].strip()
if 'parent_version' in elem.attrs:
return elem.attrs['parent_version']


def incoming_deps(elem, elem_name_patterns, deptypes):
Expand Down Expand Up @@ -164,13 +184,17 @@ 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. 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 UNRESOLVED_VERSION.fullmatch(version):
if not version or ';' in version or UNRESOLVED_VERSION.fullmatch(version):
return f'pkg:maven/{group_id}/{artifact_id}'
return f'pkg:maven/{group_id}/{artifact_id}@{version}'

Expand Down Expand Up @@ -228,6 +252,10 @@ def purl_for(elem, v):
properties.append({'name': PURL_TYPE_SOURCE_PROPERTY, 'value': 'ecosystem unresolved'})

v = v.lstrip('^')
# 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


Expand Down Expand Up @@ -641,14 +669,96 @@ 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; the ones pulled in
# through an internal element are annotated with the element that routed them here.
components = []
seen_refs = set()
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)
external_refs_of[path] = [c['bom-ref'] for c in ext_components]
for component in ext_components:
if component['bom-ref'] in seen_refs:
continue
seen_refs.add(component['bom-ref'])
if path != root_path:
component.setdefault('properties', []).append({
'name': 'softagram:via',
'value': elem.name
})
components.append(component)

# 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),
and a generalized model for inter-repo dependency detection.

: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
Expand Down Expand Up @@ -705,10 +815,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}
Expand All @@ -730,31 +842,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()
Expand All @@ -774,12 +892,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)

Expand Down
55 changes: 55 additions & 0 deletions tests/converters/modelfile_for_sbom_maven_coordinates_tests.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,19 @@
<r r="22" t="ref" />
</e>
</e>
<!-- These references are the whole difference between managed-lib and husk-lib: the
inclusion rule for versionless elements is incoming references, and only one of the
pair has any. Removing either reference or pointing one at husk-lib collapses the
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. -->
<e i="11" n="pom.xml" t="file">
<r r="30" t="mvn" />
<r r="32" t="mvn_parent" />
<r r="33" t="mvn_parent" />
<r r="34" t="mvn_parent" />
<r r="35" t="mvn" />
</e>
</e>
<e n="External">
<!-- The Maven bucket sits under a JVM layer here, as analyzer output does. The branch
Expand Down Expand Up @@ -48,6 +61,48 @@
<e i="24" n="org.example.unresolved unresolved-lib of version ${project.version}"
artifactId="unresolved-lib" groupId="org.example.unresolved"
repotype="Maven" version="${project.version}" />
<!-- No version anywhere: it is managed by a BOM or parent the analyzer never parses.
The element is referenced from pom.xml, which is what earns it a component.
Giving it a version attribute would turn the versionless-inclusion test into a
restatement of the ordinary versioned case. -->
<e i="30" n="org.example.managed managed-lib"
artifactId="managed-lib" groupId="org.example.managed"
repotype="Maven" />
<!-- The unreferenced control for managed-lib: identical shape, no incoming reference.
Version-management redirection leaves elements like this behind after their
references were re-pointed at versioned ones. If this ever appears as a
component, the inclusion rule has decayed into mere coordinate presence. -->
<e i="31" n="org.example.husk husk-lib"
artifactId="husk-lib" groupId="org.example.husk"
repotype="Maven" />
<!-- An external parent pom: its exact version is known but arrives under
parent_version, because that is the attribute a <parent> block produces. Renaming
this to version would make the parent_version extraction guard vacuous. -->
<e i="32" n="org.example.parentpom parent-pom"
artifactId="parent-pom" groupId="org.example.parentpom"
parent_version="7.1" repotype="Maven" />
<!-- The old-model shape of a parent: every model persisted before the analyzer
started writing coordinates onto parents carries parent_version alone. Emitting
it would splice the space-bearing element name into a generic purl, the exact
class the maven-purl work eliminated. Adding coordinates here would turn this
back into parent-pom and retire the only stored-model regression guard. -->
<e i="33" n="org.example.legacyparent legacy-parent"
parent_version="5.5" repotype="Maven" />
<!-- Two poms naming the same parent at different versions land on one element, and
the attribute transfer joins the values with a semicolon. A purl carrying the
joined value would assert a version that exists nowhere; the raw value must stay
disclosed in the component's version field only. -->
<e i="34" n="org.example.multiparent multi-parent"
artifactId="multi-parent" groupId="org.example.multiparent"
parent_version="4.1.0;3.2.0" repotype="Maven" />
<!-- Versionless AND charset-rejected coordinates: a ${} groupId whose property lives
in an external parent. With a version such an element takes the generic residual;
with neither version nor usable identity there is nothing spec-clean to emit, and
the fallback would splice the space-bearing name into the purl. Resolving the
groupId here would collapse this into managed-lib and retire the guard. -->
<e i="35" n="org.example.propgroup prop-group-lib"
artifactId="prop-group-lib" groupId="${project.groupId}"
repotype="Maven" />
</e>
</e>
</e>
Expand Down
Loading
Loading