---
title: 'Host-Based Firewall and Prevention'
source: 'https://academia.sh/en/courses/endpoint-security/host-based-firewall'
course: 'Endpoint and System Hardening'
language: en
updated: '2026-09-26T03:02:51+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Counts intra-zone and inter-zone connection paths separately and derives the set the perimeter rule never sees'
    - 'Measures together the paths the host-based allowlist closes, the legitimate flow it cuts, and the denial that lands in the record'
    - 'Compares the number of endpoints reachable in a single hop, filtered and unfiltered, by the class of the compromised endpoint'
---

# Host-Based Firewall and Prevention

In a 432-endpoint model, 729,252 connection paths are counted and the 290,772 intra-zone paths the zone-boundary rule never sees at all are separated out: the paths a twelve-item host-based allowlist closes, the peer-to-peer traffic it has to leave open, the two legitimate flows it cuts because they were never written into the list, the way a denied path lands in the record, and the number of endpoints reachable in a single hop from a compromised endpoint.

The previous lesson measured the telemetry on the endpoint and showed that the one source that saw
the connection between endpoints could not be defended on coverage grounds. This lesson counts that
connection itself. The box standing at the network's edge was measured in another course, and there
the traffic between zones was counted; that measurement had a structural blind spot: the connection
between two endpoints in the same zone never crosses the boundary at all, so no rule at the boundary
either sees or evaluates it.

Three numbers are measured. The first is how many connection paths exist inside a zone — this is the
set the perimeter rule never sees at all. The second is how many of them a filter sitting on the host
side closes. The third is the cost of closing: how many legitimate flows have to be written into the
list, how many unwritten flows get cut, and which paths cannot be closed. No attack step is written in
this lesson; what is counted is the path itself.

- **EP19.** 432 endpoints, four endpoint classes, eighteen listening-service definitions, and twelve
  legitimate flows belong to a fictional regional measurement network. What is measured is the model
  built with `node`.
- **EP20.** The path definition is fixed: a path is the combination of a directed endpoint pair and a
  service listening at the destination. Reaching the same service by two different routes is a single
  path in the model.
- **EP21.** All endpoints in a class listen on the same services. In reality endpoints drift, and
  drift grows the path count; the fixed class makes the number look smaller than it is.
- **EP22.** The allowlist was written at the endpoint **class** level. A list written to endpoint
  addresses closes more paths and multiplies the maintenance item by the endpoint count.
- **EP23.** The host-side rule set was assumed to be unchangeable on the endpoint; if a privilege
  exists that can remove the rule, this measurement is invalid.
- **EP24.** The rule at the zone boundary evaluates only inter-zone paths. This is the boundary's
  definition; the paths that rule closes are taken here as a given input.

## The Set the Boundary Never Sees

```js
// network.mjs — endpoint classes, listening services, and legitimate internal flows; model, not a real network
// class -> [zone, endpoint count, listening services]
export const CLASS = {
  'field laptop': ['field', 120, ['remote support', 'file sharing', 'printer discovery']],
  'branch endpoint': ['branch', 240, ['remote support', 'file sharing', 'printer discovery', 'local cache']],
  'aggregator': ['aggregator', 60, ['remote support', 'metric ingestion', 'status query', 'file sharing', 'management interface']],
  'central server': ['central', 12, ['remote support', 'metric ingestion', 'status query', 'database', 'management interface', 'reporting interface']],
};
export const NAME = Object.keys(CLASS);
export const zone = (s) => CLASS[s][0];
export const count = (s) => CLASS[s][1];
export const services = (s) => CLASS[s][2];
// [source class, destination class, service] — legitimate flows written into the list
export const ALLOWED = [
  ['aggregator', 'central server', 'metric ingestion'],
  ['central server', 'aggregator', 'status query'],
  ['field laptop', 'central server', 'reporting interface'],
  ['branch endpoint', 'central server', 'reporting interface'],
  ['central server', 'field laptop', 'remote support'],
  ['central server', 'branch endpoint', 'remote support'],
  ['central server', 'aggregator', 'remote support'],
  ['branch endpoint', 'branch endpoint', 'local cache'],
  ['branch endpoint', 'branch endpoint', 'printer discovery'],
  ['field laptop', 'field laptop', 'file sharing'],
  ['aggregator', 'aggregator', 'file sharing'],
  ['central server', 'central server', 'database'],
];
// legitimate flows not written into the list, discovered later
export const FORGOTTEN = [
  ['branch endpoint', 'field laptop', 'file sharing'],
  ['field laptop', 'aggregator', 'status query'],
];
export const pair = (a, b) => (a === b ? count(a) * (count(a) - 1) : count(a) * count(b));
export function paths() {
  const l = [];
  for (const a of NAME) for (const b of NAME) for (const h of services(b)) l.push([a, b, h, pair(a, b)]);
  return l;
}
export const isInternal = (y) => zone(y[0]) === zone(y[1]);
export const isAllowed = (y, list) => list.some((i) => i[0] === y[0] && i[1] === y[1] && i[2] === y[2]);
export const sum = (l) => l.reduce((a, y) => a + y[3], 0);
```

```js
// run1.mjs — intra-zone and inter-zone path count: what the perimeter rule sees and never sees
import { CLASS, NAME, count, services, paths, isInternal, sum } from './network.mjs';

const Y = paths(), N = NAME.reduce((a, s) => a + count(s), 0);
console.log(`${N} endpoints, ${NAME.length} endpoint classes, ${NAME.reduce((a, s) => a + services(s).length, 0)} listening-service definitions\n`);
console.log(`${'endpoint class'.padEnd(16)} ${'zone'.padStart(11)} ${'eps'.padStart(4)} ${'svcs'.padStart(6)} ` +
  `${'intra-zone'.padStart(13)} ${'inter-zone'.padStart(14)}`);
for (const s of NAME) {
  const target = Y.filter((y) => y[1] === s);
  console.log(`${s.padEnd(16)} ${CLASS[s][0].padStart(11)} ${String(count(s)).padStart(4)} ${String(services(s).length).padStart(6)} ` +
    `${String(sum(target.filter(isInternal))).padStart(13)} ${String(sum(target.filter((y) => !isInternal(y)))).padStart(14)}`);
}
const [inside, outside] = [sum(Y.filter(isInternal)), sum(Y.filter((y) => !isInternal(y)))];
console.log(`${'total'.padEnd(16)} ${''.padStart(11)} ${String(N).padStart(4)} ${''.padStart(6)} ` +
  `${String(inside).padStart(13)} ${String(outside).padStart(14)}`);
console.log(`\ntotal paths                    : ${inside + outside}`);
console.log(`paths the perimeter sees       : ${outside} (${(outside * 100 / (inside + outside)).toFixed(1)}%)`);
console.log(`paths the perimeter never sees : ${inside} (${(inside * 100 / (inside + outside)).toFixed(1)}%)`);
```

```
432 endpoints, 4 endpoint classes, 18 listening-service definitions

endpoint class          zone  eps   svcs    intra-zone     inter-zone
field laptop           field  120      3         42840         112320
branch endpoint       branch  240      4        229440         184320
aggregator        aggregator   60      5         17700         111600
central server       central   12      6           792          30240
total                         432               290772         438480

total paths                    : 729252
paths the perimeter sees       : 438480 (60.1%)
paths the perimeter never sees : 290772 (39.9%)
```

Of the seven hundred twenty-nine thousand two hundred fifty-two total paths, two hundred ninety
thousand seven hundred seventy-two stay inside a zone: thirty-nine point nine percent. No rule at the
boundary evaluates this set — it is not denied, not allowed, not logged. The boundary rule cannot even
be said to "allow" these paths; it never sees them at all.

The distribution is not one-directional either. Two hundred twenty-nine thousand four hundred forty
of the intra-zone paths come from the branch zone; the reason lies in the path definition itself,
because the path count grows with the square of the endpoint count. The central zone listens on the
most services and has only seven hundred ninety-two intra-zone paths.

## What the Allowlist Closes and Cuts

```js
// run2.mjs — host-based default-deny: closed paths, remaining paths, and cut legitimate work
import { NAME, ALLOWED, FORGOTTEN, count, paths, isInternal, isAllowed, sum } from './network.mjs';

const Y = paths(), IN = Y.filter(isInternal), OUT = Y.filter((y) => !isInternal(y));
const setup = (list) => ({
  intra: sum(IN.filter((y) => isAllowed(y, list))), inter: sum(OUT.filter((y) => isAllowed(y, list))),
  cut: FORGOTTEN.filter((u) => !list.some((i) => i.join() === u.join())),
});
console.log(`${'setup'.padEnd(30)} ${'intra-zone left'.padStart(15)} ${'inter-zone left'.padStart(16)} ` +
  `${'total left'.padStart(12)} ${'cut legit flows'.padStart(18)}`);
for (const [name, list] of [['no host filter', Y.map((y) => [y[0], y[1], y[2]])],
  ['allowlist (12 items)', ALLOWED], ['+ two forgotten flows added', [...ALLOWED, ...FORGOTTEN]]]) {
  const r = setup(list);
  console.log(`${name.padEnd(30)} ${String(r.intra).padStart(15)} ${String(r.inter).padStart(16)} ` +
    `${String(r.intra + r.inter).padStart(12)} ${String(r.cut.length).padStart(18)}`);
}
const r = setup(ALLOWED), t = sum(IN), d = sum(OUT);
console.log(`\nintra-zone paths closed  : ${t - r.intra}/${t} (${((t - r.intra) * 100 / t).toFixed(1)}%)`);
console.log(`inter-zone paths closed  : ${d - r.inter}/${d} (${((d - r.inter) * 100 / d).toFixed(1)}%)`);
console.log(`paths closed in the set the perimeter never sees: ${t - r.intra}`);
console.log(`denied path types that land in the record: ${IN.filter((y) => !isAllowed(y, ALLOWED)).length} intra + ` +
  `${OUT.filter((y) => !isAllowed(y, ALLOWED)).length} inter = ${Y.filter((y) => !isAllowed(y, ALLOWED)).length}`);
console.log(`\nintra-zone flows left open by the allowlist:`);
for (const y of IN.filter((y) => isAllowed(y, ALLOWED)))
  console.log(`  ${`${y[0]} -> ${y[1]}`.padEnd(36)} ${y[2].padEnd(20)} ${String(y[3]).padStart(6)} paths`);
console.log(`\nlegitimate flows cut because they were not written into the list:`);
for (const u of setup(ALLOWED).cut) {
  const y = Y.find((y) => y[0] === u[0] && y[1] === u[1] && y[2] === u[2]);
  console.log(`  ${`${u[0]} -> ${u[1]}`.padEnd(36)} ${u[2].padEnd(20)} ${String(y[3]).padStart(6)} paths`);
}

console.log(`\n${'breached class'.padEnd(16)} ${'no filter'.padStart(9)} ${'with allowlist'.padStart(15)} ${'closed'.padStart(7)}`);
for (const a of NAME) {
  const targets = NAME.filter((b) => ALLOWED.some((i) => i[0] === a && i[1] === b));
  const reached = targets.reduce((t, b) => t + (a === b ? count(b) - 1 : count(b)), 0);
  const N = NAME.reduce((t, s) => t + count(s), 0) - 1;
  console.log(`${a.padEnd(16)} ${String(N).padStart(9)} ${String(reached).padStart(15)} ${String(N - reached).padStart(7)}`);
}
```

```
setup                          intra-zone left  inter-zone left   total left    cut legit flows
no host filter                          290772           438480       729252                  0
allowlist (12 items)                    132672            10800       143472                  2
+ two forgotten flows added             132672            46800       179472                  0

intra-zone paths closed  : 158100/290772 (54.4%)
inter-zone paths closed  : 427680/438480 (97.5%)
paths closed in the set the perimeter never sees: 158100
denied path types that land in the record: 13 intra + 47 inter = 60

intra-zone flows left open by the allowlist:
  field laptop -> field laptop         file sharing          14280 paths
  branch endpoint -> branch endpoint   printer discovery     57360 paths
  branch endpoint -> branch endpoint   local cache           57360 paths
  aggregator -> aggregator             file sharing           3540 paths
  central server -> central server     database                132 paths

legitimate flows cut because they were not written into the list:
  branch endpoint -> field laptop      file sharing          28800 paths
  field laptop -> aggregator           status query           7200 paths

breached class   no filter  with allowlist  closed
field laptop           431             131     300
branch endpoint        431             251     180
aggregator             431              71     360
central server         431             431       0
```

A twelve-item list closes fifty-four point four percent of the intra-zone paths. Where the remaining
one hundred thirty-two thousand six hundred seventy-two come from is written in the list below and
sums to a single line: the printer discovery and local cache flows that branch endpoints open to each
other, one hundred fourteen thousand seven hundred twenty paths together. These are legitimate flows,
and when they are removed from the list, two hundred forty endpoints stop seeing each other. In a
zone with legitimate peer-to-peer traffic, there is a ceiling on the paths host-based filtering can
close, and what sets that ceiling is not the attack — it is the work itself.

The inter-zone column shows the opposite: ninety-seven point five percent closes. The difference
comes from how few legitimate flows exist between zones — seven items. If the same list behaves this
differently across the two sets, the value of host-based filtering is not in **replacing** the
boundary rule, but in doing something in the set the boundary never looks at at all: one hundred
fifty-eight thousand one hundred paths close without the boundary rule changing at all.

The third row gives the shape of the cost. When the allowlist was written, two legitimate flows were
not in the list and were cut: file sharing from a branch endpoint to a field laptop, and a status
query from a field laptop to an aggregator. When the two are added, the intra-zone remainder does not
change at all — both are inter-zone — but the inter-zone remainder rises from ten thousand eight
hundred to forty-six thousand eight hundred. Adding two items quadruples the open inter-zone path
count. Every item written into the list behaves this way, and this is the real cost of maintenance:
the twelve-item list is not a one-time job, it is an inventory that keeps growing.

The sixty denied path types, meanwhile, are written into the second column, visibility. The filter on
the host side is the one layer in this lesson that both closes and records in the same move: the
connection attempt it denies produces a record, and that record is added to the previous lesson's
telemetry sources. The closed path and the path that lands in the record are the same set here — one
of the rare places in the course where these two columns overlap.

The last table gives the lesson's harshest number. The number of endpoints reachable in a single hop
from a compromised aggregator drops from four hundred thirty-one to seventy-one, and from a field
laptop to one hundred thirty-one. From a compromised central server, four hundred thirty-one endpoints
remain reachable and the closed count is zero. The reason is plain: the remote support flow is
written from the center to all three of the other endpoint classes, and if that flow is removed there
is no such thing as remote support left. The management path itself cannot be closed; a path that
cannot be closed must at least be made visible.

## How Much Each Item Opens

List items are not equal. Starting from an empty list and adding items one at a time shows,
separately, how many paths each item opens and how many legitimate flows it saves in return.

```js
// run3.mjs — paths opened as the list grows: how many paths each item opens, how many legitimate flows it saves
import { ALLOWED, FORGOTTEN, paths, isInternal, isAllowed, sum } from './network.mjs';

const Y = paths(), FULL = [...ALLOWED, ...FORGOTTEN];
console.log(`${'item'.padEnd(4)} ${'added flow'.padEnd(58)} ${'new paths'.padStart(10)} ` +
  `${'remaining'.padStart(10)} ${'still cut'.padStart(12)}`);
for (let n = 0; n <= FULL.length; n++) {
  const list = FULL.slice(0, n);
  const remaining = sum(Y.filter((y) => isAllowed(y, list)));
  const added = n === 0 ? 0 : remaining - sum(Y.filter((y) => isAllowed(y, FULL.slice(0, n - 1))));
  const label = n === 0 ? '(empty list: everything is denied)' : `${FULL[n - 1][0]} -> ${FULL[n - 1][1]} / ${FULL[n - 1][2]}`;
  console.log(`${String(n).padEnd(4)} ${label.padEnd(58)} ${String(added).padStart(10)} ${String(remaining).padStart(10)} ` +
    `${String(FULL.length - n).padStart(12)}`);
}
const firstThree = sum(Y.filter((y) => isAllowed(y, FULL.slice(0, 3))));
console.log(`\nfirst 3 items open ${(firstThree * 100 / sum(Y)).toFixed(2)}% of the total paths`);
const in_ = FULL.filter((i) => isInternal([i[0], i[1]]));
console.log(`intra-zone item count: ${in_.length}/${FULL.length}; paths they open: ` +
  `${sum(Y.filter((y) => isAllowed(y, in_)))} (${(sum(Y.filter((y) => isAllowed(y, in_))) * 100 / sum(Y.filter((y) => isAllowed(y, FULL)))).toFixed(1)}% of the remainder)`);
```

```
item added flow                                                  new paths  remaining    still cut
0    (empty list: everything is denied)                                  0          0           14
1    aggregator -> central server / metric ingestion                   720        720           13
2    central server -> aggregator / status query                       720       1440           12
3    field laptop -> central server / reporting interface             1440       2880           11
4    branch endpoint -> central server / reporting interface          2880       5760           10
5    central server -> field laptop / remote support                  1440       7200            9
6    central server -> branch endpoint / remote support               2880      10080            8
7    central server -> aggregator / remote support                     720      10800            7
8    branch endpoint -> branch endpoint / local cache                57360      68160            6
9    branch endpoint -> branch endpoint / printer discovery          57360     125520            5
10   field laptop -> field laptop / file sharing                     14280     139800            4
11   aggregator -> aggregator / file sharing                          3540     143340            3
12   central server -> central server / database                       132     143472            2
13   branch endpoint -> field laptop / file sharing                  28800     172272            1
14   field laptop -> aggregator / status query                        7200     179472            0

first 3 items open 0.39% of the total paths
intra-zone item count: 5/14; paths they open: 132672 (73.9% of the remainder)
```

The curve splits into two regions. The first seven items — all of the inter-zone flows — open ten
thousand eight hundred paths in total; half of the fourteen items produce six percent of the
remaining paths. The eighth and ninth items alone open one hundred fourteen thousand seven hundred
twenty paths. Two items open roughly twice as much as the other twelve combined, and both can be
defended with the same sentence: branch endpoints need to see each other.

A list measured by item count is therefore misleading. Five of the fourteen items are intra-zone, and
they open seventy-three point nine percent of the remaining paths. Reviewing an allowlist is not
counting items — it is writing down, next to each item, the path it opens.

## Summary

- The 432-endpoint model has 729,252 connection paths; 290,772 of them (39.9%) are intra-zone, and
  the boundary rule neither denies nor logs this set — it never sees it at all.
- 79% of the intra-zone paths come from a single zone (branch): the path count grows with the square
  of the endpoint count and linearly with the service count.
- The 12-item host-based allowlist closes 54.4% of the intra-zone paths (158,100 paths) and 97.5% of
  the inter-zone paths. What makes the difference is the legitimate peer-to-peer traffic inside a
  zone: the printer discovery and local cache flows between branch endpoints alone leave 114,720
  paths open.
- The cost is two items: 2 legitimate flows not written into the list were cut; adding them raised
  the open inter-zone paths from 10,800 to 46,800. The list is not a one-time job — it is an
  inventory that grows the surface with every addition.
- The 60 denied path types land in the record in the same move: the host-based filter is the rare
  layer where the closing column and the visibility column overlap.
- The number of endpoints reachable in a single hop from a compromised endpoint drops from 431 to 71
  for an aggregator and to 131 for a field laptop; it stays at 431 for a central server, and the
  closed count is zero — the remote support flow cannot be removed.

## Next Step

This lesson's allowlist rested on an assumption: that whatever wants to use a closed path has a tool
**of its own**. The legitimate flows written into the list were already open, and the tools that
carried out those flows already sat on the endpoint. If the tool that carries the remote support flow
is itself run with malicious intent, no rule is violated; no path closes, no denial record is
produced. The next lesson's question is this: how many of the built-in and signed tools on the
endpoint can do the same work, why can a signature-based rule not tell them apart, and how many can
behavioral context — parent process, command pattern, timing — tell apart.
