Lesson 11 / 16
Firewall Configuration
Open surface is counted item by item at the machine level: how many ports listen after install, how many zones can reach them, how many attempts stop once the switch to default-deny happens, how many legitimate flows close by mistake, and what stays open when the outbound direction is left unrestricted.
Published Updated
In this lesson
- Counts the ports listening after install, the inbound direction, the outbound direction, and management access item by item
- Writes the same allow list at three rule widths: measures the trade-off between row count and open item count
- Counts the outbound restriction separately and measures the surface the rule set itself introduces through a single row
Contents
The previous topic closed with verifying the name and the sender: the query reaches the right name server, and the email’s origin can be proven. What was verified was the name itself; the machine the name points to still stands with the settings it was installed with, and those settings left it reachable from everywhere. The machine behind it can be reached without ever using the name — the address is enough.
Network policy was measured earlier at the cluster-internal scale: which pod can reach which pod. The scale here is the machine underneath the cluster. Three things are asked: how many items stay open when nothing is done, how many rows closing them takes, and how many legitimate jobs stop when they close.
- HB1. The arrangement of six machines and twenty-five listening ports belongs to the fictional
regional measurement network. No real machine is run; what is measured is the model built with
node. - HB2. Source zones were reduced to four. In reality zone separation is written with address ranges; that grows the item count without changing the direction of the result.
- HB3. Attempt counts were written by hand; there is no randomness, every run produces the same numbers.
- HB4. A port is either open or closed; rate limiting and state tracking are not modeled.
- HB5. The allow list was not derived from a trace, it was written by hand; the model
deliberately includes the oversight of the two rare flows marked with
SKIPPED. In reality the number of missed flows depends on the observation period. - HB6. The outbound direction was modeled at the destination-class level. In reality outbound rules are written by address and port; the item count grows, but the meaning of an unrestricted outbound direction does not change.
Rule Set Model
The model holds three things: which port is listening on which machine, how many attempts came to
which port from which zone during the period, and the allow list the operator wrote. Rules are
written in our own small schema: one row names a source zone, a machine, and a port name, and *
means no distinction is made on that axis.
// firewall.mjs — machine, listening port, attempt, and rule set model; not a real network export const ZONE = ['internal-net', 'office-net', 'field-net', 'external']; const COMMON = [['management-shell', 'management'], ['metric-endpoint', 'auxiliary'], ['log-shipping', 'auxiliary']]; const SPECIFIC = { // ports the install leaves open per machine 'collector-1': [['read-intake', 'work']], 'collector-2': [['read-intake', 'work']], 'validator': [['validation-api', 'work']], 'billing': [['billing-api', 'work']], 'data-store': [['data-endpoint', 'work'], ['backup-endpoint', 'work']], 'management': [['management-interface', 'management']], }; export const MACHINE = Object.keys(SPECIFIC); export const PORT = MACHINE.flatMap((m) => [...COMMON, ...SPECIFIC[m]].map(([portName, portKind]) => ({ machine: m, name: portName, type: portKind }))); export const key = (z, m, p) => `${z}|${m}|${p}`; // Thirty-day attempt log: [zone, machine, port, count, legitimate?] const perMachine = (z, p, f, ms) => MACHINE.map((m, i) => [z, m, p, f(i), ms]); export const ATTEMPT = [ ['field-net', 'collector-1', 'read-intake', 41200, true], ['field-net', 'collector-2', 'read-intake', 38900, true], ['internal-net', 'validator', 'validation-api', 26400, true], ['internal-net', 'data-store', 'data-endpoint', 91500, true], ['internal-net', 'billing', 'billing-api', 8600, true], ['office-net', 'billing', 'billing-api', 1240, true], ['office-net', 'management', 'management-interface', 860, true], ['office-net', 'management', 'management-shell', 310, true], ['office-net', 'data-store', 'backup-endpoint', 4, true], // monthly backup verification ['office-net', 'validator', 'validation-api', 12, true], // end-of-quarter manual verification ...perMachine('internal-net', 'metric-endpoint', () => 8640, true), ...perMachine('internal-net', 'log-shipping', () => 30, true), ...perMachine('internal-net', 'management-shell', () => 62, true), // config distribution ...perMachine('external', 'management-shell', (i) => 12000 + i * 1730, false), ...perMachine('external', 'metric-endpoint', (i) => 90 + i * 7, false), ['external', 'data-store', 'data-endpoint', 2400, false], ['external', 'data-store', 'backup-endpoint', 180, false], ['external', 'collector-1', 'read-intake', 640, false], ['external', 'management', 'management-interface', 5100, false], ['field-net', 'data-store', 'data-endpoint', 26, false], ['field-net', 'management', 'management-shell', 14, false], ['office-net', 'data-store', 'data-endpoint', 310, false], ]; export const LEGITIMATE = ATTEMPT.filter((d) => d[4]).map((d) => key(d[0], d[1], d[2])); // The operator wrote the allow list by hand; two rare flows were missed. const SKIPPED = ['office-net|data-store|backup-endpoint', 'office-net|validator|validation-api']; export const ALLOW = LEGITIMATE.filter((a) => !SKIPPED.includes(a)).map((a) => a.split('|')); // The same list can be written in three widths; '*' means no distinction on that axis. export const widths = () => ({ 'port-name': [...new Set(ALLOW.map((r) => r[2]))].map((k) => ['*', '*', k]), 'zone+port': [...new Set(ALLOW.map((r) => `${r[0]}|${r[2]}`))].map((s) => [s.split('|')[0], '*', s.split('|')[1]]), 'zone+machine+port': ALLOW, }); export function opened(rules) { // rule set -> open item set const open = new Set(); for (const [z, m, p] of rules) for (const zz of (z === '*' ? ZONE : [z])) for (const port of PORT) if ((m === '*' || port.machine === m) && (p === '*' || port.name === p)) open.add(key(zz, port.machine, port.name)); return open; } export const ALL = opened([['*', '*', '*']]); // the default-allow arrangement equals a single rule export function count(open) { const closed = (d) => !open.has(key(d[0], d[1], d[2])); const sum = (f) => ATTEMPT.filter(f).reduce((a, d) => a + d[3], 0); return { items: open.size, unmatched: [...open].filter((a) => !LEGITIMATE.includes(a)).length, blocked: sum((d) => !d[4] && closed(d)), passed: sum((d) => !d[4] && !closed(d)), wronglyBlocked: ATTEMPT.filter((d) => d[4] && closed(d)).length }; } export const OUTBOUND_LEGITIMATE = [ ['collector-1', 'internal:queue'], ['collector-2', 'internal:queue'], ['validator', 'internal:data-store'], ['billing', 'internal:data-store'], ['billing', 'external:mail-gateway'], ['management', 'external:package-repo'], ['data-store', 'internal:backup-area'], ...MACHINE.map((m) => [m, 'external:time-source']), ]; export const OUTBOUND_ALLOW = OUTBOUND_LEGITIMATE.filter(([m, h]) => h !== 'external:time-source' || m === 'management');
The ALL row is the model’s silent claim: the default-allow arrangement is equivalent to a single
* -> * -> * rule; nobody wrote that rule, but its effect is as if it had been. count takes an
open item set and returns five numbers: how many items are open, how many of them have no match,
how many illegitimate attempts were blocked, how many got through, and how many legitimate flows
were closed by mistake.
The Surface Left Open by Default
// run1.mjs — items left open when nothing is done after install are counted import { ZONE, MACHINE, PORT, ATTEMPT, LEGITIMATE, OUTBOUND_LEGITIMATE, ALL, count } from './firewall.mjs'; const s = count(ALL); const byType = (t) => PORT.filter((k) => k.type === t).length; const sum = (f) => ATTEMPT.filter(f).reduce((a, d) => a + d[3], 0); const portType = (m, p) => PORT.find((x) => x.machine === m && x.name === p).type; for (const [label, value, note = ''] of [ ['listening port', PORT.length, `${MACHINE.length} machines: work ${byType('work')}, auxiliary ${byType('auxiliary')}, management ${byType('management')}`], ['inbound open item', s.items, `${PORT.length} ports x ${ZONE.length} zones`], [' matching a legitimate flow', LEGITIMATE.length], [' open unmatched', s.unmatched], ['management port reachable from external', byType('management')], ['outbound restriction', '-', `${MACHINE.length} machines have unrestricted targets, ${OUTBOUND_LEGITIMATE.length} legitimate flows`], ['thirty-day illegitimate attempt', sum((d) => !d[4]), `from ${new Set(ATTEMPT.filter((d) => !d[4]).map((d) => d[0])).size} source zones`], [' hitting a management port', sum((d) => !d[4] && portType(d[1], d[2]) === 'management')], [' blocked', s.blocked], [' reached target', s.passed], ]) console.log(`${label.padEnd(41)}${String(value).padStart(7)} ${note}`.trimEnd());
listening port 25 6 machines: work 6, auxiliary 12, management 7 inbound open item 100 25 ports x 4 zones matching a legitimate flow 28 open unmatched 72 management port reachable from external 7 outbound restriction - 6 machines have unrestricted targets, 13 legitimate flows thirty-day illegitimate attempt 107265 from 3 source zones hitting a management port 103064 blocked 0 reached target 107265
Only six of the twenty-five listening ports are open for the work itself. The remaining nineteen are for metric collection, log shipping, and the management shell; none of them were opened on purpose, the install opened them. The hundred items come from these ports being reachable from all four zones at once — twenty-eight of them have a matching legitimate flow, seventy-two do not.
Seven of the management ports are also reachable from the external zone, and one hundred three thousand of the period’s one hundred seven thousand illegitimate attempts land exactly there. The last two rows are the punch line: zero of these attempts were blocked. A firewall layer whose default decision is “allow” produces the same number as no layer at all. In the outbound direction there is not even an item called restriction.
Default-Deny and Rule Width
// run2.mjs — switch to default-deny; the same allow list is written at three rule widths import { ZONE, ALLOW, widths, opened, ALL, count } from './firewall.mjs'; const row = (label, n, open) => { const s = count(open); console.log(`${label.padEnd(52)} ${String(n).padEnd(6)} ${String(s.items).padEnd(7)} ` + `${String(s.unmatched).padEnd(12)} ${String(s.blocked).padEnd(18)} ${String(s.passed).padEnd(17)} ${s.wronglyBlocked}`); }; console.log(`the same allow list (${ALLOW.length} flows) at three widths:\n` +`${'rule width'.padEnd(52)} rows items unmatched blocked attempts passed attempts wrongly blocked`); row('default allow', 0, ALL); for (const [label, k] of Object.entries(widths())) row(label, k.length, opened(k)); const narrow = widths()['zone+machine+port']; const A = ['office-net', '*', '*'], B = ['external', 'management', 'management-shell'], C = ['*', '*', 'metric-endpoint']; console.log('\nadding a temporary row to the narrowest shape:'); for (const [label, ek] of [['office-net -> * -> *', [A]], ['external -> management -> management-shell', [B]], ['all three together (+ * -> * -> metric-endpoint)', [A, B, C]]]) row(` ${label}`, narrow.length + ek.length, opened([...narrow, ...ek])); const wide = widths()['port-name'].map(([z, m, p]) => [p, opened([[z, m, p]]).size]).filter(([, n]) => n > 4); console.log(`\nitems opened by a single rule written for a port name (${ZONE.length} zones x machines listening on that port):`); console.log(' ' + wide.map(([p, n]) => `${p} ${n}`).join(', '));
the same allow list (26 flows) at three widths: rule width rows items unmatched blocked attempts passed attempts wrongly blocked default allow 0 100 72 0 107265 0 port-name 8 96 69 180 107085 1 zone+port 10 31 5 107265 0 2 zone+machine+port 26 26 0 107265 0 2 adding a temporary row to the narrowest shape: office-net -> * -> * 27 48 20 106955 310 0 external -> management -> management-shell 27 27 1 86615 20650 2 all three together (+ * -> * -> metric-endpoint) 29 61 33 85660 21605 0 items opened by a single rule written for a port name (4 zones x machines listening on that port): read-intake 8, management-shell 24, metric-endpoint 24, log-shipping 24
Switching to default-deny does not by itself produce a number. The port-name row shows this:
eight rules were written, it is default-deny, the allow list is applied — and the open items drop
from a hundred to ninety-six, and only a hundred eighty of the illegitimate attempts are blocked.
The last row gives the reason: management-shell written as a single rule opens twenty-four items,
because it spans all six machines and all four zones at once. The allow list had six rows for this
port, and all six were from the internal network; dropping the zone axis opens the external zone
too. Three broad rules produce seventy-two of the ninety-six items.
Adding the zone axis raises the row count from eight to ten: open items drop to thirty-one and all illegitimate attempts are blocked. Two rows close one hundred seven thousand attempts. Adding the machine axis too asks for twenty-six rows; unmatched items drop from five to zero, but the blocked attempt count does not change. The zone axis is the axis that closes things, the machine axis only cleans up the remainder: sixteen rows of extra work buy five items.
The wrongly-blocked column is two in both narrow shapes — the monthly backup verification and the
end-of-quarter manual verification marked with SKIPPED in the model, four and twelve attempts
during the period. In a trace of hundreds of thousands of rows, these sixteen rows are invisible.
Nothing breaks on the day the rule is written; it breaks at month- and quarter-end, and what shows
up is a connection error, not an allow-list row.
The middle block measures the surface closing itself introduces; the rule set is itself an item
that gets written, expanded for a reason, and mostly never rolled back. The wildcard row opened for
office-net adds twenty-two items but only lets three hundred ten attempts through; the single
management port opened to the external zone adds one item and lets twenty thousand six hundred
fifty attempts through — which zone an item faces matters more than the count. The wildcard row’s
last column shows the real problem: wrongly-blocked drops from two to zero, because the row
accidentally reopens the two skipped legitimate flows. The gap goes unnoticed, and no reason arises
to remove the row. When all three stay together, twenty-nine rows open sixty-one items; the rule
set only grows in one direction, because adding a row has a justification and removing one does not.
Outbound Direction
Every item counted so far belonged to the inbound direction. The outbound direction is left entirely unrestricted in most setups, and because it does not show up in the item count, nobody notices it is a surface.
// run3.mjs — outbound direction is counted separately; the inbound rule set touches none of these numbers import { MACHINE, ALLOW, OUTBOUND_LEGITIMATE, OUTBOUND_ALLOW, widths, opened, count } from './firewall.mjs'; const extClasses = (l) => new Set(l.filter(([, h]) => h.startsWith('external:')).map(([, h]) => h)).size; const items = count(opened(widths()['zone+machine+port'])).items; const blocked = OUTBOUND_LEGITIMATE.filter(([k, h]) => !OUTBOUND_ALLOW.some(([a, b]) => a === k && b === h)); console.log('machine default allowed dest blocked legitimate flow'); for (const m of MACHINE) console.log((`${m.padEnd(13)} ${'unrestricted'.padEnd(14)} ` + `${String(OUTBOUND_ALLOW.filter(([k]) => k === m).length).padEnd(14)} ` + blocked.filter(([k]) => k === m).map(([, h]) => h).join(', ')).trimEnd()); console.log(`\nlegitimate outbound flow ${OUTBOUND_LEGITIMATE.length} (${extClasses(OUTBOUND_LEGITIMATE)} external dest classes)`); console.log(`outbound rule row ${OUTBOUND_ALLOW.length} (0 by default, unrestricted target)`); console.log(`allowed external dest class ${extClasses(OUTBOUND_ALLOW)} (any, by default)`); console.log(`wrongly blocked legitimate flow ${blocked.length}`); console.log(`inbound open item ${items} -> ${items} (unchanged)`); console.log(`total rule row ${ALLOW.length} -> ${ALLOW.length + OUTBOUND_ALLOW.length}`);
machine default allowed dest blocked legitimate flow collector-1 unrestricted 1 external:time-source collector-2 unrestricted 1 external:time-source validator unrestricted 1 external:time-source billing unrestricted 2 external:time-source data-store unrestricted 1 external:time-source management unrestricted 2 legitimate outbound flow 13 (3 external dest classes) outbound rule row 8 (0 by default, unrestricted target) allowed external dest class 3 (any, by default) wrongly blocked legitimate flow 5 inbound open item 26 -> 26 (unchanged) total rule row 26 -> 34
Eight outbound rules bring the open target count per machine down to one or two; that column read “unrestricted” by default. The difference is what a compromised machine can do: even if the inbound rule set is complete, a process running inside that can connect out can exfiltrate data, take commands, and establish its own persistence. Closing this path does not change a single inbound item; open items stay at twenty-six, and the rule count rises to thirty-four.
The wrongly-blocked column is five. Whoever wrote the allow list saw the time-source dependency on the management node and wrote it there, and did not think of it for the others. Its defining trait is that it does not show up right away: a clock does not stop at once, it drifts. The drift surfaces days later in signature verification, log ordering, and metric windows; all of it looks like a symptom of something else. The two wrongly-blocked items in the inbound direction throw a connection error and get fixed; the five in the outbound direction can be misdiagnosed for weeks.
This is also where the new surface closing introduces shows up. One of the three allowed external destination classes is the package repository, and it is open for the management node; the price of being able to fetch patches is that a path leading outward stays permanently open. The thirty-four-row rule set leaves one item that cannot be closed: the management node, privileged in both directions.
Summary
- After install, 25 ports listen across six machines; only 6 are for the work itself. Reachability from four zones turns this into 100 open items, 72 of which have no matching legitimate flow, and all 107,265 of the period’s illegitimate attempts reach their target — 103,064 of them at management ports.
- Switching to default-deny is not enough by itself: 8 rows written by port name block only 180 attempts. The zone axis closes all of the attempts with 10 rows; the machine axis asks for 26 rows and drops the unmatched item count from 5 to 0 without adding anything to the blocked attempt count.
- Both narrow shapes wrongly block 2 legitimate flows; because the two together produce only 16 attempts during the period, they do not show up in the trace and break at month- and quarter-end.
- The rule set itself is a surface: the single row opened to the external zone adds 1 item and lets through 20,650 attempts, and the wildcard row opened to the office network adds 22 items and hides the incomplete allow list by dropping wrongly-blocked from 2 to 0.
- When the outbound direction is restricted with 8 rows, the open target count per machine drops to 1–2 and the inbound direction’s 26 items do not change; the price is 5 wrongly-blocked flows that show up days later as clock drift. The item that cannot be closed is the one machine that faces outward so it can fetch patches.
Next Step
The narrowest rule set left the management shell open to the internal network on six machines. The port narrowed, but what is behind it did not change: whoever connects still logs in with a password, and password attempts can be made freely from the zones the firewall allows. The next lesson’s question is not which zone a port is open to, but what getting through the port depends on: with password-based access, how many attempts come from how many sources in a period; how much of this surface closes when switching to key-based access; how many copies a key exists in, where they sit, and how many machines each is authorized on; how many touches revoking it takes — and reducing access to a single bastion host lowers the number of directly reachable machines while introducing what single target in return.
To keep your progress and take notes, Log in
My notes
Log in to take notes.