SBOM: emit spec-valid purl types instead of '???' placeholders - #165
Merged
villelaitila merged 1 commit intoAug 1, 2026
Merged
Conversation
Infer purl types for repo-committed binaries from referencing-file extensions (dll/exe/nupkg -> nuget, whl/egg -> pypi, gem -> gem), fall back to 'generic' instead of the spec-invalid '???' / '??Java', label every inferred or fallback type with a purlTypeResolution property, and deduplicate identical bom-refs on the single-SBOM path.
Softagram Impact Report for pull/165 (head commit: fc659fc)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
bom_ref()in the CycloneDX generator falls back topkg:???/<name>@<version>when itcannot resolve an ecosystem, and to
pkg:??Java/...for elements under a parent namedJava.Neither is a valid package URL. The purl spec
requires the type to begin with a letter and hold only letters, digits,
.and-, and tobe canonically lowercase; the spec's own test vectors
reject a
?in the type. Validating consumers (Dependency-Track, for example) reject suchcomponents outright, so they are never matched against vulnerability databases — the
affected components are invisible in exactly the tooling an SBOM exists to feed.
In a large real-world model (674 SBOMs, ~12.5k components) this affected 115 components,
several of them packages with known critical CVEs.
Cause
The affected elements all share one shape: a binary committed into the repository and
referenced from source, with
repotypeattribute, or one that names no ecosystem — the analyzer's ownrecord that it could not identify one,
NPM,PIP,Maven,Assemblies),refassociations fromt="file"elements — typically.dll, sometimes.exe.These are legacy .NET
<Reference><HintPath>pointers at checked-in assemblies, notpackage-manager dependencies. Both existing inference paths (attribute, ancestor name) have
nothing to read. The one remaining signal is the extension of the referencing file.
Change
This is two things, and it is worth separating them because only one is spec-backed:
???and??Javaare invalid types;genericis the correctfallback. No judgement is involved and the spec settles it.
nugetfrom a referencing.dllis aninference the spec neither blesses nor forbids —
nugetidentifies packages fromnuget.org, not .NET assemblies in general, and purl has no type for a bare assembly. It
is justified empirically, by assembly names routinely equalling NuGet package ids.
The
purlTypeResolutionproperty below is what keeps the two distinguishable in theoutput, so a consumer can filter the second out and keep the first.
infer_pkgtype_from_referencing_files(elem)derives a purl type from the extensions ofthe files referencing the element, via a documented mapping (
dll/exe/nupkg→nuget,whl/egg→pypi,gem→gem). A type is inferred only where theinferred name is by itself a complete package identifier for that type — see
"Why
dll/exemap tonugetbut.jardoes not" below. Extensions are countedrather than taken first-hit, since a binary can be referenced from several file kinds;
ties break alphabetically so output stays byte-stable across runs.
generic— the purl type for packages that fit noother type, and the only registered type with no repository behind it — instead of
???. The spec asks that another type be used where possible; inference runs first andgenericis reached only when it finds nothing, which satisfies that constraint. (Asidtype for binary-only software with no registry behind it is proposed but notadopted, so
genericis correct today and there is a named successor to watch.)??Javabranch is removed. Elements under aJavaparent go through the sameinference, which for JVM artifacts resolves to
generic(see limitation 2).analyze_3rdparty()now skips a component whosebom-refit has already emitted,mirroring the
seen_refsguard that_collect_3rdparty_for_subtree()has always had.Without it, making previously-distinct purls agree would make them collide as
bom-refs — and CycloneDX requires those to be unique within a BOM. JSON Schema cannotexpress that constraint, but the XML schema can and does, at document scope: a duplicate
rejects the whole document rather than one component. Fixing purl types while opening
that hole would trade a component-scoped defect for a document-scoped one.
bom_ref()becomes a thin wrapper over a newpurl_for()that additionally returnsprovenance properties. The signature, return type and behaviour of
bom_ref()areunchanged.
Provenance: guesses are labelled
Any component whose type was inferred or fell back carries a
purlTypeResolutionproperty:A type read from
repotypeor from an ecosystem-named ancestor gets no such property. Readthat absence precisely: it means the type was not inferred by this mechanism, not that it
is certainly right. The pre-existing ancestor-name branches are themselves heuristics — they
are simply older and unlabelled, and relabelling them would change output for every existing
component, so it is left alone here. The value cites only the extensions
that voted for the winning type, so it never lists evidence that argued against the type
actually chosen.
component.propertiesis CycloneDX's sanctioned place for this and is what the modulealready uses for
sourceCodeReferences.component.evidence.identity(techniquefilename) would be the standards-idiomatic home for identity provenance specifically, andis the natural follow-up; it is not adopted here because it would make this one field
inconsistent with the module's existing property-based convention for no gain in validity.
Migration note: anyone currently grepping for
pkg:???as an "unresolved ecosystem"marker should switch to the
purlTypeResolutionproperty with the valueecosystem unresolved, which is now the discriminator. This matters more than a rename:???announced its own failure, whereaspkg:generic/...looks plausible while matchingnothing. The property is what keeps unresolved components findable (see limitation 7).
Why
dll/exemap tonugetbut.jardoes notThe rule governing the map: infer a type only when the inferred name is by itself a
complete identifier for that type. Whether that holds depends on the purl type definition,
and the two ecosystems differ.
nugetprohibits a namespace — the package id is the whole identity. A HintPath assemblyreference is not proof of a NuGet package, but assembly names routinely match real package
ids, so the inferred purl carries a complete identifier and stands a real chance of
matching. An in-house
assembly gets a purl that matches nothing in any database — a harmless miss rather than a
false alarm.
mavenrequires a groupId namespace, and Maven Central identity isgroupId:artifactId.A file extension supplies no groupId and no way to derive one, so
pkg:maven/<artifact>@<v>would be malformed and unmatchable — an artifact name alone is ambiguous across groups.
Mapping
jar/war/aartomavenwould therefore trade one class of invalid purl foranother, which is exactly what this change exists to stop. JVM artifacts fall through to
genericinstead.The
purlTypeResolutionproperty keeps every inference auditable either way. Note that itcannot rescue a malformed purl: a validating consumer rejects the component before any
property is read, which is why the completeness rule gates the map rather than provenance
alone.
Effect on a real model (reported, not reproduced here)
These figures come from the environment where the problem was found: same model, same
command, this change the only variable, measured on the per-element path
(
generate_multi_from_sgraph). That environment is not reachable from this repository, sothey are reported rather than independently reproduced. "What was verified here" below
separates the two.
???typesnugetgenericThe component total drops by 31 because those formerly-
???components now resolve topurls that already existed as NuGet components in the same SBOM. The same package had been
counted twice — once as an
assembly_reffrom a csproj/vbproj, once as the committed DLL —and now collapses to one entry. This is a correction, not lost data.
Reduced to the smallest case that shows it, with
bom_refas the only variable:Both output paths now collapse such a pair, because change 4 gives the single-SBOM path
the same
bom-refguard the per-element path already had. Before that guard the twocomponents would have survived side by side there, sharing one
bom-ref.What was verified here
Measured over eight models available to this change, single-SBOM path, before and after:
The six become
generic, each carrying apurlTypeResolutionproperty.The conformance half is therefore reproduced rather than reported: real models emit invalid
purl types today and stop after the change. The last row is the
bom-refguard on its own,and it is the clearer statement of what that guard does — those 32 duplicates existed
beforehand and CycloneDX does not permit them, so the guard repairs a live document-level
defect rather than only preventing one this change would otherwise have introduced. The
32-component drop is the same guard, concentrated entirely in one of the eight models
(limitation 5).
Note what those six became —
generic, every one of them, and no mapped ecosystem type.The inference half never fired here, because no model available to this change contains a
single
.dll,.exe,.nupkg,.whl,.eggor.gem; there is one.jarin total.Also reproduced, against the new fixtures: the
genericfallback, the presence and absenceof the provenance property, the alphabetical tie-break, the removal of the
??Javabranch,and the deduplication pair shown above.
The map's entries do not all rest on the same kind of evidence, and it is worth being
explicit about which:
nupkg→nuget.nupkgis a NuGet packagedll/exe→nugetwhl/egg→pypi,gem→gemThe reported extension distribution and component counts therefore rest on the reference
environment alone.
The separation that matters for review: the change's justification is the conformance
argument — measured above, six invalid types to zero — plus the structural validity of each
mapped type, which is checkable from the purl type definitions. Neither needs the reported
figures. Those are the change's motivation: they say the problem was worth fixing and at
what scale, not whether the fix is correct. Nothing in the design depends on them, which is
why they are attributed rather than asserted.
Known limitations
happens to share a name with a real NuGet package produces a false-positive purl. A
precise fix needs model-root access inside
purl_for(); out of scope here..jar/.war/.aar) resolve togenericand so get no vulnerabilitymatching, for the reason given above: a maven purl needs a groupId that no file
extension can supply. Recovering coordinates from a JAR's
META-INF/maven/*/pom.properties,or from a pom.xml elsewhere in the model, would make the inference possible later. Out
of scope here, and deliberately left as an honest gap rather than a plausible-looking
guess that would match nothing anyway.
produce_source_code_references()already does. Where several versioned children shareone name directory, a reference landing on that directory votes for every unresolved
child, so the recorded evidence can be broader than the evidence for one specific child.
path, the surviving
sourceCodeReferencespoint at the DLL rather than at the csproj.Because
seen_refskeeps whichever component it met first, the survivor can also lose apurlTypeResolutionlabel its twin carried, or keep one when the discarded twin wasauthoritatively typed. That is at stake only where a collision pairs an inferred
component with an ancestor-resolved one: there exactly one twin carries the label, so
traversal order decides which survives. A collision can equally pair two
ancestor-resolved components, with no label on either side and nothing to lose — which
describes every one of the 32 collisions measured across the models available here. So
where this bites is the mixed class, and no mixed collision occurs in any model
available to this change. It is inferred rather than measured that the reference
environment's merges were of that kind: they are reported there as formerly-
???components meeting the
assembly_reftwins they had been double-counted against, andthat pairing is one inferred component and one ancestor-resolved one. This is existing
_collect_3rdparty_for_subtreebehaviour, not introduced here, but this change makesmore components collide and so makes it more visible.
bom-refguard on the single-SBOM path also collapses duplicates that predate thischange — any two externals resolving to the same purl already collided there. Measured
across eight models, seven were unaffected and one lost 32 of 261 components, so the
effect is real but concentrated. That model's single-SBOM output already carried 32
duplicate
bom-refs and was invalid under the XML schema before any change here, sothe guard repairs it rather than degrading it. No distinct package is lost: collapse is
by identical purl, every unique purl survives, and vulnerability matching keys on purl.
What narrows is
sourceCodeReferences— the guard discards rather than merges, and thesurvivor is whichever the traversal reaches first, so it may be the twin with the
shorter reference list. Merging the two lists instead would preserve them, but that is
a different semantic than the per-element path has ever applied and belongs in its own
change. One class this closes was already invalid under JSON Schema too: two
indistinguishable unresolved elements produced byte-identical component objects, which
bom.componentsforbids viauniqueItems.independent conformance gaps remain and are deliberately not touched: package names
are emitted unencoded, so pre-existing components whose names contain literal spaces
stay non-conformant; and the existing
Mavenancestor branch emitspkg:maven/<name>without the groupId namespace that the maven type requires — the same namespace problem
that keeps
jarout of the inference map, in a branch this change does not touch.Widening the fix to those would change output well beyond the paths measured above.
NPM,APT,PIPandAssembliesmatch a parent or grandparent;Python,GoandMavenmatch only adirect parent, although the documented
External/layout nests packages one leveldeeper. Packages nested under
Python,GoorMaventherefore miss their branch andfall to inference, which has no mapping for
.pyor.goand so yieldsgeneric.Before this change they yielded
???. Both are wrong, but???was loud and greppablewhere
genericlooks plausible, so this change makes an existing failure quieter — thepurlTypeResolutionproperty is what keeps it detectable. Aligning those three branchesis a small, obvious follow-up; it is excluded here because it changes branches the
measurements above did not exercise.
Tests
New fixture
tests/converters/modelfile_for_sbom_binary_refs_tests.xmlreproduces theproduction shape: both attribute variants — absent, and a
repotypenaming no ecosystem —with the version in the
' of version 'name suffix and arefassociation from at="file"element. New tests cover each mapped extension family, thegenericfallback, presence and absence of the provenance property, the alphabeticaltie-break that keeps output byte-stable, and the removal of the
??Javabranch.A
.jar-referenced binary is asserted to yieldgenericrather thanmaven, sothe deliberate exclusion is pinned by a test and cannot be "fixed" back into a defect. One
regression test asserts the spec invariant itself over every generated purl:
encoding the rule rather than a list of expected strings, so future ecosystem additions
cannot silently reintroduce an invalid type.
The guard is deliberately type-scoped, and the fixture carries a witness for why: one
component emits a maven purl whose name contains a literal space. That component is
asserted as-is rather than corrected, so the name-encoding gap in limitation 6 is recorded
as executable characterization rather than left as prose — and so a future full-purl
validator has a known case to fail against.
Existing tests are unchanged and pass.