Skip to content

Fix wrong SPDX license expression when a package has multiple licenses - #5150

Open
sueun-dev wants to merge 1 commit into
anchore:mainfrom
sueun-dev:fix-spdx-license-expression-join
Open

Fix wrong SPDX license expression when a package has multiple licenses#5150
sueun-dev wants to merge 1 commit into
anchore:mainfrom
sueun-dev:fix-spdx-license-expression-join

Conversation

@sueun-dev

Copy link
Copy Markdown

Description

Both the SPDX and the CycloneDX encoder parenthesise a license expression before joining it onto the other licenses with AND, and both lean on the same check — does it start with ( or end with ):

// syft/format/internal/spdxutil/helpers/license.go
if !strings.HasPrefix(v, "(") && !strings.HasSuffix(v, ")") {
// syft/format/internal/cyclonedxutil/helpers/licenses.go
if !strings.HasPrefix(e, "(") && !strings.HasSuffix(e, ")") {

An expression can do either and still carry an OR at the top level. SPDX applies AND before OR, so those get joined unwrapped and the result stops meaning what was declared. Both checks came in together in 42fa9e4.

SPDX side. Syft's Arch cataloger splits pacman's %LICENSE% block line by line (syft/pkg/cataloger/arch/package.go), so an Arch package can carry many licenses. linux-lts-headers 6.18.41 ships 42 of them, 8 skip the wrapping and 7 of those carry a top-level OR(GPL-2.0-only WITH Linux-syscall-note) OR BSD-2-Clause, ... OR MIT, and so on. Scanning a pacman root holding that real license block:

before: ... AND MIT AND (GPL-2.0-only WITH Linux-syscall-note) OR BSD-2-Clause AND ...
after:  ... AND MIT AND ((GPL-2.0-only WITH Linux-syscall-note) OR BSD-2-Clause) AND ...

What that costs, checked with spdxexp.Satisfies (already in go.mod) on the same shape as the AUR android-*-liburing packages, whose %LICENSE% is ["(GPL-2.0-only WITH Linux-syscall-note) OR MIT", "LGPL-2.0-or-later", "MIT"]:

allowed licenses emitted today with this change
GPL-2.0-only WITH Linux-syscall-note satisfied not satisfied

So the SBOM says the package can be taken under the kernel syscall-note GPL on its own, dropping the LGPL-2.0-or-later and MIT declared next to it.

Being straight about how often that fires: I walked all 10045 Core+Extra x86_64 packages on archlinux.org and 3 hit it — core/linux-lts-headers, extra/linux-rt-headers, extra/linux-rt-lts-headers. Other ecosystems mostly carry one license string per package, so there is nothing to join.

CycloneDX side. mergeSPDX has the same defect, reached when every license on a package is a compound expression. syft convert on a CycloneDX SBOM whose component carries two expressions:

before: (GPL-3.0-only AND MIT-0) AND MIT OR (Apache-2.0 AND BSD-3-Clause)
after:  (GPL-3.0-only AND MIT-0) AND (MIT OR (Apache-2.0 AND BSD-3-Clause))

Satisfies on the emitted one with Apache-2.0 plus BSD-3-Clause alone is true today and false after — the GPL-3.0-only drops out the same way. Any SBOM in that shape is corrupted on re-encode, so this half is not tied to one distro.

The change adds spdxlicense.HasTopLevelOr — both packages already import internal/spdxlicense, no new imports — and wraps when it reports true. The rest of each condition is untouched, so expressions whose top-level operator is AND keep the text they have today: ISC AND (BSD-3-Clause OR MIT), the real alpine libretls value in syft/format/testdata/alpine-syft.json, does not move. Scanning this repo with a binary built before and after gives identical license fields for all 2113 SPDX packages and all 2649 CycloneDX components.

One output change beyond the corruption, so it is not a surprise in review: a package with a single license whose expression has a top-level OR and a paren at an edge now gains a redundant outer paren ((GPL-2.0-only WITH Linux-syscall-note) OR MIT((GPL-2.0-only WITH Linux-syscall-note) OR MIT)). Nothing is joined onto it so the parens are redundant, but a lone MIT OR Apache-2.0 is already wrapped today, so this makes the two shapes consistent rather than splitting on whether a paren happens to sit at the edge. Both are pinned by tests. Happy to special-case len == 1 instead if you would rather that output stayed exactly as-is.

Not addressed: spdxexp.ValidateLicenses accepts operators written without surrounding spaces, like MIT OR(Apache-2.0), and neither the old check nor this one wraps those. Pre-existing, left alone.

Type of change

  • Bug fix (non-breaking change which fixes an issue)

Checklist

  • I have added unit tests that cover changed behavior
  • I have tested my code in common scenarios and confirmed there are no regressions
  • I have added comments to my code, particularly in hard-to-understand sections

Eleven cases added across the two tables: five fail on main, six pin behaviour that must not change. mergeSPDX had no test naming it — it was covered incidentally through Test_encodeLicense — so Test_mergeSPDX is its first direct one. go build ./..., go test ./syft/format/... ./internal/spdxlicense/ ./syft/pkg/ ./syft/license/... and golangci-lint run --timeout 15m ./... are clean.

Both the SPDX and the CycloneDX encoder parenthesise an expression before joining
it onto the others with AND, and both decide with the same check: does it start
with "(" or end with ")". An expression can do either and still carry an OR at
the top level, and SPDX applies AND before OR, so those get joined unwrapped and
the result no longer means what was declared.

"MIT OR (Apache-2.0 AND BSD-3-Clause)" joined onto "GPL-3.0-only" reads as a
choice that no longer requires GPL-3.0-only at all.

Wrap when the expression has an OR outside of any parentheses. Expressions whose
top-level operator is AND or WITH keep the text they are emitted with today.

Signed-off-by: Sueun Cho <sueun.dev@gmail.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.

1 participant