Support SPDX expressions in allow/deny lists

This change updates license validation to support full SPDX expressions
(such as 'EPL-1.0 AND LGPL-2.1') in both allow-lists and deny-lists. This
enables the action to correctly validate packages that declare multiple
licenses using SPDX conjunctions like AND/OR, which are common in complex
open-source projects.

Previously, only simple license identifiers were supported, which caused
multi-licensed packages to be improperly flagged as invalid even when
they matched the intent of the allow-list.

The new logic uses `spdx.satisfies()` to evaluate whether a package’s
declared license satisfies any expression in the allow/deny list, and
comprehensive tests have been added to verify behavior for various SPDX
combinations.

This improves compatibility with projects using compound SPDX license
expressions and ensures more accurate license policy enforcement.
This commit is contained in:
Jacques-Etienne Beaudet
2025-04-09 12:19:46 -04:00
parent ce3cf9537a
commit 4eb8182aba
4 changed files with 138 additions and 6 deletions
+121 -1
View File
@@ -21,7 +21,6 @@ const npmChange: Change = {
}
]
}
const rubyChange: Change = {
change_type: 'added',
manifest: 'Gemfile.lock',
@@ -101,6 +100,105 @@ beforeEach(async () => {
jest.resetModules()
})
test('it should handle SPDX expressions in allow-list that matches a single license project', async () => {
const change: Change = getChangeWithLicense('MIT')
const changes: Changes = [change]
const {forbidden} = await getInvalidLicenseChanges(changes, {
allow: ['EPL-1.0 OR MIT']
})
expect(forbidden).toStrictEqual([])
})
test('it should handle SPDX expressions in allow-list with operators and a valid triple licensed project', async () => {
const change: Change = getChangeWithLicense(
'EPL-1.0 AND LGPL-2.1 AND LGPL-2.1-only'
)
const changes: Changes = [change]
const {forbidden} = await getInvalidLicenseChanges(changes, {
allow: ['EPL-1.0 AND LGPL-2.1 AND LGPL-2.1-only']
})
expect(forbidden).toStrictEqual([])
})
test('it should handle a valid triple licensed project that does not have a match in the allow-list', async () => {
const change = getChangeWithLicense('EPL-1.0 AND LGPL-2.1 AND LGPL-2.1-only')
const changes: Changes = [change]
const {forbidden} = await getInvalidLicenseChanges(changes, {
allow: ['EPL-1.0', 'LGPL-2.1', 'LGPL-2.1-only']
})
expect(forbidden[0]).toBe(change)
expect(forbidden.length).toEqual(1)
})
test('it should handle license with OR SPDX expression and only match on one license in the allow-list', async () => {
const change = getChangeWithLicense('EPL-1.0 OR LGPL-2.1')
const changes: Changes = [change]
for (const allowedLicense of ['EPL-1.0', 'LGPL-2.1']) {
const {forbidden} = await getInvalidLicenseChanges(changes, {
allow: [allowedLicense]
})
expect(forbidden).toStrictEqual([])
}
})
test('it should handle SPDX expressions in allow-list with operators when license matches', async () => {
const changes: Changes = [
npmChange // MIT license
]
const {forbidden} = await getInvalidLicenseChanges(changes, {
allow: ['MIT OR Apache-2.0', 'MIT', 'BSD-3-Clause']
})
expect(forbidden).toStrictEqual([])
})
test('it should handle SPDX expressions in allow-list with operators when license does not match', async () => {
const changes: Changes = [
npmChange // MIT license
]
const {forbidden} = await getInvalidLicenseChanges(changes, {
allow: ['MIT AND Apache-2.0', 'BSD-3-Clause']
})
expect(forbidden[0]).toBe(npmChange)
expect(forbidden.length).toEqual(1)
})
test('it should handle SPDX expressions in deny-list with operators when license matches deny list entry', async () => {
const changes: Changes = [
npmChange // MIT license
]
const {forbidden} = await getInvalidLicenseChanges(changes, {
deny: ['MIT OR Apache-2.0', 'BSD-3-Clause']
})
expect(forbidden[0]).toBe(npmChange)
expect(forbidden.length).toEqual(1)
})
test('it should handle SPDX expressions in deny-list with operators when license does not match any deny list entry', async () => {
const changes: Changes = [
npmChange // MIT license
]
const {forbidden} = await getInvalidLicenseChanges(changes, {
deny: ['MIT AND Apache-2.0', 'BSD-3-Clause']
})
expect(forbidden).toStrictEqual([])
})
test('it adds license outside the allow list to forbidden changes', async () => {
const changes: Changes = [
npmChange, // MIT license
@@ -264,3 +362,25 @@ describe('GH License API fallback', () => {
expect(unlicensed.length).toEqual(0)
})
})
function getChangeWithLicense(license: string): Change {
return {
manifest: 'pom.xml',
change_type: 'added',
ecosystem: 'maven',
name: 'dummy-library',
version: '1.0.0',
package_url: 'pkg:org.something:sdummy-library@1.0.0',
license,
source_repository_url: 'github.com/some-repo',
scope: 'runtime',
vulnerabilities: [
{
severity: 'critical',
advisory_ghsa_id: 'first-random_string',
advisory_summary: 'very dangerous',
advisory_url: 'github.com/future-funk'
}
]
}
}
Generated Vendored
+8 -2
View File
@@ -435,7 +435,10 @@ function getInvalidLicenseChanges(changes, licenses) {
try {
if (allow !== undefined) {
if (spdx.isValid(license)) {
const found = spdx.satisfiesAny(license, allow);
let found = false;
for (const allowedLicense of allow) {
found || (found = spdx.satisfies(allowedLicense, license));
}
validityCache.set(license, found);
}
else {
@@ -444,7 +447,10 @@ function getInvalidLicenseChanges(changes, licenses) {
}
else if (deny !== undefined) {
if (spdx.isValid(license)) {
const found = spdx.satisfiesAny(license, deny);
let found = false;
for (const deniedLicense of deny) {
found || (found = spdx.satisfies(deniedLicense, license));
}
validityCache.set(license, !found);
}
else {
Generated Vendored
+1 -1
View File
File diff suppressed because one or more lines are too long
+8 -2
View File
@@ -88,14 +88,20 @@ export async function getInvalidLicenseChanges(
try {
if (allow !== undefined) {
if (spdx.isValid(license)) {
const found = spdx.satisfiesAny(license, allow)
let found = false
for (const allowedLicense of allow) {
found ||= spdx.satisfies(allowedLicense, license)
}
validityCache.set(license, found)
} else {
invalidLicenseChanges.unresolved.push(change)
}
} else if (deny !== undefined) {
if (spdx.isValid(license)) {
const found = spdx.satisfiesAny(license, deny)
let found = false
for (const deniedLicense of deny) {
found ||= spdx.satisfies(deniedLicense, license)
}
validityCache.set(license, !found)
} else {
invalidLicenseChanges.unresolved.push(change)