From 748f8b98a19cf0b9d6c6fe48347a93db5eacd510 Mon Sep 17 00:00:00 2001 From: Jake Fineman Date: Thu, 3 Sep 2026 21:28:55 -0400 Subject: [PATCH 1/2] fix(legal): the package declared MIT while shipping the Apache-2.0 text (LEGAL-001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `package.json` said "license": "MIT" and the README's License section said MIT, but LICENSE has been the Apache-2.0 text since 5da8018 ("chore: adopt Apache-2.0 license + add NOTICE", 2026-06-04), which states the governing intent: "Standardize the open WAVE protocol/SDK surface on Apache-2.0 (patent grant for adoption)." Every other WAVE npm package has already moved — @wave-av/sdk 2.1.3, @wave-av/adk 1.0.15 and @wave-av/mcp-server 0.2.0 all publish Apache-2.0. This one had not. Second defect: npm always includes LICENSE regardless of the `files` array, but never NOTICE. `npm pack --dry-run` on origin/main listed exactly one license-ish file, LICENSE — so the NOTICE reserving the WAVE marks, which Apache-2.0 §4(d) requires redistributions to carry, was not in any published tarball. Fixed all four declarations (package.json, README.md, package-lock.json, and the LICENSE file they must match) and added LICENSE + NOTICE to `files`. Added `npm run license:check`: an offline gate that reads the license TEXT and fails when any declaration disagrees with it, when LICENSE/NOTICE would not ship, or when a runtime dependency carries strong copyleft. It fails on the pre-fix tree with exactly the two contradictions above. Added `npm run license:ledger`, which downloads every published WAVE tarball and wheel, reads the LICENSE inside, and compares it to what each source repo declares today; LICENSE-LEDGER.md is its output and names four more drifts this change does not touch. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/license-truth.yml | 84 +++++ CHANGELOG.md | 22 ++ LICENSE-LEDGER.md | 69 ++++ README.md | 3 +- package-lock.json | 2 +- package.json | 8 +- scripts/lib/archive.mjs | 122 ++++++++ scripts/lib/audit.mjs | 207 ++++++++++++ scripts/lib/registry.mjs | 227 ++++++++++++++ scripts/lib/spdx.mjs | 99 ++++++ scripts/license-audit.test.mjs | 470 ++++++++++++++++++++++++++++ scripts/license-manifest.json | 25 ++ scripts/license-spdx.test.mjs | 95 ++++++ scripts/license-truth.mjs | 273 ++++++++++++++++ 14 files changed, 1702 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/license-truth.yml create mode 100644 LICENSE-LEDGER.md create mode 100644 scripts/lib/archive.mjs create mode 100644 scripts/lib/audit.mjs create mode 100644 scripts/lib/registry.mjs create mode 100644 scripts/lib/spdx.mjs create mode 100644 scripts/license-audit.test.mjs create mode 100644 scripts/license-manifest.json create mode 100644 scripts/license-spdx.test.mjs create mode 100644 scripts/license-truth.mjs diff --git a/.github/workflows/license-truth.yml b/.github/workflows/license-truth.yml new file mode 100644 index 0000000..7dc3be6 --- /dev/null +++ b/.github/workflows/license-truth.yml @@ -0,0 +1,84 @@ +name: license-truth + +# Blocks the class of defect this repo shipped for five months: `package.json` declared MIT +# while the LICENSE file beside it was the Apache-2.0 text, and the Apache-2.0 NOTICE the +# repo adopted never reached the published tarball at all. Nothing in the existing gates +# reads a license FILE, so nothing could see it. +# +# Two jobs on purpose: +# local-truth — offline, reads only files in the repo. Deterministic, safe to require. +# registry-drift — hits npm and PyPI. Scheduled/manual ONLY, because a required check that +# depends on a third-party registry being up is a check that goes red for +# reasons that have nothing to do with the pull request. + +on: + pull_request: + push: + branches: [main] + schedule: + # Mondays 08:00 UTC — reconcile every published WAVE artifact against its source repo. + - cron: "0 8 * * 1" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + local-truth: + if: github.event_name != 'schedule' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + + - run: npm ci --include=dev + + # Declared vs shipped: package.json, README, lockfile, the LICENSE file's actual text, + # whether LICENSE and NOTICE are in the tarball npm would publish, and strong copyleft + # in the runtime dependency tree. + - name: License truth gate + run: npm run license:check + + - name: License gate unit tests + run: npx vitest run scripts/ + + registry-drift: + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + + - run: npm ci --include=dev + + # Downloads every published WAVE tarball and wheel, reads the LICENSE inside it, and + # compares it against what the source repo declares today. `--check` makes a drifted + # artifact fail the run rather than quietly regenerating a ledger nobody reads. + - name: Reconcile published artifacts against source + run: npm run license:ledger -- --check + + - name: Upload regenerated ledger + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: license-ledger + path: LICENSE-LEDGER.md + if-no-files-found: error diff --git a/CHANGELOG.md b/CHANGELOG.md index d1bb8d8..def139c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,29 @@ All notable changes to this project are documented here. The format is based on [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixed +- **The package declared MIT while shipping the Apache-2.0 license text.** `package.json` + said `"license": "MIT"` and the README's License section said MIT, but `LICENSE` has been + the Apache-2.0 text since 5da8018 ("chore: adopt Apache-2.0 license + add NOTICE", + 2026-06-04). All four declarations — `package.json`, `README.md`, `package-lock.json` and + the `LICENSE` file — now say Apache-2.0. +- **The Apache-2.0 `NOTICE` never reached the published tarball.** npm always includes + `LICENSE` regardless of the `files` array, but not `NOTICE`; Apache-2.0 §4(d) requires + redistributions to carry it. `NOTICE` (and `LICENSE`, explicitly) are now in `files`. +- The `[1.0.8]` entry below records "License changed to Apache-2.0, replacing MIT". That is + true of the repository, not of the release: `@wave-av/cli@1.0.8` was published to npm on + 2026-04-03, two months before the Apache-2.0 adoption commit, and its tarball contains the + MIT text with MIT metadata. Apache-2.0 has never been published for this package. The + history is left as written; this note is the correction. + ### Added +- `npm run license:check` — an offline gate that fails when any declared license disagrees + with the license text actually in `LICENSE`, when `LICENSE`/`NOTICE` would not ship in the + tarball, or when a runtime dependency carries strong copyleft. Wired into CI as + `license-truth / local-truth`. +- `npm run license:ledger` — regenerates `LICENSE-LEDGER.md` by downloading every published + WAVE npm tarball and PyPI wheel, reading the LICENSE inside it, and comparing all of it + against what each source repository declares today. Runs weekly and on demand. - `wave webhook-subscriptions list|create` — manage the platform's own event-subscription surface, distinct from `wave connect` third-party webhooks (#37). - `wave identity resolve ` — resolve an agent identity through the fleet directory diff --git a/LICENSE-LEDGER.md b/LICENSE-LEDGER.md new file mode 100644 index 0000000..c7e9698 --- /dev/null +++ b/LICENSE-LEDGER.md @@ -0,0 +1,69 @@ +# WAVE license ledger + + + +Generated: 2026-09-04T01:25:11.236Z + +**Intended license for the open WAVE surface: `Apache-2.0`** — per wave-av/cli@5da8018 ("chore: adopt Apache-2.0 license + add NOTICE"): Standardize the open WAVE protocol/SDK surface on Apache-2.0 (patent grant for adoption). Replaces any prior license; adds NOTICE reserving the WAVE marks. + +## Published artifacts + +`declared` is the identifier in the published artifact's own metadata. `ships` is the license whose TEXT is in the file inside that artifact. `source declares` is what the manifest on the source repository's default branch says today. All three must agree; any disagreement is drift, and the last pair is the one an artifact cannot self-report. + +| package | registry | version | declared | ships | source declares | NOTICE | verdict | +| --- | --- | --- | --- | --- | --- | --- | --- | +| `@wave-av/cli` | npm | 1.0.8 | `MIT` | `MIT` | `MIT` | no | **DRIFT** — source manifest says "MIT" but LICENSE is the Apache-2.0 text | +| `@wave-av/sdk` | npm | 2.1.3 | `Apache-2.0` | `Apache-2.0` | `Apache-2.0` | no | consistent | +| `@wave-av/adk` | npm | 1.0.15 | `Apache-2.0` | `Apache-2.0` | `Apache-2.0` | no | consistent | +| `@wave-av/mcp-server` | npm | 0.2.0 | `Apache-2.0` | `Apache-2.0` | `Apache-2.0` | no | consistent | +| `@wave-av/workflow-sdk` | npm | 1.0.6 | `MIT` | `MIT` | `Apache-2.0` | no | **DRIFT** — published as "MIT" but source declares "Apache-2.0"; source manifest says "Apache-2.0" but sdk-typescript/packages/workflow-sdk/LICENSE is the MIT text | +| `@wave-av/create-app` | npm | 1.0.9 | `MIT` | `MIT` | _unresolved_ | no | **unverified** — artifact self-consistent; source unresolved (UNVERIFIED — no package.json found on any wave-av default branch) | +| `wave-sdk` | pypi | 2.0.0 | `MIT` | `MIT` | `MIT` | no | **DRIFT** — source manifest says "MIT" but LICENSE is the Apache-2.0 text | +| `wave-av-sdk` | pypi | 2.0.0 | `MIT` | `MIT` | `Apache-2.0` | no | **DRIFT** — published as "MIT" but source declares "Apache-2.0"; source manifest says "Apache-2.0" but sdk-python/LICENSE is the MIT text | + +| package | source of truth | LICENSE file in that repo | +| --- | --- | --- | +| `@wave-av/cli` | wave-av/cli:package.json | `LICENSE` is `Apache-2.0` | +| `@wave-av/sdk` | wave-av/sdk:package.json | `LICENSE` is `Apache-2.0` | +| `@wave-av/adk` | wave-av/adk:package.json | `LICENSE` is `Apache-2.0` | +| `@wave-av/mcp-server` | wave-av/mcp-server:package.json | `LICENSE` is `Apache-2.0` | +| `@wave-av/workflow-sdk` | wave-av/sdks:sdk-typescript/packages/workflow-sdk/package.json | `sdk-typescript/packages/workflow-sdk/LICENSE` is `MIT` | +| `@wave-av/create-app` | UNVERIFIED — no package.json found on any wave-av default branch | _unresolved: UNVERIFIED — no package.json found on any wave-av default branch_ | +| `wave-sdk` | wave-av/sdk-python:pyproject.toml | `LICENSE` is `Apache-2.0` | +| `wave-av-sdk` | wave-av/sdks:sdk-python/pyproject.toml | `sdk-python/LICENSE` is `MIT` | + +## This repository + +- package: `@wave-av/cli@1.0.9` +- `package.json` declares: `Apache-2.0` +- `LICENSE` file text is: `Apache-2.0` +- `README.md` License section: `Apache-2.0` +- `package-lock.json` root: `Apache-2.0` +- `NOTICE` present in repo: yes +- offline gate: clean + +## Dependency licenses + +Strong copyleft (GPL/AGPL/SSPL/EUPL/CC-BY-SA) in a **runtime** dependency fails the gate. Weak, file-level copyleft (MPL/LGPL/EPL/CDDL) is listed here but does not block. + +| scope | total | permissive | weak copyleft | strong copyleft | unknown | +| --- | --- | --- | --- | --- | --- | +| runtime | 127 | 127 | 0 | 0 | 0 | +| dev | 235 | 223 | 12 | 0 | 0 | + +Notable (non-permissive) dependencies: + +| dependency | license | class | +| --- | --- | --- | +| `lightningcss@1.33.0` | `MPL-2.0` | weak | +| `lightningcss-android-arm64@1.33.0` | `MPL-2.0` | weak | +| `lightningcss-darwin-arm64@1.33.0` | `MPL-2.0` | weak | +| `lightningcss-darwin-x64@1.33.0` | `MPL-2.0` | weak | +| `lightningcss-freebsd-x64@1.33.0` | `MPL-2.0` | weak | +| `lightningcss-linux-arm-gnueabihf@1.33.0` | `MPL-2.0` | weak | +| `lightningcss-linux-arm64-gnu@1.33.0` | `MPL-2.0` | weak | +| `lightningcss-linux-arm64-musl@1.33.0` | `MPL-2.0` | weak | +| `lightningcss-linux-x64-gnu@1.33.0` | `MPL-2.0` | weak | +| `lightningcss-linux-x64-musl@1.33.0` | `MPL-2.0` | weak | +| `lightningcss-win32-arm64-msvc@1.33.0` | `MPL-2.0` | weak | +| `lightningcss-win32-x64-msvc@1.33.0` | `MPL-2.0` | weak | diff --git a/README.md b/README.md index b6823c8..5082aaa 100644 --- a/README.md +++ b/README.md @@ -183,4 +183,5 @@ jobs: ## License -MIT +Apache-2.0 — see [LICENSE](LICENSE). The [NOTICE](NOTICE) file reserves the WAVE +trademarks; the Apache License grants rights to the software only. diff --git a/package-lock.json b/package-lock.json index 376dcb7..f324cfb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,7 @@ "": { "name": "@wave-av/cli", "version": "1.0.9", - "license": "MIT", + "license": "Apache-2.0", "dependencies": { "@wave-av/sdk": "2.0.14", "chalk": "^5.4.1", diff --git a/package.json b/package.json index 56eebc2..62e345b 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "dist", "templates", "README.md", - "CHANGELOG.md" + "CHANGELOG.md", + "LICENSE", + "NOTICE" ], "scripts": { "build": "tsup", @@ -20,6 +22,8 @@ "test": "vitest run", "test:watch": "vitest", "lint": "eslint src/", + "license:check": "node scripts/license-truth.mjs check", + "license:ledger": "node scripts/license-truth.mjs ledger", "prepublishOnly": "npm run build" }, "keywords": [ @@ -36,7 +40,7 @@ "terminal" ], "author": "WAVE Online, LLC", - "license": "MIT", + "license": "Apache-2.0", "repository": { "type": "git", "url": "https://github.com/wave-av/cli.git", diff --git a/scripts/lib/archive.mjs b/scripts/lib/archive.mjs new file mode 100644 index 0000000..f271213 --- /dev/null +++ b/scripts/lib/archive.mjs @@ -0,0 +1,122 @@ +/** + * Dependency-free readers for the two archive formats a registry actually serves: + * npm's `.tgz` (gzip'd tar) and PyPI's `.whl` (a zip). We parse both in pure Node so the + * license ledger can inspect PUBLISHED artifacts without shelling out to `tar`/`unzip` + * (not guaranteed on every runner) and without adding a dependency to a CLI whose whole + * point in this change is a clean, auditable license surface. + * + * Only what the ledger needs is implemented: list entry names, and read one entry's bytes. + */ +import { gunzipSync, inflateRawSync } from 'node:zlib'; + +/* ── tar.gz ────────────────────────────────────────────────────────────────── */ + +/** + * Parse a gzip'd POSIX tar into a Map of path -> Buffer. + * Handles the ustar `prefix` field and GNU long names (`L` typeflag); skips + * directories, PAX headers and other metadata entries. + * @param {Buffer} tgz + * @returns {Map} + */ +export function readTarGz(tgz) { + const buf = gunzipSync(tgz); + const out = new Map(); + let offset = 0; + let longName = null; + + while (offset + 512 <= buf.length) { + const header = buf.subarray(offset, offset + 512); + // Two consecutive zero blocks terminate the archive. + if (header.every((b) => b === 0)) break; + + const name = cstr(header.subarray(0, 100)); + const size = octal(header.subarray(124, 136)); + const typeflag = String.fromCharCode(header[156] || 0x30); + const prefix = cstr(header.subarray(345, 500)); + const dataStart = offset + 512; + const dataEnd = dataStart + size; + + if (typeflag === 'L') { + // GNU long-name: the NEXT header's real name lives in this entry's body. + longName = cstr(buf.subarray(dataStart, dataEnd)); + } else if (typeflag === '0' || typeflag === '\0') { + const full = longName ?? (prefix ? `${prefix}/${name}` : name); + longName = null; + out.set(full, buf.subarray(dataStart, dataEnd)); + } else { + longName = null; + } + + offset = dataStart + Math.ceil(size / 512) * 512; + } + return out; +} + +/* ── zip (.whl) ────────────────────────────────────────────────────────────── */ + +const EOCD_SIG = 0x06054b50; +const CD_SIG = 0x02014b50; +const LFH_SIG = 0x04034b50; + +/** + * Parse a zip archive into a Map of path -> Buffer. Supports stored (0) and + * deflate (8) — the only two methods pip/wheel emits. + * @param {Buffer} zip + * @returns {Map} + */ +export function readZip(zip) { + const eocd = findEocd(zip); + if (eocd < 0) throw new Error('not a zip archive: no end-of-central-directory record'); + + const entryCount = zip.readUInt16LE(eocd + 10); + let cd = zip.readUInt32LE(eocd + 16); + const out = new Map(); + + for (let i = 0; i < entryCount; i++) { + if (zip.readUInt32LE(cd) !== CD_SIG) throw new Error(`corrupt central directory at entry ${i}`); + const method = zip.readUInt16LE(cd + 10); + const compressedSize = zip.readUInt32LE(cd + 20); + const nameLen = zip.readUInt16LE(cd + 28); + const extraLen = zip.readUInt16LE(cd + 30); + const commentLen = zip.readUInt16LE(cd + 32); + const localOffset = zip.readUInt32LE(cd + 42); + const name = zip.subarray(cd + 46, cd + 46 + nameLen).toString('utf8'); + + if (!name.endsWith('/')) { + if (zip.readUInt32LE(localOffset) !== LFH_SIG) { + throw new Error(`corrupt local header for ${name}`); + } + // The local header's own name/extra lengths are authoritative — they may differ + // from the central directory's extra field. + const lNameLen = zip.readUInt16LE(localOffset + 26); + const lExtraLen = zip.readUInt16LE(localOffset + 28); + const start = localOffset + 30 + lNameLen + lExtraLen; + const raw = zip.subarray(start, start + compressedSize); + out.set(name, method === 8 ? inflateRawSync(raw) : Buffer.from(raw)); + } + + cd += 46 + nameLen + extraLen + commentLen; + } + return out; +} + +function findEocd(buf) { + // The EOCD is at most 22 + 65535 bytes from the end (comment field). + const min = Math.max(0, buf.length - (22 + 0xffff)); + for (let i = buf.length - 22; i >= min; i--) { + if (buf.readUInt32LE(i) === EOCD_SIG) return i; + } + return -1; +} + +/* ── helpers ───────────────────────────────────────────────────────────────── */ + +function cstr(b) { + const end = b.indexOf(0); + return b.subarray(0, end === -1 ? b.length : end).toString('utf8'); +} + +function octal(b) { + const s = cstr(b).trim(); + return s ? parseInt(s, 8) || 0 : 0; +} diff --git a/scripts/lib/audit.mjs b/scripts/lib/audit.mjs new file mode 100644 index 0000000..49ec0f7 --- /dev/null +++ b/scripts/lib/audit.mjs @@ -0,0 +1,207 @@ +/** + * The offline half of the license-truth gate: everything that can be proven from files in + * THIS repo, with no network. This is what CI blocks on, because a required check that + * depends on registry.npmjs.org being up is a check that will be red for reasons that have + * nothing to do with the pull request. + * + * Each rule below exists because a specific contradiction actually shipped: + * declared-vs-text — @wave-av/cli declared "MIT" while LICENSE was the Apache-2.0 text. + * notice-shipped — the Apache-2.0 NOTICE was in the repo but not in the npm tarball, + * which Apache-2.0 §4(d) requires redistributions to carry. + * readme / lockfile — third and fourth copies of the identifier that drift silently. + */ +import { readFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { detectSpdxFromText, classifyCopyleft, UNKNOWN } from './spdx.mjs'; + +/** @typedef {{rule:string, severity:"error"|"warn", message:string}} Problem */ + +/** + * Read every place this repo states a license. + * @param {string} root repository root + */ +export function readRepoTruth(root) { + const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); + const licensePath = join(root, 'LICENSE'); + const noticePath = join(root, 'NOTICE'); + const licenseText = existsSync(licensePath) ? readFileSync(licensePath, 'utf8') : null; + + let lockDeclared = null; + const lockPath = join(root, 'package-lock.json'); + if (existsSync(lockPath)) { + const lock = JSON.parse(readFileSync(lockPath, 'utf8')); + lockDeclared = lock.packages?.['']?.license ?? null; + } + + let readmeDeclared = null; + const readmePath = join(root, 'README.md'); + if (existsSync(readmePath)) { + readmeDeclared = readmeLicense(readFileSync(readmePath, 'utf8')); + } + + return { + name: pkg.name, + version: pkg.version, + declared: typeof pkg.license === 'string' ? pkg.license : null, + files: Array.isArray(pkg.files) ? pkg.files : null, + licenseFilePresent: licenseText !== null, + licenseFileSpdx: detectSpdxFromText(licenseText), + noticeFilePresent: existsSync(noticePath), + lockDeclared, + readmeDeclared, + }; +} + +/** + * Pull the identifier out of the README's "## License" section. Returns the first SPDX-ish + * token on the first non-empty line after the heading, or null when there is no section. + * @param {string} md + */ +export function readmeLicense(md) { + const lines = md.split(/\r?\n/); + const at = lines.findIndex((l) => /^#{1,6}\s+licen[sc]e\s*$/i.test(l.trim())); + if (at === -1) return null; + for (let i = at + 1; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line) continue; + if (/^#{1,6}\s/.test(line)) return null; // next heading — empty section + const m = line.match(/\b(Apache-2\.0|MIT|ISC|BSD-[23]-Clause|MPL-2\.0|GPL-[0-9.]+[a-z-]*)\b/i); + return m ? m[1] : line.slice(0, 60); + } + return null; +} + +/** + * Licenses of every dependency the lockfile resolves, split by runtime vs dev. + * Read from package-lock.json rather than node_modules so the gate needs no install and + * gives the same answer on every machine. + * @param {string} root + */ +export function dependencyLicenses(root) { + const lockPath = join(root, 'package-lock.json'); + if (!existsSync(lockPath)) return { runtime: [], dev: [] }; + const lock = JSON.parse(readFileSync(lockPath, 'utf8')); + const runtime = []; + const dev = []; + for (const [path, entry] of Object.entries(lock.packages ?? {})) { + if (!path) continue; // the root package itself + const name = entry.name ?? path.replace(/^(.*\/)?node_modules\//, ''); + const record = { + name, + version: entry.version ?? null, + license: entry.license ?? UNKNOWN, + class: classifyCopyleft(entry.license ?? UNKNOWN), + }; + (entry.dev || entry.devOptional ? dev : runtime).push(record); + } + const byName = (a, b) => a.name.localeCompare(b.name); + return { runtime: runtime.sort(byName), dev: dev.sort(byName) }; +} + +/** + * Run every offline rule. + * @param {string} root + * @param {{packedFiles?: string[]|null}} [opts] file list `npm pack` would produce; when + * omitted the two shipped-artifact rules are skipped (and said so in the notes). + * @returns {{truth: object, deps: object, problems: Problem[], notes: string[]}} + */ +export function auditRepo(root, opts = {}) { + const truth = readRepoTruth(root); + const deps = dependencyLicenses(root); + /** @type {Problem[]} */ + const problems = []; + const notes = []; + + if (!truth.licenseFilePresent) { + problems.push({ + rule: 'license-file-present', + severity: 'error', + message: 'no LICENSE file at the repository root', + }); + } else if (truth.licenseFileSpdx === UNKNOWN) { + problems.push({ + rule: 'license-file-identifiable', + severity: 'error', + message: 'LICENSE text does not match any known license — cannot verify the declaration', + }); + } + + if (!truth.declared) { + problems.push({ + rule: 'declared-license-present', + severity: 'error', + message: 'package.json has no "license" field', + }); + } else if (truth.licenseFileSpdx !== UNKNOWN && truth.declared !== truth.licenseFileSpdx) { + problems.push({ + rule: 'declared-matches-text', + severity: 'error', + message: + `package.json declares "${truth.declared}" but the LICENSE file is the ` + + `${truth.licenseFileSpdx} text — the published metadata and the shipped file disagree`, + }); + } + + if (truth.lockDeclared && truth.declared && truth.lockDeclared !== truth.declared) { + problems.push({ + rule: 'lockfile-matches-manifest', + severity: 'error', + message: + `package-lock.json records "${truth.lockDeclared}" for the root package but ` + + `package.json declares "${truth.declared}" — run \`npm install\` to regenerate`, + }); + } + + if (truth.readmeDeclared && truth.declared && truth.readmeDeclared !== truth.declared) { + problems.push({ + rule: 'readme-matches-manifest', + severity: 'error', + message: + `README's License section says "${truth.readmeDeclared}" but package.json declares ` + + `"${truth.declared}"`, + }); + } + + const packed = opts.packedFiles ?? null; + if (packed) { + const has = (f) => packed.some((p) => p === f || p.endsWith(`/${f}`)); + if (!has('LICENSE')) { + problems.push({ + rule: 'license-shipped', + severity: 'error', + message: 'the packed tarball contains no LICENSE file', + }); + } + if (truth.noticeFilePresent && truth.licenseFileSpdx === 'Apache-2.0' && !has('NOTICE')) { + problems.push({ + rule: 'notice-shipped', + severity: 'error', + message: + 'the repo has a NOTICE file and is Apache-2.0, but NOTICE is not in the packed ' + + 'tarball — Apache-2.0 §4(d) requires redistributions to carry it. Add "NOTICE" to ' + + 'the "files" array in package.json.', + }); + } + } else { + notes.push('packed-file rules skipped: no `npm pack` file list was supplied'); + } + + const strong = deps.runtime.filter((d) => d.class === 'strong'); + for (const d of strong) { + problems.push({ + rule: 'no-strong-copyleft-runtime', + severity: 'error', + message: `runtime dependency ${d.name}@${d.version} is ${d.license} (strong copyleft)`, + }); + } + + const weak = [...deps.runtime, ...deps.dev].filter((d) => d.class === 'weak'); + if (weak.length) { + notes.push( + `weak/file-level copyleft present (reported, not blocked): ` + + weak.map((d) => `${d.name}@${d.version} ${d.license}`).join(', ') + ); + } + + return { truth, deps, problems, notes }; +} diff --git a/scripts/lib/registry.mjs b/scripts/lib/registry.mjs new file mode 100644 index 0000000..1a0264c --- /dev/null +++ b/scripts/lib/registry.mjs @@ -0,0 +1,227 @@ +/** + * The online half: what the REGISTRIES actually serve. A repo can be perfectly consistent + * and still have a published artifact that contradicts it — @wave-av/cli@1.0.8 (MIT) was + * published on 2026-04-03, two months before the repo adopted Apache-2.0, and nothing in + * the repo can tell you that. Only the registry can. + * + * Every function here downloads the REAL artifact and reads the license file inside it, + * because registry metadata is a claim and the tarball is the evidence. + */ +import { readTarGz, readZip } from './archive.mjs'; +import { detectSpdxFromText, UNKNOWN } from './spdx.mjs'; + +const NPM_REGISTRY = 'https://registry.npmjs.org'; +const PYPI = 'https://pypi.org/pypi'; + +async function getJson(url) { + const res = await fetch(url, { headers: { accept: 'application/json' } }); + if (!res.ok) throw new Error(`GET ${url} -> ${res.status} ${res.statusText}`); + return res.json(); +} + +async function getBuffer(url) { + const res = await fetch(url); + if (!res.ok) throw new Error(`GET ${url} -> ${res.status} ${res.statusText}`); + return Buffer.from(await res.arrayBuffer()); +} + +/** + * Inspect the latest published version of an npm package. + * Always talks to registry.npmjs.org explicitly — a scoped `.npmrc` entry pointing + * @wave-av at GitHub Packages otherwise silently answers 404 for the public package. + * @param {string} name + */ +export async function inspectNpm(name) { + const doc = await getJson(`${NPM_REGISTRY}/${name.replace('/', '%2F')}`); + const version = doc['dist-tags']?.latest; + const manifest = doc.versions?.[version]; + if (!manifest) throw new Error(`${name}: no latest version in registry document`); + + const tgz = await getBuffer(manifest.dist.tarball); + const entries = readTarGz(tgz); + const paths = [...entries.keys()]; + const licenseEntry = paths.find((p) => /^package\/LICEN[SC]E(\.[a-z]+)?$/i.test(p)); + const noticeEntry = paths.find((p) => /^package\/NOTICE(\.[a-z]+)?$/i.test(p)); + + return { + ecosystem: 'npm', + name, + version, + declared: typeof manifest.license === 'string' ? manifest.license : UNKNOWN, + publishedAt: doc.time?.[version] ?? null, + licenseFileInArtifact: Boolean(licenseEntry), + licenseFileSpdx: licenseEntry + ? detectSpdxFromText(entries.get(licenseEntry).toString('utf8')) + : UNKNOWN, + noticeFileInArtifact: Boolean(noticeEntry), + repository: manifest.repository?.url ?? null, + description: manifest.description ?? null, + }; +} + +/** + * Inspect the latest published version of a PyPI package (wheel preferred, sdist fallback). + * @param {string} name + */ +export async function inspectPyPI(name) { + const doc = await getJson(`${PYPI}/${name}/json`); + const info = doc.info ?? {}; + const wheel = (doc.urls ?? []).find((u) => u.packagetype === 'bdist_wheel'); + const licenseClassifier = + (info.classifiers ?? []).find((c) => c.startsWith('License ::')) ?? null; + + let licenseFileInArtifact = false; + let licenseFileSpdx = UNKNOWN; + let noticeFileInArtifact = false; + + if (wheel) { + const entries = readZip(await getBuffer(wheel.url)); + const paths = [...entries.keys()]; + const licensePath = paths.find((p) => /\.dist-info\/(licenses\/)?LICEN[SC]E/i.test(p)); + noticeFileInArtifact = paths.some((p) => /\.dist-info\/(licenses\/)?NOTICE/i.test(p)); + if (licensePath) { + licenseFileInArtifact = true; + licenseFileSpdx = detectSpdxFromText(entries.get(licensePath).toString('utf8')); + } + } + + return { + ecosystem: 'pypi', + name, + version: info.version ?? null, + // PEP 639 moved the identifier to License-Expression; older wheels still use License. + declared: info.license_expression || info.license || UNKNOWN, + declaredClassifier: licenseClassifier, + publishedAt: wheel?.upload_time_iso_8601 ?? null, + licenseFileInArtifact, + licenseFileSpdx, + noticeFileInArtifact, + repository: info.project_urls?.Repository ?? null, + description: info.summary ?? null, + }; +} + +/** + * A published artifact is INCONSISTENT when the identifier it declares is not the license + * whose text it actually ships. + * @param {{declared:string, licenseFileInArtifact:boolean, licenseFileSpdx:string}} row + */ +export function artifactProblems(row) { + const out = []; + if (!row.licenseFileInArtifact) { + out.push('no LICENSE file inside the published artifact'); + } else if (row.licenseFileSpdx === UNKNOWN) { + out.push('LICENSE file inside the artifact is unrecognizable'); + } else if (normalize(row.declared) !== normalize(row.licenseFileSpdx)) { + out.push( + `declares "${row.declared}" but ships the ${row.licenseFileSpdx} text` + ); + } + return out; +} + +function normalize(id) { + return String(id ?? '').trim().toLowerCase(); +} + +/* ── source of truth ───────────────────────────────────────────────────────── */ + +/** + * Read what a package's SOURCE repository declares, straight off its default branch. + * + * This is the column that actually exposed LEGAL-001. Every published WAVE artifact is + * internally consistent — its metadata matches the LICENSE file beside it — so comparing + * an artifact to itself finds nothing. The contradiction is between the artifact and the + * repo it claims to come from: wave-av-sdk 2.0.0 is on PyPI as MIT while + * wave-av/sdks:sdk-python/pyproject.toml declares Apache-2.0 at the SAME version string. + * + * @param {string} spec "/:", or free text when unknown. + */ +export async function inspectSource(spec) { + if (typeof spec !== 'string' || !/^[\w.-]+\/[\w.-]+:/.test(spec)) { + return { available: false, reason: spec || 'no source recorded' }; + } + const [repo, path] = [spec.slice(0, spec.indexOf(':')), spec.slice(spec.indexOf(':') + 1)]; + const raw = (p) => `https://raw.githubusercontent.com/${repo}/HEAD/${p}`; + + try { + const manifest = await getText(raw(path)); + const declared = path.endsWith('.json') + ? JSON.parse(manifest).license ?? UNKNOWN + : pyprojectLicense(manifest); + + // The LICENSE that governs a nested package is the one beside it, if there is one; + // otherwise the repository root's. + const dir = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : ''; + let licensePath = dir ? `${dir}/LICENSE` : 'LICENSE'; + let text = await getTextOrNull(raw(licensePath)); + if (text === null && dir) { + licensePath = 'LICENSE'; + text = await getTextOrNull(raw(licensePath)); + } + + return { + available: true, + repo, + path, + declared: typeof declared === 'string' ? declared : declared?.text ?? UNKNOWN, + licensePath: text === null ? null : licensePath, + licenseFileSpdx: detectSpdxFromText(text), + }; + } catch (err) { + return { available: false, reason: String(err.message) }; + } +} + +/** + * Pull the identifier out of a pyproject's `[project]` table. Handles both the PEP 621 + * table form (`license = {text = "MIT"}`) and the PEP 639 string form (`license = "MIT"`), + * which is exactly the pair that drifted between wave-av/sdk-python and wave-av/sdks. + * @param {string} toml + */ +export function pyprojectLicense(toml) { + const table = toml.match(/^\s*license\s*=\s*\{[^}]*text\s*=\s*["']([^"']+)["']/m); + if (table) return table[1]; + const str = toml.match(/^\s*license\s*=\s*["']([^"']+)["']/m); + if (str) return str[1]; + const classifier = toml.match(/License :: OSI Approved :: ([^"']+?) License/); + return classifier ? classifier[1] : UNKNOWN; +} + +/** + * A published artifact has DRIFTED when it does not carry the license its source repository + * declares today — the defect that no amount of inspecting the artifact alone can reveal. + * @param {{declared:string, version:string|null}} artifact + * @param {{available:boolean, declared?:string, licenseFileSpdx?:string, licensePath?:string|null}} source + */ +export function sourceProblems(artifact, source) { + if (!source?.available) return []; + const out = []; + if (normalize(artifact.declared) !== normalize(source.declared)) { + out.push( + `published as "${artifact.declared}" but source declares "${source.declared}"` + ); + } + if ( + source.licenseFileSpdx && + source.licenseFileSpdx !== UNKNOWN && + normalize(source.declared) !== normalize(source.licenseFileSpdx) + ) { + out.push( + `source manifest says "${source.declared}" but ${source.licensePath} is the ` + + `${source.licenseFileSpdx} text` + ); + } + return out; +} + +async function getText(url) { + const res = await fetch(url); + if (!res.ok) throw new Error(`GET ${url} -> ${res.status} ${res.statusText}`); + return res.text(); +} + +async function getTextOrNull(url) { + const res = await fetch(url); + return res.ok ? res.text() : null; +} diff --git a/scripts/lib/spdx.mjs b/scripts/lib/spdx.mjs new file mode 100644 index 0000000..145f9a0 --- /dev/null +++ b/scripts/lib/spdx.mjs @@ -0,0 +1,99 @@ +/** + * SPDX identification from license TEXT, plus the copyleft classification the ledger + * reports on. + * + * WHY THIS EXISTS: every license defect this repo has shipped was a mismatch between a + * DECLARED identifier (`package.json` "license", a PyPI classifier, a README line) and the + * license TEXT actually in the file next to it. You cannot catch that by comparing two + * declarations — you have to read the text and name it. `detectSpdxFromText` is that step. + */ + +/** + * Ordered longest-signature-first. Apache-2.0 is tested before MIT because the Apache + * appendix ("Licensed under the Apache License") contains no MIT phrasing but a naive + * substring search over a concatenated dual-license file could otherwise mis-rank. + */ +const SIGNATURES = [ + { + spdx: 'Apache-2.0', + test: (t) => + /apache\s+license\s*\n?\s*version\s+2\.0/i.test(t) || + /apache\.org\/licenses\/license-2\.0/i.test(t), + }, + { + spdx: 'MPL-2.0', + test: (t) => /mozilla public license\s*,?\s*(version\s+)?2\.0/i.test(t), + }, + { + spdx: 'BSD-3-Clause', + test: (t) => + /redistribution and use in source and binary forms/i.test(t) && + /neither the name of/i.test(t), + }, + { + spdx: 'BSD-2-Clause', + test: (t) => /redistribution and use in source and binary forms/i.test(t), + }, + { + spdx: 'ISC', + test: (t) => /permission to use, copy, modify,? and\/or distribute this software/i.test(t), + }, + { + spdx: 'MIT', + test: (t) => + /\bmit license\b/i.test(t) || + /permission is hereby granted, free of charge, to any person obtaining a copy/i.test(t), + }, + { + spdx: 'GPL-3.0-only', + test: (t) => /gnu general public license\s*\n?\s*version 3/i.test(t), + }, +]; + +export const UNKNOWN = 'UNKNOWN'; + +/** + * Name the license a body of text actually is. + * @param {string|null|undefined} text + * @returns {string} an SPDX identifier, or "UNKNOWN" when nothing matches. + */ +export function detectSpdxFromText(text) { + if (typeof text !== 'string' || text.trim().length === 0) return UNKNOWN; + for (const sig of SIGNATURES) { + if (sig.test(text)) return sig.spdx; + } + return UNKNOWN; +} + +/** + * Strong copyleft: reciprocal at the WORK level. Linking one of these into a distributed + * Apache-2.0 binary changes the obligations of the whole distribution, so it is a hard + * gate failure in runtime dependencies rather than a note. + */ +const STRONG_COPYLEFT = /\b(A?GPL-[123]|GPL-[123]|SSPL|OSL-|CC-BY-SA|EUPL)/i; + +/** + * Weak / file-level copyleft: obligations attach to the modified FILES, not the combined + * work. Reported in the ledger so a human can see it; not a gate failure. + */ +const WEAK_COPYLEFT = /\b(LGPL-|MPL-|EPL-|CDDL-|MS-RL)/i; + +/** + * @param {string} expr an SPDX expression as it appears in package metadata + * @returns {"strong"|"weak"|"permissive"|"unknown"} + */ +export function classifyCopyleft(expr) { + if (typeof expr !== 'string' || !expr.trim()) return 'unknown'; + // A disjunction that offers ANY permissive option is satisfiable permissively — + // "(MPL-2.0 OR Apache-2.0)" is not a copyleft obligation for a consumer who picks Apache. + // The separator must be whitespace-delimited: "LGPL-3.0-or-later" is ONE identifier whose + // "-or-" is part of the name, and splitting on it would recurse on the same string forever. + const options = expr.replace(/[()]/g, '').split(/\s+OR\s+/i).map((s) => s.trim()); + if (options.length > 1 && options.some((o) => classifyCopyleft(o) === 'permissive')) { + return 'permissive'; + } + if (STRONG_COPYLEFT.test(expr)) return 'strong'; + if (WEAK_COPYLEFT.test(expr)) return 'weak'; + if (/^(UNKNOWN|UNLICENSED|SEE LICENSE)/i.test(expr)) return 'unknown'; + return 'permissive'; +} diff --git a/scripts/license-audit.test.mjs b/scripts/license-audit.test.mjs new file mode 100644 index 0000000..2b4be64 --- /dev/null +++ b/scripts/license-audit.test.mjs @@ -0,0 +1,470 @@ +/** + * Rule-level and end-to-end tests for the license gate. + * + * The load-bearing case is `catches the exact contradiction @wave-av/cli shipped`: a fixture + * repo whose package.json says MIT while its LICENSE file is the Apache-2.0 text. That was + * the real state of this repository at 1.0.9. If that assertion ever stops failing on the + * fixture, the gate has stopped working. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, writeFileSync, readFileSync, rmSync, readdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { deflateRawSync } from 'node:zlib'; + +import { detectSpdxFromText, UNKNOWN } from './lib/spdx.mjs'; +import { auditRepo, readRepoTruth, dependencyLicenses } from './lib/audit.mjs'; +import { readTarGz, readZip } from './lib/archive.mjs'; +import { artifactProblems, sourceProblems, pyprojectLicense } from './lib/registry.mjs'; +import { packedFileList, renderLedger } from './license-truth.mjs'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const APACHE_HEAD = readFileSync(join(ROOT, 'LICENSE'), 'utf8'); +const MIT_TEXT = `MIT License + +Copyright (c) 2026 WAVE Online, LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction. +`; + +/* ── fixture repos ─────────────────────────────────────────────────────────── */ + +/** Build a throwaway package tree so the rules can be exercised against known-bad input. */ +function fixture({ declared, licenseText, notice = false, readme = null, lockLicense = null, deps = [] }) { + const dir = mkdtempSync(join(tmpdir(), 'license-truth-')); + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ name: 'fixture', version: '0.0.0', license: declared, files: ['dist'] }, null, 2) + ); + if (licenseText !== null) writeFileSync(join(dir, 'LICENSE'), licenseText); + if (notice) writeFileSync(join(dir, 'NOTICE'), 'Fixture\nCopyright 2026\n'); + if (readme !== null) writeFileSync(join(dir, 'README.md'), readme); + if (lockLicense !== null || deps.length) { + const packages = { '': { name: 'fixture', version: '0.0.0', license: lockLicense ?? declared } }; + for (const d of deps) { + packages[`node_modules/${d.name}`] = { version: d.version, license: d.license, ...(d.dev ? { dev: true } : {}) }; + } + writeFileSync(join(dir, 'package-lock.json'), JSON.stringify({ lockfileVersion: 3, packages }, null, 2)); + } + return dir; +} + +const trash = []; +afterAll(() => { + for (const d of trash) rmSync(d, { recursive: true, force: true }); +}); +function tmpFixture(spec) { + const d = fixture(spec); + trash.push(d); + return d; +} + +describe('auditRepo rules', () => { + it('catches the exact contradiction @wave-av/cli shipped: declared MIT, Apache-2.0 text', () => { + const dir = tmpFixture({ declared: 'MIT', licenseText: APACHE_HEAD }); + const { problems } = auditRepo(dir); + const rule = problems.find((p) => p.rule === 'declared-matches-text'); + expect(rule).toBeDefined(); + expect(rule.message).toContain('declares "MIT"'); + expect(rule.message).toContain('Apache-2.0'); + }); + + it('passes when the declaration matches the text', () => { + const dir = tmpFixture({ declared: 'Apache-2.0', licenseText: APACHE_HEAD }); + expect(auditRepo(dir).problems).toEqual([]); + }); + + it('flags a missing LICENSE file', () => { + const dir = tmpFixture({ declared: 'Apache-2.0', licenseText: null }); + expect(auditRepo(dir).problems.map((p) => p.rule)).toContain('license-file-present'); + }); + + it('flags an unrecognizable LICENSE rather than trusting the declaration', () => { + const dir = tmpFixture({ declared: 'Apache-2.0', licenseText: 'All rights reserved.' }); + expect(auditRepo(dir).problems.map((p) => p.rule)).toContain('license-file-identifiable'); + }); + + it('flags a README that still names the old license', () => { + const dir = tmpFixture({ + declared: 'Apache-2.0', + licenseText: APACHE_HEAD, + readme: '# fixture\n\n## License\n\nMIT\n', + }); + expect(auditRepo(dir).problems.map((p) => p.rule)).toContain('readme-matches-manifest'); + }); + + it('flags a lockfile whose root license drifted from the manifest', () => { + const dir = tmpFixture({ declared: 'Apache-2.0', licenseText: APACHE_HEAD, lockLicense: 'MIT' }); + expect(auditRepo(dir).problems.map((p) => p.rule)).toContain('lockfile-matches-manifest'); + }); + + it('requires NOTICE in the tarball when the package is Apache-2.0 and has one', () => { + const dir = tmpFixture({ declared: 'Apache-2.0', licenseText: APACHE_HEAD, notice: true }); + const without = auditRepo(dir, { packedFiles: ['package.json', 'LICENSE'] }); + expect(without.problems.map((p) => p.rule)).toContain('notice-shipped'); + + const with_ = auditRepo(dir, { packedFiles: ['package.json', 'LICENSE', 'NOTICE'] }); + expect(with_.problems.map((p) => p.rule)).not.toContain('notice-shipped'); + }); + + it('requires LICENSE in the tarball', () => { + const dir = tmpFixture({ declared: 'Apache-2.0', licenseText: APACHE_HEAD }); + const res = auditRepo(dir, { packedFiles: ['package.json', 'dist/index.js'] }); + expect(res.problems.map((p) => p.rule)).toContain('license-shipped'); + }); + + it('notes when the packed-file rules were skipped instead of silently passing them', () => { + const dir = tmpFixture({ declared: 'Apache-2.0', licenseText: APACHE_HEAD }); + expect(auditRepo(dir).notes.join(' ')).toContain('packed-file rules skipped'); + }); + + it('fails on strong copyleft in a runtime dependency but not a dev one', () => { + const runtime = tmpFixture({ + declared: 'Apache-2.0', + licenseText: APACHE_HEAD, + deps: [{ name: 'copyleft-lib', version: '1.0.0', license: 'GPL-3.0-only' }], + }); + expect(auditRepo(runtime).problems.map((p) => p.rule)).toContain('no-strong-copyleft-runtime'); + + const devOnly = tmpFixture({ + declared: 'Apache-2.0', + licenseText: APACHE_HEAD, + deps: [{ name: 'copyleft-lib', version: '1.0.0', license: 'GPL-3.0-only', dev: true }], + }); + expect(auditRepo(devOnly).problems).toEqual([]); + }); + + it('reports weak copyleft as a note, not a failure', () => { + const dir = tmpFixture({ + declared: 'Apache-2.0', + licenseText: APACHE_HEAD, + deps: [{ name: 'weak-lib', version: '2.0.0', license: 'MPL-2.0' }], + }); + const res = auditRepo(dir); + expect(res.problems).toEqual([]); + expect(res.notes.join(' ')).toContain('weak-lib@2.0.0 MPL-2.0'); + }); +}); + +describe('dependencyLicenses', () => { + it('splits the real lockfile into runtime and dev and classifies every entry', () => { + const { runtime, dev } = dependencyLicenses(ROOT); + expect(runtime.length).toBeGreaterThan(0); + expect(dev.length).toBeGreaterThan(0); + for (const d of [...runtime, ...dev]) { + expect(['permissive', 'weak', 'strong', 'unknown']).toContain(d.class); + } + }); + + it('finds no strong copyleft in this package\'s runtime tree', () => { + const { runtime } = dependencyLicenses(ROOT); + expect(runtime.filter((d) => d.class === 'strong')).toEqual([]); + }); +}); + +/* ── this repository, for real ─────────────────────────────────────────────── */ + +describe('this repository', () => { + it('declares Apache-2.0 in every place it states a license', () => { + const truth = readRepoTruth(ROOT); + expect(truth.declared).toBe('Apache-2.0'); + expect(truth.licenseFileSpdx).toBe('Apache-2.0'); + expect(truth.readmeDeclared).toBe('Apache-2.0'); + expect(truth.lockDeclared).toBe('Apache-2.0'); + expect(truth.noticeFilePresent).toBe(true); + }); + + it('passes the full offline gate against its own packed file list', () => { + const packed = packedFileList(ROOT); + expect(packed, 'npm pack --dry-run produced no file list').not.toBeNull(); + const res = auditRepo(ROOT, { packedFiles: packed }); + expect(res.problems).toEqual([]); + }); + + it('ships LICENSE and NOTICE in the tarball npm would publish', () => { + const packed = packedFileList(ROOT); + expect(packed).toContain('LICENSE'); + expect(packed).toContain('NOTICE'); + }); +}); + +/* ── archive readers ───────────────────────────────────────────────────────── */ + +describe('readTarGz', () => { + let dir; + let tarball; + + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'license-pack-')); + trash.push(dir); + execFileSync('npm', ['pack', '--ignore-scripts', '--pack-destination', dir], { + cwd: ROOT, + stdio: ['ignore', 'pipe', 'ignore'], + }); + const [file] = readdirSync(dir).filter((f) => f.endsWith('.tgz')); + tarball = join(dir, file); + }, 120_000); + + it('reads a real npm tarball and finds the Apache-2.0 LICENSE inside it', () => { + const entries = readTarGz(readFileSync(tarball)); + expect([...entries.keys()]).toContain('package/LICENSE'); + expect(detectSpdxFromText(entries.get('package/LICENSE').toString('utf8'))).toBe('Apache-2.0'); + }); + + it('finds the NOTICE inside the tarball this change adds it to', () => { + const entries = readTarGz(readFileSync(tarball)); + expect([...entries.keys()]).toContain('package/NOTICE'); + expect(entries.get('package/NOTICE').toString('utf8')).toContain('WAVE'); + }); + + it('round-trips the package manifest byte-for-byte', () => { + const entries = readTarGz(readFileSync(tarball)); + const packed = JSON.parse(entries.get('package/package.json').toString('utf8')); + expect(packed.license).toBe('Apache-2.0'); + expect(packed.name).toBe('@wave-av/cli'); + }); +}); + +describe('readZip', () => { + /** + * Minimal zip writer. CRC-32 is written as zero: the reader under test never verifies it + * (it only needs names and bytes), so a real checksum would test nothing here. + */ + function makeZip(files) { + const locals = []; + const central = []; + let offset = 0; + for (const [name, contentStr] of Object.entries(files)) { + const nameBuf = Buffer.from(name, 'utf8'); + const raw = Buffer.from(contentStr, 'utf8'); + const deflated = deflateRawSync(raw); + + const lfh = Buffer.alloc(30); + lfh.writeUInt32LE(0x04034b50, 0); + lfh.writeUInt16LE(20, 4); + lfh.writeUInt16LE(8, 8); // deflate + lfh.writeUInt32LE(0, 14); // crc32 (unverified by the reader) + lfh.writeUInt32LE(deflated.length, 18); + lfh.writeUInt32LE(raw.length, 22); + lfh.writeUInt16LE(nameBuf.length, 26); + locals.push(lfh, nameBuf, deflated); + + const cdh = Buffer.alloc(46); + cdh.writeUInt32LE(0x02014b50, 0); + cdh.writeUInt16LE(20, 6); + cdh.writeUInt16LE(8, 10); + cdh.writeUInt32LE(0, 16); + cdh.writeUInt32LE(deflated.length, 20); + cdh.writeUInt32LE(raw.length, 24); + cdh.writeUInt16LE(nameBuf.length, 28); + cdh.writeUInt32LE(offset, 42); + central.push(cdh, nameBuf); + + offset += lfh.length + nameBuf.length + deflated.length; + } + const cdBuf = Buffer.concat(central); + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); + eocd.writeUInt16LE(Object.keys(files).length, 8); + eocd.writeUInt16LE(Object.keys(files).length, 10); + eocd.writeUInt32LE(cdBuf.length, 12); + eocd.writeUInt32LE(offset, 16); + return Buffer.concat([...locals, cdBuf, eocd]); + } + + it('reads names and inflates bodies from a wheel-shaped zip', () => { + const zip = makeZip({ + 'wave_sdk/__init__.py': 'x = 1\n', + 'wave_sdk-2.0.0.dist-info/METADATA': 'Name: wave-sdk\nLicense: MIT\n', + 'wave_sdk-2.0.0.dist-info/licenses/LICENSE': MIT_TEXT, + }); + const entries = readZip(zip); + expect([...entries.keys()]).toContain('wave_sdk-2.0.0.dist-info/licenses/LICENSE'); + expect(entries.get('wave_sdk-2.0.0.dist-info/METADATA').toString('utf8')).toContain('License: MIT'); + expect( + detectSpdxFromText(entries.get('wave_sdk-2.0.0.dist-info/licenses/LICENSE').toString('utf8')) + ).toBe('MIT'); + }); + + it('rejects a buffer that is not a zip instead of returning nothing', () => { + expect(() => readZip(Buffer.from('not a zip at all'))).toThrow(/end-of-central-directory/); + }); +}); + +/* ── published-artifact verdicts + ledger rendering ────────────────────────── */ + +describe('artifactProblems', () => { + it('flags the wave-av-sdk shape: metadata says MIT, source moved to Apache-2.0', () => { + const problems = artifactProblems({ + declared: 'MIT', + licenseFileInArtifact: true, + licenseFileSpdx: 'Apache-2.0', + }); + expect(problems).toHaveLength(1); + expect(problems[0]).toContain('declares "MIT"'); + expect(problems[0]).toContain('Apache-2.0'); + }); + + it('accepts an artifact whose metadata and file agree', () => { + expect( + artifactProblems({ declared: 'MIT', licenseFileInArtifact: true, licenseFileSpdx: 'MIT' }) + ).toEqual([]); + }); + + it('flags an artifact with no LICENSE file at all', () => { + expect( + artifactProblems({ declared: 'MIT', licenseFileInArtifact: false, licenseFileSpdx: UNKNOWN }) + ).toEqual(['no LICENSE file inside the published artifact']); + }); +}); + +describe('pyprojectLicense', () => { + it('reads the PEP 621 table form used by wave-av/sdk-python', () => { + expect(pyprojectLicense('[project]\nname = "wave-sdk"\nlicense = {text = "MIT"}\n')).toBe('MIT'); + }); + + it('reads the PEP 639 string form used by wave-av/sdks', () => { + expect(pyprojectLicense('[project]\nlicense = "Apache-2.0"\n')).toBe('Apache-2.0'); + }); + + it('falls back to the trove classifier when there is no license key', () => { + expect( + pyprojectLicense('classifiers = [\n "License :: OSI Approved :: Apache Software License",\n]') + ).toBe('Apache Software'); + }); + + it('returns UNKNOWN rather than guessing', () => { + expect(pyprojectLicense('[project]\nname = "x"\n')).toBe(UNKNOWN); + }); +}); + +describe('sourceProblems', () => { + it('flags the wave-av-sdk defect: PyPI 2.0.0 is MIT, source declares Apache-2.0', () => { + const problems = sourceProblems( + { declared: 'MIT', version: '2.0.0' }, + { available: true, declared: 'Apache-2.0', licensePath: 'sdk-python/LICENSE', licenseFileSpdx: 'Apache-2.0' } + ); + expect(problems).toEqual(['published as "MIT" but source declares "Apache-2.0"']); + }); + + it('flags the sdk-python defect: pyproject says MIT, repo LICENSE is Apache-2.0', () => { + const problems = sourceProblems( + { declared: 'MIT', version: '2.0.0' }, + { available: true, declared: 'MIT', licensePath: 'LICENSE', licenseFileSpdx: 'Apache-2.0' } + ); + expect(problems).toEqual([ + 'source manifest says "MIT" but LICENSE is the Apache-2.0 text', + ]); + }); + + it('reports BOTH when the artifact, the manifest and the file all disagree', () => { + const problems = sourceProblems( + { declared: 'MIT', version: '1.0.6' }, + { + available: true, + declared: 'Apache-2.0', + licensePath: 'packages/workflow-sdk/LICENSE', + licenseFileSpdx: 'MIT', + } + ); + expect(problems).toHaveLength(2); + }); + + it('stays silent when the artifact, the manifest and the file agree', () => { + expect( + sourceProblems( + { declared: 'Apache-2.0', version: '2.1.3' }, + { available: true, declared: 'Apache-2.0', licensePath: 'LICENSE', licenseFileSpdx: 'Apache-2.0' } + ) + ).toEqual([]); + }); + + it('reports nothing — never a pass — when the source could not be resolved', () => { + expect(sourceProblems({ declared: 'MIT' }, { available: false, reason: 'no source recorded' })).toEqual([]); + }); +}); + +describe('renderLedger', () => { + it('marks a drifted row DRIFT and a clean row consistent', () => { + const md = renderLedger({ + manifest: { + intendedLicense: 'Apache-2.0', + governingStatement: { repo: 'wave-av/cli', commit: '5da80189e5b1', subject: 's', body: 'b' }, + }, + rows: [ + { + name: 'good', + ecosystem: 'npm', + version: '1.0.0', + declared: 'Apache-2.0', + licenseFileInArtifact: true, + licenseFileSpdx: 'Apache-2.0', + noticeFileInArtifact: true, + source: 'wave-av/x:package.json', + sourceTruth: { available: true, declared: 'Apache-2.0', licensePath: 'LICENSE', licenseFileSpdx: 'Apache-2.0' }, + problems: [], + }, + { + name: 'bad', + ecosystem: 'pypi', + version: '2.0.0', + declared: 'MIT', + licenseFileInArtifact: true, + licenseFileSpdx: 'MIT', + noticeFileInArtifact: false, + source: 'wave-av/y:pyproject.toml', + sourceTruth: { available: true, declared: 'Apache-2.0', licensePath: 'LICENSE', licenseFileSpdx: 'Apache-2.0' }, + problems: ['source declares Apache-2.0'], + }, + ], + local: auditRepo(ROOT), + now: new Date('2026-09-03T00:00:00Z'), + }); + expect(md).toContain('| `good` | npm | 1.0.0 | `Apache-2.0` | `Apache-2.0` | `Apache-2.0` | yes | consistent |'); + expect(md).toContain('**DRIFT** — source declares Apache-2.0'); + expect(md).toContain('Generated: 2026-09-03T00:00:00.000Z'); + }); + + + it('never calls an artifact consistent when its source could not be resolved', () => { + const md = renderLedger({ + manifest: { + intendedLicense: 'Apache-2.0', + governingStatement: { repo: 'r', commit: 'abcdef1234', subject: 's', body: 'b' }, + }, + rows: [ + { + name: 'orphan', + ecosystem: 'npm', + version: '1.0.9', + declared: 'MIT', + licenseFileInArtifact: true, + licenseFileSpdx: 'MIT', + noticeFileInArtifact: false, + source: 'UNVERIFIED — no package.json found', + sourceTruth: { available: false, reason: 'no source recorded' }, + problems: [], + }, + ], + local: auditRepo(ROOT), + }); + expect(md).toContain('**unverified**'); + expect(md).not.toContain('| yes | consistent |'); + }); + + it('renders a fetch failure as an explicit could-not-fetch row, never as a pass', () => { + const md = renderLedger({ + manifest: { + intendedLicense: 'Apache-2.0', + governingStatement: { repo: 'r', commit: 'abcdef1234', subject: 's', body: 'b' }, + }, + rows: [{ name: 'offline-pkg', source: 'wave-av/z', error: 'GET ... -> 503' }], + local: auditRepo(ROOT), + }); + expect(md).toContain('**could not fetch**: GET ... -> 503'); + }); +}); diff --git a/scripts/license-manifest.json b/scripts/license-manifest.json new file mode 100644 index 0000000..9c35f51 --- /dev/null +++ b/scripts/license-manifest.json @@ -0,0 +1,25 @@ +{ + "$comment": "Every WAVE package published to a public registry, and the repo that is its source of truth. The ledger walks this list; adding a package here is how it gets audited.", + "intendedLicense": "Apache-2.0", + "governingStatement": { + "repo": "wave-av/cli", + "commit": "5da80189e5b154c79de8bd294bcf830e630e848b", + "subject": "chore: adopt Apache-2.0 license + add NOTICE", + "body": "Standardize the open WAVE protocol/SDK surface on Apache-2.0 (patent grant for adoption). Replaces any prior license; adds NOTICE reserving the WAVE marks." + }, + "npm": [ + { "name": "@wave-av/cli", "source": "wave-av/cli:package.json" }, + { "name": "@wave-av/sdk", "source": "wave-av/sdk:package.json" }, + { "name": "@wave-av/adk", "source": "wave-av/adk:package.json" }, + { "name": "@wave-av/mcp-server", "source": "wave-av/mcp-server:package.json" }, + { + "name": "@wave-av/workflow-sdk", + "source": "wave-av/sdks:sdk-typescript/packages/workflow-sdk/package.json" + }, + { "name": "@wave-av/create-app", "source": "UNVERIFIED — no package.json found on any wave-av default branch" } + ], + "pypi": [ + { "name": "wave-sdk", "source": "wave-av/sdk-python:pyproject.toml" }, + { "name": "wave-av-sdk", "source": "wave-av/sdks:sdk-python/pyproject.toml" } + ] +} diff --git a/scripts/license-spdx.test.mjs b/scripts/license-spdx.test.mjs new file mode 100644 index 0000000..e820329 --- /dev/null +++ b/scripts/license-spdx.test.mjs @@ -0,0 +1,95 @@ +/** + * Unit tests for the two pure classifiers the license gate is built on: naming a license + * from its TEXT, and deciding how reciprocal that license is. Every license defect this + * repo shipped was a DECLARED identifier disagreeing with a license FILE, so the ability to + * read a file and name it correctly is the foundation the rest of the gate stands on. + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join, resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { detectSpdxFromText, classifyCopyleft, UNKNOWN } from './lib/spdx.mjs'; +import { readmeLicense } from './lib/audit.mjs'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const APACHE_HEAD = readFileSync(join(ROOT, 'LICENSE'), 'utf8'); +const MIT_TEXT = `MIT License + +Copyright (c) 2026 WAVE Online, LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction. +`; + +/* ── SPDX detection ────────────────────────────────────────────────────────── */ + +describe('detectSpdxFromText', () => { + it("names this repository's own LICENSE file", () => { + expect(detectSpdxFromText(APACHE_HEAD)).toBe('Apache-2.0'); + }); + + it('names MIT text', () => { + expect(detectSpdxFromText(MIT_TEXT)).toBe('MIT'); + }); + + it('names ISC text without confusing it for MIT', () => { + const isc = + 'ISC License\n\nPermission to use, copy, modify, and/or distribute this software for any purpose\nwith or without fee is hereby granted.'; + expect(detectSpdxFromText(isc)).toBe('ISC'); + }); + + it('names BSD-3-Clause by its third clause', () => { + const bsd = + 'Redistribution and use in source and binary forms, with or without modification, are permitted.\n' + + 'Neither the name of the copyright holder nor the names of its contributors may be used to endorse.'; + expect(detectSpdxFromText(bsd)).toBe('BSD-3-Clause'); + }); + + it('returns UNKNOWN rather than guessing', () => { + expect(detectSpdxFromText('')).toBe(UNKNOWN); + expect(detectSpdxFromText(null)).toBe(UNKNOWN); + expect(detectSpdxFromText('All rights reserved. Do not copy.')).toBe(UNKNOWN); + }); +}); + +describe('classifyCopyleft', () => { + it.each([ + ['MIT', 'permissive'], + ['Apache-2.0', 'permissive'], + ['MPL-2.0', 'weak'], + ['LGPL-3.0-or-later', 'weak'], + ['GPL-3.0-only', 'strong'], + ['AGPL-3.0', 'strong'], + ['SSPL-1.0', 'strong'], + ['UNKNOWN', 'unknown'], + ])('%s -> %s', (expr, expected) => { + expect(classifyCopyleft(expr)).toBe(expected); + }); + + it('treats a disjunction offering a permissive option as permissive', () => { + expect(classifyCopyleft('(MPL-2.0 OR Apache-2.0)')).toBe('permissive'); + expect(classifyCopyleft('(MIT OR WTFPL)')).toBe('permissive'); + }); + + it('keeps a disjunction of only copyleft options copyleft', () => { + expect(classifyCopyleft('(GPL-3.0-only OR AGPL-3.0)')).toBe('strong'); + }); +}); + +describe('readmeLicense', () => { + it('reads the identifier under a License heading', () => { + expect(readmeLicense('# T\n\n## License\n\nApache-2.0 — see LICENSE.\n')).toBe('Apache-2.0'); + expect(readmeLicense('## License\n\nMIT\n')).toBe('MIT'); + }); + + it('returns null when there is no License section', () => { + expect(readmeLicense('# Title\n\nsome prose\n')).toBeNull(); + }); + + it('returns null for an empty License section', () => { + expect(readmeLicense('## License\n\n## Next\n\nbody\n')).toBeNull(); + }); +}); + diff --git a/scripts/license-truth.mjs b/scripts/license-truth.mjs new file mode 100644 index 0000000..33c6e98 --- /dev/null +++ b/scripts/license-truth.mjs @@ -0,0 +1,273 @@ +#!/usr/bin/env node +/** + * license-truth — prove that what this package DECLARES is what it SHIPS. + * + * node scripts/license-truth.mjs check (default) offline audit of this repo; exit 1 on drift + * node scripts/license-truth.mjs ledger fetch every published WAVE artifact, write LICENSE-LEDGER.md + * node scripts/license-truth.mjs ledger --check as above, but exit 1 if any artifact is inconsistent + * + * `check` is what CI blocks on: it reads only files in the repo, so it is deterministic and + * cannot go red because a registry is having a bad afternoon. `ledger` is the periodic + * reconciliation against the registries, run on a schedule and on demand. + */ +import { execFileSync } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { auditRepo } from './lib/audit.mjs'; +import { + inspectNpm, + inspectPyPI, + inspectSource, + artifactProblems, + sourceProblems, +} from './lib/registry.mjs'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const LEDGER_PATH = join(ROOT, 'LICENSE-LEDGER.md'); + +/** + * The file list `npm publish` would upload. `--ignore-scripts` keeps this from triggering a + * build; the answer is about `files`/`.npmignore` semantics, not about build output. + * @returns {string[]|null} null when npm is unavailable (the two packed rules then skip). + */ +export function packedFileList(root = ROOT) { + try { + const out = execFileSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], { + cwd: root, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + const start = out.indexOf('['); + return JSON.parse(out.slice(start))[0].files.map((f) => f.path); + } catch { + return null; + } +} + +function runCheck({ json }) { + const result = auditRepo(ROOT, { packedFiles: packedFileList() }); + if (json) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return result.problems.length ? 1 : 0; + } + + const { truth, problems, notes } = result; + console.log(`license-truth: ${truth.name}@${truth.version}`); + console.log(` declared (package.json) : ${truth.declared}`); + console.log(` LICENSE file text is : ${truth.licenseFileSpdx}`); + console.log(` README section says : ${truth.readmeDeclared ?? '(no License section)'}`); + console.log(` lockfile records : ${truth.lockDeclared ?? '(none)'}`); + console.log(` NOTICE file present : ${truth.noticeFilePresent}`); + for (const n of notes) console.log(` note: ${n}`); + + if (!problems.length) { + console.log('\nOK — every license surface agrees.'); + return 0; + } + console.error(`\n${problems.length} license contradiction(s):`); + for (const p of problems) console.error(` [${p.rule}] ${p.message}`); + console.error( + '\nFix the source of truth (the LICENSE file), then make every declaration match it.' + ); + return 1; +} + +async function runLedger({ json, strict }) { + const manifest = JSON.parse(readFileSync(join(ROOT, 'scripts/license-manifest.json'), 'utf8')); + const rows = []; + const failures = []; + + for (const entry of manifest.npm) { + rows.push(await collect(() => inspectNpm(entry.name), entry, failures)); + } + for (const entry of manifest.pypi) { + rows.push(await collect(() => inspectPyPI(entry.name), entry, failures)); + } + + const local = auditRepo(ROOT, { packedFiles: packedFileList() }); + const markdown = renderLedger({ manifest, rows, local }); + writeFileSync(LEDGER_PATH, markdown); + + if (json) { + process.stdout.write(`${JSON.stringify({ manifest, rows, local }, null, 2)}\n`); + } else { + console.log(`wrote ${LEDGER_PATH} (${rows.length} published artifacts)`); + for (const r of rows) { + const flag = r.error + ? 'ERROR' + : r.problems.length + ? 'DRIFT' + : r.sourceTruth?.available + ? 'ok' + : 'UNVER'; + console.log(` ${flag.padEnd(5)} ${r.name}@${r.version ?? '?'} ${r.error ?? r.problems.join('; ')}`); + } + } + + const drifted = rows.filter((r) => r.problems?.length || r.error); + const unverified = rows.filter((r) => !r.error && !r.problems.length && !r.sourceTruth?.available); + if (unverified.length) { + console.error( + `\n${unverified.length} artifact(s) could not be checked against a source repo: ` + + unverified.map((r) => r.name).join(', ') + ); + } + if (strict && drifted.length) { + console.error(`\n${drifted.length} published artifact(s) disagree with their own metadata.`); + return 1; + } + return 0; +} + +async function collect(fn, entry, failures) { + try { + const row = await fn(); + const source = await inspectSource(entry.source); + return { + ...row, + source: entry.source, + sourceTruth: source, + // Two independent questions: is the ARTIFACT self-consistent, and does it match the + // SOURCE it claims to come from? Every WAVE artifact passes the first and several + // fail the second, so reporting only the first would report "all clear" on a + // repository that is provably contradicting itself. + problems: [...artifactProblems(row), ...sourceProblems(row, source)], + error: null, + }; + } catch (err) { + failures.push(entry.name); + return { name: entry.name, source: entry.source, problems: [], error: String(err.message) }; + } +} + +/** @returns {string} the LICENSE-LEDGER.md body */ +export function renderLedger({ manifest, rows, local, now = new Date() }) { + const lines = []; + lines.push('# WAVE license ledger'); + lines.push(''); + lines.push( + '' + ); + lines.push(''); + lines.push(`Generated: ${now.toISOString()}`); + lines.push(''); + lines.push( + `**Intended license for the open WAVE surface: \`${manifest.intendedLicense}\`** — per ` + + `${manifest.governingStatement.repo}@${manifest.governingStatement.commit.slice(0, 7)} ` + + `("${manifest.governingStatement.subject}"): ${manifest.governingStatement.body}` + ); + lines.push(''); + lines.push('## Published artifacts'); + lines.push(''); + lines.push( + '`declared` is the identifier in the published artifact\'s own metadata. `ships` is the ' + + 'license whose TEXT is in the file inside that artifact. `source declares` is what the ' + + 'manifest on the source repository\'s default branch says today. All three must agree; ' + + 'any disagreement is drift, and the last pair is the one an artifact cannot self-report.' + ); + lines.push(''); + lines.push('| package | registry | version | declared | ships | source declares | NOTICE | verdict |'); + lines.push('| --- | --- | --- | --- | --- | --- | --- | --- |'); + for (const r of rows) { + if (r.error) { + lines.push(`| \`${r.name}\` | — | — | — | — | — | — | **could not fetch**: ${r.error} |`); + continue; + } + // An unresolved source is NOT a pass: the artifact agrees with itself, and nothing + // has checked it against the repo it claims to come from. + const verdict = r.problems.length + ? `**DRIFT** — ${r.problems.join('; ')}` + : r.sourceTruth?.available + ? 'consistent' + : `**unverified** — artifact self-consistent; source unresolved (${r.sourceTruth?.reason ?? 'not fetched'})`; + const shipped = r.licenseFileInArtifact ? `\`${r.licenseFileSpdx}\`` : '**no LICENSE file**'; + const src = r.sourceTruth?.available ? `\`${r.sourceTruth.declared}\`` : '_unresolved_'; + lines.push( + `| \`${r.name}\` | ${r.ecosystem} | ${r.version} | \`${r.declared}\` | ${shipped} | ` + + `${src} | ${r.noticeFileInArtifact ? 'yes' : 'no'} | ${verdict} |` + ); + } + lines.push(''); + lines.push('| package | source of truth | LICENSE file in that repo |'); + lines.push('| --- | --- | --- |'); + for (const r of rows) { + const st = r.sourceTruth; + const licCol = st?.available + ? st.licensePath + ? `\`${st.licensePath}\` is \`${st.licenseFileSpdx}\`` + : '**no LICENSE file found**' + : `_unresolved: ${st?.reason ?? 'not fetched'}_`; + lines.push(`| \`${r.name}\` | ${r.source} | ${licCol} |`); + } + lines.push(''); + + lines.push('## This repository'); + lines.push(''); + lines.push(`- package: \`${local.truth.name}@${local.truth.version}\``); + lines.push(`- \`package.json\` declares: \`${local.truth.declared}\``); + lines.push(`- \`LICENSE\` file text is: \`${local.truth.licenseFileSpdx}\``); + lines.push(`- \`README.md\` License section: \`${local.truth.readmeDeclared ?? 'none'}\``); + lines.push(`- \`package-lock.json\` root: \`${local.truth.lockDeclared ?? 'none'}\``); + lines.push(`- \`NOTICE\` present in repo: ${local.truth.noticeFilePresent ? 'yes' : 'no'}`); + lines.push( + `- offline gate: ${local.problems.length ? `**${local.problems.length} problem(s)**` : 'clean'}` + ); + for (const p of local.problems) lines.push(` - \`${p.rule}\` — ${p.message}`); + lines.push(''); + + lines.push('## Dependency licenses'); + lines.push(''); + lines.push( + 'Strong copyleft (GPL/AGPL/SSPL/EUPL/CC-BY-SA) in a **runtime** dependency fails the gate. ' + + 'Weak, file-level copyleft (MPL/LGPL/EPL/CDDL) is listed here but does not block.' + ); + lines.push(''); + lines.push('| scope | total | permissive | weak copyleft | strong copyleft | unknown |'); + lines.push('| --- | --- | --- | --- | --- | --- |'); + for (const [scope, list] of [['runtime', local.deps.runtime], ['dev', local.deps.dev]]) { + const n = (c) => list.filter((d) => d.class === c).length; + lines.push( + `| ${scope} | ${list.length} | ${n('permissive')} | ${n('weak')} | ${n('strong')} | ${n('unknown')} |` + ); + } + lines.push(''); + const notable = [...local.deps.runtime, ...local.deps.dev].filter( + (d) => d.class === 'weak' || d.class === 'strong' || d.class === 'unknown' + ); + if (notable.length) { + lines.push('Notable (non-permissive) dependencies:'); + lines.push(''); + lines.push('| dependency | license | class |'); + lines.push('| --- | --- | --- |'); + for (const d of notable) { + lines.push(`| \`${d.name}@${d.version}\` | \`${d.license}\` | ${d.class} |`); + } + } else { + lines.push('No non-permissive dependencies in either scope.'); + } + lines.push(''); + return `${lines.join('\n')}`; +} + +async function main() { + const argv = process.argv.slice(2); + const command = argv.find((a) => !a.startsWith('-')) ?? 'check'; + const json = argv.includes('--json'); + const strict = argv.includes('--check'); + + if (command === 'check') return runCheck({ json }); + if (command === 'ledger') return runLedger({ json, strict }); + console.error(`unknown command "${command}" — expected "check" or "ledger"`); + return 2; +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) { + main().then( + (code) => process.exit(code), + (err) => { + console.error(err); + process.exit(2); + } + ); +} From 02b8a0d82e0ae6df6474a18c507fa3e07e1c2b3a Mon Sep 17 00:00:00 2001 From: Jake Fineman Date: Thu, 3 Sep 2026 21:42:19 -0400 Subject: [PATCH 2/2] fix(security): validate every registry URL instead of escaping the first slash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL js/incomplete-sanitization (high) on scripts/lib/registry.mjs:35 — `name.replace('/', '%2F')` replaces only the FIRST occurrence, so a name with a second slash reaches a registry path this code never intended to request. The same splice-without-validation shape appeared twice more: the PyPI project name and the raw.githubusercontent repo path. Replaced all three with validated builders — npmPackageUrl, pypiProjectUrl and rawGithubUrl — that reject anything outside each ecosystem's name grammar (and any path with a ".." segment or a leading slash) before encoding. `replaceAll` now escapes every slash; path segments go through encodeURIComponent. These names come from license-manifest.json today, which is repo-controlled, so this is defence in depth rather than a live exploit — but a validated builder is the correct shape for a function that turns a string into a URL it will fetch. Four tests added, including the exact CodeQL case: a name whose second slash would have survived the old replace. Co-Authored-By: Claude Opus 5 (1M context) --- LICENSE-LEDGER.md | 2 +- scripts/lib/registry.mjs | 47 +++++++++++++++++++++++++++++++--- scripts/license-audit.test.mjs | 38 ++++++++++++++++++++++++++- 3 files changed, 82 insertions(+), 5 deletions(-) diff --git a/LICENSE-LEDGER.md b/LICENSE-LEDGER.md index c7e9698..155c58f 100644 --- a/LICENSE-LEDGER.md +++ b/LICENSE-LEDGER.md @@ -2,7 +2,7 @@ -Generated: 2026-09-04T01:25:11.236Z +Generated: 2026-09-04T01:42:06.068Z **Intended license for the open WAVE surface: `Apache-2.0`** — per wave-av/cli@5da8018 ("chore: adopt Apache-2.0 license + add NOTICE"): Standardize the open WAVE protocol/SDK surface on Apache-2.0 (patent grant for adoption). Replaces any prior license; adds NOTICE reserving the WAVE marks. diff --git a/scripts/lib/registry.mjs b/scripts/lib/registry.mjs index 1a0264c..3b9f1ef 100644 --- a/scripts/lib/registry.mjs +++ b/scripts/lib/registry.mjs @@ -25,6 +25,47 @@ async function getBuffer(url) { return Buffer.from(await res.arrayBuffer()); } +/* ── URL construction ──────────────────────────────────────────────────────── */ + +/** + * Package names and repo paths reach these functions from `license-manifest.json`, and are + * then spliced into registry URLs. Validate the shape and encode every reserved character — + * a partial escape (`name.replace('/', '%2F')` replaces only the FIRST slash) leaves a name + * able to reach a path this code did not intend to request. + */ +const NPM_NAME = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/; +const PYPI_NAME = /^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$/; +const REPO_SLUG = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; +const REPO_PATH = /^(?!\/)(?!.*\.\.)[A-Za-z0-9._\-\/]+$/; + +/** @param {string} name an npm package name, scoped or not */ +export function npmPackageUrl(name) { + if (typeof name !== 'string' || !NPM_NAME.test(name)) { + throw new Error(`refusing to fetch: "${name}" is not a valid npm package name`); + } + // The registry addresses a scoped package as one path segment with the slash escaped. + return `${NPM_REGISTRY}/${name.replaceAll('/', '%2F')}`; +} + +/** @param {string} name a PyPI project name */ +export function pypiProjectUrl(name) { + if (typeof name !== 'string' || !PYPI_NAME.test(name)) { + throw new Error(`refusing to fetch: "${name}" is not a valid PyPI project name`); + } + return `${PYPI}/${encodeURIComponent(name)}/json`; +} + +/** + * @param {string} repo "/" + * @param {string} path a repo-relative file path; no leading slash, no ".." segment + */ +export function rawGithubUrl(repo, path) { + if (!REPO_SLUG.test(repo)) throw new Error(`refusing to fetch: bad repo slug "${repo}"`); + if (!REPO_PATH.test(path)) throw new Error(`refusing to fetch: bad repo path "${path}"`); + const segments = path.split('/').map(encodeURIComponent).join('/'); + return `https://raw.githubusercontent.com/${repo}/HEAD/${segments}`; +} + /** * Inspect the latest published version of an npm package. * Always talks to registry.npmjs.org explicitly — a scoped `.npmrc` entry pointing @@ -32,7 +73,7 @@ async function getBuffer(url) { * @param {string} name */ export async function inspectNpm(name) { - const doc = await getJson(`${NPM_REGISTRY}/${name.replace('/', '%2F')}`); + const doc = await getJson(npmPackageUrl(name)); const version = doc['dist-tags']?.latest; const manifest = doc.versions?.[version]; if (!manifest) throw new Error(`${name}: no latest version in registry document`); @@ -64,7 +105,7 @@ export async function inspectNpm(name) { * @param {string} name */ export async function inspectPyPI(name) { - const doc = await getJson(`${PYPI}/${name}/json`); + const doc = await getJson(pypiProjectUrl(name)); const info = doc.info ?? {}; const wheel = (doc.urls ?? []).find((u) => u.packagetype === 'bdist_wheel'); const licenseClassifier = @@ -142,7 +183,7 @@ export async function inspectSource(spec) { return { available: false, reason: spec || 'no source recorded' }; } const [repo, path] = [spec.slice(0, spec.indexOf(':')), spec.slice(spec.indexOf(':') + 1)]; - const raw = (p) => `https://raw.githubusercontent.com/${repo}/HEAD/${p}`; + const raw = (p) => rawGithubUrl(repo, p); try { const manifest = await getText(raw(path)); diff --git a/scripts/license-audit.test.mjs b/scripts/license-audit.test.mjs index 2b4be64..dde2e17 100644 --- a/scripts/license-audit.test.mjs +++ b/scripts/license-audit.test.mjs @@ -17,7 +17,14 @@ import { deflateRawSync } from 'node:zlib'; import { detectSpdxFromText, UNKNOWN } from './lib/spdx.mjs'; import { auditRepo, readRepoTruth, dependencyLicenses } from './lib/audit.mjs'; import { readTarGz, readZip } from './lib/archive.mjs'; -import { artifactProblems, sourceProblems, pyprojectLicense } from './lib/registry.mjs'; +import { + artifactProblems, + sourceProblems, + pyprojectLicense, + npmPackageUrl, + pypiProjectUrl, + rawGithubUrl, +} from './lib/registry.mjs'; import { packedFileList, renderLedger } from './license-truth.mjs'; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); @@ -388,6 +395,35 @@ describe('sourceProblems', () => { }); }); +describe('URL construction', () => { + it('escapes EVERY slash in a package name, not just the first', () => { + expect(npmPackageUrl('@wave-av/cli')).toBe('https://registry.npmjs.org/@wave-av%2Fcli'); + expect(npmPackageUrl('chalk')).toBe('https://registry.npmjs.org/chalk'); + }); + + it('refuses a package name that could reach a path this code did not intend', () => { + for (const bad of ['@a/b/../../etc', '../../etc/passwd', '@a/b?x=1', 'a b', '', null]) { + expect(() => npmPackageUrl(bad)).toThrow(/not a valid npm package name/); + } + }); + + it('refuses a PyPI project name with path or query characters', () => { + expect(pypiProjectUrl('wave-sdk')).toBe('https://pypi.org/pypi/wave-sdk/json'); + for (const bad of ['wave/sdk', '../wave', 'wave?x', '']) { + expect(() => pypiProjectUrl(bad)).toThrow(/not a valid PyPI project name/); + } + }); + + it('refuses a repo path that escapes the repository', () => { + expect(rawGithubUrl('wave-av/sdks', 'sdk-python/pyproject.toml')).toBe( + 'https://raw.githubusercontent.com/wave-av/sdks/HEAD/sdk-python/pyproject.toml' + ); + expect(() => rawGithubUrl('wave-av/sdks', '../secrets')).toThrow(/bad repo path/); + expect(() => rawGithubUrl('wave-av/sdks', '/etc/passwd')).toThrow(/bad repo path/); + expect(() => rawGithubUrl('not-a-slug', 'LICENSE')).toThrow(/bad repo slug/); + }); +}); + describe('renderLedger', () => { it('marks a drifted row DRIFT and a clean row consistent', () => { const md = renderLedger({