Skip to content

SBOM: build maven purls from the model's Maven coordinates - #166

Merged
villelaitila merged 5 commits into
softagram:mainfrom
villelaitila:feature/sbom-maven-purls
Aug 3, 2026
Merged

SBOM: build maven purls from the model's Maven coordinates#166
villelaitila merged 5 commits into
softagram:mainfrom
villelaitila:feature/sbom-maven-purls

Conversation

@villelaitila

Copy link
Copy Markdown
Contributor

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 the
namespace separator belongs.

Two things are wrong with it, and the second is the one that bites:

  • The purl spec makes the namespace required for
    the maven type, because Maven Central identity is groupId:artifactId. A single-segment
    pkg:maven/<artifact> names no package.
  • Dependency-Track — the tool most likely to consume this output — matches components on
    (type, namespace, name, version). The namespace is part of the matching key, so a
    namespace-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-ref is the purl, and the dependency graph is built from
bom-refs, so the space-bearing string lands raw inside a CycloneDX dependsOn array. Today's
output 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 Maven branch resolves the type and then falls through to the shared name-based identifier:

elif elem.parent.name == 'Maven':
    pkgtype = 'maven'

Meanwhile the elements already carry groupId and artifactId as attributes, which the generator
never reads. The coordinates were present and discarded.

They are present because the analyzer copies them from the POM's <dependency> element rather than
deriving 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).

  1. maven_purl(elem, version) builds pkg:maven/<groupId>/<artifactId>@<version> from the two
    attributes. 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.
  2. Both coordinates must pass ^[A-Za-z0-9._-]+$ and be neither . nor ... That is Maven's
    own 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 uses
    ids verbatim as directory names, so accepting them would splice a path-traversal segment into
    the namespace position.
  3. Absent, partial and rejected coordinates all take the same residual: type generic with a
    purlTypeResolution property reading maven coordinates unavailable. Emitting
    pkg:maven/<group>/ or pkg:maven//<artifact> would trade one malformed purl for another.
  4. A version that is entirely an unresolved build-property expression yields a versionless purl.
    The analyzer resolves properties against the local POM's <properties> element only, so
    properties defined in an ancestor POM — and Maven built-ins such as ${project.version}, which
    are never <properties> children — cannot be resolved. The information is not in the model and
    cannot 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.version keeps the raw expression, so
    nothing is lost and the version field is itself the disclosure.
  5. Case is preserved. The maven type is case-sensitive in both namespace and name; six real
    artifactIds in the model measured carry uppercase, so a normalisation would corrupt them.

No qualifiers are emitted. bom_ref(), generate_from_sgraph and generate_multi_from_sgraph
keep their signatures and return types.

Output shape change — read this before upgrading

Maven purls change, and bom-ref is the purl, so bom-refs change with them. Anything that
persisted a maven bom-ref across BOM versions re-baselines once: BOM diffing, VEX statements keyed
on bom-ref, and component history in a tracking server will see the old components disappear and
new ones appear. This is a one-time change, not an ongoing instability.

Affected components are identifiable before upgrading: a purl beginning pkg:maven/ that
contains a space, or that contains no / after the type.

before : pkg:maven/org.apache.commons commons-lang3@3.12.0
after  : pkg:maven/org.apache.commons/commons-lang3@3.12.0

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.name is 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:

"purl": "pkg:generic/example-lib@1.0",
"properties": [
  { "name": "purlTypeResolution", "value": "maven coordinates unavailable" }
]

That value is deliberately distinct from ecosystem unresolved, which #165's migration note
promoted 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 unresolved and three carry maven coordinates unavailable — so the
distinction 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-valid
and 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 .jar out of the
extension-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 consumer
accepts it and then silently matches nothing. Worth knowing that groupId == artifactId is genuinely
common, 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

  • General name/version percent-encoding. clean_name() turns __slash__ into a / that is
    load-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.
  • Ecosystem bucket nesting. SBOM: emit spec-valid purl types instead of '???' placeholders #165's limitation 7 said the Maven branch 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 Go the same way would make
    Go 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 labelled
    generic with a confidently-typed purl that matches nothing.
  • The extension-inference map. jar/war/aar stay unmapped. This change reads coordinates
    where they exist; a file extension is exactly the case where they do not.
  • The Java bucket. Java names a language, not a package ecosystem with resolvable identity,
    and there is no java purl type. This is the same rule as the two entries above and as the
    coordinate-less residual: emit a type only when a complete identifier for it is in hand.
  • CycloneDX group / name fields. Setting group=groupId, name=artifactId would 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.
  • Gradle dependencies under the JVM bucket. They are written with no attributes at all, so
    they carry no version, fail valid_for_bom() and never become components. That is an upstream
    coverage 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:

SBOM test file                    25 ->  38   (13 new tests)
purls changing value                    3     (one per existing fixture)
existing assertions changing            1
non-maven purls                        20     all unchanged, none lost or altered

That last row is the compatibility claim in its checkable form: the shape change is confined to
maven exactly 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/classifier
qualifiers; 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 the
repository, 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 passes
standalone.

Known limitations

  1. Versions that are only partly unresolved are still emitted verbatim. A version like
    1.0-${suffix} is partly known, and dropping the known part would change which components exist
    under 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.
  2. Package names remain unencoded throughout the module, for every type. A version or name
    containing characters outside purl's permitted set would not be canonical. No such case occurs
    anywhere measured — 8 non-Maven elements carrying a version attribute, across the fixtures and
    the one real model, none affected. The path is unguarded rather than exercised. Pre-existing and
    unchanged here.
  3. Qualifiers are dropped. 23 of 204 elements in the model measured carry type or classifier,
    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.
  4. In-house artifacts are not distinguished from published ones. A purl is emitted for
    coordinates that may name nothing on Maven Central. Same limitation SBOM: emit spec-valid purl types instead of '???' placeholders #165 recorded for NuGet.
  5. Components whose coordinates are absent or malformed become generic and get no
    vulnerability 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.
  6. Licenses remain empty for these components — a separate concern.

Tests

13 new tests and one new fixture, modelfile_for_sbom_maven_coordinates_tests.xml, whose elements
exist 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 JVM layer, matching the real layout, and asserts that
as 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.

@softagram-bot

Copy link
Copy Markdown

Softagram Impact Report for pull/166 (head commit: db6196f)

TL;DR Changed code files: 3 | 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

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

[]

📄 Full report

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

@villelaitila
villelaitila merged commit b919151 into softagram:main Aug 3, 2026
1 check passed
@villelaitila
villelaitila deleted the feature/sbom-maven-purls branch August 3, 2026 11:58
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