/** * §10 — what the audit says about a firewall's interfaces. * * The condition that produced `fw-keeper.sh` was not that celilo lacked * information; it was that celilo said nothing. So the load-bearing assertions * here are about what gets NAMED — an alien interface by name AND address, a * carrier leg, an ambiguous edge — and about §10.2's converse: a fully declared * firewall must produce no findings at all, or the report trains the operator to * ignore it. */ import { describe, expect, test } from 'bun:test'; import { type FirewallInterfaceView, auditFirewallInterfaces, auditInterfaceClassification, describeClassification, } from './interface-classification'; const DECLARED = [ { zone: 'internal', subnet: '192.168.0.0/24' }, { zone: 'dmz', subnet: '10.0.10.0/24' }, { zone: 'app', subnet: '10.0.20.0/24' }, { zone: 'secure', subnet: '10.0.30.0/24' }, ]; /** celilo's own firewall: five RFC1918 legs, four declared, wg0 not. */ function liveFleetShape(overrides: Partial = {}): FirewallInterfaceView { return { hostname: 'fw-main', interfaces: [ { name: 'eth0', ip: '192.168.0.254' }, { name: 'eth1', ip: '10.0.10.1' }, { name: 'eth2', ip: '10.0.20.1' }, { name: 'eth3', ip: '10.0.30.1' }, { name: 'wg0', ip: '10.255.255.1' }, ], zones: DECLARED, defaultRouteInterface: 'eth0', ...overrides, }; } function codes(findings: { code: string }[]): string[] { return findings.map((f) => f.code); } describe('§10.2 — a fully declared firewall produces NO interface findings', () => { test('every leg declared, default route on internal, no findings', () => { // The positive control, and not a formality: a check that cries wolf on a // healthy fleet is worse than no check, because it teaches an operator to // ignore the one signal that matters. const view = liveFleetShape({ zones: [...DECLARED, { zone: 'control-plane-vpn', subnet: '10.255.255.0/24' }], }); expect(auditFirewallInterfaces(view)).toEqual([]); }); test('a firewall that owns its WAN, fully declared, produces no findings', () => { const view: FirewallInterfaceView = { hostname: 'vps-fw', interfaces: [ { name: 'eth0', ip: '10.0.10.1' }, { name: 'eth1', ip: '203.0.113.100' }, ], zones: [{ zone: 'dmz', subnet: '10.0.10.0/24' }], defaultRouteInterface: 'eth1', }; expect(auditFirewallInterfaces(view)).toEqual([]); }); test('loopback does not produce a finding', () => { const view = liveFleetShape({ interfaces: [{ name: 'lo', ip: '127.0.0.1' }, ...liveFleetShape().interfaces.slice(0, 4)], defaultRouteInterface: 'eth0', }); expect(auditFirewallInterfaces(view)).toEqual([]); }); }); describe('§10.1 — alien interfaces are named, by name AND address', () => { const findings = auditFirewallInterfaces(liveFleetShape()); test('the undeclared VPN leg is reported', () => { expect(codes(findings)).toContain('alien_interfaces'); }); test('the message carries both the interface name and its address', () => { // "one alien interface" is useless at 3am. The operator needs to know // which, and where. const alien = findings.find((f) => f.code === 'alien_interfaces'); expect(alien?.message).toContain('wg0'); expect(alien?.message).toContain('10.255.255.1'); }); test('it is drift, not blocked — the converge decides, the audit reports', () => { expect(findings.find((f) => f.code === 'alien_interfaces')?.severity).toBe('drift'); }); test('it is subjected on the hostname, never a UUID', () => { // Suppression resolves a machine's ancestor key from its hostname; a // finding subjected on a UUID produces an alert key nothing can match // (#596). Users never see UUIDs either. expect(findings.find((f) => f.code === 'alien_interfaces')?.subject).toBe('fw-main'); }); test('several alien legs are all named, not just the first', () => { const view = liveFleetShape({ zones: [{ zone: 'internal', subnet: '192.168.0.0/24' }] }); const alien = auditFirewallInterfaces(view).find((f) => f.code === 'alien_interfaces'); for (const ip of ['10.0.10.1', '10.0.20.1', '10.0.30.1', '10.255.255.1']) { expect(alien?.message).toContain(ip); } }); }); describe('§10.1 — the blocking findings match what the converge does', () => { test('a carrier-grade NAT leg blocks, naming the interface and address', () => { const view = liveFleetShape({ interfaces: [...liveFleetShape().interfaces, { name: 'eth9', ip: '100.83.4.17' }], zones: [...DECLARED, { zone: 'control-plane-vpn', subnet: '10.255.255.0/24' }], }); const finding = auditFirewallInterfaces(view).find( (f) => f.code === 'carrier_grade_nat_interface', ); expect(finding?.severity).toBe('blocked'); expect(finding?.message).toContain('eth9'); expect(finding?.message).toContain('100.83.4.17'); }); test('two public legs with no designation block, naming both candidates', () => { const view: FirewallInterfaceView = { hostname: 'fw-two-wans', interfaces: [ { name: 'eth0', ip: '203.0.113.100' }, { name: 'eth1', ip: '198.51.100.7' }, ], zones: [], defaultRouteInterface: 'eth0', }; const finding = auditFirewallInterfaces(view).find((f) => f.code === 'ambiguous_external_edge'); expect(finding?.severity).toBe('blocked'); expect(finding?.message).toContain('203.0.113.100'); expect(finding?.message).toContain('198.51.100.7'); expect(finding?.remediation).toContain('zone.external.ip'); }); test('a designated edge resolves the ambiguity, producing no finding', () => { const view: FirewallInterfaceView = { hostname: 'fw-two-wans', interfaces: [ { name: 'eth0', ip: '203.0.113.100' }, { name: 'eth1', ip: '198.51.100.7' }, ], zones: [], defaultRouteInterface: 'eth0', designatedExternalIp: '198.51.100.7', }; expect(codes(auditFirewallInterfaces(view))).not.toContain('ambiguous_external_edge'); }); test('a default route on a segmented zone blocks', () => { const view = liveFleetShape({ zones: [...DECLARED, { zone: 'control-plane-vpn', subnet: '10.255.255.0/24' }], defaultRouteInterface: 'eth1', // the dmz leg }); const finding = auditFirewallInterfaces(view).find((f) => f.code === 'default_route_off_edge'); expect(finding?.severity).toBe('blocked'); expect(finding?.message).toContain('eth1'); }); test('an unknown default route is not checked rather than guessed', () => { // `undefined` means celilo did not read the routing table. Reporting a // finding from an absent measurement would be inventing evidence. const view = liveFleetShape({ zones: [...DECLARED, { zone: 'control-plane-vpn', subnet: '10.255.255.0/24' }], defaultRouteInterface: undefined, }); expect(codes(auditFirewallInterfaces(view))).not.toContain('default_route_off_edge'); }); test('blocking findings come before drift, so the report reads top-down', () => { const view = liveFleetShape({ interfaces: [...liveFleetShape().interfaces, { name: 'eth9', ip: '100.83.4.17' }], }); const severities = auditFirewallInterfaces(view).map((f) => f.severity); expect(severities[0]).toBe('blocked'); expect(severities.at(-1)).toBe('drift'); }); }); describe('describeClassification — the picture, not a finding', () => { test('names the role of every interface, and why an alien one is alien', () => { const lines = describeClassification(liveFleetShape()); expect(lines).toContain('eth0: 192.168.0.254 → zone:internal'); expect(lines).toContain('wg0: 10.255.255.1 → alien (no declared subnet contains it)'); }); test('a publicly routable but unclaimed leg is described as external', () => { const lines = describeClassification({ hostname: 'vps-fw', interfaces: [{ name: 'eth1', ip: '203.0.113.100' }], zones: [], }); expect(lines[0]).toBe('eth1: 203.0.113.100 → external (the WAN edge)'); }); }); describe('auditInterfaceClassification — every firewall, flattened', () => { test('reports across several firewalls, each subjected on its own hostname', () => { const findings = auditInterfaceClassification({ unreachableFirewalls: [], views: [liveFleetShape(), liveFleetShape({ hostname: 'fw-branch' })], }); expect(findings.map((f) => f.subject).sort()).toEqual(['fw-branch', 'fw-main']); }); test('no firewalls means no findings', () => { expect(auditInterfaceClassification({ views: [], unreachableFirewalls: [] })).toEqual([]); }); test('a firewall whose interfaces could not be read is unmeasured, not silent (D7)', () => { const findings = auditInterfaceClassification({ views: [liveFleetShape()], unreachableFirewalls: ['fw-branch'], }); const unmeasured = findings.filter((f) => f.severity === 'unmeasured'); expect(unmeasured).toHaveLength(1); expect(unmeasured[0].subject).toBe('fw-branch'); expect(unmeasured[0].code).toBe('interface_classification_unmeasured'); }); });