morphit/scripts/operations-hardening-smoke.ts

233 lines
9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env tsx
/**
* scripts/operations-hardening-smoke.ts
*
* Verify OPERATIONS.md §37 (comprehensive server hardening)
* stays internally cohesive:
*
* 1. Subsections numbered §37.1 through §37.N exist with no gaps.
* 2. Every cross-reference (§37.X) resolves to an existing
* subsection.
* 3. Every cross-reference to other top-level sections (§5, §34,
* etc.) resolves to a section that actually exists in
* OPERATIONS.md or RUN-A-MORPHIT-NODE.md.
* 4. The final checklist table at §37.18 references each of
* 37.1 through 37.17 exactly once (so the operator gets a
* summary that doesn't silently drop a defense layer).
*
* This guards against the most likely regression mode: someone
* adds a §37.X subsection mid-doc but forgets to update the
* cross-reference table at the bottom, leaving the operator
* unaware of a hardening step.
*/
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const REPO = join(HERE, '..');
const OPS = join(REPO, 'docs/OPERATIONS.md');
const RUN = join(REPO, 'docs/RUN-A-MORPHIT-NODE.md');
const failures: string[] = [];
const ops = readFileSync(OPS, 'utf8');
const run = readFileSync(RUN, 'utf8');
console.log('\n── operations-hardening smoke ──────────────────────────\n');
// ─── Find §37 subsection headings ────────────────────────────────
const subsections = new Set<number>();
for (const m of ops.matchAll(/^### 37\.(\d+) /gm)) {
subsections.add(Number(m[1]!));
}
const subsectionNumbers = [...subsections].sort((a, b) => a - b);
console.log(` §37 subsections found: ${subsectionNumbers.length}`);
if (subsectionNumbers.length === 0) {
failures.push('§37 has no `### 37.X` subsections');
} else {
const max = Math.max(...subsectionNumbers);
const min = Math.min(...subsectionNumbers);
if (min !== 1) {
failures.push(`§37 subsections start at 37.${min}, not 37.1`);
}
for (let i = min; i <= max; i++) {
if (!subsections.has(i)) {
failures.push(`§37 has gap: missing §37.${i}`);
}
}
}
// ─── Cross-references inside OPERATIONS.md ───────────────────────
// Every "§37.X" citation in §37's body must point to a subsection
// that exists.
for (const m of ops.matchAll(/§37\.(\d+)/g)) {
const target = Number(m[1]!);
if (!subsections.has(target)) {
failures.push(`OPERATIONS.md references §37.${target}, which doesn't exist`);
}
}
for (const m of run.matchAll(/§37\.(\d+)/g)) {
const target = Number(m[1]!);
if (!subsections.has(target)) {
failures.push(
`RUN-A-MORPHIT-NODE.md references §37.${target}, which doesn't exist`
);
}
}
// ─── Final checklist table coverage ──────────────────────────────
// The §37.18 table is a summary that should reference every
// §37.1§37.17 at least once. This catches the regression where
// someone adds §37.X but forgets the table.
const tableStart = ops.search(/^### 37\.18 /m);
if (tableStart === -1) {
failures.push('§37.18 final-checklist table is missing');
} else {
// Take from §37.18 to the next `## ` (top-level section) or end of file.
const remainder = ops.slice(tableStart);
const nextTopMatch = remainder.search(/\n## /);
const tableBody = nextTopMatch === -1 ? remainder : remainder.slice(0, nextTopMatch);
for (const n of subsectionNumbers) {
if (n >= 18) continue; // table itself; checklist references go up to 17
if (!new RegExp(`37\\.${n}\\b`).test(tableBody)) {
failures.push(
`§37.18 final checklist doesn't reference §37.${n}` +
`a defense layer is missing from the operator's summary table.`
);
}
}
}
// ─── Cross-references to other OPERATIONS.md sections ────────────
// Find every "§N" reference in §37 and confirm §N is a real
// top-level section.
const opsSectionNums = new Set<number>();
for (const m of ops.matchAll(/^## (\d+)\. /gm)) {
opsSectionNums.add(Number(m[1]!));
}
const sec37Match = ops.match(/^## 37\. [\s\S]*$/m);
if (sec37Match) {
for (const m of sec37Match[0].matchAll(/§(\d+)\b(?!\.\d)/g)) {
const target = Number(m[1]!);
if (target === 37) continue;
if (!opsSectionNums.has(target)) {
// May reference RUN-A-MORPHIT-NODE.md sections — those
// are numbered separately; we only flag if neither doc
// has it.
const runHas = new RegExp(`^## ${target}\\. `, 'm').test(run);
if (!runHas) {
failures.push(
`§37 references §${target}, which isn't a real section in ` +
`OPERATIONS.md or RUN-A-MORPHIT-NODE.md`
);
}
}
}
}
// ─── Required hardening categories must exist ────────────────────
// Lock in the major defense layers — if someone deletes a whole
// subsection, this surfaces it.
const REQUIRED_KEYWORDS_PER_LAYER: Array<[string, string]> = [
['SSH hardening', 'PasswordAuthentication no'],
['Unattended upgrades', 'unattended-upgrades'],
['Kernel sysctl', 'kptr_restrict'],
['Mount hardening', 'nosuid,nodev,noexec'],
['Systemd hardening', 'ProtectSystem'],
['auditd', 'auditd'],
['Postgres SCRAM', 'scram-sha-256'],
['Postgres statement_timeout', 'statement_timeout'],
['Filesystem integrity (AIDE)', 'aide'],
['Secrets perms', '0600'],
['Disk encryption', 'LUKS'],
['Backup encryption', 'age'],
['Outbound network policy', 'default deny outgoing'],
['Operator alerting', 'msmtp'],
['Rootkit scanner', 'rkhunter'],
['GRUB password', 'grub-mkpasswd'],
['Password discipline', 'pwquality']
];
for (const [layer, keyword] of REQUIRED_KEYWORDS_PER_LAYER) {
if (!ops.toLowerCase().includes(keyword.toLowerCase())) {
failures.push(
`Hardening layer "${layer}" missing — keyword "${keyword}" not found in OPERATIONS.md`
);
}
}
// ─── §38 squatter-defense playbook cohesion ──────────────────────
// §38 must reference all six Layer 7 / Layer 8 environment
// knobs by name AND the diamond-hardened preset. If someone
// removes one of these, the operator's tactical guide silently
// loses a defense.
const sec38Start = ops.search(/^## 38\. /m);
if (sec38Start === -1) {
failures.push('§38 squatter-defense playbook is missing');
} else {
const remainder = ops.slice(sec38Start);
const next = remainder.search(/\n## /);
const sec38 = next === -1 ? remainder : remainder.slice(0, next);
const REQUIRED_SQUATTER_KEYS = [
'MORPHIT_RELAY_HIGHVALUE_NAME_POLICY',
'MORPHIT_RELAY_HIGHVALUE_SHORT_NAME_THRESHOLD',
'MORPHIT_RELAY_SEQUENTIAL_DETECTOR_ENABLED',
'MORPHIT_RELAY_SEQUENTIAL_THRESHOLD',
'MORPHIT_RELAY_SEQUENTIAL_WINDOW_MS',
'MORPHIT_RELAY_SEQUENTIAL_MIN_PREFIX',
'highvalue_name_rejected', // log event name
'sequential_pattern_rejected', // log event name
'DIAMOND-HARDENED' // the preset must be present
];
for (const key of REQUIRED_SQUATTER_KEYS) {
if (!sec38.includes(key)) {
failures.push(
`§38 squatter playbook missing required reference: ${key}`
);
}
}
}
// ─── §32 BunkerWeb trusted-proxy section ─────────────────────────
// The compatibility advice for BunkerWeb deployments must
// remain present; an operator skimming §32 should immediately
// see the security-critical trusted-proxy guidance.
const sec32Start = ops.search(/^## 32\. /m);
if (sec32Start === -1) {
failures.push('§32 BunkerWeb section is missing');
} else {
const remainder = ops.slice(sec32Start);
const next = remainder.search(/\n## /);
const sec32 = next === -1 ? remainder : remainder.slice(0, next);
if (!sec32.includes('MORPHIT_RELAY_TRUSTED_PROXY_IPS')) {
failures.push(
'§32 BunkerWeb section missing MORPHIT_RELAY_TRUSTED_PROXY_IPS guidance'
);
}
if (!sec32.includes('Docker bridge')) {
failures.push('§32 BunkerWeb section missing Docker-bridge advice');
}
if (!/SECURITY WARNING/i.test(sec32)) {
failures.push(
'§32 BunkerWeb section missing security warning about overly-broad CIDRs'
);
}
}
// ─── Report ──────────────────────────────────────────────────────
if (failures.length > 0) {
console.log(`\n ✗ ${failures.length} issue(s):`);
for (const f of failures) console.log(` - ${f}`);
console.log('\n──────────────────────────────────────────────────────');
console.log(`${failures.length}/${failures.length} scenarios failed`);
process.exit(1);
} else {
console.log(
` ✓ §37 has ${subsectionNumbers.length} subsections, ` +
`all cross-references resolve, all defense layers present`
);
console.log('\n──────────────────────────────────────────────────────');
console.log('✓ all 1 scenarios passed');
}