From 699deceaa496d59b1c152aec559a671c8410916c Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:53:13 +0100 Subject: [PATCH 1/2] Reject stale Solid serialization overrides before frontend installation Bind the existing qualified security override to its exact consumer and serializer pair instead of letting npm overrides silently carry it into a newer Solid runtime. Resolve nested lock edges, retain the strict complete audit and security floors, and cover the two stale dependencies actually present in PR 2759 with positive, partial-repair and nested-resolution controls. Change-source: pulse-maintainer --- .github/workflows/build-and-test.yml | 5 + .../subsystems/deployment-installability.md | 13 ++ .../__tests__/dependencySecurity.test.ts | 11 ++ scripts/check-frontend-serialization.mjs | 99 +++++++++++++ .../check-frontend-serialization.test.mjs | 133 ++++++++++++++++++ 5 files changed, 261 insertions(+) create mode 100644 scripts/check-frontend-serialization.mjs create mode 100644 scripts/tests/check-frontend-serialization.test.mjs diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index f91d61997d..39cffa2cdb 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -206,6 +206,11 @@ jobs: cache: npm cache-dependency-path: frontend-modern/package-lock.json + # npm overrides can install an older serializer than a new Solid asks + # for without npm ci failing. The old security exception is source-bound. + - name: Check Solid serialization dependency edges + run: node scripts/check-frontend-serialization.mjs + - name: Install frontend dependencies working-directory: frontend-modern # The explicit bounded audit below owns the advisory verdict. Avoid diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 1ca8c91856..2826ba8d24 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -5135,6 +5135,19 @@ including nested copies. Exact acquired-graph source/build/browser validation and the complete hosted audit remain distinct obligations; official patch metadata and a lockfile do not by themselves clear an audit failure. +Ordinary frontend CI must check Solid's actual locked Seroval and plugin +dependency edges before installing the graph. An npm override cannot silently +reuse an older compatibility proof after the consumer changes its requirements. +`scripts/check-frontend-serialization.mjs` preserves only the exact qualified +Solid `1.9.15` / Seroval `1.6.3` root override of `~1.5.4`; otherwise each +resolved serializer must satisfy Solid's full stable tilde range, including +nested consumers and dependencies. New range forms require review. The +standalone regression suite rejects the Solid `1.9.16` / `~1.6.8` update with +stale Seroval `1.6.3` or plugins `1.5.5`, accepts matched updates, and checks +the non-optional pre-install CI wiring. This is a graph-admission check, not +official-byte authenticity, a security-audit verdict or installed UI proof; +the existing security floors and actual consumer tests remain required. + That same dev-runtime dependency-manifest boundary now also owns the maintained Docker engine module floor. `go.mod`, `go.sum`, and `internal/cloudcp/docker/manager.go` must route hosted runtime orchestration diff --git a/frontend-modern/src/security/__tests__/dependencySecurity.test.ts b/frontend-modern/src/security/__tests__/dependencySecurity.test.ts index 1d0eda04b1..389ebc1a29 100644 --- a/frontend-modern/src/security/__tests__/dependencySecurity.test.ts +++ b/frontend-modern/src/security/__tests__/dependencySecurity.test.ts @@ -1,6 +1,8 @@ // @vitest-environment node import { readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; interface PackageManifest { @@ -78,6 +80,15 @@ const prettierPinIsReviewed = (version: string): boolean => /^3\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/.test(version) && atLeast(version, [3, 9, 9]); describe('frontend dependency security floors', () => { + it('checks the actual locked Solid serialization edges before accepting security overrides', () => { + const check = fileURLToPath( + new URL('../../../../scripts/check-frontend-serialization.mjs', import.meta.url), + ); + expect(execFileSync(process.execPath, [check], { encoding: 'utf8' })).toContain( + 'Solid runtime edges match, or use the exact qualified security override', + ); + }); + it.each<[string, [number, number, number]]>([ // GHSA-p6vx-979v-rg4c and GHSA-jp82-f5mq-hwhp: both Seroval fixes // are required, including the TypedArray fix after the first 1.6.x patch. diff --git a/scripts/check-frontend-serialization.mjs b/scripts/check-frontend-serialization.mjs new file mode 100644 index 0000000000..d18673226b --- /dev/null +++ b/scripts/check-frontend-serialization.mjs @@ -0,0 +1,99 @@ +#!/usr/bin/env node +// A security override is not blanket compatibility approval for a new Solid. +// Check the locked runtime edges before npm ci can hide them behind overrides. +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const defaultLock = new URL('../frontend-modern/package-lock.json', import.meta.url); +const stableVersion = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; + +function versionParts(value) { + const match = typeof value === 'string' && stableVersion.exec(value); + const parts = match ? match.slice(1).map(Number) : null; + return parts?.every(Number.isSafeInteger) ? parts : null; +} + +function resolvedDependency(packages, consumerPath, dependency) { + let parent = consumerPath; + while (true) { + const nested = `${parent}/node_modules/${dependency}`; + if (Object.hasOwn(packages, nested)) return { path: nested, entry: packages[nested] }; + const boundary = parent.lastIndexOf('/node_modules/'); + if (boundary < 0) break; + parent = parent.slice(0, boundary); + } + const root = `node_modules/${dependency}`; + return Object.hasOwn(packages, root) ? { path: root, entry: packages[root] } : null; +} + +export function serializationGraphErrors(lock) { + const packages = lock?.packages; + if (lock?.lockfileVersion !== 3 || !packages || typeof packages !== 'object' || Array.isArray(packages)) { + return ['Expected an npm v3 lock with a complete packages map.']; + } + const consumers = Object.entries(packages).filter(([path]) => + path === 'node_modules/solid-js' || path.endsWith('/node_modules/solid-js'), + ); + if (!consumers.some(([path]) => path === 'node_modules/solid-js')) { + return ['The frontend lock must contain its direct Solid runtime.']; + } + const errors = []; + for (const [path, consumer] of consumers) { + if (!versionParts(consumer?.version)) { + errors.push('Solid must have an exact stable locked version.'); + continue; + } + for (const dependency of ['seroval', 'seroval-plugins']) { + const range = consumer.dependencies?.[dependency]; + // These runtime edges currently use full stable tilde ranges. A changed + // range format needs review rather than an invented semver interpretation. + const floor = typeof range === 'string' && range.startsWith('~') && versionParts(range.slice(1)); + if (!floor) { + errors.push(`Solid ${consumer.version}: review the new ${dependency} requirement (expected ~major.minor.patch).`); + continue; + } + const resolved = resolvedDependency(packages, path, dependency); + const installed = versionParts(resolved?.entry?.version); + if (!installed) { + errors.push(`Solid ${consumer.version}: ${dependency} must resolve to an exact stable locked version.`); + continue; + } + const compatible = installed[0] === floor[0] && installed[1] === floor[1] && installed[2] >= floor[2]; + // 795e534517 qualified this one security override with actual cyclic, + // typed-array and plugin consumers. Preserve it, but do not apply that + // old proof to a new consumer, dependency, declared range or nested copy. + const qualifiedOverride = path === 'node_modules/solid-js' && + consumer.version === '1.9.15' && dependency === 'seroval' && + range === '~1.5.4' && resolved.path === 'node_modules/seroval' && + resolved.entry.version === '1.6.3'; + if (!compatible && !qualifiedOverride) { + errors.push(`Solid ${consumer.version} requires ${dependency} ${range}, but resolves ${resolved.entry.version}. Update the coupled graph and retain consumer compatibility proof; an npm override is not that proof.`); + } + } + } + return errors; +} + +export function main(args = process.argv.slice(2)) { + if (args.length !== 0 && (args.length !== 2 || args[0] !== '--lockfile')) { + console.error('Usage: node scripts/check-frontend-serialization.mjs [--lockfile FILE]'); + return 2; + } + let lock; + try { + lock = JSON.parse(readFileSync(args.length ? args[1] : defaultLock, 'utf8')); + } catch { + console.error('Frontend serialization graph: lock read/JSON parse failed.'); + return 2; + } + const errors = serializationGraphErrors(lock); + for (const error of errors) console.error(`Frontend serialization graph: ${error}`); + if (errors.length) return 1; + console.log('Frontend serialization graph: Solid runtime edges match, or use the exact qualified security override.'); + return 0; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.exitCode = main(); +} diff --git a/scripts/tests/check-frontend-serialization.test.mjs b/scripts/tests/check-frontend-serialization.test.mjs new file mode 100644 index 0000000000..12bede218d --- /dev/null +++ b/scripts/tests/check-frontend-serialization.test.mjs @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; +import { serializationGraphErrors } from '../check-frontend-serialization.mjs'; + +function graph(solid = '1.9.16', range = '~1.6.8', seroval = '1.6.8', plugins = '1.6.8') { + return { + lockfileVersion: 3, + packages: { + 'node_modules/solid-js': { version: solid, dependencies: { seroval: range, 'seroval-plugins': range } }, + 'node_modules/seroval': { version: seroval }, + 'node_modules/seroval-plugins': { version: plugins }, + }, + }; +} + +test('current complete lock preserves the qualified security override', () => { + const lock = JSON.parse(readFileSync(new URL('../../frontend-modern/package-lock.json', import.meta.url), 'utf8')); + assert.deepEqual(serializationGraphErrors(lock), []); +}); + +test('the exact old qualified tuple is not mistaken for a broken graph', () => { + assert.deepEqual(serializationGraphErrors(graph('1.9.15', '~1.5.4', '1.6.3', '1.5.5')), []); +}); + +test('PR 2759 shape rejects both stale serializer dependencies despite npm overrides', () => { + const errors = serializationGraphErrors(graph('1.9.16', '~1.6.8', '1.6.3', '1.5.5')); + assert.equal(errors.length, 2); + assert.match(errors[0], /requires seroval ~1\.6\.8, but resolves 1\.6\.3/); + assert.match(errors[1], /requires seroval-plugins ~1\.6\.8, but resolves 1\.5\.5/); +}); + +test('correcting only seroval still rejects the stale plugin', () => { + const errors = serializationGraphErrors(graph('1.9.16', '~1.6.8', '1.6.8', '1.5.5')); + assert.equal(errors.length, 1); + assert.match(errors[0], /requires seroval-plugins/); +}); + +test('correcting only the plugin still rejects stale seroval', () => { + const errors = serializationGraphErrors(graph('1.9.16', '~1.6.8', '1.6.3', '1.6.8')); + assert.equal(errors.length, 1); + assert.match(errors[0], /requires seroval /); +}); + +test('the coupled update and later matching patches are not permanently pinned', () => { + assert.deepEqual(serializationGraphErrors(graph()), []); + assert.deepEqual(serializationGraphErrors(graph('1.9.17', '~1.6.8', '1.6.9', '1.6.10')), []); +}); + +test('major and minor mismatches do not satisfy a tilde requirement', () => { + for (const version of ['2.6.8', '1.7.8', '1.5.99']) { + assert.equal(serializationGraphErrors(graph('1.9.16', '~1.6.8', version)).length, 1); + } +}); + +test('a new Solid cannot borrow the old consumer compatibility exception', () => { + const errors = serializationGraphErrors(graph('1.9.17', '~1.5.4', '1.6.3', '1.5.5')); + assert.equal(errors.length, 1); +}); + +test('a different serializer cannot borrow the exact old override', () => { + assert.equal(serializationGraphErrors(graph('1.9.15', '~1.5.4', '1.6.4', '1.5.5')).length, 1); +}); + +test('nested resolution wins over a correct root serializer', () => { + const lock = graph(); + lock.packages['node_modules/solid-js/node_modules/seroval'] = { version: '1.6.3' }; + const errors = serializationGraphErrors(lock); + assert.equal(errors.length, 1); + assert.match(errors[0], /resolves 1\.6\.3/); +}); + +test('a nested Solid resolves its nearest dependency instead of the root', () => { + const lock = graph(); + lock.packages['node_modules/fixture/node_modules/solid-js'] = structuredClone(lock.packages['node_modules/solid-js']); + lock.packages['node_modules/fixture/node_modules/seroval-plugins'] = { version: '1.5.5' }; + assert.equal(serializationGraphErrors(lock).length, 1); + lock.packages['node_modules/fixture/node_modules/seroval-plugins'] = { version: '1.6.8' }; + assert.deepEqual(serializationGraphErrors(lock), []); +}); + +test('the old root-only override does not qualify a new nested resolution', () => { + const lock = graph('1.9.15', '~1.5.4', '1.6.3', '1.5.5'); + lock.packages['node_modules/solid-js/node_modules/seroval'] = { version: '1.6.3' }; + assert.equal(serializationGraphErrors(lock).length, 1); +}); + +test('missing dependency and consumer entries fail closed', () => { + for (const name of ['seroval', 'seroval-plugins', 'solid-js']) { + const lock = graph(); + delete lock.packages[`node_modules/${name}`]; + assert.equal(serializationGraphErrors(lock).length, 1); + } +}); + +test('incomplete lock formats fail closed', () => { + for (const lock of [null, {}, { lockfileVersion: 2, packages: {} }, { lockfileVersion: 3, packages: [] }]) { + assert.equal(serializationGraphErrors(lock).length, 1); + } +}); + +test('unknown ranges require explicit review rather than a made-up semver verdict', () => { + for (const range of ['^1.6.8', '~1.6', '*', '~1.6.8-beta.1']) { + const errors = serializationGraphErrors(graph('1.9.16', range)); + assert.equal(errors.length, 2); + } + const lock = graph(); + delete lock.packages['node_modules/solid-js'].dependencies.seroval; + assert.equal(serializationGraphErrors(lock).length, 1); +}); + +test('prereleases and malformed locked versions cannot claim compatibility', () => { + for (const version of ['1.6.8-beta.1', '1.6', '01.6.8', 'not-a-version', undefined]) { + const lock = graph(); + lock.packages['node_modules/seroval'].version = version; + assert.equal(serializationGraphErrors(lock).length, 1); + } + const lock = graph(); + lock.packages['node_modules/solid-js'] = null; + assert.equal(serializationGraphErrors(lock).length, 1); +}); + +test('CI fails the graph check before installation without relaxing the audit', () => { + const workflow = readFileSync(new URL('../../.github/workflows/build-and-test.yml', import.meta.url), 'utf8'); + const frontend = workflow.slice(workflow.indexOf('\n frontend:')); + const check = frontend.indexOf('- name: Check Solid serialization dependency edges'); + const install = frontend.indexOf('- name: Install frontend dependencies'); + assert.ok(check >= 0 && check < install); + assert.match(frontend.slice(check, install), /run: node scripts\/check-frontend-serialization\.mjs/); + assert.doesNotMatch(frontend.slice(check, install), /continue-on-error/); + assert.match(frontend, /NPM_AUDIT_REQUIRE_RESULT: \$\{\{ needs\.changes\.outputs\.frontend_deps \}\}/); + assert.match(frontend, /npm-audit-retry\.sh" all/); +}); From 1691d91bf8e8b2396c980d8f3d1134570ba7460d Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:54:51 +0100 Subject: [PATCH 2/2] Run serialization graph regression controls in the pre-install CI check Keep the negative, partial-repair and nested-resolution controls beside the actual locked-graph check so future edits cannot silently disable the intended safeguard. Contract-Neutral: Run the existing serialization regression suite in its pre-install CI check; no additional contract or user-visible runtime change. Change-source: pulse-maintainer --- .github/workflows/build-and-test.yml | 4 +++- scripts/tests/check-frontend-serialization.test.mjs | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 39cffa2cdb..f2f9b7a86f 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -209,7 +209,9 @@ jobs: # npm overrides can install an older serializer than a new Solid asks # for without npm ci failing. The old security exception is source-bound. - name: Check Solid serialization dependency edges - run: node scripts/check-frontend-serialization.mjs + run: | + node --test scripts/tests/check-frontend-serialization.test.mjs + node scripts/check-frontend-serialization.mjs - name: Install frontend dependencies working-directory: frontend-modern diff --git a/scripts/tests/check-frontend-serialization.test.mjs b/scripts/tests/check-frontend-serialization.test.mjs index 12bede218d..aac66a2014 100644 --- a/scripts/tests/check-frontend-serialization.test.mjs +++ b/scripts/tests/check-frontend-serialization.test.mjs @@ -126,7 +126,8 @@ test('CI fails the graph check before installation without relaxing the audit', const check = frontend.indexOf('- name: Check Solid serialization dependency edges'); const install = frontend.indexOf('- name: Install frontend dependencies'); assert.ok(check >= 0 && check < install); - assert.match(frontend.slice(check, install), /run: node scripts\/check-frontend-serialization\.mjs/); + assert.match(frontend.slice(check, install), /node --test scripts\/tests\/check-frontend-serialization\.test\.mjs/); + assert.match(frontend.slice(check, install), /node scripts\/check-frontend-serialization\.mjs/); assert.doesNotMatch(frontend.slice(check, install), /continue-on-error/); assert.match(frontend, /NPM_AUDIT_REQUIRE_RESULT: \$\{\{ needs\.changes\.outputs\.frontend_deps \}\}/); assert.match(frontend, /npm-audit-retry\.sh" all/);