From 26c5e18b14f8bb65163559c340f7a4cef7c344d0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:16:08 +0800 Subject: [PATCH] fix(notices): validate SPDX expressions strictly --- THIRD_PARTY_NOTICES.md | 2 ++ package.json | 2 ++ pnpm-lock.yaml | 29 +++++++++++++++++++++++++ scripts/gen-third-party-notices.spec.ts | 11 ++++++++++ scripts/gen-third-party-notices.ts | 27 ++++++++++++++++------- 5 files changed, 63 insertions(+), 8 deletions(-) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index e1f6f39953..075e1b34c4 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -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 | diff --git a/package.json b/package.json index 57851edf1c..f46b4f2555 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b309cfbbe2..e3036e9640 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index f0cd55c09b..707c30ff70 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -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', () => { diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index 8e961377c7..5306f9a003 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -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): 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()) } /**