SBOM: build maven purls from the model's Maven coordinates - #166
Merged
villelaitila merged 5 commits intoAug 3, 2026
Conversation
Softagram Impact Report for pull/166 (head commit: db6196f)TL;DR Changed code files: 3 | Directly impacted code files: 1⭐ Change Overview
⭐ Details of Dependency Changes
[] 📄 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.


Problem
The CycloneDX generator builds a maven purl from the element's name, producing
pkg:maven/<groupId> <artifactId>@<version>— one string, with a literal space where thenamespace separator belongs.
Two things are wrong with it, and the second is the one that bites:
the
maventype, because Maven Central identity isgroupId:artifactId. A single-segmentpkg:maven/<artifact>names no package.(type, namespace, name, version). The namespace is part of the matching key, so anamespace-less maven purl is unmatchable by construction, not merely untidy. These
components are invisible in exactly the tooling an SBOM exists to feed.
It is worse on the multi-SBOM path.
bom-refis the purl, and the dependency graph is built frombom-refs, so the space-bearing string lands raw inside a CycloneDXdependsOnarray. Today'soutput is an invalid dependency reference, not only an invalid component identifier.
Scale, in the one real analyzer-produced model available to this change: 204 of 204
Maven-bucketed elements produce a namespace-less, non-conforming purl. The defect is total on that
model rather than occasional. See "What was verified here" for what that figure does and does not
support.
This was deferred deliberately from #165, which fixed purl types and disclosed this as
limitation 6.
Cause
The
Mavenbranch resolves the type and then falls through to the shared name-based identifier:Meanwhile the elements already carry
groupIdandartifactIdas attributes, which the generatornever reads. The coordinates were present and discarded.
They are present because the analyzer copies them from the POM's
<dependency>element rather thanderiving them, so coordinate presence is a property of the analyzer's design rather than of one
project. That was confirmed in the analyzer source and observed in an analyzer-produced model of a
large open-source Java project.
Change
61 changed lines in
sbom_cyclonedx_generator.py(59 added, 2 removed).maven_purl(elem, version)buildspkg:maven/<groupId>/<artifactId>@<version>from the twoattributes. This is a projection of what the model already holds, not an inference about it —
which is what separates it from the labelled guesses SBOM: emit spec-valid purl types instead of '???' placeholders #165 introduced.
^[A-Za-z0-9._-]+$and be neither.nor... That is Maven'sown id charset, not purl's. Maven's model validator rejects anything outside it at ERROR
severity for every POM, and the set is a strict subset of what purl leaves unencoded — so a
coordinate that passes is spliced in verbatim and needs no encoding step. The dot exclusion is
not redundant with the pattern:
.and..match it, and Maven's local repository layout usesids verbatim as directory names, so accepting them would splice a path-traversal segment into
the namespace position.
genericwith apurlTypeResolutionproperty readingmaven coordinates unavailable. Emittingpkg:maven/<group>/orpkg:maven//<artifact>would trade one malformed purl for another.The analyzer resolves properties against the local POM's
<properties>element only, soproperties defined in an ancestor POM — and Maven built-ins such as
${project.version}, whichare never
<properties>children — cannot be resolved. The information is not in the model andcannot be. A purl version must be percent-encoded, so the expression would be either
non-canonical raw or canonical-and-unmatchable encoded; omitting it gives a purl that is
canonical and still matches at package level.
component.versionkeeps the raw expression, sonothing is lost and the version field is itself the disclosure.
artifactIds in the model measured carry uppercase, so a normalisation would corrupt them.
No qualifiers are emitted.
bom_ref(),generate_from_sgraphandgenerate_multi_from_sgraphkeep their signatures and return types.
Output shape change — read this before upgrading
Maven purls change, and
bom-refis the purl, sobom-refs change with them. Anything thatpersisted a maven
bom-refacross BOM versions re-baselines once: BOM diffing, VEX statements keyedon
bom-ref, and component history in a tracking server will see the old components disappear andnew ones appear. This is a one-time change, not an ongoing instability.
Affected components are identifiable before upgrading: a purl beginning
pkg:maven/thatcontains a space, or that contains no
/after the type.Component counts do not move. Measured on both output paths against the real model: 204 components
before and after, 204 distinct
bom-refs before and after, 18 refs differing, zero collapses.The versionless-purl rule was the one part of this change that could have merged two components
into one, so it was gated on that measurement rather than assumed safe.
component.nameis unchanged and still carries the space, so name-keyed lookups keep working.Provenance
The attribute path carries no provenance property: the analyzer declared the coordinates and
reading them is not a guess. Only the residual is labelled, and it uses a new value rather than
a new property:
That value is deliberately distinct from
ecosystem unresolved, which #165's migration notepromoted to a documented discriminator one release ago. Reusing it here would mark a component
whose ecosystem is known perfectly well as ecosystem-unknown, and would break the filter that note
asked consumers to adopt. Both values are present and separable in the fixture output — four
components carry
ecosystem unresolvedand three carrymaven coordinates unavailable— so thedistinction is asserted rather than merely intended.
Divergence from Syft, stated rather than left to be noticed
Syft never emits a namespace-less maven purl: when it cannot find a groupId it duplicates the
artifact name into the namespace, giving
pkg:maven/<name>/<name>@<version>. That is spec-validand frequently names a package that does not exist on Maven Central.
This change does not do that, under the same completeness rule #165 used to keep
.jarout of theextension-inference map: emit a type only when a complete identifier for it is in hand. A fabricated
identity that looks authoritative is worse than an honest
generic, because a validating consumeraccepts it and then silently matches nothing. Worth knowing that
groupId == artifactIdis genuinelycommon, so Syft's fallback is right more often than it looks — but being right by coincidence is not
a rule a consumer can reason about.
What is deliberately not changed
clean_name()turns__slash__into a/that isload-bearing for golang and scoped-npm namespaces, so encoding the finished identifier would
break purls that are correct today. Encoding would also have to happen per component after the
split, never on a joined string. Measured across the models available here, blanket encoding
would change 760 of 2853 produced purls while the existing suite would see a single failure.
Out of scope; an encoded component in a maven purl is evidence of a missed split, not of correct
encoding.
Mavenbranch matches too shallowly.That turns out to be wrong about the mechanism: the branch tests the package's parent name, so
the bucket's own depth is irrelevant, and all 204 real packages match today. The nesting
inconsistency as a class is not closed. In particular, aligning
Gothe same way would makeGo worse, not better — Go nests by module-path segment, so ancestor matching would emit
pkg:golang/<leaf>@<version>, dropping the module path and replacing an honest labelledgenericwith a confidently-typed purl that matches nothing.jar/war/aarstay unmapped. This change reads coordinateswhere they exist; a file extension is exactly the case where they do not.
Javabucket.Javanames a language, not a package ecosystem with resolvable identity,and there is no
javapurl type. This is the same rule as the two entries above and as thecoordinate-less residual: emit a type only when a complete identifier for it is in hand.
group/namefields. Settinggroup=groupId, name=artifactIdwould be the"complete" fix, but it changes the field consumers display and join on — a larger surface than
the purl change — and breaks the generator's existing name-keyed lookup. Separate change.
JVMbucket. They are written with no attributes at all, sothey carry no version, fail
valid_for_bom()and never become components. That is an upstreamcoverage gap this change neither helps nor harms. Everything here concerns Maven-bucketed
dependencies specifically.
What was verified here, and what is only reported
Reproduced in this repository, from tracked fixtures — a reviewer can re-run all of it:
That last row is the compatibility claim in its checkable form: the shape change is confined to
mavenexactly as scoped, and nothing else in the output moved.The gates pass, each pre-registered with its expected values before the implementation existed,
and each demonstrated able to fail: a purl-shape gate derived from the spec, a mutation-and-budget
gate, a flake8 identity gate, and the version-omission collapse gate. Every gate pre-registered to
fail flipped green, and no preservation guard broke. They were also re-checked for failability
after the change landed — a deliberately broken variant still trips the guard — so the suite did
not quietly become vacuous once it went green, which is the usual way a green gate stops meaning
anything. flake8 on
src/is unchanged at 142 pre-existing findings.Measured in this environment on one real model, not reproducible by a reviewer — this model is
not part of the repository and is not redistributable, so these are attributed rather than asserted:
204 of 204 Maven-bucketed elements affected before the change and 204 of 204 carrying a namespace
after it, with zero purls containing a raw space and zero carrying a coordinate-resolution
property — the attribute path is authoritative and therefore silent, as intended; 18 of 204
versioned elements carrying an unresolved expression; 23 of 204 carrying
type/classifierqualifiers; zero component collapses on either path.
The separation that matters for review: the change's justification is the conformance argument
— the namespace is required by the type definition and is part of Dependency-Track's matching key —
plus the fixture-level evidence above. Neither needs the one-model figures. Those are the change's
motivation: they say how much of a real model was affected, not whether the fix is correct.
The expected purls asserted in the tests were checked against Maven Central itself, with a negative
control.
One note on running the suite. The full suite is 193 passed in a tree where an untracked local
model file happens to be present. One pre-existing test,
tests/converters/test_xml_to_hierarchical_json.py, reads a model file that is not in therepository, so on a clean checkout it fails and the suite is 192 passed + 1 failed. That failure
is unrelated to this change, predates it, and is not fixed here. The tests for this change live in
tests/converters/sbom_cyclonedx_generator_test.py, which has no such dependency and passesstandalone.
Known limitations
1.0-${suffix}is partly known, and dropping the known part would change which components existunder a rule nobody has measured — so the match is deliberately anchored to the whole string.
Such a purl is not canonical. Not observed in any model available here.
containing characters outside purl's permitted set would not be canonical. No such case occurs
anywhere measured — 8 non-Maven elements carrying a
versionattribute, across the fixtures andthe one real model, none affected. The path is unguarded rather than exercised. Pre-existing and
unchanged here.
typeorclassifier,so a classified artifact and its main artifact share one purl. Package-level identity is what
vulnerability matching needs, and omitting them produced zero collisions across 204 distinct
coordinate triples — but the fidelity loss is real.
coordinates that may name nothing on Maven Central. Same limitation SBOM: emit spec-valid purl types instead of '???' placeholders #165 recorded for NuGet.
genericand get novulnerability matching. This is honest rather than good: the ecosystem is known, only the
identity is missing, which is why the property value says exactly that.
Tests
13 new tests and one new fixture,
modelfile_for_sbom_maven_coordinates_tests.xml, whose elementsexist to force paths that are otherwise unreachable: partial coordinates, absent coordinates, a
coordinate carrying a character outside the Maven id charset, a mixed-case coordinate, and an
unresolved version. Several of these cases are reachable by construction rather than observed —
the analyzer copies POM child tags verbatim and synthesizes nothing, so a POM omitting a coordinate
yields an element missing that attribute — and the tests are documented that way rather than as
covering something that was seen.
The fixture nests the Maven bucket under a
JVMlayer, matching the real layout, and asserts thatas a premise. That makes the shallow-matching question answerable from tracked code: the branch
matches on the package's parent name, so the bucket's depth does not matter. Note the direction of
that evidence — the fixture demonstrates the mechanism; the layout was observed in the real
model, and a fixture built to match an observation cannot corroborate it.
Three existing expectations changed, all of them this fix working as intended: one asserted purl
string, and two comments whose premises the change falsifies. The purl assertion was pinned in #165
as characterization of known-nonconforming output, with a comment instructing that a fix should
update the expected string rather than relax the assertion — which is what happened.