---
title: 'Access Control Lists'
source: 'https://academia.sh/en/courses/network-security/access-control-lists'
course: 'Network Security'
language: en
updated: '2026-10-21T05:02:57+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Finds the first matching rule for each flow in a known flow set and derives the decision flow by flow'
    - 'Evaluates the same rule text in five separate orders and counts the flows whose outcome changes'
    - 'Names conflicting rule pairs and shadowed rules, and measures the cost of ordering intuitions'
---

# Access Control Lists

Rule evaluation order is run across ten rules and sixteen known flows: the first matching rule is found for each flow, the same list is evaluated in five separate orders, and how many flows' outcomes change when the order changes is counted; how many legitimate flows a hardening move cuts and the path that specific-to-general ordering opens are measured.

Six lessons of writing defenses have each come down to the same thing: a rule list. Zone boundaries are
fifteen lines, remote-access zones are four lines, inspection points and block lists brought their own
lines. These lines sit side by side in a single box, and when a flow arrives, they are not all looked at
at once — they are looked at in order, and **the first match wins**.

This lesson's question is not which rule is written but which one comes first. Without a single character
of the rule text changing, only the order of the lines changing, the same flow set's outcomes can change.
This is what is measured: for a known flow set, the first matching rule is found for each flow, and how
many flows' outcomes change when the order changes is counted.

- **PD34.** The ten rules, sixteen flows, and zone names belong to a fictional zonal measurement network.
  No real packet flows; what is measured is the model built with `node`.
- **PD35.** Matching is done on three axes: source zone, destination zone, and service name. In practice,
  address range, protocol, and port are also written; that grows the number of axes but does not change
  the effect of order.
- **PD36.** Whether flows are legitimate is marked by hand and stays fixed throughout the model.
- **PD37.** Rule numbers are the order the operator wrote them in: older lines have smaller numbers, ones
  added later have larger ones. Moving a line is modeled by giving it a new number.
- **PD38.** State and the return direction are not modeled; every flow is evaluated in a single direction.
- **PD39.** A flow can match every rule in the list; only the first match decides the outcome.

## The List and the First Matching Rule

The rule list was not written all at once. The line with the smallest number is the oldest, and it gives
the management zone access everywhere; this is exactly the line measured in the Network Segmentation and the Demilitarized Zone lesson. The
line numbered a hundred sixty was added later, during hardening, and it denies the management shell from
everywhere. The two sit in the same list, and both match the same flows.

```js
// rules.mjs — numbered rule list and known flow set; a model, not a real filter
// [no, source zone, dest zone, service, decision] — '*' means no distinction on that axis
export const RULE = [
  [100, 'management', '*', '*', 'allow'],              // oldest line: management reaches everywhere
  [110, '*', 'dmz', 'public-interface', 'allow'],
  [120, 'dmz', 'app', 'app-api', 'allow'],
  [130, 'branch', 'app', 'billing-api', 'allow'],
  [140, 'field', 'dmz', 'measurement-intake', 'allow'],
  [150, 'app', 'data', 'data-endpoint', 'allow'],
  [160, '*', '*', 'management-shell', 'deny'],          // added during hardening
  [170, 'remote', 'dmz', '*', 'allow'],
  [180, 'data', 'backup', 'backup-endpoint', 'allow'],
  [190, '*', '*', '*', 'deny'],                          // default deny
];
// [name, source, dest, service, legit?]
export const FLOW = [
  ['management -> app shell', 'management', 'app', 'management-shell', true],
  ['management -> data shell', 'management', 'data', 'management-shell', true],
  ['management -> dmz interface', 'management', 'dmz', 'public-interface', true],
  ['branch -> billing api', 'branch', 'app', 'billing-api', true],
  ['field -> measurement intake', 'field', 'dmz', 'measurement-intake', true],
  ['external -> public interface', 'external', 'dmz', 'public-interface', true],
  ['dmz -> app api', 'dmz', 'app', 'app-api', true],
  ['app -> data endpoint', 'app', 'data', 'data-endpoint', true],
  ['data -> backup endpoint', 'data', 'backup', 'backup-endpoint', true],
  ['remote -> public interface', 'remote', 'dmz', 'public-interface', true],
  ['remote -> measurement intake', 'remote', 'dmz', 'measurement-intake', true],
  ['field -> data endpoint (quarter close)', 'field', 'data', 'data-endpoint', true],
  ['remote -> dmz shell', 'remote', 'dmz', 'management-shell', false],
  ['branch -> app shell', 'branch', 'app', 'management-shell', false],
  ['external -> app api', 'external', 'app', 'app-api', false],
  ['dmz -> data endpoint', 'dmz', 'data', 'data-endpoint', false],
];
const matches = (k, a) => (k[1] === '*' || k[1] === a[1]) && (k[2] === '*' || k[2] === a[2]) &&
  (k[3] === '*' || k[3] === a[3]);
export const firstMatch = (list, a) => list.find((k) => matches(k, a));
export const decide = (list, a) => firstMatch(list, a)[4];
export function tally(list) {
  const r = FLOW.map((a) => [a, decide(list, a)]);
  const n = (f) => r.filter(([a, k]) => f(a, k)).length;
  return { allow: n((a, k) => k === 'allow'), deny: n((a, k) => k === 'deny'),
    wronglyClosed: n((a, k) => a[4] && k === 'deny'), leaked: n((a, k) => !a[4] && k === 'allow') };
}
export const shadowed = (list) => list.filter((k) => !FLOW.some((a) => firstMatch(list, a)[0] === k[0]));
export const move = (no, next) => [...RULE].map((k) => (k[0] === no ? [next, ...k.slice(1)] : k))
  .sort((a, b) => a[0] - b[0]);
export const wildcardCount = (k) => k.slice(1, 4).filter((x) => x === '*').length;
export const ORDER = {
  'as written': RULE,
  '160 -> 95': move(160, 95),
  '170 -> 105': move(170, 105),
  'specific to general': [...RULE].sort((a, b) => wildcardCount(a) - wildcardCount(b) || a[0] - b[0]),
  'reversed': [...RULE].reverse(),
};
export const changed = (a1, a2) => FLOW.filter((a) => decide(a1, a) !== decide(a2, a));
```

```js
// run1.mjs — first matching rule and outcome for each flow, in the order the rules were written
import { RULE, FLOW, firstMatch, decide, tally, shadowed } from './rules.mjs';

console.log(`${RULE.length} rules, ${FLOW.length} known flows\n`);
console.log(`${'flow'.padEnd(40)} ${'legit'.padStart(5)} ${'first match'.padStart(11)} ${'decision'.padStart(8)}  outcome`);
for (const a of FLOW) {
  const k = firstMatch(RULE, a), c = decide(RULE, a);
  const s = a[4] === (c === 'allow') ? 'correct' : (a[4] ? 'wrongly closed' : 'leaked');
  console.log(`${a[0].padEnd(40)} ${String(a[4] ? 'yes' : 'no').padStart(5)} ${String(k[0]).padStart(11)} ${c.padStart(8)}  ${s}`);
}
const s = tally(RULE);
console.log(`\nallow ${s.allow}, deny ${s.deny}, wrongly closed ${s.wronglyClosed}, leaked ${s.leaked}`);
console.log(`shadowed rules: ${shadowed(RULE).map((k) => k[0]).join(', ') || 'none'}`);
const k160 = FLOW.filter((a) => a[3] === 'management-shell');
console.log(`\n"management-shell" service: the ${k160.length} flows that touch it and the rule that decides each:`);
for (const a of k160) console.log(`  ${a[0].padEnd(40)} ${firstMatch(RULE, a)[0]} rule -> ${decide(RULE, a)}`);
```

```
10 rules, 16 known flows

flow                                     legit first match decision  outcome
management -> app shell                    yes         100    allow  correct
management -> data shell                   yes         100    allow  correct
management -> dmz interface                yes         100    allow  correct
branch -> billing api                      yes         130    allow  correct
field -> measurement intake                yes         140    allow  correct
external -> public interface               yes         110    allow  correct
dmz -> app api                             yes         120    allow  correct
app -> data endpoint                       yes         150    allow  correct
data -> backup endpoint                    yes         180    allow  correct
remote -> public interface                 yes         110    allow  correct
remote -> measurement intake               yes         170    allow  correct
field -> data endpoint (quarter close)     yes         190     deny  wrongly closed
remote -> dmz shell                         no         160     deny  correct
branch -> app shell                         no         160     deny  correct
external -> app api                         no         190     deny  correct
dmz -> data endpoint                        no         190     deny  correct

allow 11, deny 5, wrongly closed 1, leaked 0
shadowed rules: none

"management-shell" service: the 4 flows that touch it and the rule that decides each:
  management -> app shell                  100 rule -> allow
  management -> data shell                 100 rule -> allow
  remote -> dmz shell                      160 rule -> deny
  branch -> app shell                      160 rule -> deny
```

As written, the list looks good: fifteen of sixteen flows resolve correctly, and the one wrong closure is
the quarter-close reconciliation — that lesson cut the same flow, and here too no line looks
out for it. No flow leaks, and no rule sits shadowed.

The sub-list poses the lesson's real question. Four flows touch the management-shell service, and the line
numbered a hundred decides two of them, the line numbered a hundred sixty the other two. The line numbered
a hundred sixty was written as "deny the management shell from everywhere"; its text says that, and it
reads the same way in the audit record. What it does at runtime is "deny from everywhere outside the
management zone," because flows coming from the management zone never reach it — they stop at the line
numbered a hundred. The two lines conflict, and what resolves the conflict is not the text but the number.

The same list has another example running the opposite way. The line numbered a hundred seventy decides
the flow from the remote-access zone to measurement intake; the line numbered a hundred ten decides the flow
to the public interface, because it comes first and carries a wildcard on the source axis. Both flows get
allowed, the outcome is the same — but the lines that decide it are different. In an audit record, the
answer to "which rule allowed this" may not be the line the person who wrote the rule expected. Someone
removing the line numbered a hundred seventy might see remote access still reaching the public interface
and conclude the change had no effect; the measurement-intake flow, by then, is already cut.

## When the Order Changes

```js
// run2.mjs — the same rule set in five orders; how many flows change outcome when order changes
import { RULE, FLOW, ORDER, decide, tally, shadowed, changed } from './rules.mjs';

console.log(`${'order'.padEnd(20)} ${'allow'.padStart(5)} ${'deny'.padStart(6)} ${'wrongly closed'.padStart(14)} ` +
  `${'leaked'.padStart(6)} ${'changed flows'.padStart(13)} ${'shadowed rules'.padStart(14)}`);
for (const [name, list] of Object.entries(ORDER)) {
  const s = tally(list), g = shadowed(list);
  console.log(`${name.padEnd(20)} ${String(s.allow).padStart(5)} ${String(s.deny).padStart(6)} ${String(s.wronglyClosed).padStart(14)} ` +
    `${String(s.leaked).padStart(6)} ${String(changed(RULE, list).length).padStart(13)} ${(g.map((k) => k[0]).join(',') || '-').padStart(14)}`);
}
for (const name of ['160 -> 95', 'specific to general']) {
  console.log(`\nflows whose outcome changes under "${name}" order:`);
  for (const a of changed(RULE, ORDER[name]))
    console.log(`  ${a[0].padEnd(40)} ${decide(RULE, a)} -> ${decide(ORDER[name], a)}  (legit: ${a[4] ? 'yes' : 'no'})`);
}
console.log(`\nthe rule text never changed across the ${RULE.length} lines; the only thing that changed is order`);
const O = RULE.filter((k) => k[0] !== 190);      // default deny conflicts with every allow line; left out
const pairs = O.flatMap((a, i) => O.slice(i + 1).filter((b) =>
  FLOW.some((x) => [a, b].every((k) => (k[1] === '*' || k[1] === x[1]) && (k[2] === '*' || k[2] === x[2]) &&
    (k[3] === '*' || k[3] === x[3]))) && a[4] !== b[4]).map((b) => [a[0], b[0]]));
console.log(`outside the default-deny line, the pairs that match the same flow and decide differently: ${pairs.map(([a, b]) => `${a}/${b}`).join(', ')}`);
```

```
order                allow   deny wrongly closed leaked changed flows shadowed rules
as written              11      5              1      0             0              -
160 -> 95                9      7              3      0             2              -
170 -> 105              12      4              1      1             1              -
specific to general     12      4              1      1             1              -
reversed                 0     16             12      0            11 180,170,160,150,140,130,120,110,100

flows whose outcome changes under "160 -> 95" order:
  management -> app shell                  allow -> deny  (legit: yes)
  management -> data shell                 allow -> deny  (legit: yes)

flows whose outcome changes under "specific to general" order:
  remote -> dmz shell                      deny -> allow  (legit: no)

the rule text never changed across the 10 lines; the only thing that changed is order
outside the default-deny line, the pairs that match the same flow and decide differently: 100/160, 160/170
```

The second row is the result of the hardening intuition. The line that denies the management shell is
moved to the top of the list; now it really does deny from everywhere. Wrongly closed goes from one to
three, and both of the two changed flows are legitimate: shell sessions going from the management zone to
the app and data zones. The move is a correction in the text and a cutoff of management access at runtime.
When the cutoff will be noticed is also predictable — at the next maintenance window.

The third and fourth rows do the same thing running the opposite way. "Specific rule first, general rule
last" is a common ordering intuition, and running it here brings the leaked count from zero to one. Why is
in the pair list in the last row: the deny line numbered a hundred sixty carries a wildcard on two axes,
the allow line numbered a hundred seventy on one. An ordering that sorts by specificity brings the allow
line forward, and the flow from the remote-access zone to the management shell gets through. The same
outcome also comes from moving the line numbered a hundred seventy forward by hand; the two moves have
different justifications and the same result.

The fifth row is the limit case. When the list is reversed, the default-deny line moves to the top, all
sixteen flows are denied, and nine rules sit shadowed. The shadowed column is therefore a health measure:
a rule that decides no flow is either unnecessary or covered by a line ahead of it. The two are different
things, and they cannot be told apart by looking at the list text; what tells them apart is running it
against the flow set.

Read together, the two move-to-front moves show the lesson's measure. Moving the hundred sixty forward cut
two legitimate flows; moving the hundred seventy forward let one illegitimate flow through. Both were
single-line changes, both had a reasonable justification, and neither could be assessed by looking at the
list's text. The only way to make them measurable was to run a known flow set against both orders and
compare the results: the changed-flows column reads two and one, respectively. Every ordering change made
without this column is a change whose outcome is learned from the next incident report.

The last row gives the two conflicting pairs: a hundred with a hundred sixty, a hundred sixty with a
hundred seventy. In a ten-rule list, only these two pairs are order-sensitive, and the same line sits in
the middle of both. As the rule count grows, the pair count grows fast; the effect of order is measured
not by looking at lines one by one but by running a known flow set.

The topic's measure completes here. Every defense counted across seven lessons ended up looking at a
field: source zone, destination zone, service name, subject identity. All of these fields are claims, and
which of them could be proven was counted in the first lesson. The rule list uses those claims but does
not prove them; the value written in the source-zone field is not proof that the packet really came from
that zone, only proof that the direction it arrived from fits the expected block. The ordering discussion
is therefore one layer deeper: the list decides not only which rule wins but also which claim is never
tested at all. While a flow sits on the line numbered a hundred, the service axis is never read.

## Summary

- As written, 15 of 16 flows resolve correctly; the one wrong closure is the quarter-close reconciliation
  that the Network Segmentation and the Demilitarized Zone lesson also cut, and here too no line looks out for it.
- The line written as "deny the management shell from everywhere" means, at runtime, "deny from everywhere
  outside the management zone": a line ahead of it decides 2 of the 4 shell flows.
- Moving that line to the top of the list is a correction in the text; at runtime it cuts 2 legitimate
  management flows and brings wrongly closed from 1 to 3.
- "Specific first, general last" ordering brings leaked from 0 to 1: because the deny line carries 2
  wildcards and the allow line 1, the allow line moves forward, and the flow from the remote-access zone
  to the management shell gets through.
- When the list is reversed, all 16 flows are denied and 9 rules sit shadowed. The rule text never changed
  in any order; the only thing that changed is order, and the order-sensitive pairs are 2 out of 10 rules.

## Next Step

Across seven lessons the perimeter was drawn, zones were split apart, inspection points were placed, and
rules were put in order. Closed paths in the zone graph were counted, the zone a tunnel lands in was
measured, the first matching rule was found flow by flow. All of these boundaries shared one property, and
it was never written down anywhere: every one of them was the boundary of a **cable**. Belonging to a zone
meant sitting at the end of a cable connected to that zone's switch; the source-zone field in the rule list
rested on this same assumption. Signal does not stop at the building's wall. The next topic is `Wireless
Security`: how far an access point's coverage reaches beyond the building's boundary, which claim can be
verified over the air, and what belonging to a zone rests on when there is no cable.
