Files
stack/scripts/gate-inventory-shrink-control.mjs

120 lines
4.3 KiB
JavaScript

#!/usr/bin/env node
import assert from 'node:assert/strict';
import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
const root = process.cwd();
const removedGateId = 'hook-pre-push';
const inventoryEntry = " ['hook-pre-push', '.husky/pre-push'],\n";
function shrinkManifest(manifest) {
const removedGate = manifest.gates.find((gate) => gate.id === removedGateId);
assert.ok(removedGate);
const removedCaseRefs = new Set(
removedGate.cases.map((gateCase) => `${removedGateId}/${gateCase.id}`),
);
const removedCriterionIds = new Set(
manifest.criteria
.filter(
(criterion) =>
criterion.caseRefs.length > 0 &&
criterion.caseRefs.every((caseRef) => removedCaseRefs.has(caseRef)),
)
.map((criterion) => criterion.id),
);
manifest.gates = manifest.gates.filter((gate) => gate.id !== removedGateId);
manifest.criteria = manifest.criteria
.filter((criterion) => !removedCriterionIds.has(criterion.id))
.map((criterion) => ({
...criterion,
caseRefs: criterion.caseRefs.filter((caseRef) => !removedCaseRefs.has(caseRef)),
...(criterion.gateRefs
? { gateRefs: criterion.gateRefs.filter((gateId) => gateId !== removedGateId) }
: {}),
}));
manifest.proseClaims = manifest.proseClaims.filter(
(claim) => !removedCriterionIds.has(claim.criterionId) && !removedCaseRefs.has(claim.caseRef),
);
manifest.compatibilityScenarios = manifest.compatibilityScenarios
.map((scenario) => ({
...scenario,
caseRefs: scenario.caseRefs.filter((caseRef) => !removedCaseRefs.has(caseRef)),
}))
.filter((scenario) => scenario.caseRefs.length > 0);
for (const gate of manifest.gates) {
for (const gateCase of gate.cases) {
gateCase.criterionIds = gateCase.criterionIds.filter(
(criterionId) => !removedCriterionIds.has(criterionId),
);
}
}
}
async function attack(mode) {
const fixture = await mkdtemp(path.join(os.tmpdir(), `gate-inventory-${mode}-`));
try {
await mkdir(path.join(fixture, 'scripts'), { recursive: true });
await mkdir(path.join(fixture, 'gates'), { recursive: true });
const source = await readFile(path.join(root, 'scripts', 'gate-verify.mjs'), 'utf8');
if (source.split(inventoryEntry).length - 1 !== 1) {
throw new Error('source inventory fixture drifted');
}
await writeFile(
path.join(fixture, 'scripts', 'gate-verify.mjs'),
mode === 'source-manifest' ? source.replace(inventoryEntry, '') : source,
);
const baseline = JSON.parse(
await readFile(path.join(root, 'gates', 'required-gates.baseline.json'), 'utf8'),
);
if (mode === 'baseline-manifest') {
baseline.gates = baseline.gates.filter((gate) => gate.id !== removedGateId);
}
await writeFile(
path.join(fixture, 'gates', 'required-gates.baseline.json'),
`${JSON.stringify(baseline)}\n`,
);
const manifest = JSON.parse(
await readFile(path.join(root, 'gates', 'gates.manifest.json'), 'utf8'),
);
shrinkManifest(manifest);
await writeFile(
path.join(fixture, 'gates', 'gates.manifest.json'),
`${JSON.stringify(manifest)}\n`,
);
const result = spawnSync(
process.execPath,
[
path.join(fixture, 'scripts', 'gate-verify.mjs'),
'--root',
fixture,
'--manifest',
'gates/gates.manifest.json',
'--structure-only',
],
{ cwd: fixture, encoding: 'utf8' },
);
const combined = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
return (
result.status !== 0 &&
new RegExp(`(?:baseline|verifier inventory).*${removedGateId}`, 'i').test(combined)
);
} finally {
await rm(fixture, { recursive: true, force: true });
}
}
const sourceManifestRejected = await attack('source-manifest');
const baselineManifestRejected = await attack('baseline-manifest');
if (sourceManifestRejected && baselineManifestRejected) {
process.stderr.write(
'INVENTORY_SHRINK_REJECTED: source+manifest and baseline+manifest shrink attacks detected\n',
);
process.exit(83);
}
process.stdout.write(
`inventory shrink attack escaped: source-manifest=${sourceManifestRejected} baseline-manifest=${baselineManifestRejected}\n`,
);