Integrate reviewed Solid serializer graph admission guard

Preserve the existing exact security exception while rejecting unqualified newer Solid serializer edges before installation. Retain exact candidate identity and completed regression proof.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-14 03:00:19 +01:00
5 changed files with 264 additions and 0 deletions
+7
View File
@@ -206,6 +206,13 @@ 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 --test scripts/tests/check-frontend-serialization.test.mjs
node scripts/check-frontend-serialization.mjs
- name: Install frontend dependencies
working-directory: frontend-modern
# The explicit bounded audit below owns the advisory verdict. Avoid
@@ -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
@@ -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.
+99
View File
@@ -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();
}
@@ -0,0 +1,134 @@
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), /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/);
});