Jelajahi Sumber

fix(notices): validate SPDX expressions strictly

Tianyi Cui 2 bulan lalu
induk
melakukan
26c5e18b14

+ 2 - 0
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 |

+ 2 - 0
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",

+ 29 - 0
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: {}

+ 11 - 0
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', () => {

+ 19 - 8
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<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))
-  }
-  if (normalized.includes(' OR ')) {
-    return normalized.split(' OR ').some(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
   }
-  return PERMISSIVE_LICENSES.has(normalized.trim())
 }
 
 /**