diff --git a/.changeset/25174-rest-uuid-advisory.md b/.changeset/25174-rest-uuid-advisory.md new file mode 100644 index 00000000000..466c5b07610 --- /dev/null +++ b/.changeset/25174-rest-uuid-advisory.md @@ -0,0 +1,15 @@ +--- +'@objectstack/cli': patch +'create-objectstack': patch +--- + +fix(cli,create-objectstack): new projects override `uuid` below 12.0.0 to `^11.1.1`, so `npm audit` lists no GHSA-w5hq-g745-h8pq entry + +Clause-②: no +Metadata-protocol: none + +`@objectstack/rest` depends on `exceljs ^4.4.0`, and exceljs 4.4.0, its newest release, depends on `uuid ^8.3.0`. GHSA-w5hq-g745-h8pq affects `uuid` below 11.1.1, so `npm audit` in a fresh npm-installed scaffold reported 9 moderate entries, all from that one advisory. The npm `overrides` block every standalone scaffold emits now carries `"uuid@<12.0.0": "^11.1.1"`: the `create-objectstack` blank template, `os init` (`-t app`, `-t plugin`, `-t empty`) and `os create plugin`. A new project resolves `uuid` 11.1.1 and `npm audit` lists no `uuid` entry, measured on npm 10.9.9 and 11.21.1. exceljs calls only `uuid`'s `v4()`, which `uuid` 11 keeps; exceljs's buffered and streaming writers, its loader and `@objectstack/rest`'s `parseXlsxToRows` were run on the installed tree. pnpm ignores npm's `overrides` field. + +The selector stops at `<12.0.0`, so a project can still add a newer `uuid` of its own: `npm install uuid@14` installs. A spec-less `npm install uuid` is refused with `EOVERRIDE` (npm reads it as `uuid@*`, which meets the selector); name the major instead. + +**An existing project made by an earlier `create-objectstack`, `os init` or `os create plugin`**, or any other npm project that installs `@objectstack/rest`, directly or through `@objectstack/runtime`, `@objectstack/cli`, `@objectstack/plugin-auth` or `@objectstack/verify`: add `"uuid@<12.0.0": "^11.1.1"` to the `overrides` block of its root `package.json` and run `npm install`. npm reads `overrides` only from the root manifest, so no `@objectstack/*` release can apply it for you. diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 47a2518d9a2..e7fbe82ddf9 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -434,13 +434,89 @@ export const SCAFFOLD_TYPESCRIPT_RANGE = '^5.3.0'; */ export const SCAFFOLD_VITEST_RANGE = '~4.1.12'; +/** + * The npm `overrides` SELECTOR that routes `uuid` out of GHSA-w5hq-g745-h8pq, + * and the range it routes to ({@link SCAFFOLD_UUID_OVERRIDE_RANGE}). + * + * The advisory affects `uuid` below 11.1.1 (and 12.0.0 and 13.0.0, fixed in + * 12.0.1 and 13.0.1). A scaffold reaches it through ONE edge: + * `@objectstack/cli`, `@objectstack/runtime`, `@objectstack/plugin-auth` and + * `@objectstack/verify` depend on `@objectstack/rest`, which depends on + * `exceljs ^4.4.0`, which depends on `uuid ^8.3.0`. exceljs + * 4.4.0 is its newest release, and its only later one, `4.4.1-prerelease.0`, + * declares `uuid ^8.3.0` too, so no exceljs upgrade leaves the 8.x line. + * + * Why the scaffold and not `@objectstack/rest` itself: npm reads `overrides` + * only from the ROOT manifest of an install. Measured 2026-10-22, each manifest + * in an empty directory with a fresh `--cache`: the published + * `@objectstack/rest` 18.0.0 repacked with `"overrides": { "uuid": "^11.1.1" }` + * and installed as a dependency still resolves `uuid` 8.3.2 on npm 10.9.9 and + * 11.21.1, and `npm audit` reports the same 3 entries as the unmodified + * package. The monorepo's own `pnpm-workspace.yaml` override (the same + * selector and range as here) has the same limit: it never reaches an npm + * consumer. This block reaches the projects the scaffolders create, and no + * other npm consumer of `@objectstack/rest`. + * + * The selector is `uuid@<12.0.0`, not a bare `uuid`, so that it rewrites only + * edges that can resolve below the fix, and it stops at the major boundary so + * that a later advisory on the 11.x line moves the range alone. It also leaves + * a project free to add a newer `uuid` of its own. Measured on a consumer of + * the published `@objectstack/rest` 18.0.0, npm 10.9.9 and 11.21.1: with this + * selector, a later `npm install uuid@14` exits 0, declares `^14.0.3`, and + * exceljs keeps `uuid` 11.1.1; with a bare `uuid` key it exits 1 with + * `EOVERRIDE`. A spec-less `npm install uuid` exits 1 with `EOVERRIDE: + * Override for uuid@* conflicts with direct dependency` under either key, + * because npm reads it as `uuid@*`, which meets `<12.0.0`. Naming the major is + * the fix, the same cost the `vitest` entry carries. + * + * Safe at runtime because exceljs calls exactly one `uuid` export, `v4()`, with + * no arguments (`lib/xlsx/xform/sheet/cf-ext/cf-rule-ext-xform.js`), and + * `uuid` 11 keeps it. The monorepo's own suites already run exceljs on `uuid` + * 11.1.1 through the workspace override. + */ +export const SCAFFOLD_UUID_OVERRIDE_SELECTOR = 'uuid@<12.0.0'; + +/** + * The `uuid` range {@link SCAFFOLD_UUID_OVERRIDE_SELECTOR} routes to: the + * `legacy-11` line from its first release outside GHSA-w5hq-g745-h8pq, the + * same target as the workspace override, so a scaffold resolves the `uuid` the + * monorepo's own exceljs suites run on. + * + * Measured 2026-10-22, each manifest in an empty directory with a fresh + * `--cache`, the `@objectstack/*` ranges resolving to the published 18.0.0 + * (blank template, `os init -t app`, `-t plugin` and `-t empty`): + * + * without the entry npm 10.9.9, 11.21.1 exit 0, `uuid` 8.3.2; `npm + * audit` reports 9 moderate entries, + * all GHSA-w5hq-g745-h8pq + * with the entry npm 10.9.9, 11.21.1 exit 0, `uuid` 11.1.1; `npm + * audit` lists no `uuid` entry + * + * `os create plugin` has no `@objectstack/rest` in its closure and audits + * clean either way; the entry is inert there. Under the entry, exceljs's + * buffered write (with a rule that calls `v4()`), its streaming + * `WorkbookWriter`, its buffered load and `@objectstack/rest`'s own + * `parseXlsxToRows` ran on the installed tree, on both npm versions. + * + * The re-check that lets this go: `npm ls uuid --all` in an emitted `-t app` + * install WITHOUT the entry, on the newest npm 10 and on npm 11. When no path + * resolves a `uuid` inside the advisory's range, delete it here, in + * {@link SCAFFOLD_NPM_OVERRIDES}, and its `POLICY_STAMPS` row together. + */ +export const SCAFFOLD_UUID_OVERRIDE_RANGE = '^11.1.1'; + /** * The npm `overrides` block every STANDALONE scaffold emits: `os init`'s three * templates, `os create plugin` outside the monorepo, and — by generation, not - * import — `create-objectstack`'s bundled template (the `overrides.vitest` row - * in `scripts/sync-scaffold-emission-policy.mjs`'s `POLICY_STAMPS`). + * import — `create-objectstack`'s bundled template (the `overrides.vitest` and + * `overrides.uuid@<12.0.0` rows in `scripts/sync-scaffold-emission-policy.mjs`'s + * `POLICY_STAMPS`). + * + * It carries two entries with two reasons. `uuid` is the advisory route on + * {@link SCAFFOLD_UUID_OVERRIDE_SELECTOR}. The rest of this comment is about + * `vitest`. * - * Why it exists. vitest 4.1.12 (published 2026-10-15) peers + * Why the `vitest` entry exists. vitest 4.1.12 (published 2026-10-15) peers * `@vitest/browser-webdriverio ^4.1.11`, and that package's only match, 4.1.11, * peers `vitest: 4.1.11` exactly — a peer set that contradicts itself. A * scaffold reaches it through its own root vitest, now that the range is @@ -487,6 +563,7 @@ export const SCAFFOLD_VITEST_RANGE = '~4.1.12'; */ export const SCAFFOLD_NPM_OVERRIDES: Readonly> = Object.freeze({ vitest: SCAFFOLD_VITEST_RANGE, + [SCAFFOLD_UUID_OVERRIDE_SELECTOR]: SCAFFOLD_UUID_OVERRIDE_RANGE, }); /** The `@types/node` range a scaffolded project declares. */ @@ -570,7 +647,8 @@ export function renderScaffoldPackageJson( dependencies: template.dependencies, devDependencies: template.devDependencies, // Every template, whether or not it declares vitest itself — without it - // npm 10 cannot install any of them (see SCAFFOLD_NPM_OVERRIDES). + // npm 10 cannot install any of them, and `npm audit` reports the `uuid` + // advisory exceljs pulls in (see SCAFFOLD_NPM_OVERRIDES). overrides: { ...SCAFFOLD_NPM_OVERRIDES }, }; } diff --git a/packages/cli/test/scaffold-emission-policy.e2e.test.ts b/packages/cli/test/scaffold-emission-policy.e2e.test.ts index 050a00e54bd..bc21399499e 100644 --- a/packages/cli/test/scaffold-emission-policy.e2e.test.ts +++ b/packages/cli/test/scaffold-emission-policy.e2e.test.ts @@ -78,6 +78,7 @@ import { childEnv } from './helpers/serve-process.js'; import { renderScaffoldPackageJson, renderScaffoldTsconfig, + SCAFFOLD_NPM_OVERRIDES, SCAFFOLD_PNPM_RANGE, SCAFFOLD_TSCONFIG_INCLUDE_WITH_ROOT_CONFIG, SCAFFOLD_TYPES_NODE_RANGE, @@ -421,6 +422,25 @@ describe('the on-ramp emits the same policy — measured by DRIVING it', () => { ]); }); + it('emits ONE npm overrides block across ALL THREE scaffolders, and it IS SCAFFOLD_NPM_OVERRIDES', () => { + // The on-ramp receives its block by GENERATION, value by value, from + // `POLICY_STAMPS` rows whose KEYS are spelled in the stamp script, the + // `uuid@<12.0.0` selector among them. Its `--check` holds each VALUE to + // the policy and cannot see a key spelled differently from the constant + // `os init` and `os create` emit from. Comparing whole blocks here can. + const expected = { ...SCAFFOLD_NPM_OVERRIDES }; + // Control: the block names more than vitest, so a comparison that only + // ever met `{ vitest }` cannot pass for this one. + expect(Object.keys(expected).length).toBeGreaterThan(1); + for (const { id, manifest } of allScaffolderManifests()) { + expect( + manifest.overrides, + `${id} must emit the shared npm overrides block. The on-ramp reaches it by generation: ` + + 'run `pnpm gen:scaffold-emission-policy`, and keep each POLICY_STAMPS override key equal to its constant', + ).toEqual(expected); + } + }); + it('carries the committed template through unchanged — the generator ran, the build copied', () => { // The one place the committed template is read, and deliberately as a // CONSEQUENCE rather than as the expectation: the drive above already diff --git a/packages/cli/test/scaffold-npm-overrides.test.ts b/packages/cli/test/scaffold-npm-overrides.test.ts index 96d0c817d4a..8511a3ace9f 100644 --- a/packages/cli/test/scaffold-npm-overrides.test.ts +++ b/packages/cli/test/scaffold-npm-overrides.test.ts @@ -2,7 +2,8 @@ /** * PIN — every standalone manifest `os init` and `os create` emit carries the - * npm `overrides` block, built from the one vitest constant. + * npm `overrides` block, built from the vitest constant and the two `uuid` + * constants (the `uuid` entry is an advisory route; its pins are at the end). * * ## The defect * @@ -26,8 +27,8 @@ * Every equality is DERIVED from the exported constants, never a literal: a * pin comparing against a spelled-out range would stay green on a tree where * the constant moved and one emission was left behind — the drift it exists - * to catch. The one literal is a LOWER bound, the advisory's fixed release, in - * the floor pin at the end of this file. + * to catch. The literals are LOWER bounds, the advisories' fixed releases, in + * the floor pins at the end of this file. * * 1. Each emission's `overrides.vitest` IS `SCAFFOLD_VITEST_RANGE`. * 2. Where an emission also declares `vitest` directly, the two are equal — @@ -47,6 +48,8 @@ import { describe, it, expect } from 'vitest'; import { renderScaffoldPackageJson, SCAFFOLD_NPM_OVERRIDES, + SCAFFOLD_UUID_OVERRIDE_RANGE, + SCAFFOLD_UUID_OVERRIDE_SELECTOR, SCAFFOLD_VITEST_RANGE, TEMPLATES, } from '../src/commands/init.js'; @@ -84,8 +87,10 @@ describe('npm overrides in every standalone scaffold manifest', () => { expect(direct.map((m) => m.id)).toEqual(expect.arrayContaining(['os init -t plugin', 'os create plugin'])); }); - it('the override map is built from the vitest constant', () => { + it('the override map is built from the vitest and uuid constants, and nothing else', () => { expect(SCAFFOLD_NPM_OVERRIDES.vitest).toBe(SCAFFOLD_VITEST_RANGE); + expect(SCAFFOLD_NPM_OVERRIDES[SCAFFOLD_UUID_OVERRIDE_SELECTOR]).toBe(SCAFFOLD_UUID_OVERRIDE_RANGE); + expect(Object.keys(SCAFFOLD_NPM_OVERRIDES).sort()).toEqual(['vitest', SCAFFOLD_UUID_OVERRIDE_SELECTOR].sort()); expect(Object.isFrozen(SCAFFOLD_NPM_OVERRIDES)).toBe(true); }); @@ -143,3 +148,47 @@ describe('the scaffold vitest range stays outside GHSA-qxg6-89mg-rqv8', () => { expect(atOrAbove, `SCAFFOLD_VITEST_RANGE "${SCAFFOLD_VITEST_RANGE}" admits ${floor.join('.')}, below the fix ${fixed!.join('.')}`).toBe(true); }); }); + +/** + * The `uuid` override must keep routing exceljs's `uuid ^8.3.0` (reached + * through `@objectstack/rest`) out of GHSA-w5hq-g745-h8pq. The advisory affects + * `uuid` below 11.1.1, and 12.0.0 and 13.0.0; the fixed releases are 11.1.1, + * 12.0.1 and 13.0.1 (read from the npm registry's bulk advisory endpoint, + * 2026-10-22). + * Two properties, both read off the constants: + * + * 1. The TARGET's lowest admitted version is at or above the fix for its + * major. A major with no row is refused: moving the target there is the + * moment to read the advisory for that line. + * 2. The SELECTOR is `uuid@> = { + 11: [11, 1, 1], + 12: [12, 0, 1], + 13: [13, 0, 1], +}; + +describe('the scaffold uuid override stays outside GHSA-w5hq-g745-h8pq', () => { + it('its target admits nothing below the advisory fix for its major', () => { + const match = /^\^(\d+)\.(\d+)\.(\d+)$/.exec(SCAFFOLD_UUID_OVERRIDE_RANGE); + expect(match, `SCAFFOLD_UUID_OVERRIDE_RANGE "${SCAFFOLD_UUID_OVERRIDE_RANGE}" is not a ^X.Y.Z range`).not.toBeNull(); + const floor = match!.slice(1).map(Number) as [number, number, number]; + const fixed = UUID_FIXED_FLOOR_BY_MAJOR[floor[0]]; + expect(fixed, `no advisory floor is recorded for uuid ${floor[0]}.x — read the advisory, then add the row`).toBeDefined(); + const atOrAbove = floor[1] !== fixed![1] ? floor[1] > fixed![1] : floor[2] >= fixed![2]; + expect( + atOrAbove, + `SCAFFOLD_UUID_OVERRIDE_RANGE "${SCAFFOLD_UUID_OVERRIDE_RANGE}" admits ${floor.join('.')}, below the fix ${fixed!.join('.')}`, + ).toBe(true); + }); + + it('its selector covers every version below the target line, bounded at the next major', () => { + const selector = /^uuid@<(\d+)\.0\.0$/.exec(SCAFFOLD_UUID_OVERRIDE_SELECTOR); + expect(selector, `SCAFFOLD_UUID_OVERRIDE_SELECTOR "${SCAFFOLD_UUID_OVERRIDE_SELECTOR}" is not uuid@=99.1', SCAFFOLD_TYPESCRIPT_RANGE: '^9.9.9', SCAFFOLD_VITEST_RANGE: '~8.8.0', + SCAFFOLD_UUID_OVERRIDE_RANGE: '^7.7.7', +}; +const STALE = { + SCAFFOLD_PNPM_RANGE: '>=1.0', + SCAFFOLD_TYPESCRIPT_RANGE: '^1.0.0', + SCAFFOLD_VITEST_RANGE: '^1.1.0', + SCAFFOLD_UUID_OVERRIDE_RANGE: '^1.2.3', }; -const STALE = { SCAFFOLD_PNPM_RANGE: '>=1.0', SCAFFOLD_TYPESCRIPT_RANGE: '^1.0.0', SCAFFOLD_VITEST_RANGE: '^1.1.0' }; function policySource(policy = SELF_TEST_POLICY) { return [ @@ -642,6 +664,8 @@ function policySource(policy = SELF_TEST_POLICY) { '', `export const SCAFFOLD_VITEST_RANGE = '${policy.SCAFFOLD_VITEST_RANGE}';`, '', + `export const SCAFFOLD_UUID_OVERRIDE_RANGE = '${policy.SCAFFOLD_UUID_OVERRIDE_RANGE}';`, + '', ].join('\n'); } @@ -661,7 +685,10 @@ function templatePkg(policy, { overrideVitest } = {}) { typescript: policy.SCAFFOLD_TYPESCRIPT_RANGE, vitest: policy.SCAFFOLD_VITEST_RANGE, }, - overrides: { vitest: overrideVitest ?? policy.SCAFFOLD_VITEST_RANGE }, + overrides: { + vitest: overrideVitest ?? policy.SCAFFOLD_VITEST_RANGE, + 'uuid@<12.0.0': policy.SCAFFOLD_UUID_OVERRIDE_RANGE, + }, }, null, 2, @@ -813,6 +840,12 @@ function selfTest() { }), 'BOTH vitest stamps — devDependencies.vitest and overrides.vitest — now read the policy', ); + // The selector key carries `@`, `<` and `.`: the rewrite must land on it + // exactly, never on a span a looser pattern happened to match. + ok( + after.every((t) => JSON.parse(t).overrides['uuid@<12.0.0'] === '^7.7.7'), + 'the uuid override, keyed by its npm selector `uuid@<12.0.0`, now reads the policy', + ); ok(runFixture(script, ['--check']).status === 0, '--check is green after the rewrite (idempotent)'); }, { templatePolicy: STALE },