120 lines
4.3 KiB
JavaScript
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`,
|
|
);
|