Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/25174-rest-uuid-advisory.md
Original file line number Diff line number Diff line change
@@ -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.
86 changes: 82 additions & 4 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -487,6 +563,7 @@ export const SCAFFOLD_VITEST_RANGE = '~4.1.12';
*/
export const SCAFFOLD_NPM_OVERRIDES: Readonly<Record<string, string>> = Object.freeze({
vitest: SCAFFOLD_VITEST_RANGE,
[SCAFFOLD_UUID_OVERRIDE_SELECTOR]: SCAFFOLD_UUID_OVERRIDE_RANGE,
});

/** The `@types/node` range a scaffolded project declares. */
Expand Down Expand Up @@ -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 },
};
}
Expand Down
20 changes: 20 additions & 0 deletions packages/cli/test/scaffold-emission-policy.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
57 changes: 53 additions & 4 deletions packages/cli/test/scaffold-npm-overrides.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand All @@ -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 —
Expand All @@ -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';
Expand Down Expand Up @@ -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);
});

Expand Down Expand Up @@ -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@<M.0.0` with M the major right above the target,
* so every edge that can resolve below the fix is rewritten (the
* exceljs edge is `^8.3.0`), and a later advisory on the target line
* moves the target alone.
*/
const UUID_FIXED_FLOOR_BY_MAJOR: Readonly<Record<number, readonly [number, number, number]>> = {
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@<M.0.0`).not.toBeNull();
const targetMajor = Number(/^\^(\d+)\./.exec(SCAFFOLD_UUID_OVERRIDE_RANGE)?.[1]);
expect(Number(selector![1])).toBe(targetMajor + 1);
});
});
3 changes: 2 additions & 1 deletion packages/create-objectstack/src/templates/blank/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"vitest": "~4.1.12"
},
"overrides": {
"vitest": "~4.1.12"
"vitest": "~4.1.12",
"uuid@<12.0.0": "^11.1.1"
}
}
39 changes: 36 additions & 3 deletions scripts/sync-scaffold-emission-policy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,20 @@ export const POLICY_STAMPS = [
// direct `devDependencies.vitest` (`EOVERRIDE`), so the two rows above and
// here must move together, and only generation makes them.
},
{
key: 'overrides.uuid@<12.0.0',
block: 'overrides',
field: 'uuid@<12.0.0',
constant: 'SCAFFOLD_UUID_OVERRIDE_RANGE',
// The npm override that routes exceljs's `uuid ^8.3.0` (through
// `@objectstack/rest`) out of GHSA-w5hq-g745-h8pq. The field is
// `SCAFFOLD_UUID_OVERRIDE_SELECTOR`, spelled here as the key the template
// declares; `os init` / `os create` emit it from that constant. This
// script's `--check` compares VALUES only, so the key spellings are held
// equal by `packages/cli/test/scaffold-emission-policy.e2e.test.ts`
// (nightly tier), which compares the on-ramp's whole `overrides` block
// with theirs.
},
];

/**
Expand Down Expand Up @@ -332,7 +346,9 @@ export function stampPolicy(text, policy, { label = TEMPLATE_PKG_FILE } = {}) {
}
const span = findBlockSpan(out, stamp.block, label);
const block = out.slice(span.start, span.end);
const field = new RegExp(`("${stamp.field}"\\s*:\\s*)"([^"]*)"`, 'g');
// Escaped: an npm override selector (`uuid@<12.0.0`) carries `.`, which
// unescaped would match any character.
const field = new RegExp(`("${escapeRe(stamp.field)}"\\s*:\\s*)"([^"]*)"`, 'g');
const hits = [...block.matchAll(field)];
if (hits.length !== 1) {
throw new Error(
Expand Down Expand Up @@ -630,8 +646,14 @@ const SELF_TEST_POLICY = {
SCAFFOLD_PNPM_RANGE: '>=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 [
Expand All @@ -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');
}

Expand All @@ -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,
Expand Down Expand Up @@ -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 },
Expand Down
Loading