morphit/apps/matrix-bot/scripts/render-alert-hardening-smoke.ts

174 lines
5.5 KiB
TypeScript

#!/usr/bin/env tsx
/**
* render-alert-hardening-smoke — regression test for cp18 AUDIT-2,
* AUDIT-3, AUDIT-4: matrix-bot's renderAlertBody must defend
* against attacker-influenced payload content reaching Matrix.
*
* Threat model: an attacker who can write attacker-controlled
* strings into a sidecar's structured-log payload (the cp18
* AUDIT-1 fix blocks newline-injection forging, but doesn't
* sanitize the CONTENT of legitimate raw_line fields). Possible
* via:
* - process `comm` names from OOM-kill messages
* - container names + image tags from Docker
* - apt package names from third-party PPAs
* - mount paths from FUSE filesystems
*
* The cp18 defenses:
* AUDIT-2 — strip C0 control chars (terminal escape sequences
* could clear the operator's terminal when viewing
* journalctl directly)
* AUDIT-3 — defang Matrix mxid + room-alias patterns by
* inserting ZWJ after the sigil (no pill render in
* Matrix clients — strings like `@victim:matrix.org`
* in raw_line don't ping random users)
* AUDIT-4 — cap per-field + total payload size so a compromised
* sidecar can't DoS the bot with mega-payloads
* (Matrix message size limit ~65KB)
*/
import { renderAlertBody } from '../src/classifier.ts';
import type { ClassifiedAlert } from '../src/classifier.ts';
interface Scenario {
readonly name: string;
readonly alert: ClassifiedAlert;
/** Assertions to apply against the rendered plain+html. */
readonly assert: (rendered: { plain: string; html: string }) => string | null;
}
function alert(payload: Record<string, unknown>): ClassifiedAlert {
return {
tier: 'WARN',
category: 'host-resource',
alert: {
module: 'dmesg',
event: 'oom_kill',
payload,
source: 'morphit-dmesg-monitor.service',
ts: '2026-05-15T00:00:00Z'
}
};
}
const scenarios: Scenario[] = [
{
name: 'AUDIT-2: ESC char in payload is stripped (no terminal injection)',
alert: alert({ victim_proc: 'evil\x1b[2J\x1b[H' }),
assert: (r) => {
if (r.plain.includes('\x1b')) return 'plain body still contains ESC byte';
if (r.html.includes('\x1b')) return 'html body still contains ESC byte';
return null;
}
},
{
name: 'AUDIT-2: NUL + bell + form-feed stripped',
alert: alert({ raw_line: 'msg\x00bell\x07ff\x0cend' }),
assert: (r) => {
if (/[\x00\x07\x0c]/.test(r.plain)) return 'plain body has unstripped control char';
return null;
}
},
{
name: 'AUDIT-2: tab and newline ARE preserved (legitimate formatting)',
alert: alert({ raw_line: 'line1\nline2\tcolumn' }),
assert: (r) => {
if (!r.plain.includes('line1\nline2\tcolumn'))
return 'plain body should preserve tab and newline in raw_line';
return null;
}
},
{
name: 'AUDIT-3: Matrix mxid is defanged with ZWJ',
alert: alert({ raw_line: 'ping @victim:matrix.org maybe' }),
assert: (r) => {
// Bare mxid should NOT appear; defanged form should
// (contains ZWJ between @ and victim).
if (r.plain.includes('@victim:matrix.org'))
return 'plain body still contains bare mxid (no ZWJ defang)';
if (!r.plain.includes('@\u200dvictim:matrix.org'))
return 'plain body missing the defanged mxid form';
return null;
}
},
{
name: 'AUDIT-3: Matrix room alias is defanged with ZWJ',
alert: alert({ raw_line: 'announce in #general:matrix.org' }),
assert: (r) => {
if (r.plain.includes('#general:matrix.org'))
return 'plain body still contains bare room alias';
if (!r.plain.includes('#\u200dgeneral:matrix.org'))
return 'plain body missing defanged room alias';
return null;
}
},
{
name: 'AUDIT-4: huge per-field gets truncated to ~1KB',
alert: alert({ huge: 'x'.repeat(50_000) }),
assert: (r) => {
// Plain body should contain "…(truncated)" somewhere.
if (!r.plain.includes('…(truncated)'))
return 'plain body should mark truncation';
// And total plain length should be well under 50KB even
// though one field was 50KB.
if (r.plain.length > 12_000)
return `plain body too large: ${r.plain.length} bytes`;
return null;
}
},
{
name: 'AUDIT-4: many-field payload gets capped overall',
alert: alert(
Object.fromEntries(
Array.from({ length: 200 }, (_, i) => [`f${i}`, 'x'.repeat(500)])
)
),
assert: (r) => {
if (r.plain.length > 12_000)
return `plain body too large: ${r.plain.length} bytes`;
if (!r.plain.includes('…(payload truncated)'))
return 'plain body should mark overall truncation';
return null;
}
},
{
name: 'AUDIT-2+3 combined: attacker payload with control char AND mxid',
alert: alert({
raw_line: 'msg\x1bbypass and @target:matrix.org'
}),
assert: (r) => {
if (r.plain.includes('\x1b')) return 'plain has ESC';
if (r.plain.includes('@target:matrix.org'))
return 'plain has bare mxid';
return null;
}
}
];
console.log(`render-alert-hardening smoke: ${scenarios.length} scenarios\n`);
let failed = 0;
for (const s of scenarios) {
let rendered: { plain: string; html: string };
try {
rendered = renderAlertBody(s.alert);
} catch (err) {
console.log(`${s.name}: renderAlertBody threw: ${err}`);
failed++;
continue;
}
const fail = s.assert(rendered);
if (fail === null) {
console.log(`${s.name}`);
} else {
console.log(`${s.name}: ${fail}`);
failed++;
}
}
console.log('');
if (failed === 0) {
console.log(`✓ all ${scenarios.length} render-alert-hardening checks hold`);
process.exit(0);
}
console.error(`${failed} failed, ${scenarios.length - failed} passed`);
process.exit(1);