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:
+121
-1
@@ -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'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+8
-2
@@ -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 {
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+8
-2
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user