mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
feat(build): assert security-relevant branches survive into the compiled output (#3983 detection) (#4068)
* feat(build): assert security-relevant branches survive into the compiled output
Release 5.1.18 shipped `resolveStoreVisibilityScopeUninstrumented` as
async function S(e){if(await p(e))return"full"}
Two of its three `return`s were absent from the emitted server chunk, so the
function fell off the end and produced `undefined` for every non-privileged
caller. One read path defaulted that to `'full'` and served the whole App-store
catalog to anonymous callers; the other defaulted the same missing value to
`'none'` and showed the intended cohort an empty store (#3983).
The TypeScript is correct — that is the whole problem. A 75-test unit suite, an
integration suite driving the real feature-flag client, and four rounds of
review were all structurally incapable of seeing it, because every one of them
exercises the source. Nothing looked at the artefact.
This adds a gate that looks at the artefact. It reads the emitted `.js.map`
`mappings` and asserts that each watched fail-closed branch still has a
representation in the output, attributing code to source modules by source map
rather than by grepping minified JS — minified names are per-chunk, the module
is inlined into 234 chunks, the literal `"public-external"` appears ~481 times
in the build without ever being a return, and the flag name
`app-listings-public-external` contains that literal as a substring.
Every entry carries a positive control: a branch known to survive that must also
be mapped. If the control is missing the gate exits 2 ("cannot observe") instead
of exit 1 ("violation"), so a build that simply did not emit the module is never
reported as a pile of violations.
Wired into the Dockerfile beside `check-server-graph-singletons.mjs`, and for
the same reason — `.next` exists in that stage, so there is no second build.
🔴 It runs `--warn-only` for now, because the underlying bundler defect is NOT
fixed: `main` and `release` carry byte-identical source for that function and
both emit it truncated, so a hard gate would fail every production build today.
`--warn-only` does not downgrade exit 2. Removing that flag is the definition of
done for #3983.
Verification: 13 cases, each asserting its own failure branch's specific message
and exit code, driven over synthetic `.next` trees with real base64-VLQ maps
encoded by an independent implementation. Six mutations of the gate were each
killed by their own case — one of them (breaking the VLQ sign branch) initially
SURVIVED, because every fixture listed source lines in ascending order and so
never produced a negative delta; the reordered-segments case was added to close
that. The gate was also run against the real 5.1.18 server artefact, where it
correctly reports both missing branches.
* style(build): prettier the compiled-branch gate + watchlist
This commit is contained in:
+29
@@ -73,6 +73,35 @@ RUN --mount=type=cache,target=/app/.next/cache \
|
||||
# report health. Cheap: a few seconds of file reads.
|
||||
RUN node scripts/check-server-graph-singletons.mjs
|
||||
|
||||
# Compiled-branch gate — the second HARD build-output gate, and for the same reason as the
|
||||
# one above: nothing that reads SOURCE can see this class of defect.
|
||||
#
|
||||
# Release 5.1.18 shipped `resolveStoreVisibilityScopeUninstrumented` as
|
||||
# `async function S(e){if(await p(e))return"full"}` — two of its three returns were absent
|
||||
# from the emitted chunk, so it returned `undefined` for every non-privileged caller. One
|
||||
# read path defaulted that to `'full'` and served the whole App-store catalog to anonymous
|
||||
# callers; the other defaulted it to `'none'` and showed the intended cohort an empty
|
||||
# store. The TypeScript was correct throughout, so a 75-test unit suite, an integration
|
||||
# suite driving the real feature-flag client, and four rounds of review were all
|
||||
# structurally incapable of catching it (civitai#3983).
|
||||
#
|
||||
# This reads the emitted `.js.map` `mappings` and asserts that each watched fail-closed
|
||||
# branch still has a representation in the output. Same placement rationale as the gate
|
||||
# above: `.next` exists in this stage, so there is no second build; and it exits 2 (not 0)
|
||||
# when it cannot observe its input. Adding a gate is one entry in
|
||||
# `scripts/compiled-branch-watchlist.mjs`.
|
||||
#
|
||||
# 🔴 `--warn-only` because THIS BUILD CURRENTLY VIOLATES IT. The bundler defect behind
|
||||
# civitai#3983 is not fixed — `main` and `release` carry byte-identical source for that
|
||||
# function and both emit it truncated — so a hard gate here would fail every production
|
||||
# build immediately. It reports loudly and exits 0 instead. `--warn-only` does NOT
|
||||
# downgrade exit 2, so a gate that cannot see its own input still fails the build.
|
||||
#
|
||||
# REMOVE `--warn-only` as part of fixing the underlying defect. That flip is the
|
||||
# definition of done for civitai#3983, and it is the only thing that turns this from a log
|
||||
# line back into a gate.
|
||||
RUN node scripts/assert-compiled-branches.mjs --warn-only
|
||||
|
||||
# Bundle-size budget (report-only during the soak). Next 16 (Turbopack) emits
|
||||
# opaque hashed chunks and removed per-route build stats, so scripts/bundle-budget.mjs
|
||||
# parses .next/build-manifest.json to reconstruct per-page First Load JS (brotli)
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { COMPILED_BRANCH_WATCHLIST } from '../compiled-branch-watchlist.mjs';
|
||||
|
||||
/**
|
||||
* `scripts/assert-compiled-branches.mjs` is a BUILD gate: it reads the emitted server
|
||||
* source maps and fails when a watched, security-relevant branch has no representation in
|
||||
* the compiled output — the civitai#3983 shape, where two of a resolver's three `return`s
|
||||
* were absent from the shipped chunk while the TypeScript was correct.
|
||||
*
|
||||
* These cases drive it over synthetic `.next/server` trees. That is deliberate: a real
|
||||
* `next build` takes minutes, so nobody would run one per case, and the branches that
|
||||
* matter (a missing return, an unobservable control, a build with no maps) are exactly
|
||||
* the ones a healthy real build cannot produce on demand.
|
||||
*
|
||||
* 🔴 Each case must fail for ITS OWN reason, so every expectation asserts the specific
|
||||
* message that branch emits and the specific exit code, not merely "non-zero". A gate
|
||||
* with four failure branches and one generic assertion is a gate with one tested branch.
|
||||
*
|
||||
* 🔴 The healthy baseline is derived from the gate's REAL watchlist and the REAL source
|
||||
* files, never a hand-written copy. That makes the positive control meaningful in two
|
||||
* ways at once: a new watchlist entry is proven satisfiable by this suite instead of
|
||||
* breaking it, AND every entry's anchors are proven to resolve against the source they
|
||||
* name — an anchor that rotted because the code moved fails here, at PR time, rather than
|
||||
* turning the gate into an exit-2 no-op on the build host.
|
||||
*
|
||||
* 🔴 The fixtures encode REAL base64-VLQ mappings rather than a stub string, because the
|
||||
* decoder is the part of the gate most able to be silently wrong. `encodeMappings` below
|
||||
* is an independent implementation: if the gate's decoder and this encoder ever disagree,
|
||||
* the healthy case goes red.
|
||||
*/
|
||||
|
||||
const GATE = path.resolve(__dirname, '../assert-compiled-branches.mjs');
|
||||
const REPO_ROOT = path.resolve(__dirname, '../..');
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(path.join(tmpdir(), 'compiled-branches-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------------------
|
||||
// A minimal, independent base64-VLQ encoder, so the fixtures exercise the gate's real
|
||||
// decode path instead of a hand-written `mappings` string that happens to parse.
|
||||
// --------------------------------------------------------------------------------------
|
||||
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
|
||||
function encodeVLQ(value: number): string {
|
||||
let v = value < 0 ? (-value << 1) | 1 : value << 1;
|
||||
let out = '';
|
||||
do {
|
||||
let digit = v & 31;
|
||||
v >>>= 5;
|
||||
if (v > 0) digit |= 32;
|
||||
out += B64[digit];
|
||||
} while (v > 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `mappings` string that places one segment per requested SOURCE line (1-based),
|
||||
* all attributed to source index 0, each on its own generated line.
|
||||
*/
|
||||
function encodeMappings(sourceLines: number[]): string {
|
||||
let prevSourceLine = 0;
|
||||
return sourceLines
|
||||
.map((line) => {
|
||||
const delta = line - 1 - prevSourceLine;
|
||||
prevSourceLine = line - 1;
|
||||
// [generatedColumn, sourceIndex, sourceLine, sourceColumn]
|
||||
return encodeVLQ(0) + encodeVLQ(0) + encodeVLQ(delta) + encodeVLQ(0);
|
||||
})
|
||||
.join(';');
|
||||
}
|
||||
|
||||
/** Write one emitted chunk + its map into the fake `.next/server`. */
|
||||
function chunk(name: string, sourceModule: string, mappedSourceLines: number[]) {
|
||||
const full = path.join(dir, 'server', name);
|
||||
mkdirSync(path.dirname(full), { recursive: true });
|
||||
writeFileSync(full, '/* emitted */');
|
||||
writeFileSync(
|
||||
`${full}.map`,
|
||||
JSON.stringify({
|
||||
version: 3,
|
||||
// Turbopack writes paths like `[project]/src/server/x.ts`; the gate matches by
|
||||
// substring, so the fixture uses the same shape rather than a bare relative path.
|
||||
sources: [`[project]/${sourceModule}`],
|
||||
names: [],
|
||||
mappings: encodeMappings(mappedSourceLines),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** 1-based line of the single source line containing `code`. Throws if not unique. */
|
||||
function lineOf(module: string, code: string): number {
|
||||
const text = readFileSync(path.join(REPO_ROOT, module), 'utf8').split('\n');
|
||||
const hits = text.map((l, i) => (l.includes(code) ? i + 1 : 0)).filter(Boolean);
|
||||
if (hits.length !== 1) {
|
||||
throw new Error(`anchor ${JSON.stringify(code)} matched ${hits.length} lines in ${module}`);
|
||||
}
|
||||
return hits[0];
|
||||
}
|
||||
|
||||
type Entry = (typeof COMPILED_BRANCH_WATCHLIST)[number];
|
||||
|
||||
/** Every anchor line an entry declares — control first, then required. */
|
||||
function allLines(entry: Entry): { control: number[]; required: number[] } {
|
||||
return {
|
||||
control: entry.control.map((a) => lineOf(entry.module, a.code)),
|
||||
required: entry.required.map((a) => lineOf(entry.module, a.code)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The healthy baseline: for every watchlist entry, one chunk that maps every anchor line
|
||||
* it declares. `omitRequired` drops specific required anchors so a case can express "this
|
||||
* one branch is missing" without hand-writing the rest of the list.
|
||||
*/
|
||||
function healthyBaseline(opts: { skipEntryId?: string } = {}) {
|
||||
COMPILED_BRANCH_WATCHLIST.forEach((entry, i) => {
|
||||
if (entry.id === opts.skipEntryId) return;
|
||||
const { control, required } = allLines(entry);
|
||||
chunk(`chunks/healthy-${i}.js`, entry.module, [...control, ...required]);
|
||||
});
|
||||
}
|
||||
|
||||
const run = (extra: string[] = []) =>
|
||||
spawnSync(process.execPath, [GATE, '--next-dir', dir, '--repo-root', REPO_ROOT, ...extra], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
const output = (r: ReturnType<typeof run>) => `${r.stdout}${r.stderr}`;
|
||||
|
||||
// --------------------------------------------------------------------------------------
|
||||
|
||||
describe('assert-compiled-branches', () => {
|
||||
it('the watchlist is non-empty and every anchor resolves to exactly one source line', () => {
|
||||
// The gate is worth nothing if its entries no longer point at real code. This asserts
|
||||
// it directly rather than leaving it to the exit-2 branch on a build host.
|
||||
expect(COMPILED_BRANCH_WATCHLIST.length).toBeGreaterThan(0);
|
||||
for (const entry of COMPILED_BRANCH_WATCHLIST) {
|
||||
expect(entry.control.length, `${entry.id} must declare a control anchor`).toBeGreaterThan(0);
|
||||
expect(entry.required.length, `${entry.id} must declare required anchors`).toBeGreaterThan(0);
|
||||
expect(() => allLines(entry)).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('passes when every watched branch is represented in the output', () => {
|
||||
healthyBaseline();
|
||||
const r = run();
|
||||
expect(output(r)).toContain('every watched branch is represented');
|
||||
expect(r.status).toBe(0);
|
||||
});
|
||||
|
||||
it('reports the number of chunks each module was emitted into (the scan observed something)', () => {
|
||||
// Positive control on the instrument: a zero here would be indistinguishable from a
|
||||
// scan wired to nothing, so the count must move with the fixture.
|
||||
const entry = COMPILED_BRANCH_WATCHLIST[0];
|
||||
const { control, required } = allLines(entry);
|
||||
healthyBaseline({ skipEntryId: entry.id });
|
||||
chunk('chunks/copy-a.js', entry.module, [...control, ...required]);
|
||||
chunk('chunks/ssr/copy-b.js', entry.module, [...control, ...required]);
|
||||
const r = run();
|
||||
expect(r.status).toBe(0);
|
||||
expect(output(r)).toContain('emitted into 2 chunk(s)');
|
||||
});
|
||||
|
||||
it('decodes NEGATIVE source-line deltas (segments emitted out of source order)', () => {
|
||||
// 🔴 This case exists because a mutation survived without it. Every other fixture
|
||||
// lists its source lines ascending, so every encoded delta is non-negative and the
|
||||
// decoder's sign branch is never reached — breaking it left the suite fully green.
|
||||
// Real maps reorder freely (the minifier hoists and merges), so negative deltas are
|
||||
// the common case in the output this gate actually reads.
|
||||
const entry = COMPILED_BRANCH_WATCHLIST[0];
|
||||
const { control, required } = allLines(entry);
|
||||
const descending = [...control, ...required].sort((a, b) => b - a);
|
||||
expect(descending[0]).toBeGreaterThan(descending[descending.length - 1]); // a real negative delta
|
||||
|
||||
healthyBaseline({ skipEntryId: entry.id });
|
||||
chunk('chunks/reordered.js', entry.module, descending);
|
||||
|
||||
const r = run();
|
||||
expect(output(r)).toContain('every watched branch is represented');
|
||||
expect(r.status).toBe(0);
|
||||
});
|
||||
|
||||
it('FAILS (exit 1) naming the branch when a required anchor has no mapping', () => {
|
||||
const entry = COMPILED_BRANCH_WATCHLIST[0];
|
||||
const { control, required } = allLines(entry);
|
||||
healthyBaseline({ skipEntryId: entry.id });
|
||||
// control mapped, first required anchor deliberately absent
|
||||
chunk('chunks/truncated.js', entry.module, [...control, ...required.slice(1)]);
|
||||
|
||||
const r = run();
|
||||
expect(r.status).toBe(1);
|
||||
const out = output(r);
|
||||
expect(out).toContain('A SECURITY-RELEVANT BRANCH IS ABSENT FROM THE COMPILED OUTPUT');
|
||||
expect(out).toContain(`MISSING line ${required[0]}: ${entry.required[0].code}`);
|
||||
// and it must NOT claim the still-present ones are missing
|
||||
expect(out).not.toContain(`MISSING line ${required[1] ?? -1}:`);
|
||||
});
|
||||
|
||||
it('--warn-only downgrades a violation to exit 0 but still prints the whole report', () => {
|
||||
const entry = COMPILED_BRANCH_WATCHLIST[0];
|
||||
const { control, required } = allLines(entry);
|
||||
healthyBaseline({ skipEntryId: entry.id });
|
||||
chunk('chunks/truncated.js', entry.module, [...control, ...required.slice(1)]);
|
||||
|
||||
const r = run(['--warn-only']);
|
||||
expect(r.status).toBe(0);
|
||||
const out = output(r);
|
||||
expect(out).toContain('A SECURITY-RELEVANT BRANCH IS ABSENT FROM THE COMPILED OUTPUT');
|
||||
expect(out).toContain(`MISSING line ${required[0]}`);
|
||||
expect(out).toContain('--warn-only is set');
|
||||
});
|
||||
|
||||
it('--warn-only does NOT downgrade "could not run" — a blind gate still fails', () => {
|
||||
// The whole point of exit 2 is that it is not a verdict about the code. Letting
|
||||
// --warn-only swallow it would turn a gate that stopped looking into a gate that
|
||||
// reports health, which is the failure mode this file exists to prevent.
|
||||
mkdirSync(path.join(dir, 'server', 'chunks'), { recursive: true });
|
||||
writeFileSync(path.join(dir, 'server', 'chunks', 'lonely.js'), '/* no map */');
|
||||
const r = run(['--warn-only']);
|
||||
expect(r.status).toBe(2);
|
||||
expect(output(r)).toContain('found no .js.map');
|
||||
});
|
||||
|
||||
it('REFUSES (exit 2) rather than reporting a violation when the control anchor is unmapped', () => {
|
||||
// A module the build never emitted makes every anchor unmapped. That is a broken
|
||||
// input, not N violations — and reporting it as violations is how a gate gets
|
||||
// dismissed as noisy and then ignored.
|
||||
const entry = COMPILED_BRANCH_WATCHLIST[0];
|
||||
const { required } = allLines(entry);
|
||||
healthyBaseline({ skipEntryId: entry.id });
|
||||
chunk('chunks/no-control.js', entry.module, required);
|
||||
|
||||
const r = run();
|
||||
expect(r.status).toBe(2);
|
||||
const out = output(r);
|
||||
expect(out).toContain('CANNOT OBSERVE');
|
||||
expect(out).not.toContain('A SECURITY-RELEVANT BRANCH IS ABSENT');
|
||||
});
|
||||
|
||||
it('REFUSES (exit 2) when the build emitted no source maps at all', () => {
|
||||
mkdirSync(path.join(dir, 'server', 'chunks'), { recursive: true });
|
||||
writeFileSync(path.join(dir, 'server', 'chunks', 'lonely.js'), '/* no map */');
|
||||
const r = run();
|
||||
expect(r.status).toBe(2);
|
||||
expect(output(r)).toContain('found no .js.map');
|
||||
});
|
||||
|
||||
it('REFUSES (exit 2) when there is no server output to read', () => {
|
||||
const r = spawnSync(
|
||||
process.execPath,
|
||||
[GATE, '--next-dir', path.join(dir, 'does-not-exist'), '--repo-root', REPO_ROOT],
|
||||
{ encoding: 'utf8' }
|
||||
);
|
||||
expect(r.status).toBe(2);
|
||||
expect(output(r)).toContain('no server output at');
|
||||
});
|
||||
|
||||
it('REFUSES (exit 2) when an anchor matches no line in the module it names', () => {
|
||||
healthyBaseline();
|
||||
const wl = path.join(dir, 'wl-missing.mjs');
|
||||
writeFileSync(
|
||||
wl,
|
||||
`export const COMPILED_BRANCH_WATCHLIST = [{
|
||||
id: 'synthetic', module: 'scripts/compiled-branch-watchlist.mjs', why: 'test',
|
||||
control: [{ code: 'COMPILED_BRANCH_WATCHLIST', why: 'c' }],
|
||||
required: [{ code: 'this string is not in that file at all', why: 'r' }],
|
||||
}];`
|
||||
);
|
||||
const r = run(['--watchlist', wl]);
|
||||
expect(r.status).toBe(2);
|
||||
expect(output(r)).toContain('no line contains this anchor');
|
||||
});
|
||||
|
||||
it('REFUSES (exit 2) when an anchor is ambiguous', () => {
|
||||
// An anchor matching several lines would silently check whichever one it landed on.
|
||||
healthyBaseline();
|
||||
const wl = path.join(dir, 'wl-ambiguous.mjs');
|
||||
writeFileSync(
|
||||
wl,
|
||||
`export const COMPILED_BRANCH_WATCHLIST = [{
|
||||
id: 'synthetic', module: 'scripts/compiled-branch-watchlist.mjs', why: 'test',
|
||||
control: [{ code: 'COMPILED_BRANCH_WATCHLIST', why: 'c' }],
|
||||
required: [{ code: ' * ', why: 'r' }],
|
||||
}];`
|
||||
);
|
||||
const r = run(['--watchlist', wl]);
|
||||
expect(r.status).toBe(2);
|
||||
expect(output(r)).toContain('anchor is ambiguous');
|
||||
});
|
||||
|
||||
it('ignores chunks whose maps do not carry the watched module', () => {
|
||||
// Attribution is by source map, so unrelated chunks must neither satisfy nor break an
|
||||
// entry. Without this, a build with many chunks could mask a real miss.
|
||||
healthyBaseline();
|
||||
const entry = COMPILED_BRANCH_WATCHLIST[0];
|
||||
const { control, required } = allLines(entry);
|
||||
chunk('chunks/unrelated.js', 'src/some/other/module.ts', [...control, ...required]);
|
||||
const r = run();
|
||||
expect(r.status).toBe(0);
|
||||
expect(output(r)).toContain(`emitted into 1 chunk(s)`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,350 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Compiled-branch gate.
|
||||
*
|
||||
* ---------------------------------------------------------------------------
|
||||
* What this exists to catch
|
||||
* ---------------------------------------------------------------------------
|
||||
* A bundler can emit a function whose body is not the body you wrote. Release 5.1.18
|
||||
* shipped `resolveStoreVisibilityScopeUninstrumented` as
|
||||
*
|
||||
* async function S(e){if(await p(e))return"full"}
|
||||
*
|
||||
* — the `public-external` grant and the fail-closed `return 'none'` were absent, so the
|
||||
* function fell off the end and produced `undefined` for every non-privileged caller.
|
||||
* A `?? 'full'` default downstream turned that into a full-catalog grant to anonymous
|
||||
* callers; a `?? 'none'` default on the other read path turned the same missing value
|
||||
* into an empty store for the cohort that was supposed to see something. See
|
||||
* civitai#3983.
|
||||
*
|
||||
* That defect is STRUCTURALLY INVISIBLE to everything else we run. `tsc` type-checks the
|
||||
* source. ESLint reads the source. Vitest imports the source module. All three were green
|
||||
* — the TypeScript is correct. Only the emitted artefact was wrong, and nothing looked at
|
||||
* it. This gate looks at it.
|
||||
*
|
||||
* ---------------------------------------------------------------------------
|
||||
* How it measures — and why not by grepping the emitted JS
|
||||
* ---------------------------------------------------------------------------
|
||||
* Grepping the bundle for `return"public-external"` is the obvious approach and it is a
|
||||
* trap in at least three ways on this codebase:
|
||||
*
|
||||
* 1. Minified names are per-chunk, and one source module is inlined into ~200 chunks,
|
||||
* so there is no stable symbol to anchor on.
|
||||
* 2. The literal `"public-external"` occurs ~481 times in the server build — every one
|
||||
* of them an element of the closed-set array `["full","public-external","none"]`,
|
||||
* never a return. A literal match says nothing about the position it matched in.
|
||||
* 3. The Flipt flag NAME `"app-listings-public-external"` CONTAINS the scope literal as
|
||||
* a substring, so a naive grep finds ~238 more "hits" that are a different string.
|
||||
*
|
||||
* So the gate reads the source maps instead. Every emitted chunk ships a `.js.map` whose
|
||||
* `sources` names the source modules inlined into it and whose `mappings` says which
|
||||
* source LINE each emitted token came from. A watched line either has a mapping somewhere
|
||||
* in the server output or it does not — decoy-free, immune to renaming, and immune to the
|
||||
* minifier collapsing `if (a) return x; return y;` into `return a?x:y`, because the
|
||||
* collapsed token still maps back to both source lines.
|
||||
*
|
||||
* This needs server source maps, which this repo emits in production
|
||||
* (`productionBrowserSourceMaps: true` → Turbopack's `turbopackSourceMaps`, which covers
|
||||
* `.next/server/**`). Same dependency as `check-server-graph-singletons.mjs`.
|
||||
*
|
||||
* ---------------------------------------------------------------------------
|
||||
* Why every entry carries a positive control
|
||||
* ---------------------------------------------------------------------------
|
||||
* A reassuring "not found" is indistinguishable from a scan wired to nothing. If a module
|
||||
* simply was not emitted — renamed, moved, tree-shaken out of the server graph entirely —
|
||||
* then EVERY watched line is unmapped and the gate would report a pile of violations that
|
||||
* are really one missing input. So each entry names a control line in the same function
|
||||
* that must ALSO be mapped. Control unmapped ⇒ exit 2 ("could not observe"), never exit 1
|
||||
* ("violation"). A gate that cannot see must not report health OR breakage.
|
||||
*
|
||||
* Anchors are exact source substrings, resolved to line numbers at run time, so an entry
|
||||
* cannot silently rot when the file is reformatted or code moves. An anchor that matches
|
||||
* zero or multiple lines is itself a hard error — an ambiguous anchor would otherwise
|
||||
* check whichever line it happened to land on.
|
||||
*
|
||||
* Usage: node scripts/assert-compiled-branches.mjs [--next-dir .next] [--json]
|
||||
* Exit: 0 = pass · 1 = a watched branch is absent from the output · 2 = the gate could
|
||||
* not run (no build, no source maps, unresolvable anchor, unobservable control)
|
||||
*/
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { COMPILED_BRANCH_WATCHLIST } from './compiled-branch-watchlist.mjs';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
function argValue(flag, fallback) {
|
||||
const i = args.indexOf(flag);
|
||||
return i !== -1 && args[i + 1] ? args[i + 1] : fallback;
|
||||
}
|
||||
const NEXT_DIR = argValue('--next-dir', process.env.NEXT_DIR || '.next');
|
||||
const REPO_ROOT = argValue('--repo-root', process.cwd());
|
||||
// Test-only seam. The suite needs to drive the "anchor matches no line" and "anchor is
|
||||
// ambiguous" branches, which the real watchlist must never contain — so it supplies its
|
||||
// own list instead of the gate growing a deliberately-broken entry to test against.
|
||||
const WATCHLIST_PATH = argValue('--watchlist', '');
|
||||
// Downgrade a VIOLATION (exit 1) to a loud report (exit 0). Deliberately does NOT
|
||||
// downgrade exit 2 — "the gate could not run" must still fail the build, or a gate that
|
||||
// silently stopped looking would read exactly like a gate that found nothing.
|
||||
const WARN_ONLY = args.includes('--warn-only');
|
||||
const SERVER_DIR = join(NEXT_DIR, 'server');
|
||||
const AS_JSON = args.includes('--json');
|
||||
|
||||
function die(code, message) {
|
||||
console.error(`compiled-branches: ${message}`);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
if (!existsSync(SERVER_DIR)) {
|
||||
die(2, `no server output at ${SERVER_DIR} — was \`next build\` run first?`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Base64 VLQ, the source-map segment encoding. Inlined rather than pulled from a
|
||||
// dependency: this runs inside the Docker build stage, where adding a runtime dep to the
|
||||
// gate means adding it to the image.
|
||||
// ---------------------------------------------------------------------------
|
||||
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
const CHARS = new Map([...B64].map((c, i) => [c, i]));
|
||||
|
||||
function decodeVLQ(segment) {
|
||||
const out = [];
|
||||
let shift = 0;
|
||||
let value = 0;
|
||||
for (const ch of segment) {
|
||||
const digit = CHARS.get(ch);
|
||||
if (digit === undefined) throw new Error(`invalid base64-VLQ character ${JSON.stringify(ch)}`);
|
||||
const hasContinuation = digit & 32;
|
||||
value += (digit & 31) << shift;
|
||||
if (hasContinuation) {
|
||||
shift += 5;
|
||||
continue;
|
||||
}
|
||||
const negative = value & 1;
|
||||
value >>= 1;
|
||||
out.push(negative ? (value === 0 ? -0x80000000 : -value) : value);
|
||||
value = 0;
|
||||
shift = 0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union of source lines that have at least one mapping, per source module, across every
|
||||
* emitted chunk. Only the modules we watch are retained — the full set is ~4,600 modules
|
||||
* and holding all of them costs memory for nothing.
|
||||
*/
|
||||
function collectMappedLines(mapJson, wantedModules) {
|
||||
const hits = new Map();
|
||||
const sourceIndexToModule = (mapJson.sources ?? []).map((raw) => {
|
||||
const normalised = String(raw).replace(/\\/g, '/');
|
||||
for (const m of wantedModules) if (normalised.includes(m)) return m;
|
||||
return null;
|
||||
});
|
||||
if (!sourceIndexToModule.some(Boolean)) return hits;
|
||||
|
||||
let sourceIndex = 0;
|
||||
let sourceLine = 0;
|
||||
for (const group of String(mapJson.mappings ?? '').split(';')) {
|
||||
if (!group) continue;
|
||||
for (const segment of group.split(',')) {
|
||||
if (!segment) continue;
|
||||
const fields = decodeVLQ(segment);
|
||||
if (fields.length < 4) continue; // generated-column-only segment: no source position
|
||||
sourceIndex += fields[1];
|
||||
sourceLine += fields[2];
|
||||
const mod = sourceIndexToModule[sourceIndex];
|
||||
if (!mod) continue;
|
||||
let set = hits.get(mod);
|
||||
if (!set) hits.set(mod, (set = new Set()));
|
||||
set.add(sourceLine + 1); // source maps are 0-based; humans and editors are 1-based
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an anchor (an exact source substring) to the 1-based line it occurs on.
|
||||
* Zero matches or more than one match is a hard error: an ambiguous anchor silently
|
||||
* checks a line the author did not mean.
|
||||
*/
|
||||
function resolveAnchor(sourceText, code) {
|
||||
const lines = sourceText.split('\n');
|
||||
const found = [];
|
||||
lines.forEach((line, i) => {
|
||||
if (line.includes(code)) found.push(i + 1);
|
||||
});
|
||||
if (found.length === 0) return { error: 'no line contains this anchor' };
|
||||
if (found.length > 1) return { error: `anchor is ambiguous — matches lines ${found.join(', ')}` };
|
||||
return { line: found[0] };
|
||||
}
|
||||
|
||||
async function walk(dir, out = []) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return out;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) await walk(full, out);
|
||||
else if (entry.name.endsWith('.js.map')) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let watched = COMPILED_BRANCH_WATCHLIST;
|
||||
if (WATCHLIST_PATH) {
|
||||
const mod = await import(new URL(`file://${WATCHLIST_PATH}`).href);
|
||||
watched = mod.COMPILED_BRANCH_WATCHLIST;
|
||||
if (!Array.isArray(watched))
|
||||
die(2, `--watchlist ${WATCHLIST_PATH} exports no COMPILED_BRANCH_WATCHLIST array`);
|
||||
}
|
||||
if (!watched.length) die(2, 'the watchlist is empty — this gate would examine nothing');
|
||||
|
||||
// ---- resolve every anchor against source BEFORE reading any map ----
|
||||
const entries = [];
|
||||
for (const entry of watched) {
|
||||
const abs = join(REPO_ROOT, entry.module);
|
||||
if (!existsSync(abs)) die(2, `[${entry.id}] source module not found: ${entry.module}`);
|
||||
const text = readFileSync(abs, 'utf8');
|
||||
const resolve = (a, kind) => {
|
||||
const r = resolveAnchor(text, a.code);
|
||||
if (r.error) die(2, `[${entry.id}] ${kind} anchor ${JSON.stringify(a.code)}: ${r.error}`);
|
||||
return { ...a, line: r.line };
|
||||
};
|
||||
if (!entry.control?.length) die(2, `[${entry.id}] has no control anchor — see the header`);
|
||||
if (!entry.required?.length) die(2, `[${entry.id}] has no required anchors`);
|
||||
entries.push({
|
||||
...entry,
|
||||
control: entry.control.map((a) => resolve(a, 'control')),
|
||||
required: entry.required.map((a) => resolve(a, 'required')),
|
||||
});
|
||||
}
|
||||
|
||||
const wantedModules = [...new Set(entries.map((e) => e.module))];
|
||||
|
||||
// ---- scan the emitted maps ----
|
||||
const mapFiles = await walk(SERVER_DIR);
|
||||
if (mapFiles.length === 0) {
|
||||
die(
|
||||
2,
|
||||
`found no .js.map under ${SERVER_DIR}. This gate reads source maps; a build without ` +
|
||||
`them cannot be checked, and a scan that can see nothing must not report health.`
|
||||
);
|
||||
}
|
||||
|
||||
const mapped = new Map(wantedModules.map((m) => [m, new Set()]));
|
||||
const chunkCount = new Map(wantedModules.map((m) => [m, 0]));
|
||||
let unreadable = 0;
|
||||
for (const file of mapFiles) {
|
||||
let json;
|
||||
try {
|
||||
json = JSON.parse(readFileSync(file, 'utf8'));
|
||||
} catch {
|
||||
unreadable++;
|
||||
continue;
|
||||
}
|
||||
const hits = collectMappedLines(json, wantedModules);
|
||||
for (const [mod, lines] of hits) {
|
||||
chunkCount.set(mod, chunkCount.get(mod) + 1);
|
||||
const set = mapped.get(mod);
|
||||
for (const l of lines) set.add(l);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- verdict ----
|
||||
const violations = [];
|
||||
const report = [];
|
||||
for (const entry of entries) {
|
||||
const lines = mapped.get(entry.module);
|
||||
const chunks = chunkCount.get(entry.module);
|
||||
|
||||
const deadControls = entry.control.filter((a) => !lines.has(a.line));
|
||||
if (deadControls.length) {
|
||||
die(
|
||||
2,
|
||||
`[${entry.id}] CANNOT OBSERVE. ${entry.module} was found in ${chunks} emitted chunk(s), ` +
|
||||
`but its control anchor is not represented in any of them:\n` +
|
||||
deadControls.map((a) => ` line ${a.line}: ${a.code}`).join('\n') +
|
||||
`\n The control is the branch that is known to survive. If it is missing, this gate ` +
|
||||
`is looking at a build that did not emit this function — a stale .next, a moved file, ` +
|
||||
`or a module dropped from the server graph — not at a violation. Fix the input, or ` +
|
||||
`update the watchlist entry if the code genuinely moved.`
|
||||
);
|
||||
}
|
||||
|
||||
const missing = entry.required.filter((a) => !lines.has(a.line));
|
||||
report.push({
|
||||
id: entry.id,
|
||||
module: entry.module,
|
||||
chunks,
|
||||
required: entry.required.length,
|
||||
missing: missing.map((a) => ({ line: a.line, code: a.code, why: a.why })),
|
||||
});
|
||||
if (missing.length) violations.push({ entry, missing });
|
||||
}
|
||||
|
||||
if (AS_JSON) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{ ok: violations.length === 0, chunksScanned: mapFiles.length, report },
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
} else {
|
||||
console.log(`compiled-branches: scanned ${mapFiles.length} source maps under ${SERVER_DIR}`);
|
||||
if (unreadable) console.log(`compiled-branches: ${unreadable} map(s) unreadable (skipped)`);
|
||||
for (const r of report) {
|
||||
const verdict = r.missing.length
|
||||
? `✗ ${r.missing.length}/${r.required} MISSING`
|
||||
: `✓ ${r.required}/${r.required}`;
|
||||
console.log(` ${verdict} ${r.id} (${r.module}, emitted into ${r.chunks} chunk(s))`);
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length) {
|
||||
console.error('');
|
||||
console.error(
|
||||
'compiled-branches: A SECURITY-RELEVANT BRANCH IS ABSENT FROM THE COMPILED OUTPUT.'
|
||||
);
|
||||
console.error('');
|
||||
for (const { entry, missing } of violations) {
|
||||
console.error(` ${entry.id} — ${entry.module}`);
|
||||
console.error(` ${entry.why}`);
|
||||
for (const a of missing) {
|
||||
console.error(` MISSING line ${a.line}: ${a.code}`);
|
||||
console.error(` ${a.why}`);
|
||||
}
|
||||
console.error('');
|
||||
}
|
||||
console.error(
|
||||
'The TypeScript for these branches is correct — that is the point. No source-level test\n' +
|
||||
'can see this, because every one of them exercises the TypeScript. The emitted server\n' +
|
||||
'chunk does not contain the branch, so at runtime it does not happen.\n' +
|
||||
'\n' +
|
||||
'Do NOT silence this by editing the watchlist. Read the emitted chunk, confirm what the\n' +
|
||||
'function actually became, and treat it as a build defect (civitai#3983 is the worked\n' +
|
||||
'example). Only remove an entry when the code it names is genuinely gone.'
|
||||
);
|
||||
if (WARN_ONLY) {
|
||||
console.error('');
|
||||
console.error(
|
||||
'compiled-branches: --warn-only is set, so this is NOT failing the build. That flag exists\n' +
|
||||
'for exactly one situation — a KNOWN, tracked, currently-unfixed bundler defect that would\n' +
|
||||
'otherwise make every production build red. Remove it the moment the branch above is back\n' +
|
||||
'in the output; a gate that is permanently red and permanently ignored is worse than none.'
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`compiled-branches: OK — every watched branch is represented in the emitted output`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => die(2, `unexpected failure: ${err?.stack || err}`));
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Security-relevant branches that must survive into the COMPILED server output.
|
||||
*
|
||||
* WHY THIS EXISTS
|
||||
* ---------------
|
||||
* A bundler can emit a function whose body is not the body you wrote. On the build that
|
||||
* shipped release 5.1.18, `resolveStoreVisibilityScopeUninstrumented` in
|
||||
* `src/server/services/app-blocks-flag.ts` was emitted as
|
||||
*
|
||||
* async function S(e){if(await p(e))return"full"}
|
||||
*
|
||||
* — two of its three `return`s were gone, so it fell off the end and produced `undefined`
|
||||
* for every non-privileged caller. One missing value, two `??` defaults pointing opposite
|
||||
* ways: the REST listing service defaulted it to `'full'` and served the whole catalog to
|
||||
* anonymous callers, while the tRPC procedures defaulted it to `'none'` and showed the
|
||||
* cohort an empty store.
|
||||
*
|
||||
* Nothing else we run can see this. The TypeScript is correct, so `tsc` is green; ESLint
|
||||
* reads source; Vitest imports the source module, not the emitted chunk. A 75-test unit
|
||||
* suite, an integration suite driving the real feature-flag client, and four rounds of
|
||||
* review were all STRUCTURALLY incapable of catching it — every one of them exercises the
|
||||
* TypeScript. Only the emitted artefact knows, and it does not complain.
|
||||
*
|
||||
* WHY THIS IS ITS OWN FILE
|
||||
* ------------------------
|
||||
* Two consumers need this list: `assert-compiled-branches.mjs`, which enforces it against
|
||||
* a real build, and `__tests__/assert-compiled-branches.test.ts`, which drives the gate
|
||||
* over synthetic builds. Mirrors `server-graph-watchlist.mjs` for the same reason: one
|
||||
* list, one place, so a new entry is proven satisfiable by the suite rather than breaking
|
||||
* its positive control.
|
||||
*
|
||||
* HOW AN ENTRY IS CHECKED
|
||||
* -----------------------
|
||||
* Not by grepping the emitted JS. Minified names differ per chunk, the same module is
|
||||
* inlined into ~200 of them, and a returned literal is indistinguishable from the same
|
||||
* string in an array — `["full","public-external","none"]` occurs ~481 times in this
|
||||
* build and none of them is a return. Instead the gate reads the emitted `.js.map`
|
||||
* `sources`/`mappings`: an anchor's SOURCE LINE either has a mapping somewhere in the
|
||||
* server output or it does not. That is decoy-free, survives renaming, and survives the
|
||||
* minifier collapsing `if (a) return x; return y;` into a ternary — the collapsed token
|
||||
* still maps back to both source lines.
|
||||
*
|
||||
* WRITING AN ENTRY
|
||||
* ----------------
|
||||
* module Repo-relative source path.
|
||||
* required Anchors that MUST be represented in the output. Each `code` is an exact,
|
||||
* unique substring of a line in that file — the gate resolves it to a line
|
||||
* number at run time, so the entry cannot rot when the file moves around.
|
||||
* control Anchors in the SAME function that must ALSO be mapped. These are the
|
||||
* positive control: if the control is unmapped the gate reports that it could
|
||||
* not observe the function at all (exit 2) instead of claiming a violation.
|
||||
* Without one, a module that simply was not emitted reads as N violations.
|
||||
*
|
||||
* Keep this list SMALL and justified — a fail-closed branch whose loss changes who can
|
||||
* see what. Every entry must say what goes wrong when the branch disappears.
|
||||
*/
|
||||
export const COMPILED_BRANCH_WATCHLIST = [
|
||||
{
|
||||
id: 'store-visibility-scope',
|
||||
module: 'src/server/services/app-blocks-flag.ts',
|
||||
why: 'The App-store read-path scope resolver. Losing the axis-2 grant or the fail-closed default makes the function return `undefined`, which the read paths then default in OPPOSITE directions — the whole catalog to anonymous callers on one side, an empty store on the other. This is the exact shape that shipped in release 5.1.18 (civitai#3983).',
|
||||
control: [
|
||||
{
|
||||
code: "if (await isAppListingsEnabled(opts)) return 'full';",
|
||||
why: 'axis 1 — the branch that DID survive; if this is unmapped the gate is looking at a build that never emitted this function',
|
||||
},
|
||||
],
|
||||
required: [
|
||||
{
|
||||
code: "if (await isExternalListingsPublicEnabled(opts)) return 'public-external';",
|
||||
why: 'axis 2 — the external-only grant. Lost, the cohort resolves no scope at all.',
|
||||
},
|
||||
{
|
||||
code: "return 'none';",
|
||||
why: "the fail-closed default. Lost, the function falls off the end and returns `undefined`, which a `?? 'full'` default upstream turns into a full-catalog grant.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user