Skip to content

fix: emit valid SPDX license expressions when enriching - #151

Draft
nissessenap wants to merge 3 commits into
snyk:mainfrom
nissessenap:fix/spdx-license-expressions
Draft

nissessenap wants to merge 3 commits into
snyk:mainfrom
nissessenap:fix/spdx-license-expressions

Conversation

@nissessenap

Copy link
Copy Markdown

Fixes #80.

Problem

ecosyste.ms passes package metadata through as-is, so license strings from registries that do not enforce SPDX (Maven being the usual offender) land in the SBOM verbatim, leaving a concluded license that does not validate.

While in there, a second instance of the same bug: the SPDX path joined multiple licenses with a comma, which is not an SPDX operator, so any multi-license package was invalid regardless of the strings involved.

Approach

Each license string is resolved against the SPDX license list, per the algorithm suggested in the issue:

  • Valid identifiers are kept, normalized (apache-2.0 → Apache-2.0). Valid expressions and WITH exceptions pass through.
  • Anything else becomes a LicenseRef-<sanitized original>, with the original string captured as extracted licensing info.

Validation is per license string rather than per package, so the valid identifiers in a mixed set survive:

"MIT,The Apache Software License"
  → licenseConcluded: (MIT OR LicenseRef-The-Apache-Software-License)
  → hasExtractedLicensingInfos: [{ licenseId: LicenseRef-The-Apache-Software-License,
                                   extractedText: "The Apache Software License" }]

Refs are declared once per document and shared between packages. Two different strings that sanitize to the same identifier get disambiguated, so a package is never put under a license it is not under.

CycloneDX keeps emitting an expression when everything is valid, and falls back to a licenses list — id for the valid, name for the rest — when it is not, since CycloneDX cannot mix the two forms.

Dependency

Validation uses github/go-spdx, per @goneall's recommendation on the issue. It carries the license list offline, so enrichment keeps working without network access. It requires cobra 1.10.2, hence the transitive bump in go.mod.

Notes for reviewers

Three deliberate limitations, flagged rather than papered over:

  1. CycloneDX loses the choice in mixed sets. Naming is the only escape hatch CycloneDX offers for a non-SPDX license, and multiple licenses[] entries may be read as all applying at once. An expression containing LicenseRef- would be worse: CycloneDX has no equivalent of hasExtractedLicensingInfos, so the ref would dangle undefined.
  2. licenses is a comma-separated field, so a single license name containing a comma splits in two — "The Apache Software License, Version 2.0" yields two refs. Pre-existing behaviour; rejoining the fragments needs a heuristic. Happy to add one if you would rather.
  3. NONE / NOASSERTION are dropped rather than joined, since neither is legal inside an expression. The field is left untouched, as it already is when ecosyste.ms has no license data.

Testing

ClassifyLicenses is table-tested over the valid, invalid, normalized, expression, exception, incoming-ref and empty cases; both enrichers have tests for the mixed-validity path, and the ref-collision case has a regression test. Existing expectations updated for the two output changes: SPDX multi-license is now an OR expression rather than comma-joined, and a single license is no longer wrapped in redundant parentheses.

lib/snyk tests hang on main as well as on this branch — unrelated, they appear to want network access.

🤖 Generated with Claude Code

ecosyste.ms passes package metadata through as-is, so license strings from
registries that do not enforce SPDX (Maven being the usual offender) end up in
the SBOM verbatim, leaving a concluded license that does not validate. The SPDX
path also joined multiple licenses with a comma, which is not an operator, so
any multi-license package was invalid regardless of the strings involved.

Each license string is now resolved against the SPDX license list. Valid
identifiers are normalized and kept; anything else becomes a LicenseRef- with
the original captured as extracted licensing info, so the valid identifiers in
a mixed set survive. CycloneDX keeps emitting an expression when everything is
valid, and falls back to a license list naming the rest when it is not, since
it cannot mix the two forms.

Validation uses github/go-spdx, which carries the license list offline and so
keeps enrichment working without network access. It requires cobra 1.10.2,
hence the transitive bump.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@snyk-io

snyk-io Bot commented Aug 21, 2026 •

Copy link
Copy Markdown

⚠️ Snyk checks are incomplete.

Status Scan Engine Critical High Medium Low Total (0)
⚠️ Open Source Security 0 0 0 0 See details
⚠️ Licenses 0 0 0 0 See details
✅ Code Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

nissessenap and others added 2 commits August 21, 2026 15:18
Review follow-up on the SPDX license work.

The versioned license field is comma separated, so "The Apache Software
License, Version 2.0" split into two licenses and enrichment emitted a choice
between "The Apache Software License" and a license named "Version 2.0" -
valid SPDX, but wrong, and it is the exact string issue snyk#80 opens with. A
fragment that is just a version now rejoins the name it belongs to.

CycloneDX validates license ids against a closed list that kept only six of
the "or later" identifiers, while SPDX allows a "+" suffix on any of them, so
a license such as Apache-2.0+ was emitted as an id no CycloneDX consumer
accepts. Those are named instead.

Licenses are also deduplicated on what they resolve to rather than on the
incoming string, so a package listing both MIT and mit no longer concludes
(MIT OR MIT), and the identifier a LicenseRef falls back to is re-derived
until it is free rather than once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Collapse the hand-rolled character map and "--" loop in licenseRefID into
one regexp pass, drop the unreachable case in LicenseExpression, and reduce
the extracted-licensing-info rehash loop to a single suffix attempt, whose
ceiling is now named in a comment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

Ecosystem license strings are not always valid SPDX license expressions

1 participant