fix(notices): validate SPDX expressions strictly
This commit is contained in:
@@ -105,6 +105,7 @@ External packages **directly declared** only by repository tooling, test infrast
|
|||||||
| [`@types/picomatch`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
|
| [`@types/picomatch`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
|
||||||
| [`@types/react`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
|
| [`@types/react`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
|
||||||
| [`@types/react-dom`](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 |
|
| [`@types/turndown`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
|
||||||
| [`@typescript-eslint/parser`](https://github.com/typescript-eslint/typescript-eslint) | MIT |
|
| [`@typescript-eslint/parser`](https://github.com/typescript-eslint/typescript-eslint) | MIT |
|
||||||
| [`@vitejs/plugin-react`](https://github.com/vitejs/vite-plugin-react) | 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 |
|
| [`playwright`](https://github.com/microsoft/playwright) | Apache-2.0 |
|
||||||
| [`publint`](https://github.com/publint/publint) | MIT |
|
| [`publint`](https://github.com/publint/publint) | MIT |
|
||||||
| [`smol-toml`](https://github.com/squirrelchat/smol-toml) | BSD-3-Clause |
|
| [`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 |
|
| [`tsdown`](https://github.com/rolldown/tsdown) | MIT |
|
||||||
| [`typescript-language-server`](https://github.com/typescript-language-server/typescript-language-server) | Apache-2.0 |
|
| [`typescript-language-server`](https://github.com/typescript-language-server/typescript-language-server) | Apache-2.0 |
|
||||||
| [`vite`](https://github.com/vitejs/vite) | MIT |
|
| [`vite`](https://github.com/vitejs/vite) | MIT |
|
||||||
|
|||||||
@@ -121,6 +121,7 @@
|
|||||||
"@types/jsdom": "^28.0.3",
|
"@types/jsdom": "^28.0.3",
|
||||||
"@types/mdast": "^4.0.4",
|
"@types/mdast": "^4.0.4",
|
||||||
"@types/node": "^22.20.0",
|
"@types/node": "^22.20.0",
|
||||||
|
"@types/spdx-expression-parse": "^4.0.0",
|
||||||
"@typescript-eslint/parser": "8.61.0",
|
"@typescript-eslint/parser": "8.61.0",
|
||||||
"@vitest/coverage-v8": "^4.1.8",
|
"@vitest/coverage-v8": "^4.1.8",
|
||||||
"@yarnpkg/cli-dist": "4.17.1",
|
"@yarnpkg/cli-dist": "4.17.1",
|
||||||
@@ -142,6 +143,7 @@
|
|||||||
"oxlint-tsgolint": "7.0.2001",
|
"oxlint-tsgolint": "7.0.2001",
|
||||||
"publint": "^0.3.21",
|
"publint": "^0.3.21",
|
||||||
"smol-toml": "^1.7.1",
|
"smol-toml": "^1.7.1",
|
||||||
|
"spdx-expression-parse": "^5.0.0",
|
||||||
"tsdown": "^0.22.2",
|
"tsdown": "^0.22.2",
|
||||||
"tsx": "^4.22.4",
|
"tsx": "^4.22.4",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
|
|||||||
Generated
+29
@@ -39,6 +39,9 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.20.0
|
specifier: ^22.20.0
|
||||||
version: 22.20.0
|
version: 22.20.0
|
||||||
|
'@types/spdx-expression-parse':
|
||||||
|
specifier: ^4.0.0
|
||||||
|
version: 4.0.0
|
||||||
'@typescript-eslint/parser':
|
'@typescript-eslint/parser':
|
||||||
specifier: 8.61.0
|
specifier: 8.61.0
|
||||||
version: 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)
|
version: 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)
|
||||||
@@ -102,6 +105,9 @@ importers:
|
|||||||
smol-toml:
|
smol-toml:
|
||||||
specifier: ^1.7.1
|
specifier: ^1.7.1
|
||||||
version: 1.7.1
|
version: 1.7.1
|
||||||
|
spdx-expression-parse:
|
||||||
|
specifier: ^5.0.0
|
||||||
|
version: 5.0.0
|
||||||
tsdown:
|
tsdown:
|
||||||
specifier: ^0.22.2
|
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)
|
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':
|
'@types/retry@0.12.0':
|
||||||
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
|
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
|
||||||
|
|
||||||
|
'@types/spdx-expression-parse@4.0.0':
|
||||||
|
resolution: {integrity: sha512-odQzy87phelGS4inXOzjmusx4hoCVD0IbxUANxHzVkmTzMRTNnUPoq1urIl7S1qf09KcDWKLFIftPmLtgbsAHA==}
|
||||||
|
|
||||||
'@types/tough-cookie@4.0.5':
|
'@types/tough-cookie@4.0.5':
|
||||||
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
|
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
|
||||||
|
|
||||||
@@ -11090,6 +11099,15 @@ packages:
|
|||||||
space-separated-tokens@2.0.2:
|
space-separated-tokens@2.0.2:
|
||||||
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
|
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:
|
speakingurl@14.0.1:
|
||||||
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
|
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -13716,6 +13734,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/retry@0.12.0': {}
|
'@types/retry@0.12.0': {}
|
||||||
|
|
||||||
|
'@types/spdx-expression-parse@4.0.0': {}
|
||||||
|
|
||||||
'@types/tough-cookie@4.0.5': {}
|
'@types/tough-cookie@4.0.5': {}
|
||||||
|
|
||||||
'@types/trusted-types@2.0.7':
|
'@types/trusted-types@2.0.7':
|
||||||
@@ -16590,6 +16610,15 @@ snapshots:
|
|||||||
|
|
||||||
space-separated-tokens@2.0.2: {}
|
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: {}
|
speakingurl@14.0.1: {}
|
||||||
|
|
||||||
stackback@0.0.2: {}
|
stackback@0.0.2: {}
|
||||||
|
|||||||
@@ -181,6 +181,17 @@ describe('isPermissive', () => {
|
|||||||
// An exception clause is not a recognized identifier, so it fails closed.
|
// 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)
|
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', () => {
|
describe('manifestPatterns', () => {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { existsSync, globSync, readdirSync, readFileSync, writeFileSync } from '
|
|||||||
import { resolve } from 'node:path'
|
import { resolve } from 'node:path'
|
||||||
import * as yaml from 'js-yaml'
|
import * as yaml from 'js-yaml'
|
||||||
import { parse as parseToml, type TomlTableWithoutBigInt, type TomlValueWithoutBigInt } from 'smol-toml'
|
import { parse as parseToml, type TomlTableWithoutBigInt, type TomlValueWithoutBigInt } from 'smol-toml'
|
||||||
|
import parseSpdx from 'spdx-expression-parse'
|
||||||
|
|
||||||
const root = resolve(import.meta.dirname, '..')
|
const root = resolve(import.meta.dirname, '..')
|
||||||
const OUT = 'THIRD_PARTY_NOTICES.md'
|
const OUT = 'THIRD_PARTY_NOTICES.md'
|
||||||
@@ -433,6 +434,18 @@ function verifyBuildTimePins(): void {
|
|||||||
/** SPDX identifiers this project may ship without further review. */
|
/** 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'])
|
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.
|
* Whether an SPDX expression grants terms this project may ship under.
|
||||||
* `OR` needs one permissive alternative, because the consumer chooses; `AND`
|
* `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.
|
* @returns true when the expression's obligations are all permissive.
|
||||||
*/
|
*/
|
||||||
export function isPermissive(license: string): boolean {
|
export function isPermissive(license: string): boolean {
|
||||||
// npm's legacy dual-license notation predates SPDX `OR`.
|
// Some npm manifests use a slash for a choice despite SPDX requiring `OR`.
|
||||||
const normalized = license.replace(/\s*\/\s*/g, ' OR ').replace(/[()]/g, ' ').trim()
|
const normalized = license.replace(/\s*\/\s*/g, ' OR ').trim()
|
||||||
if (normalized.includes(' AND ')) {
|
try {
|
||||||
return normalized.split(' AND ').every(operand => isPermissive(operand))
|
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())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user