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..b1925881 --- /dev/null +++ b/.changeset/fix-create-sei-next-sharp-advisories.md @@ -0,0 +1,17 @@ +--- +'@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 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/.changeset/fix-global-wallet-sharp-override.md b/.changeset/fix-global-wallet-sharp-override.md new file mode 100644 index 00000000..ebaf3564 --- /dev/null +++ b/.changeset/fix-global-wallet-sharp-override.md @@ -0,0 +1,21 @@ +--- +'@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. + +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/create-sei/templates/next-template/README.md b/packages/create-sei/templates/next-template/README.md index a3db7c8d..e2d77be9 100644 --- a/packages/create-sei/templates/next-template/README.md +++ b/packages/create-sei/templates/next-template/README.md @@ -30,11 +30,13 @@ 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. 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 -`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..2bfbbc17 100644 --- a/packages/create-sei/templates/next-template/next.config.mjs +++ b/packages/create-sei/templates/next-template/next.config.mjs @@ -5,8 +5,11 @@ 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. + // 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) => { 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" diff --git a/packages/sei-global-wallet/README.md b/packages/sei-global-wallet/README.md index 86fbd036..b03582c3 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, 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 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..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 }>; @@ -37,7 +38,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; @@ -58,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}`); }; @@ -155,10 +162,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: { @@ -175,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, @@ -610,8 +637,52 @@ 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) => { - assert.equal(report.metadata?.vulnerabilities?.total ?? Object.keys(report.vulnerabilities ?? {}).length, 0, `${label} audit was not clean`); + assert.equal(report.metadata?.vulnerabilities?.total, 0, `${label} audit was not clean: ${describeAuditFindings(report).join('; ')}`); +}; + +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 = 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.` + ); + } +}; + +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; + } + + 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) => { @@ -627,34 +698,20 @@ 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())); - 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.` + assertWithinWaiver( + auditFindings.flatMap((finding) => ghsaIdsIn(finding)), + acceptedBunAdvisories, + 'Bun AA consumer' ); - 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.` - ); - } - - // 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) @@ -759,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); @@ -785,14 +843,15 @@ 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. - 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,8 +870,7 @@ try { ], waivedDir ); - const waivedAudit = parseJsonOutput((await run(['npm', 'audit', '--json'], waivedDir)).stdout); - assertAuditClean(waivedAudit, 'Waived npm consumer'); + assertAuditClean(parseNpmAudit(await run(['npm', 'audit', '--json'], waivedDir, true), 'Waived npm consumer'), 'Waived npm consumer'); await assertDeclarationsResolveWithoutOptionalPeers(waivedDir); } @@ -878,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); - assertAuditClean(parseJsonOutput((await run(['npm', 'audit', '--json'], npmConsumerDir)).stdout), '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'); @@ -901,7 +959,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 }); 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; + }); +};