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:
Zachary Lowden
2026-08-18 10:58:55 -05:00
committed by GitHub
parent b8265bd17d
commit 8dd728eabe
4 changed files with 770 additions and 0 deletions
+29
View File
@@ -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)`);
});
});
+350
View File
@@ -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}`));
+79
View File
@@ -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.",
},
],
},
];