fix(notices): validate SPDX expressions strictly

This commit is contained in:
Tianyi Cui
2026-07-31 17:16:37 +08:00
parent c7a04a10f9
commit 26c5e18b14
5 changed files with 63 additions and 8 deletions
+2
View File
@@ -105,6 +105,7 @@ External packages **directly declared** only by repository tooling, test infrast
| [`@types/picomatch`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/react`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/react-dom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/spdx-expression-parse`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/turndown`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@typescript-eslint/parser`](https://github.com/typescript-eslint/typescript-eslint) | MIT |
| [`@vitejs/plugin-react`](https://github.com/vitejs/vite-plugin-react) | MIT |
@@ -131,6 +132,7 @@ External packages **directly declared** only by repository tooling, test infrast
| [`playwright`](https://github.com/microsoft/playwright) | Apache-2.0 |
| [`publint`](https://github.com/publint/publint) | MIT |
| [`smol-toml`](https://github.com/squirrelchat/smol-toml) | BSD-3-Clause |
| [`spdx-expression-parse`](https://github.com/jslicense/spdx-expression-parse.js) | MIT |
| [`tsdown`](https://github.com/rolldown/tsdown) | MIT |
| [`typescript-language-server`](https://github.com/typescript-language-server/typescript-language-server) | Apache-2.0 |
| [`vite`](https://github.com/vitejs/vite) | MIT |
+2
View File
@@ -121,6 +121,7 @@
"@types/jsdom": "^28.0.3",
"@types/mdast": "^4.0.4",
"@types/node": "^22.20.0",
"@types/spdx-expression-parse": "^4.0.0",
"@typescript-eslint/parser": "8.61.0",
"@vitest/coverage-v8": "^4.1.8",
"@yarnpkg/cli-dist": "4.17.1",
@@ -142,6 +143,7 @@
"oxlint-tsgolint": "7.0.2001",
"publint": "^0.3.21",
"smol-toml": "^1.7.1",
"spdx-expression-parse": "^5.0.0",
"tsdown": "^0.22.2",
"tsx": "^4.22.4",
"typescript": "^6.0.3",
+29
View File
@@ -39,6 +39,9 @@ importers:
'@types/node':
specifier: ^22.20.0
version: 22.20.0
'@types/spdx-expression-parse':
specifier: ^4.0.0
version: 4.0.0
'@typescript-eslint/parser':
specifier: 8.61.0
version: 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)
@@ -102,6 +105,9 @@ importers:
smol-toml:
specifier: ^1.7.1
version: 1.7.1
spdx-expression-parse:
specifier: ^5.0.0
version: 5.0.0
tsdown:
specifier: ^0.22.2
version: 0.22.2(oxc-resolver@11.20.0)(publint@0.3.21)(tsx@4.22.4)(typescript@6.0.3)
@@ -8728,6 +8734,9 @@ packages:
'@types/retry@0.12.0':
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
'@types/spdx-expression-parse@4.0.0':
resolution: {integrity: sha512-odQzy87phelGS4inXOzjmusx4hoCVD0IbxUANxHzVkmTzMRTNnUPoq1urIl7S1qf09KcDWKLFIftPmLtgbsAHA==}
'@types/tough-cookie@4.0.5':
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
@@ -11090,6 +11099,15 @@ packages:
space-separated-tokens@2.0.2:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
spdx-exceptions@2.5.0:
resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==}
spdx-expression-parse@5.0.0:
resolution: {integrity: sha512-vngmw3Rgn+o2arXNbnZaj5UtOEBuWBfvaI+Wc8GFfykIhA5/vdK9/Sp/XkLv63dykz2rxKDvKEHupF5P0FORcQ==}
spdx-license-ids@3.0.23:
resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==}
speakingurl@14.0.1:
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
engines: {node: '>=0.10.0'}
@@ -13716,6 +13734,8 @@ snapshots:
'@types/retry@0.12.0': {}
'@types/spdx-expression-parse@4.0.0': {}
'@types/tough-cookie@4.0.5': {}
'@types/trusted-types@2.0.7':
@@ -16590,6 +16610,15 @@ snapshots:
space-separated-tokens@2.0.2: {}
spdx-exceptions@2.5.0: {}
spdx-expression-parse@5.0.0:
dependencies:
spdx-exceptions: 2.5.0
spdx-license-ids: 3.0.23
spdx-license-ids@3.0.23: {}
speakingurl@14.0.1: {}
stackback@0.0.2: {}
+11
View File
@@ -181,6 +181,17 @@ describe('isPermissive', () => {
// An exception clause is not a recognized identifier, so it fails closed.
expect(isPermissive('GPL-2.0-only WITH Classpath-exception-2.0')).toBe(false)
})
it('honors grouping and SPDX precedence', () => {
expect(isPermissive('MIT OR (GPL-3.0-only AND GPL-2.0-only)')).toBe(true)
expect(isPermissive('(MIT OR Apache-2.0) AND ISC')).toBe(true)
})
it('fails closed for malformed expressions, additions, and exceptions', () => {
expect(['MIT)', '((MIT', '(MIT OR GPL-3.0-only', 'MIT OR OR GPL-3.0-only'].some(isPermissive)).toBe(false)
expect(isPermissive('MIT+')).toBe(false)
expect(isPermissive('GPL-2.0-only WITH Classpath-exception-2.0')).toBe(false)
})
})
describe('manifestPatterns', () => {
+19 -8
View File
@@ -12,6 +12,7 @@ import { existsSync, globSync, readdirSync, readFileSync, writeFileSync } from '
import { resolve } from 'node:path'
import * as yaml from 'js-yaml'
import { parse as parseToml, type TomlTableWithoutBigInt, type TomlValueWithoutBigInt } from 'smol-toml'
import parseSpdx from 'spdx-expression-parse'
const root = resolve(import.meta.dirname, '..')
const OUT = 'THIRD_PARTY_NOTICES.md'
@@ -433,6 +434,18 @@ function verifyBuildTimePins(): void {
/** SPDX identifiers this project may ship without further review. */
const PERMISSIVE_LICENSES = new Set(['MIT', 'ISC', 'BSD-2-Clause', 'BSD-3-Clause', 'Apache-2.0', '0BSD', 'Unlicense', 'CC0-1.0', 'BlueOak-1.0.0', 'Python-2.0'])
/** Evaluate a parsed SPDX expression under the repository's license policy. */
function isPermissiveSpdx(expression: ReturnType<typeof parseSpdx>): boolean {
if ('conjunction' in expression) {
return expression.conjunction === 'and'
? isPermissiveSpdx(expression.left) && isPermissiveSpdx(expression.right)
: isPermissiveSpdx(expression.left) || isPermissiveSpdx(expression.right)
}
return expression.plus !== true
&& expression.exception === undefined
&& PERMISSIVE_LICENSES.has(expression.license)
}
/**
* Whether an SPDX expression grants terms this project may ship under.
* `OR` needs one permissive alternative, because the consumer chooses; `AND`
@@ -444,15 +457,13 @@ const PERMISSIVE_LICENSES = new Set(['MIT', 'ISC', 'BSD-2-Clause', 'BSD-3-Clause
* @returns true when the expression's obligations are all permissive.
*/
export function isPermissive(license: string): boolean {
// npm's legacy dual-license notation predates SPDX `OR`.
const normalized = license.replace(/\s*\/\s*/g, ' OR ').replace(/[()]/g, ' ').trim()
if (normalized.includes(' AND ')) {
return normalized.split(' AND ').every(operand => isPermissive(operand))
// Some npm manifests use a slash for a choice despite SPDX requiring `OR`.
const normalized = license.replace(/\s*\/\s*/g, ' OR ').trim()
try {
return isPermissiveSpdx(parseSpdx(normalized))
} catch {
return false
}
if (normalized.includes(' OR ')) {
return normalized.split(' OR ').some(operand => isPermissive(operand))
}
return PERMISSIVE_LICENSES.has(normalized.trim())
}
/**