From 1d01f26355f1a238817db359d535bd584e39d953 Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Fri, 11 Sep 2026 13:02:52 +0200 Subject: [PATCH 1/4] fix(sei-global-wallet): clear the new sharp and stream-json advisories Two advisories published against the existing dependency graph, so the nightly consumer smoke went red without any change in this repository. GHSA-rgj7-g3m4-5g8c covers sharp below 0.35.4, which @dynamic-labs/iconic pins exactly at 0.35.0. Same shape as the documented axios and uuid pins, so it is corrected the same way: a root override, here a patch-level move inside the pinned minor. Nine high findings came from that one advisory cascading up the Dynamic chain to @sei-js/sei-global-wallet itself. GHSA-528h-pc64-c93x covers every stream-json up to 3.4.0, which the Solana RPC client's CommonJS jayson requires on the optional AA path. No override can reach a fix: 3.5.0 onward is ESM-only under a moved src/ layout, so pointing jayson at a fixed version trades the advisory for MODULE_NOT_FOUND, and every CommonJS version sits inside the advisory. It joins the existing Bun waiver and a new npm one, both subset checks that still fail on anything unwaived. The wallet-only npm consumer keeps its strictly clean audit, so a default install is unaffected. Also closes two verifier gaps this exposed. The npm audits ran without allowing a non-zero exit, so any finding surfaced as a raw spawn error carrying the whole audit JSON instead of the assertion naming the consumer. The override reporting and the Bun override-effectiveness assertion now derive from the override block rather than a hardcoded axios/uuid list. Co-authored-by: Cursor --- .../fix-global-wallet-sharp-override.md | 15 +++ packages/sei-global-wallet/README.md | 22 ++-- scripts/check-sei-global-wallet.ts | 112 +++++++++++++----- 3 files changed, 112 insertions(+), 37 deletions(-) create mode 100644 .changeset/fix-global-wallet-sharp-override.md diff --git a/.changeset/fix-global-wallet-sharp-override.md b/.changeset/fix-global-wallet-sharp-override.md new file mode 100644 index 00000000..2225f9d8 --- /dev/null +++ b/.changeset/fix-global-wallet-sharp-override.md @@ -0,0 +1,15 @@ +--- +'@sei-js/sei-global-wallet': patch +--- + +Override the newly advised `sharp` pin, and waive the one optional-AA advisory that no override can reach. + +Two advisories published against the existing dependency graph, so the nightly consumer run went red without any change in this repository. + +`GHSA-rgj7-g3m4-5g8c` covers `sharp` below `0.35.4`, and `@dynamic-labs/iconic` pins `sharp@0.35.0` exactly. That is the same shape as the existing Axios and UUID pins: the vulnerable copy is reachable from `@dynamic-labs/global-wallet-client`, overrides are root-only in both npm and Bun, and this package cannot propagate them to an application. A plain install reported nine high findings, one root advisory cascading up the Dynamic chain to `@sei-js/sei-global-wallet` itself. The [Required consumer overrides](https://github.com/sei-protocol/sei-js/blob/main/packages/sei-global-wallet/README.md#required-consumer-overrides) blocks now carry `"sharp": "0.35.4"`, a patch-level move inside the pinned minor. The advisory is a heap overflow in the bundled libheif decoder, so it needs untrusted HEIF input to trigger and `sharp` is a build-time dependency of the icon package that never reaches a browser bundle, but it is high severity with a compatible fix available, so it is corrected rather than waived. + +`GHSA-528h-pc64-c93x` covers every `stream-json` up to `3.4.0`, which the Solana RPC client's `jayson` requires as CommonJS on the optional AA path. It cannot be overridden: `3.5.0` onward is ESM-only under a moved `src/` layout, so pointing `jayson` at a fixed version replaces the advisory with a `MODULE_NOT_FOUND` on its own require, and every CommonJS version is inside the advisory. It is now an accepted advisory for the full npm consumer, alongside the Bun waiver that already existed for advisories with no compatible fix. The finding is an `O(depth²)` slowdown in filters that no wallet path feeds, and the wallet-only npm consumer is still held to a strictly clean audit with no waiver, so a default install is unaffected. + +Two verifier gaps that made this harder to read are closed as well. The npm audits ran without allowing a non-zero exit, so any finding surfaced as a raw spawn error carrying the whole audit JSON rather than the assertion naming the consumer; they now fail with the offending package and advisory URL. The "overrides still required" report and the Bun "overrides are taking effect" assertion are now derived from the override block instead of a hardcoded `axios`/`uuid` list, so a newly overridden package cannot be left out and let a partial upstream fix ask for the whole waiver to be dropped. + +No published dependency or peer range changes. diff --git a/packages/sei-global-wallet/README.md b/packages/sei-global-wallet/README.md index 86fbd036..e9d3e138 100644 --- a/packages/sei-global-wallet/README.md +++ b/packages/sei-global-wallet/README.md @@ -14,7 +14,7 @@ > [!WARNING] -> **Temporary consumer security waiver:** Dynamic Global Wallet Client transitively pins vulnerable `axios` and `uuid` as of 4.96.3, the current floor. +> **Temporary consumer security waiver:** Dynamic Global Wallet Client transitively pins vulnerable `axios`, `sharp`, and `uuid` as of 4.96.3, the current floor. > Dependency overrides are root-only in both npm and Bun; this package cannot propagate them to your application. Add the overrides below before installing, and drop them once your install resolves a Dynamic release that corrects those pins. ## Required consumer overrides @@ -26,6 +26,7 @@ Complete npm root overrides when the optional AA path is not enabled: { "overrides": { "axios": "1.18.0", + "sharp": "0.35.4", "uuid": "11.1.1", "viem": { "ws": "8.21.0" @@ -39,14 +40,16 @@ Complete Bun root overrides. The block is the same with or without the optional { "overrides": { "axios": "1.18.0", + "sharp": "0.35.4", "uuid": "11.1.1" } } ``` -The Axios and UUID overrides are temporary until Dynamic updates its exact transitive pins: +The Axios, Sharp, and UUID overrides are temporary until Dynamic updates its exact transitive pins: - The high-severity Axios issue is in the Node HTTP adapter and requires a prerequisite prototype-pollution/interceptor pattern. Browser wallet paths do not use that adapter, which reduces exploitability but does not make the vulnerable install acceptable. +- The high-severity Sharp issue is the bundled libheif heap overflow reported as `GHSA-rgj7-g3m4-5g8c`, reached through `@dynamic-labs/iconic`'s exact `sharp@0.35.0` pin. It is only triggered by decoding untrusted HEIF input, which no wallet path does, and `sharp` is a build-time image dependency that never reaches a browser bundle. The override is a patch-level move within `0.35.x`, so it carries no API change. - The UUID issue affects the v3, v5, and v6 buffer APIs. Dynamic's observed call sites use UUID v4, which reduces exploitability but does not clear the audit finding. Applications using the optional `./zerodev` / Dynamic account-abstraction path must also replace the vulnerable exact `bn.js@4.11.6` copies used by `ethjs-unit` and `number-to-bn`. The complete npm root override block is: @@ -60,6 +63,7 @@ Applications using the optional `./zerodev` / Dynamic account-abstraction path m "number-to-bn": { "bn.js": "4.12.5" }, + "sharp": "0.35.4", "uuid": "11.1.1", "viem": { "ws": "8.21.0" @@ -70,17 +74,21 @@ Applications using the optional `./zerodev` / Dynamic account-abstraction path m Bun 1.3.14 does not support nested overrides. Do **not** globally override `bn.js` or `ws`: Solana/borsh require bn5 while Jayson requires ws7. Bun's complete root override block is therefore the same with or without the optional AA path. -For npm, scoped `bn.js@4.12.5` stays on the legacy dependencies' expected major while Solana resolves `bn.js@5.2.5`. Scoped `ws@8.21.0` patches Viem's ws8 subtree while Jayson resolves `ws@7.5.13` from its `^7.5.10` range. The result is audit-clean. +For npm, scoped `bn.js@4.12.5` stays on the legacy dependencies' expected major while Solana resolves `bn.js@5.2.5`. Scoped `ws@8.21.0` patches Viem's ws8 subtree while Jayson resolves `ws@7.5.13` from its `^7.5.10` range. With the overrides above, a wallet-only npm install audits clean; the optional AA path carries the one `stream-json` advisory described below, on npm and Bun alike. The verifier builds its browser consumer against `viem@2.45.3`, because the previously tested 2.55.19 pulls Ox Tempo's `node:worker_threads` path into Vite resolution while 2.45.3 predates it. That is a property of the verifier's own bundle, not a constraint on applications: the published `viem` peer range stays `^2.7.12`. If your bundler externalizes `node:worker_threads` on a newer Viem, configure it in your application rather than downgrading. -For Bun, scoped overrides are unavailable. The selected waiver therefore accepts exactly these unresolved optional-AA advisories while preserving compatible majors: +One advisory on the optional AA path has no fix any override can reach, on either package manager: + +- `GHSA-528h-pc64-c93x` on `stream-json@1.9.1` — moderate, CVSS 6.2. Reached through the Solana RPC client's `jayson`, which requires `stream-json` as CommonJS. The advisory covers every version up to `3.4.0`, and `3.5.0` onward is ESM-only under a moved `src/` layout, so overriding it to a fixed version replaces the advisory with a `MODULE_NOT_FOUND` on `jayson`'s own require. The finding is an `O(depth²)` slowdown in the `pick`/`ignore`/`filter`/`replace` filters, reachable only by feeding crafted deeply nested JSON through them, which no wallet path does. + +For Bun, scoped overrides are unavailable, so the waiver also accepts these advisories that npm resolves with the scoped blocks above, while preserving compatible majors: - `GHSA-378v-28hj-76wf` on `bn.js@4.11.6` — moderate, CVSS 5.3. - `GHSA-58qx-3vcg-4xpx` on Viem's `ws@8.18.3` — moderate, CVSS 4.4. - `GHSA-96hv-2xvq-fx4p` on Viem's `ws@8.18.3` — high, CVSS 7.5. -Jayson remains on `ws@7.5.13`; globally forcing ws8 would violate that major contract. The verifier fails on any advisory outside this set, and reports rather than fails when one of them stops being reported, so an upstream fix or a withdrawn advisory never turns an unrelated pull request red. +Jayson remains on `ws@7.5.13`; globally forcing ws8 would violate that major contract. The verifier fails on any advisory outside each waiver, and reports rather than fails when one of them stops being reported, so an upstream fix or a withdrawn advisory never turns an unrelated pull request red. The wallet-only npm consumer is held to a strictly clean audit with no waiver at all, so nothing here excuses a finding that a default install would hand an application. ## Quick start @@ -159,6 +167,6 @@ The pinned source includes its terminal newline and has SHA-256 `e288cd08b510afb ## Release verification -The dedicated `Sei Global Wallet Consumer Smoke` workflow runs on wallet-related paths for pull requests and for pushes to `main` (so the publishing commit is gated too), daily on a schedule to catch registry and advisory drift, and on demand with `workflow_dispatch`. It executes `bun run test:sei-global-wallet-release`, including an audit-clean scoped npm consumer, a waiver-aware Bun consumer, declarations that resolve with no optional peer installed, native and bundled edge-like SSR, real local ZeroDev provider operations in esbuild/Vite, dependency graphs, and package contents. Regular package tests remain deterministic and do not perform clean consumer installs. +The dedicated `Sei Global Wallet Consumer Smoke` workflow runs on wallet-related paths for pull requests and for pushes to `main` (so the publishing commit is gated too), daily on a schedule to catch registry and advisory drift, and on demand with `workflow_dispatch`. It executes `bun run test:sei-global-wallet-release`, including an audit-clean wallet-only npm consumer, waiver-aware full npm and Bun consumers, declarations that resolve with no optional peer installed, native and bundled edge-like SSR, real local ZeroDev provider operations in esbuild/Vite, dependency graphs, and package contents. Regular package tests remain deterministic and do not perform clean consumer installs. -`SEI_GLOBAL_WALLET_FAST_CHECK=1` shortens the local loop by skipping the two clean npm consumers and the entire Bun consumer path, so a green run under that flag covers neither the audit waiver nor Bun. Release verification must run without it, which is what CI does. +`SEI_GLOBAL_WALLET_FAST_CHECK=1` shortens the local loop by skipping the two clean npm consumers and the entire Bun consumer path, so a green run under that flag covers neither the strictly clean wallet-only audit nor the Bun waiver. Release verification must run without it, which is what CI does. diff --git a/scripts/check-sei-global-wallet.ts b/scripts/check-sei-global-wallet.ts index afe04f59..19798d82 100644 --- a/scripts/check-sei-global-wallet.ts +++ b/scripts/check-sei-global-wallet.ts @@ -20,7 +20,7 @@ interface AuditReport { total?: number; }; }; - vulnerabilities?: Record; + vulnerabilities?: Record }>; } interface PackResult { @@ -37,7 +37,15 @@ const packageDir = join(root, 'packages/sei-global-wallet'); // Documented in packages/sei-global-wallet/README.md: skips the clean npm // consumers and the whole Bun path, so it never substitutes for a full run. const fastCheck = process.env.SEI_GLOBAL_WALLET_FAST_CHECK === '1'; -const acceptedBunAdvisories: readonly string[] = ['GHSA-378v-28hj-76wf', 'GHSA-58qx-3vcg-4xpx', 'GHSA-96hv-2xvq-fx4p']; +// Advisories accepted on the optional AA path only, where no fix is reachable. +// The default wallet-only consumer stays strictly audit-clean, so neither list +// can excuse a finding an application gets from a plain install. +// +// `stream-json` is shared by both: the Solana RPC client's CommonJS `jayson` +// requires it, and every version outside the advisory is ESM-only under a moved +// `src/` layout, so an override turns the advisory into a MODULE_NOT_FOUND. +const acceptedNpmAdvisories: readonly string[] = ['GHSA-528h-pc64-c93x']; +const acceptedBunAdvisories: readonly string[] = ['GHSA-378v-28hj-76wf', 'GHSA-528h-pc64-c93x', 'GHSA-58qx-3vcg-4xpx', 'GHSA-96hv-2xvq-fx4p']; const manifest = JSON.parse(await readFile(join(packageDir, 'package.json'), 'utf8')) as { dependencies: Record; @@ -155,10 +163,15 @@ const assertTestedPeersFitPublishedRanges = (testedPeerVersions: TestedPeerVersi } }; +// Exact transitive pins Dynamic carries that a root override has to correct. +// Flat by necessity: Bun cannot nest overrides, so every entry here has to be +// safe to apply globally. const baseSafeOverrides = { axios: '1.18.0', + sharp: '0.35.4', uuid: '11.1.1' }; +const overriddenTransitivePins = Object.keys(baseSafeOverrides); const npmRuntimeOverrides = { ...baseSafeOverrides, viem: { @@ -611,7 +624,59 @@ const runEdgeBundle = async (bundlePath: string) => { }; const assertAuditClean = (report: AuditReport, label: string) => { - assert.equal(report.metadata?.vulnerabilities?.total ?? Object.keys(report.vulnerabilities ?? {}).length, 0, `${label} audit was not clean`); + const findings = Object.entries(report.vulnerabilities ?? {}) + .map(([name, entry]) => { + const urls = (entry.via ?? []).filter((via) => typeof via === 'object').map((via) => via.url); + return urls.length > 0 ? `${name} (${urls.join(', ')})` : name; + }) + .sort(); + assert.equal(report.metadata?.vulnerabilities?.total ?? findings.length, 0, `${label} audit was not clean: ${findings.join('; ')}`); +}; + +/** + * A subset check, not an exact set: the advisory database changes on its own + * schedule, so a withdrawn or upstream-fixed advisory must not fail an + * unrelated pull request, while any new exposure still must. + */ +const assertWithinWaiver = (reported: ReadonlySet, accepted: readonly string[], label: string) => { + const acceptedIds = new Set(accepted.map((advisory) => advisory.toLowerCase())); + const unwaived = [...reported].filter((advisory) => !acceptedIds.has(advisory)).sort(); + assert.deepEqual( + unwaived, + [], + `${label} reported advisories outside the accepted waiver: ${unwaived.join(', ')}. Assess them and update packages/sei-global-wallet/README.md before releasing.` + ); + + const fixed = accepted.filter((advisory) => !reported.has(advisory.toLowerCase())); + if (fixed.length > 0) { + reportWaiverProgress( + `${label} no longer reports ${fixed.join(', ')}. Narrow the waiver in packages/sei-global-wallet/README.md and the accepted advisory list in this script.` + ); + } +}; + +const assertAcceptedNpmAudit = (result: ProcessResult, label: string) => { + const report = parseJsonOutput(result.stdout); + if ((report.metadata?.vulnerabilities?.total ?? Object.keys(report.vulnerabilities ?? {}).length) === 0) { + reportWaiverProgress( + `${label} now audits clean: every accepted advisory was fixed upstream. Delete the waiver from packages/sei-global-wallet/README.md and acceptedNpmAdvisories in this script.` + ); + return; + } + + // Read each id from the advisory's own `url` rather than scanning the whole + // report: npm advisory titles cite unrelated GHSA ids (sharp's libheif title + // names two), which a blanket scan would count as separate findings. + const advisories = Object.values(report.vulnerabilities ?? {}) + .flatMap((entry) => entry.via ?? []) + .filter((via): via is { url?: string } => typeof via === 'object') + .map((advisory) => ({ advisory, id: advisory.url?.match(/GHSA-[a-z0-9-]+/i)?.[0] })); + const missingGhsa = advisories.filter(({ id }) => !id).map(({ advisory }) => advisory); + assert.deepEqual(missingGhsa, [], `${label} findings without a GHSA url: ${JSON.stringify(missingGhsa)}`); + + const reportedIds = advisories.flatMap(({ id }) => (id ? [id] : [])); + assertWithinWaiver(new Set(reportedIds.map((id) => id.toLowerCase())), acceptedNpmAdvisories, label); + console.log(`${label} advisories, all within the waiver: ${[...new Set(reportedIds)].sort().join(', ')}`); }; const assertAcceptedBunAudit = (result: ProcessResult) => { @@ -628,33 +693,16 @@ const assertAcceptedBunAudit = (result: ProcessResult) => { assert.deepEqual(missingGhsa, [], `Bun AA consumer findings without a GHSA id: ${JSON.stringify(missingGhsa)}`); const reported = new Set(auditFindings.flatMap((finding) => ghsaIdsIn(finding)).map((advisory) => advisory.toLowerCase())); - const accepted = new Set(acceptedBunAdvisories.map((advisory) => advisory.toLowerCase())); - - // A subset check, not an exact set: the advisory database changes on its own - // schedule, so a withdrawn or upstream-fixed advisory must not fail an - // unrelated pull request, while any new exposure still must. - const unwaived = [...reported].filter((advisory) => !accepted.has(advisory)).sort(); - assert.deepEqual( - unwaived, - [], - `Bun AA consumer reported advisories outside the accepted waiver: ${unwaived.join(', ')}. Assess them and update packages/sei-global-wallet/README.md before releasing.` - ); - - const fixed = acceptedBunAdvisories.filter((advisory) => !reported.has(advisory.toLowerCase())); - if (fixed.length > 0) { - reportWaiverProgress( - `Bun no longer reports ${fixed.join(', ')}. Narrow the waiver in packages/sei-global-wallet/README.md and acceptedBunAdvisories in this script.` - ); - } + assertWithinWaiver(reported, acceptedBunAdvisories, 'Bun AA consumer'); - // The documented Axios and UUID overrides must still be taking effect. - // Match only those package names as Bun audit keys, not last path segments + // The documented root overrides must still be taking effect. Match only + // those package names as Bun audit keys, not last path segments // (`@lukeed/uuid`) or advisory titles that happen to contain "uuid". - const blockedOverridePackages = Object.keys(report).filter((name) => name === 'axios' || name === 'uuid'); + const blockedOverridePackages = Object.keys(report).filter((name) => overriddenTransitivePins.includes(name)); assert.deepEqual( blockedOverridePackages, [], - `Bun AA consumer still reports ${blockedOverridePackages.join(', ')}; the documented Axios and UUID overrides are not taking effect.` + `Bun AA consumer still reports ${blockedOverridePackages.join(', ')}; the documented ${overriddenTransitivePins.join(', ')} overrides are not taking effect.` ); console.log( `Bun AA consumer advisories, all within the waiver: ${Object.entries(report) @@ -789,10 +837,12 @@ try { const unwaivedAudit = parseJsonOutput(unwaivedAuditResult.stdout); // Reported, not asserted: an unwaived consumer going clean means Dynamic // corrected its transitive pins, which must not read as a CI failure. - const stillVulnerable = ['axios', 'uuid'].filter((name) => unwaivedAudit.vulnerabilities?.[name]); + // Derived from the override block so a newly waived package cannot be + // left out of this reporting and make a partial fix read as a full one. + const stillVulnerable = overriddenTransitivePins.filter((name) => unwaivedAudit.vulnerabilities?.[name]); if (stillVulnerable.length === 0) { reportWaiverProgress( - 'an npm consumer without overrides now audits clean: Dynamic corrected its transitive axios and uuid pins. Remove the override guidance from packages/sei-global-wallet/README.md and the changeset.' + `an npm consumer without overrides now audits clean: Dynamic corrected its transitive ${overriddenTransitivePins.join(', ')} pins. Remove the override guidance from packages/sei-global-wallet/README.md and the changeset.` ); } else { console.log(`Overrides still required for ${stillVulnerable.map((name) => `${name}@${[...new Set(lockedVersions(name))].join('/')}`).join(', ')}`); @@ -811,7 +861,9 @@ try { ], waivedDir ); - const waivedAudit = parseJsonOutput((await run(['npm', 'audit', '--json'], waivedDir)).stdout); + // `npm audit` exits non-zero on any finding, so let the assertion below + // report which advisory broke the consumer instead of a raw spawn error. + const waivedAudit = parseJsonOutput((await run(['npm', 'audit', '--json'], waivedDir, true)).stdout); assertAuditClean(waivedAudit, 'Waived npm consumer'); await assertDeclarationsResolveWithoutOptionalPeers(waivedDir); } @@ -878,7 +930,7 @@ try { const viteFiles = (await readdir(join(npmConsumerDir, 'vite-dist'))).filter((path) => path.endsWith('.js')); assert.equal(viteFiles.length, 1, `Expected one Vite bundle, found: ${viteFiles.join(', ')}`); await runBrowserBundle(join(npmConsumerDir, 'vite-dist', viteFiles[0]), true); - assertAuditClean(parseJsonOutput((await run(['npm', 'audit', '--json'], npmConsumerDir)).stdout), 'Full npm consumer'); + assertAcceptedNpmAudit(await run(['npm', 'audit', '--json'], npmConsumerDir, true), 'Full npm consumer'); if (!fastCheck) { const bunConsumerDir = join(temporaryRoot, 'bun-full'); @@ -901,7 +953,7 @@ try { console.log( fastCheck ? 'Sei Global Wallet fast npm consumer checks passed.' - : 'Sei Global Wallet consumer checks passed: npm scoped patched bn.js/ws8 while preserving Solana bn5/Jayson ws7 with a clean audit; Bun preserved compatible majors within the accepted advisory waiver.' + : 'Sei Global Wallet consumer checks passed: the wallet-only npm consumer audited clean, npm scoped patched bn.js/ws8 while preserving Solana bn5/Jayson ws7, and both full consumers stayed within the accepted advisory waiver.' ); } finally { await rm(temporaryRoot, { force: true, recursive: true }); From 9f1cd8456a14f97f8978a41de6386e5434a1a7af Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Fri, 11 Sep 2026 13:31:13 +0200 Subject: [PATCH 2/4] fix(sei-global-wallet): fail npm audits that never ran Allowing a non-zero exit from `npm audit` so findings report as advisories also let npm's own failures through. An unreachable registry prints {"error":{"code":"ENOAUDIT"}} and exits non-zero; that body carries no counts, so both the strict wallet-only gate and the new waiver gate read it as zero findings. An audit that never ran passed, and the waiver path logged "now audits clean ... Delete the waiver" while doing so. Every npm audit result is now rejected unless it carries a real vulnerability count, so a failed audit fails the check instead of reporting a pass. The audit and override-block parsing moved into scripts/consumer-audit.ts and scripts/documented-overrides.ts with unit tests, following the existing dynamic-package-lock pattern. The ENOAUDIT case is pinned there rather than being reachable only through a full consumer run. The README override blocks are now asserted against the sets the consumers install. They are maintained by hand in three places, which this change had to edit for sharp, so they could previously document an untested override. Co-authored-by: Cursor --- .../fix-global-wallet-sharp-override.md | 8 +- packages/sei-global-wallet/README.md | 2 +- scripts/check-sei-global-wallet.ts | 110 ++++++++-------- scripts/consumer-audit.test.ts | 118 ++++++++++++++++++ scripts/consumer-audit.ts | 82 ++++++++++++ scripts/documented-overrides.test.ts | 60 +++++++++ scripts/documented-overrides.ts | 32 +++++ 7 files changed, 358 insertions(+), 54 deletions(-) create mode 100644 scripts/consumer-audit.test.ts create mode 100644 scripts/consumer-audit.ts create mode 100644 scripts/documented-overrides.test.ts create mode 100644 scripts/documented-overrides.ts diff --git a/.changeset/fix-global-wallet-sharp-override.md b/.changeset/fix-global-wallet-sharp-override.md index 2225f9d8..ebaf3564 100644 --- a/.changeset/fix-global-wallet-sharp-override.md +++ b/.changeset/fix-global-wallet-sharp-override.md @@ -10,6 +10,12 @@ Two advisories published against the existing dependency graph, so the nightly c `GHSA-528h-pc64-c93x` covers every `stream-json` up to `3.4.0`, which the Solana RPC client's `jayson` requires as CommonJS on the optional AA path. It cannot be overridden: `3.5.0` onward is ESM-only under a moved `src/` layout, so pointing `jayson` at a fixed version replaces the advisory with a `MODULE_NOT_FOUND` on its own require, and every CommonJS version is inside the advisory. It is now an accepted advisory for the full npm consumer, alongside the Bun waiver that already existed for advisories with no compatible fix. The finding is an `O(depth²)` slowdown in filters that no wallet path feeds, and the wallet-only npm consumer is still held to a strictly clean audit with no waiver, so a default install is unaffected. -Two verifier gaps that made this harder to read are closed as well. The npm audits ran without allowing a non-zero exit, so any finding surfaced as a raw spawn error carrying the whole audit JSON rather than the assertion naming the consumer; they now fail with the offending package and advisory URL. The "overrides still required" report and the Bun "overrides are taking effect" assertion are now derived from the override block instead of a hardcoded `axios`/`uuid` list, so a newly overridden package cannot be left out and let a partial upstream fix ask for the whole waiver to be dropped. +Several verifier gaps this exposed are closed as well. + +The npm audits ran without allowing a non-zero exit, so any finding surfaced as a raw spawn error carrying the whole audit JSON rather than the assertion naming the consumer; they now fail with the offending package and advisory URL. Allowing that exit means the body has to be validated, because `npm audit` fails the same way when it cannot reach the registry: an `ENOAUDIT` payload carries no counts, so an unvalidated report would read as zero findings and turn an audit that never ran into a pass on the gate this check exists to enforce. Every npm audit result is now rejected unless it carries a real vulnerability count. + +The "overrides still required" report and the Bun "overrides are taking effect" assertion are now derived from the override block instead of a hardcoded `axios`/`uuid` list, so a newly overridden package cannot be left out and let a partial upstream fix ask for the whole waiver to be dropped. The README override blocks are asserted against the sets the consumers install, so the three hand-maintained copies cannot document an override that is never tested. + +The audit and override-parsing helpers moved into `scripts/consumer-audit.ts` and `scripts/documented-overrides.ts` with unit tests, so these cases are pinned by `bun test --isolate scripts` rather than only by a full consumer run. No published dependency or peer range changes. diff --git a/packages/sei-global-wallet/README.md b/packages/sei-global-wallet/README.md index e9d3e138..b03582c3 100644 --- a/packages/sei-global-wallet/README.md +++ b/packages/sei-global-wallet/README.md @@ -167,6 +167,6 @@ The pinned source includes its terminal newline and has SHA-256 `e288cd08b510afb ## Release verification -The dedicated `Sei Global Wallet Consumer Smoke` workflow runs on wallet-related paths for pull requests and for pushes to `main` (so the publishing commit is gated too), daily on a schedule to catch registry and advisory drift, and on demand with `workflow_dispatch`. It executes `bun run test:sei-global-wallet-release`, including an audit-clean wallet-only npm consumer, waiver-aware full npm and Bun consumers, declarations that resolve with no optional peer installed, native and bundled edge-like SSR, real local ZeroDev provider operations in esbuild/Vite, dependency graphs, and package contents. Regular package tests remain deterministic and do not perform clean consumer installs. +The dedicated `Sei Global Wallet Consumer Smoke` workflow runs on wallet-related paths for pull requests and for pushes to `main` (so the publishing commit is gated too), daily on a schedule to catch registry and advisory drift, and on demand with `workflow_dispatch`. It executes `bun run test:sei-global-wallet-release`, including an audit-clean wallet-only npm consumer, waiver-aware full npm and Bun consumers, the override blocks documented above matching the sets it installs, declarations that resolve with no optional peer installed, native and bundled edge-like SSR, real local ZeroDev provider operations in esbuild/Vite, dependency graphs, and package contents. Regular package tests remain deterministic and do not perform clean consumer installs. `SEI_GLOBAL_WALLET_FAST_CHECK=1` shortens the local loop by skipping the two clean npm consumers and the entire Bun consumer path, so a green run under that flag covers neither the strictly clean wallet-only audit nor the Bun waiver. Release verification must run without it, which is what CI does. diff --git a/scripts/check-sei-global-wallet.ts b/scripts/check-sei-global-wallet.ts index 19798d82..13d1cf7d 100644 --- a/scripts/check-sei-global-wallet.ts +++ b/scripts/check-sei-global-wallet.ts @@ -5,6 +5,16 @@ import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import vm from 'node:vm'; +import { + type AuditReport, + advisoriesFixedUpstream, + advisoriesOutsideWaiver, + describeAuditFindings, + ghsaIdsIn, + npmAuditFailureReason, + npmReportedAdvisories +} from './consumer-audit.js'; +import { documentedOverrideBlocks } from './documented-overrides.js'; import { highestVersion, normalizeNpmViewVersions, parseNpmViewResult } from './dynamic-package-contract.js'; import { collectDynamicLineVersions, findDynamicLineConflicts, formatDynamicLineConflicts, listDynamicPackageInstallations } from './dynamic-package-lock.js'; @@ -14,15 +24,6 @@ interface ProcessResult { stdout: string; } -interface AuditReport { - metadata?: { - vulnerabilities?: { - total?: number; - }; - }; - vulnerabilities?: Record }>; -} - interface PackResult { filename: string; files: Array<{ path: string }>; @@ -66,8 +67,6 @@ const assertMajor = (version: string | undefined, major: string, label: string) assert(version.startsWith(`${major}.`), `${label} resolved ${version}, expected ${major}.x`); }; -const ghsaIdsIn = (value: unknown) => JSON.stringify(value).match(/GHSA-[a-z0-9-]+/gi) ?? []; - const reportWaiverProgress = (message: string) => { console.warn(`[waiver] ${message}`); }; @@ -188,6 +187,21 @@ const npmSafeOverrides = { } }; +/** + * The README's override blocks are the only form of this guidance an + * application can act on, and they are maintained by hand in three places, so + * they are asserted against the sets the consumers below actually install. + * Documented-but-untested overrides would otherwise be possible. + */ +const assertDocumentedOverridesMatch = async () => { + const readme = await readFile(join(packageDir, 'README.md'), 'utf8'); + assert.deepEqual( + documentedOverrideBlocks(readme, 'Required consumer overrides'), + [npmRuntimeOverrides, baseSafeOverrides, npmSafeOverrides], + 'The README override blocks no longer match the sets this verifier installs. Expected npm without the AA path, then Bun, then npm with it.' + ); +}; + const walletOnlyManifest = (tarball: string, overrides?: Record) => ({ name: 'sei-global-wallet-audit-consumer', private: true, @@ -623,31 +637,32 @@ const runEdgeBundle = async (bundlePath: string) => { }); }; +/** + * The audits below allow a non-zero exit so a finding reports as the advisory + * that caused it rather than as a raw spawn dump. That makes validating the + * body mandatory: `npm audit` fails the same way when it cannot reach the + * registry, and that error payload would otherwise read as zero findings. + */ +const parseNpmAudit = (result: ProcessResult, label: string) => { + const report = parseJsonOutput(result.stdout); + const failure = npmAuditFailureReason(report); + assert(!failure, `${label} ${failure}:\n${result.stderr}${result.stdout}`); + return report; +}; + const assertAuditClean = (report: AuditReport, label: string) => { - const findings = Object.entries(report.vulnerabilities ?? {}) - .map(([name, entry]) => { - const urls = (entry.via ?? []).filter((via) => typeof via === 'object').map((via) => via.url); - return urls.length > 0 ? `${name} (${urls.join(', ')})` : name; - }) - .sort(); - assert.equal(report.metadata?.vulnerabilities?.total ?? findings.length, 0, `${label} audit was not clean: ${findings.join('; ')}`); + assert.equal(report.metadata?.vulnerabilities?.total, 0, `${label} audit was not clean: ${describeAuditFindings(report).join('; ')}`); }; -/** - * A subset check, not an exact set: the advisory database changes on its own - * schedule, so a withdrawn or upstream-fixed advisory must not fail an - * unrelated pull request, while any new exposure still must. - */ -const assertWithinWaiver = (reported: ReadonlySet, accepted: readonly string[], label: string) => { - const acceptedIds = new Set(accepted.map((advisory) => advisory.toLowerCase())); - const unwaived = [...reported].filter((advisory) => !acceptedIds.has(advisory)).sort(); +const assertWithinWaiver = (reported: readonly string[], accepted: readonly string[], label: string) => { + const unwaived = advisoriesOutsideWaiver(reported, accepted); assert.deepEqual( unwaived, [], `${label} reported advisories outside the accepted waiver: ${unwaived.join(', ')}. Assess them and update packages/sei-global-wallet/README.md before releasing.` ); - const fixed = accepted.filter((advisory) => !reported.has(advisory.toLowerCase())); + const fixed = advisoriesFixedUpstream(reported, accepted); if (fixed.length > 0) { reportWaiverProgress( `${label} no longer reports ${fixed.join(', ')}. Narrow the waiver in packages/sei-global-wallet/README.md and the accepted advisory list in this script.` @@ -655,28 +670,19 @@ const assertWithinWaiver = (reported: ReadonlySet, accepted: readonly st } }; -const assertAcceptedNpmAudit = (result: ProcessResult, label: string) => { - const report = parseJsonOutput(result.stdout); - if ((report.metadata?.vulnerabilities?.total ?? Object.keys(report.vulnerabilities ?? {}).length) === 0) { +const assertAcceptedNpmAudit = (report: AuditReport, label: string) => { + if (report.metadata?.vulnerabilities?.total === 0) { reportWaiverProgress( `${label} now audits clean: every accepted advisory was fixed upstream. Delete the waiver from packages/sei-global-wallet/README.md and acceptedNpmAdvisories in this script.` ); return; } - // Read each id from the advisory's own `url` rather than scanning the whole - // report: npm advisory titles cite unrelated GHSA ids (sharp's libheif title - // names two), which a blanket scan would count as separate findings. - const advisories = Object.values(report.vulnerabilities ?? {}) - .flatMap((entry) => entry.via ?? []) - .filter((via): via is { url?: string } => typeof via === 'object') - .map((advisory) => ({ advisory, id: advisory.url?.match(/GHSA-[a-z0-9-]+/i)?.[0] })); - const missingGhsa = advisories.filter(({ id }) => !id).map(({ advisory }) => advisory); - assert.deepEqual(missingGhsa, [], `${label} findings without a GHSA url: ${JSON.stringify(missingGhsa)}`); - - const reportedIds = advisories.flatMap(({ id }) => (id ? [id] : [])); - assertWithinWaiver(new Set(reportedIds.map((id) => id.toLowerCase())), acceptedNpmAdvisories, label); - console.log(`${label} advisories, all within the waiver: ${[...new Set(reportedIds)].sort().join(', ')}`); + const { ids, withoutId } = npmReportedAdvisories(report); + assert.deepEqual(withoutId, [], `${label} findings without a GHSA url: ${JSON.stringify(withoutId)}`); + + assertWithinWaiver(ids, acceptedNpmAdvisories, label); + console.log(`${label} advisories, all within the waiver: ${ids.join(', ')}`); }; const assertAcceptedBunAudit = (result: ProcessResult) => { @@ -692,8 +698,11 @@ const assertAcceptedBunAudit = (result: ProcessResult) => { const missingGhsa = auditFindings.filter((finding) => ghsaIdsIn(finding).length === 0); assert.deepEqual(missingGhsa, [], `Bun AA consumer findings without a GHSA id: ${JSON.stringify(missingGhsa)}`); - const reported = new Set(auditFindings.flatMap((finding) => ghsaIdsIn(finding)).map((advisory) => advisory.toLowerCase())); - assertWithinWaiver(reported, acceptedBunAdvisories, 'Bun AA consumer'); + assertWithinWaiver( + auditFindings.flatMap((finding) => ghsaIdsIn(finding)), + acceptedBunAdvisories, + 'Bun AA consumer' + ); // The documented root overrides must still be taking effect. Match only // those package names as Bun audit keys, not last path segments @@ -807,6 +816,7 @@ try { ); const testedPeerVersions = makeTestedPeerVersions(dynamicContract.ethereumAa); assertTestedPeersFitPublishedRanges(testedPeerVersions); + await assertDocumentedOverridesMatch(); await run(['bun', 'run', '--cwd', packageDir, 'build'], root); const pack = await run(['npm', 'pack', '--json', '--pack-destination', temporaryRoot], packageDir); @@ -833,8 +843,7 @@ try { Object.entries(unwaivedLock.packages) .filter(([location]) => location === `node_modules/${name}` || location.endsWith(`/node_modules/${name}`)) .map(([, metadata]) => metadata.version); - const unwaivedAuditResult = await run(['npm', 'audit', '--json'], unwaivedDir, true); - const unwaivedAudit = parseJsonOutput(unwaivedAuditResult.stdout); + const unwaivedAudit = parseNpmAudit(await run(['npm', 'audit', '--json'], unwaivedDir, true), 'Unwaived npm consumer'); // Reported, not asserted: an unwaived consumer going clean means Dynamic // corrected its transitive pins, which must not read as a CI failure. // Derived from the override block so a newly waived package cannot be @@ -861,10 +870,7 @@ try { ], waivedDir ); - // `npm audit` exits non-zero on any finding, so let the assertion below - // report which advisory broke the consumer instead of a raw spawn error. - const waivedAudit = parseJsonOutput((await run(['npm', 'audit', '--json'], waivedDir, true)).stdout); - assertAuditClean(waivedAudit, 'Waived npm consumer'); + assertAuditClean(parseNpmAudit(await run(['npm', 'audit', '--json'], waivedDir, true), 'Waived npm consumer'), 'Waived npm consumer'); await assertDeclarationsResolveWithoutOptionalPeers(waivedDir); } @@ -930,7 +936,7 @@ try { const viteFiles = (await readdir(join(npmConsumerDir, 'vite-dist'))).filter((path) => path.endsWith('.js')); assert.equal(viteFiles.length, 1, `Expected one Vite bundle, found: ${viteFiles.join(', ')}`); await runBrowserBundle(join(npmConsumerDir, 'vite-dist', viteFiles[0]), true); - assertAcceptedNpmAudit(await run(['npm', 'audit', '--json'], npmConsumerDir, true), 'Full npm consumer'); + assertAcceptedNpmAudit(parseNpmAudit(await run(['npm', 'audit', '--json'], npmConsumerDir, true), 'Full npm consumer'), 'Full npm consumer'); if (!fastCheck) { const bunConsumerDir = join(temporaryRoot, 'bun-full'); diff --git a/scripts/consumer-audit.test.ts b/scripts/consumer-audit.test.ts new file mode 100644 index 00000000..566dab0a --- /dev/null +++ b/scripts/consumer-audit.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from 'bun:test'; +import { + advisoriesFixedUpstream, + advisoriesOutsideWaiver, + describeAuditFindings, + ghsaIdsIn, + npmAuditFailureReason, + npmReportedAdvisories +} from './consumer-audit.js'; + +describe('npm audit result validity', () => { + test('accepts a real report, whether or not it found anything', () => { + expect(npmAuditFailureReason({ metadata: { vulnerabilities: { total: 0 } } })).toBeUndefined(); + expect(npmAuditFailureReason({ metadata: { vulnerabilities: { total: 9 } } })).toBeUndefined(); + }); + + // `npm audit --json` prints this body and exits non-zero, so a caller that + // allows the non-zero exit must not read it as a clean result. + test('rejects an audit that never ran, naming the npm error', () => { + const reason = npmAuditFailureReason({ + error: { code: 'ENOAUDIT', summary: 'Your configured registry does not support audit requests' } + }); + expect(reason).toContain('audit did not run'); + expect(reason).toContain('ENOAUDIT'); + expect(reason).toContain('does not support audit requests'); + }); + + test('rejects an error payload with no detail rather than trusting it', () => { + expect(npmAuditFailureReason({ error: {} })).toBe('audit did not run: unknown npm error'); + }); + + test('rejects a report with no vulnerability count', () => { + expect(npmAuditFailureReason({})).toContain('did not produce a result'); + expect(npmAuditFailureReason({ metadata: {} })).toContain('did not produce a result'); + // An empty `vulnerabilities` map is not a substitute for the count: the + // error payload also has none, which is what made the two look alike. + expect(npmAuditFailureReason({ vulnerabilities: {} })).toContain('did not produce a result'); + }); +}); + +describe('npm advisory extraction', () => { + const sharpReport = { + metadata: { vulnerabilities: { total: 2 } }, + vulnerabilities: { + '@dynamic-labs/iconic': { via: ['sharp'] }, + sharp: { + via: [ + { + title: 'sharp: Vulnerabilities in libheif: GHSA-g89c-p67h-r497 and GHSA-2jg2-4ch7-h545', + url: 'https://github.com/advisories/GHSA-rgj7-g3m4-5g8c' + } + ] + } + } + }; + + // The title names two other advisories; only the advisory's own id counts. + test('reads ids from the advisory url, not from titles that cite others', () => { + expect(npmReportedAdvisories(sharpReport)).toEqual({ ids: ['GHSA-rgj7-g3m4-5g8c'], withoutId: [] }); + }); + + test('deduplicates one advisory reported against several packages', () => { + const shared = { url: 'https://github.com/advisories/GHSA-528h-pc64-c93x' }; + expect( + npmReportedAdvisories({ + vulnerabilities: { jayson: { via: [shared] }, 'stream-json': { via: [shared] } } + }).ids + ).toEqual(['GHSA-528h-pc64-c93x']); + }); + + test('separates findings with no usable id so they cannot match a waiver', () => { + const { ids, withoutId } = npmReportedAdvisories({ + vulnerabilities: { mystery: { via: [{ url: 'https://example.test/advisory' }, {}] } } + }); + expect(ids).toEqual([]); + expect(withoutId).toEqual([{ url: 'https://example.test/advisory' }, {}]); + }); + + test('reports nothing for a clean audit', () => { + expect(npmReportedAdvisories({ metadata: { vulnerabilities: { total: 0 } } })).toEqual({ ids: [], withoutId: [] }); + }); + + test('names each vulnerable package with its advisory for the failure message', () => { + expect(describeAuditFindings(sharpReport)).toEqual(['@dynamic-labs/iconic', 'sharp (https://github.com/advisories/GHSA-rgj7-g3m4-5g8c)']); + }); +}); + +describe('waiver comparison', () => { + const accepted = ['GHSA-378v-28hj-76wf', 'GHSA-528h-pc64-c93x']; + + test('passes advisories inside the waiver, case-insensitively', () => { + expect(advisoriesOutsideWaiver(['ghsa-528h-pc64-c93x'], accepted)).toEqual([]); + expect(advisoriesOutsideWaiver(['GHSA-378V-28HJ-76WF'], accepted)).toEqual([]); + }); + + test('surfaces any advisory the waiver does not accept', () => { + expect(advisoriesOutsideWaiver(['GHSA-528h-pc64-c93x', 'GHSA-rgj7-g3m4-5g8c'], accepted)).toEqual(['ghsa-rgj7-g3m4-5g8c']); + }); + + test('treats an empty waiver as accepting nothing', () => { + expect(advisoriesOutsideWaiver(['GHSA-528h-pc64-c93x'], [])).toEqual(['ghsa-528h-pc64-c93x']); + }); + + // Subset semantics: a withdrawn or upstream-fixed advisory reports progress + // instead of failing, so unrelated pull requests stay green. + test('reports accepted advisories that stopped being reported', () => { + expect(advisoriesFixedUpstream(['GHSA-528h-pc64-c93x'], accepted)).toEqual(['GHSA-378v-28hj-76wf']); + expect(advisoriesFixedUpstream([], accepted)).toEqual(accepted); + expect(advisoriesFixedUpstream(accepted, accepted)).toEqual([]); + }); +}); + +describe('bun advisory extraction', () => { + test('finds every GHSA id in a flat Bun finding', () => { + expect(ghsaIdsIn({ url: 'https://github.com/advisories/GHSA-96hv-2xvq-fx4p', severity: 'high' })).toEqual(['GHSA-96hv-2xvq-fx4p']); + expect(ghsaIdsIn({ severity: 'moderate' })).toEqual([]); + }); +}); diff --git a/scripts/consumer-audit.ts b/scripts/consumer-audit.ts new file mode 100644 index 00000000..c0499478 --- /dev/null +++ b/scripts/consumer-audit.ts @@ -0,0 +1,82 @@ +const GHSA_ID = /GHSA-[a-z0-9-]+/i; +const GHSA_IDS = /GHSA-[a-z0-9-]+/gi; + +export interface AuditReport { + error?: { code?: string; summary?: string }; + metadata?: { vulnerabilities?: { total?: number } }; + vulnerabilities?: Record }>; +} + +/** Lists every GHSA id anywhere in a value, for Bun's flat per-package findings. */ +export const ghsaIdsIn = (value: unknown) => JSON.stringify(value).match(GHSA_IDS) ?? []; + +/** + * `npm audit` exits non-zero for its own failures as well as for findings, so + * the exit code alone cannot tell the two apart. An unreachable registry prints + * `{"error":{"code":"ENOAUDIT"}}`, a body that carries no counts: read as a + * result it looks like zero findings, turning an audit that never ran into a + * pass on the gate it was meant to enforce. Returns why the report is unusable, + * or `undefined` when it is a real audit result. + */ +export const npmAuditFailureReason = (report: AuditReport) => { + if (report.error) { + const detail = [report.error.code, report.error.summary].filter(Boolean).join(' '); + return `audit did not run: ${detail || 'unknown npm error'}`; + } + if (typeof report.metadata?.vulnerabilities?.total !== 'number') { + return 'audit returned no vulnerability count, so it did not produce a result'; + } + return undefined; +}; + +/** Names each vulnerable package alongside the advisories it was reported for. */ +export const describeAuditFindings = (report: AuditReport) => + Object.entries(report.vulnerabilities ?? {}) + .map(([name, entry]) => { + const urls = (entry.via ?? []).flatMap((via) => (typeof via === 'object' && via.url ? [via.url] : [])); + return urls.length > 0 ? `${name} (${urls.join(', ')})` : name; + }) + .sort(); + +/** + * Reads each id from the advisory's own `url` rather than scanning the whole + * report: npm advisory titles cite unrelated GHSA ids — sharp's libheif title + * names two — which a blanket scan would count as separate findings. Advisories + * carrying no id come back separately, because an unidentifiable finding cannot + * be matched against a waiver and has to fail instead. + */ +export const npmReportedAdvisories = (report: AuditReport) => { + const ids: string[] = []; + const withoutId: Array<{ url?: string }> = []; + + for (const entry of Object.values(report.vulnerabilities ?? {})) { + for (const via of entry.via ?? []) { + if (typeof via !== 'object') continue; + + const id = via.url?.match(GHSA_ID)?.[0]; + if (id) ids.push(id); + else withoutId.push(via); + } + } + + return { ids: [...new Set(ids)].sort(), withoutId }; +}; + +const lowercased = (advisories: Iterable) => new Set([...advisories].map((advisory) => advisory.toLowerCase())); + +/** + * Reported advisories that the waiver does not accept. A waiver is a subset + * check rather than an exact set, so this is the only part that may fail a run: + * the advisory database changes on its own schedule, and a withdrawn or + * upstream-fixed advisory must not turn an unrelated pull request red. + */ +export const advisoriesOutsideWaiver = (reported: Iterable, accepted: readonly string[]) => { + const acceptedIds = lowercased(accepted); + return [...lowercased(reported)].filter((advisory) => !acceptedIds.has(advisory)).sort(); +}; + +/** Accepted advisories that are no longer reported, so the waiver can narrow. */ +export const advisoriesFixedUpstream = (reported: Iterable, accepted: readonly string[]) => { + const reportedIds = lowercased(reported); + return accepted.filter((advisory) => !reportedIds.has(advisory.toLowerCase())); +}; diff --git a/scripts/documented-overrides.test.ts b/scripts/documented-overrides.test.ts new file mode 100644 index 00000000..070f336c --- /dev/null +++ b/scripts/documented-overrides.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from 'bun:test'; +import { documentedOverrideBlocks } from './documented-overrides.js'; + +const readme = `# Package + +## Required consumer overrides + +Complete npm root overrides: +\`\`\`json +{ + "overrides": { + "axios": "1.18.0", + "viem": { + "ws": "8.21.0" + } + } +} +\`\`\` + +Complete Bun root overrides: +\`\`\`json +{ + "overrides": { + "axios": "1.18.0" + } +} +\`\`\` + +## Quick start + +\`\`\`json +{ + "overrides": { + "unrelated": "1.0.0" + } +} +\`\`\` +`; + +describe('documented override blocks', () => { + test('returns every block in the named section, in document order', () => { + expect(documentedOverrideBlocks(readme, 'Required consumer overrides')).toEqual([{ axios: '1.18.0', viem: { ws: '8.21.0' } }, { axios: '1.18.0' }]); + }); + + test('stops at the next section so unrelated blocks are not compared', () => { + expect(documentedOverrideBlocks(readme, 'Quick start')).toEqual([{ unrelated: '1.0.0' }]); + }); + + test('fails when the section is missing rather than silently comparing nothing', () => { + expect(() => documentedOverrideBlocks(readme, 'Absent section')).toThrow('no "## Absent section" section'); + }); + + test('fails on a block that is not valid JSON', () => { + expect(() => documentedOverrideBlocks('## S\n```json\n{ "overrides": }\n```\n', 'S')).toThrow('block 1 is not valid JSON'); + }); + + test('fails on a block that documents no overrides', () => { + expect(() => documentedOverrideBlocks('## S\n```json\n{ "dependencies": {} }\n```\n', 'S')).toThrow('block 1 has no "overrides" key'); + }); +}); diff --git a/scripts/documented-overrides.ts b/scripts/documented-overrides.ts new file mode 100644 index 00000000..b93b2eb2 --- /dev/null +++ b/scripts/documented-overrides.ts @@ -0,0 +1,32 @@ +const JSON_BLOCK = /```json\n([\s\S]*?)```/g; + +/** + * Reads the `overrides` object out of every fenced JSON block in a README + * section, in document order. + * + * The package publishes its override guidance as prose, but overrides are + * root-only in npm and Bun, so those blocks are the only thing an application + * can act on. Returning them as data lets the verifier assert that what is + * documented is what it installs, instead of leaving several hand-maintained + * copies to drift away from the tested set. + */ +export const documentedOverrideBlocks = (markdown: string, heading: string) => { + const sectionStart = markdown.indexOf(`## ${heading}`); + if (sectionStart === -1) throw new Error(`README has no "## ${heading}" section`); + + const afterHeading = markdown.slice(sectionStart + heading.length); + const nextHeading = afterHeading.indexOf('\n## '); + const section = nextHeading === -1 ? afterHeading : afterHeading.slice(0, nextHeading); + + return [...section.matchAll(JSON_BLOCK)].map(([, body], index) => { + let parsed: { overrides?: Record }; + try { + parsed = JSON.parse(body) as { overrides?: Record }; + } catch (error) { + throw new Error(`"${heading}" JSON block ${index + 1} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`); + } + + if (!parsed.overrides) throw new Error(`"${heading}" JSON block ${index + 1} has no "overrides" key`); + return parsed.overrides; + }); +}; From 7972fe74e76f2bf859032eac9e5b17da6af729e1 Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Fri, 11 Sep 2026 13:11:49 +0200 Subject: [PATCH 3/4] fix(create-sei): clear the new Next.js and sharp advisories The generated-app smoke audits every variant and fails on any high or critical finding. Three advisories landed against the pinned versions, so the check went red without any change to the template: GHSA-p293-qw3h-jr36 critical next >=13.4.0 <15.5.24 GHSA-2xp9-vwfh-vxw4 critical next >=10.0.0 <15.5.24 GHSA-rgj7-g3m4-5g8c high sharp <0.35.4 Both pins move inside their minor: next 15.5.21 to 15.5.25, and the sharp override 0.35.3 to 0.35.4. Next 15.5.25 also widened its own Sharp declaration to ^0.34.3 || ^0.35.4, so the pinned override now sits inside the range Next supports. The image notes in the template README and next.config.mjs asserted the opposite, so they are corrected: images stay unoptimized to avoid requiring a native Sharp build, which is a template choice rather than a security tradeoff. The remaining decode-uri-component finding is moderate and does not block. Co-authored-by: Cursor --- .../fix-create-sei-next-sharp-advisories.md | 15 +++++++++++++++ .../create-sei/templates/next-template/README.md | 4 ++-- .../templates/next-template/next.config.mjs | 6 ++++-- .../templates/next-template/package.json | 4 ++-- 4 files changed, 23 insertions(+), 6 deletions(-) create mode 100644 .changeset/fix-create-sei-next-sharp-advisories.md diff --git a/.changeset/fix-create-sei-next-sharp-advisories.md b/.changeset/fix-create-sei-next-sharp-advisories.md new file mode 100644 index 00000000..6f391940 --- /dev/null +++ b/.changeset/fix-create-sei-next-sharp-advisories.md @@ -0,0 +1,15 @@ +--- +'@sei-js/create-sei': patch +--- + +Bump the Next template's `next` and `sharp` pins to clear three newly published advisories. + +The generated-app smoke audits every variant and fails on any high or critical finding. Three advisories landed against the pinned versions, so the check went red without any change to the template: + +- `GHSA-p293-qw3h-jr36` — critical, unauthenticated RCE on Windows-hosted Next.js servers, `>=13.4.0 <15.5.24`. +- `GHSA-2xp9-vwfh-vxw4` — critical, unauthenticated RCE in the Image Optimization API when AVIF files are used, `>=10.0.0 <15.5.24`. +- `GHSA-rgj7-g3m4-5g8c` — high, heap overflow in Sharp's bundled libheif decoder, `<0.35.4`. + +`next` moves `15.5.21` to `15.5.25` and the `sharp` override `0.35.3` to `0.35.4`, both inside their pinned minors. Next 15.5.25 also widened its own Sharp declaration to `^0.34.3 || ^0.35.4`, so the pinned override now sits inside the range Next supports; the image notes in the template README and `next.config.mjs` said the opposite and are corrected. Images stay unoptimized, which is now a choice about not requiring a native Sharp build rather than a security tradeoff. + +The remaining `decode-uri-component` finding is moderate and does not block the smoke. diff --git a/packages/create-sei/templates/next-template/README.md b/packages/create-sei/templates/next-template/README.md index a3db7c8d..fd604508 100644 --- a/packages/create-sei/templates/next-template/README.md +++ b/packages/create-sei/templates/next-template/README.md @@ -30,11 +30,11 @@ bun run build ## Image handling -Next.js image optimization is disabled because Next 15.5.21 declares only vulnerable Sharp 0.34.x releases. The manifest resolves secure Sharp 0.35.x for audit safety, while images are served unoptimized until Next.js supports that Sharp line. +Next.js image optimization is disabled so the generated app needs no native Sharp build. Next 15.5.25 declares Sharp `^0.34.3 || ^0.35.4`, and the manifest pins `0.35.4`, the release that clears the libheif advisory in that range, so enabling optimization is a template choice rather than a security tradeoff. ## Development bundler -`bun run dev` uses the Next 15 webpack default. Do not add `--turbopack`: Next 15.5.21 cannot express the required `false` alias for MetaMask SDK's mobile-only storage import in Turbopack without adding a synthetic replacement module. The production build and smoke use the validated webpack alias in `next.config.mjs`. +`bun run dev` uses the Next 15 webpack default. Do not add `--turbopack`: the pinned Next 15 line cannot express the required `false` alias for MetaMask SDK's mobile-only storage import in Turbopack without adding a synthetic replacement module. The production build and smoke use the validated webpack alias in `next.config.mjs`. ## Sei brand diff --git a/packages/create-sei/templates/next-template/next.config.mjs b/packages/create-sei/templates/next-template/next.config.mjs index c307b7b1..c08f13dc 100644 --- a/packages/create-sei/templates/next-template/next.config.mjs +++ b/packages/create-sei/templates/next-template/next.config.mjs @@ -5,8 +5,10 @@ const nextConfig = { ignoreDuringBuilds: true, }, images: { - // Next 15.5.21 declares vulnerable Sharp 0.34.x. Keep the secure 0.35.x - // override for resolution, but do not rely on its unsupported optimizer API. + // Next 15.5.25 declares Sharp `^0.34.3 || ^0.35.4`, so the pinned 0.35.4 is + // inside its supported range. Images stay unoptimized so the generated app + // needs no native Sharp build; that is now a template choice, not a + // security constraint. unoptimized: true, }, webpack: (config) => { diff --git a/packages/create-sei/templates/next-template/package.json b/packages/create-sei/templates/next-template/package.json index 0c66fba3..221c62fe 100644 --- a/packages/create-sei/templates/next-template/package.json +++ b/packages/create-sei/templates/next-template/package.json @@ -25,7 +25,7 @@ "@tailwindcss/postcss": "4.3.3", "@tanstack/react-query": "5.85.3", "ethers": "6.17.0", - "next": "15.5.21", + "next": "15.5.25", "pino-pretty": "13.1.3", "postcss": "8.5.26", "react": "19.1.2", @@ -46,7 +46,7 @@ "@metamask/sdk": "0.33.1", "@metamask/sdk-communication-layer": "0.33.1", "postcss": "8.5.26", - "sharp": "0.35.3", + "sharp": "0.35.4", "use-sync-external-store": "1.6.0", "uuid": "11.1.1", "ws": "8.21.3" From 9cb6388229d3b2133f3aa6fdeb9fbbce6ef05326 Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Fri, 11 Sep 2026 13:39:36 +0200 Subject: [PATCH 4/4] docs(create-sei): record 0.35.4 as the Sharp advisory floor The previous wording said image handling is "a template choice, not a security constraint", which reads as if the Sharp override were optional too. It is not: Next declares ^0.34.3 || ^0.35.4, and the 0.34.x half is still inside GHSA-rgj7-g3m4-5g8c, so loosening the pin reintroduces a high finding whether or not optimization is enabled. Both notes now separate the two: images stay unoptimized to avoid a native Sharp build, while 0.35.4 is the advisory floor for the override. The README also no longer names an exact Next patch version, matching the dev-bundler note. That coupling is what made the previous text wrong. Co-authored-by: Cursor --- .changeset/fix-create-sei-next-sharp-advisories.md | 4 +++- packages/create-sei/templates/next-template/README.md | 4 +++- .../create-sei/templates/next-template/next.config.mjs | 9 +++++---- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.changeset/fix-create-sei-next-sharp-advisories.md b/.changeset/fix-create-sei-next-sharp-advisories.md index 6f391940..b1925881 100644 --- a/.changeset/fix-create-sei-next-sharp-advisories.md +++ b/.changeset/fix-create-sei-next-sharp-advisories.md @@ -10,6 +10,8 @@ The generated-app smoke audits every variant and fails on any high or critical f - `GHSA-2xp9-vwfh-vxw4` — critical, unauthenticated RCE in the Image Optimization API when AVIF files are used, `>=10.0.0 <15.5.24`. - `GHSA-rgj7-g3m4-5g8c` — high, heap overflow in Sharp's bundled libheif decoder, `<0.35.4`. -`next` moves `15.5.21` to `15.5.25` and the `sharp` override `0.35.3` to `0.35.4`, both inside their pinned minors. Next 15.5.25 also widened its own Sharp declaration to `^0.34.3 || ^0.35.4`, so the pinned override now sits inside the range Next supports; the image notes in the template README and `next.config.mjs` said the opposite and are corrected. Images stay unoptimized, which is now a choice about not requiring a native Sharp build rather than a security tradeoff. +`next` moves `15.5.21` to `15.5.25` and the `sharp` override `0.35.3` to `0.35.4`, both inside their pinned minors. + +Next also widened its own Sharp declaration to `^0.34.3 || ^0.35.4`, so the pinned override now sits inside the range Next supports. The image notes in the template README and `next.config.mjs` said the opposite and are corrected: images stay unoptimized to avoid requiring a native Sharp build, which is a template choice rather than a security tradeoff. The `sharp` override itself still is one, and both notes now say so — the `0.34.x` half of Next's range remains inside the advisory, making `0.35.4` the floor rather than a free upgrade. The remaining `decode-uri-component` finding is moderate and does not block the smoke. diff --git a/packages/create-sei/templates/next-template/README.md b/packages/create-sei/templates/next-template/README.md index fd604508..e2d77be9 100644 --- a/packages/create-sei/templates/next-template/README.md +++ b/packages/create-sei/templates/next-template/README.md @@ -30,7 +30,9 @@ bun run build ## Image handling -Next.js image optimization is disabled so the generated app needs no native Sharp build. Next 15.5.25 declares Sharp `^0.34.3 || ^0.35.4`, and the manifest pins `0.35.4`, the release that clears the libheif advisory in that range, so enabling optimization is a template choice rather than a security tradeoff. +Next.js image optimization is disabled so the generated app needs no native Sharp build. Turning it on is a template choice rather than a security tradeoff. + +The manifest's `sharp` override is a separate matter and has to stay. The pinned Next 15 line declares Sharp `^0.34.3 || ^0.35.4`, and the `0.34.x` half of that range is still inside [GHSA-rgj7-g3m4-5g8c](https://github.com/advisories/GHSA-rgj7-g3m4-5g8c), so `0.35.4` is the advisory floor. Loosening or removing the override reintroduces a high audit finding whether or not optimization is enabled. ## Development bundler diff --git a/packages/create-sei/templates/next-template/next.config.mjs b/packages/create-sei/templates/next-template/next.config.mjs index c08f13dc..2bfbbc17 100644 --- a/packages/create-sei/templates/next-template/next.config.mjs +++ b/packages/create-sei/templates/next-template/next.config.mjs @@ -5,10 +5,11 @@ const nextConfig = { ignoreDuringBuilds: true, }, images: { - // Next 15.5.25 declares Sharp `^0.34.3 || ^0.35.4`, so the pinned 0.35.4 is - // inside its supported range. Images stay unoptimized so the generated app - // needs no native Sharp build; that is now a template choice, not a - // security constraint. + // Images stay unoptimized so the generated app needs no native Sharp + // build. Keep the manifest's Sharp override: Next declares + // `^0.34.3 || ^0.35.4`, and the 0.34.x half is still inside + // GHSA-rgj7-g3m4-5g8c, so 0.35.4 is the advisory floor. Loosening that + // pin reintroduces a high finding even though this flag is off. unoptimized: true, }, webpack: (config) => {