Skip to content
academia.sh

Lesson 09 / 14

Access Control Lists

Four rule sets on the same forty flows: the seven-rule narrow set gives 40/40 correct, the six-rule wide set false-admits 2 flows, the eight-rule misordered set also admits 2 and shadows 1 rule, and the seven-rule tightened set zeroes the leak while false-denying 2 flows.

Contents

The previous lesson held the chain fixed and changed the mechanism running it. With seven rules, the stateful mechanism gave the same decision as intent on all forty flows — but we never showed that those seven rules were the right seven. It was taken on faith.

This lesson does the reverse: the mechanism is fixed, and what changes is the chain itself. An access control list is an ordered sequence of (action, criteria) pairs; the criteria requires all the fields it writes to hold at once, and a field it does not write matches any value. Its question is this: can we tell, just by looking at a rule set, how well it meets intent?

Two Things That Give the Chain Meaning

Evaluation order is as decisive as the criteria itself. The flow enters the chain from the top, the first matching rule’s action is applied, and the chain ends there. Arranging the same rules in a different order draws a different boundary.

Implicit deny stops at wherever the chain ends and is never written. Every rule in all four sets in this lesson has the permit action; the only thing that blocks is the implicit deny. This has a measurement consequence: every blocked flow is blocked from a single place, a place with no name.

The Perimeter Defense topic of the Cybersecurity curriculum taught the syntax of access control lists — which field goes where, how a list is built. That syntax is not repeated here. What is measured is not the list’s syntax but the semantics of an ordered chain itself: where the chain departs from intent in both directions at once.

Three more sets join the previous lesson’s seven-rule narrow set.

# taught rule dump , not executed
# the syntax is fictional and matches no device's actual format

wide (6) : the narrow set's first two rules collapsed into one
  1  permit  ext  dmz  *          <- 80 and 443, instead of separately
  2..6 = narrow set's rules 3..7

misordered (8) : a rule added to the front of the narrow set
  1  permit  dmz  *    *          <- new , at the very front of the chain
  2..8 = narrow set's rules 1..7

tightened (7) : the narrow set's rule 6 narrowed
  6  permit  int  ext  443        <- port 443 only, instead of every port

Rule Shadowing

The change in the third set makes one rule unreachable. When a rule that permits every flow sourced from dmz is placed at the front of the chain, no flow can reach the “only dmz to int, port 3306” rule below it — because every flow that could reach it has already matched above. This is called rule shadowing.

The definition is static: a rule is shadowed if it is fully contained by an earlier rule’s criteria. This can be said without looking at traffic at all, just by looking at the chain. Why the distinction matters will be seen after the measurement.

The measurement’s assumptions:

  • NS8 — The forty flows are taken unchanged from the shared fixture; their source, destination, and port come from the fixture. The oracle is intent itself, known because we wrote the fixture.
  • NS9 — All four sets run on the same mechanism: first match wins, the chain ends in implicit deny, every flow counts as a new connection. The previous lesson’s phase distinction is absent here.
  • NS10 — A criterion a rule does not write matches any value; the criteria it does write must all hold at once.
  • NS11 — All four sets’ rules carry the permit action. The only thing that blocks is the implicit deny; this concentrates the source of every false deny in one place.
  • NS12 — Rule shadowing is defined statically and does not look at traffic; the match counter looks only at traffic. The two are measured separately.
  • NS13 — The match counter counts where the chain cuts off: a flow stops at the first rule it matches and never enters the rules after it.
  • NS14 — In a set of forty events, the smallest measurable difference is 1/40 = 0.025.

Measurement

"""Access control lists: rule count, evaluation order, shadowing.

Part 1 - four sets on the same forty flows.
Part 2 - which rule matched how many flows, how many fell to implicit deny.
"""
SEED = 20260811
ZONES = ("ext", "dmz", "int", "mgmt")
PORTS = (80, 443, 22, 3306, 8080)


def generator(seed):
    d = seed % 2147483646 + 1

    def r(n):
        nonlocal d
        d = (d * 48271) % 2147483647
        return d % n
    return r


def flows(count=40, seed=SEED):
    r, out = generator(seed), []
    for i in range(count):
        out.append({"no": i + 1, "source": ZONES[r(4)],
                    "dest": ZONES[r(4)], "port": PORTS[r(5)]})
    return out


def policy(a):
    """Intent: only the DMZ's web entry from outside; nobody from outside to mgmt."""
    if a["source"] == "ext":
        return a["dest"] == "dmz" and a["port"] in (80, 443)
    if a["source"] == "dmz":
        return a["dest"] == "int" and a["port"] == 3306
    if a["source"] == "mgmt":
        return True
    return a["dest"] != "mgmt"


def diff(events, intent, device):
    d = {"correct_pass": 0, "correct_block": 0, "false_admit": 0, "false_deny": 0}
    for o in events:
        n, g = intent(o), device(o)
        if n and g:
            d["correct_pass"] += 1
        elif not n and not g:
            d["correct_block"] += 1
        elif g:
            d["false_admit"] += 1
        else:
            d["false_deny"] += 1
    return d


def rule_chain(rules):
    """First match wins; implicit deny at the end."""
    def run(a):
        for action, criteria in rules:
            if all(a[k] == v for k, v in criteria.items()):
                return action
        return False
    return run


def shadowed_rules(rules):
    """The index of every rule fully covered by a rule ahead of it."""
    out = []
    for i, (_, criteria) in enumerate(rules):
        for _, earlier in rules[:i]:
            if all(criteria.get(k) == v for k, v in earlier.items()):
                out.append(i)
                break
    return out


def shadowed_count(rules):
    return len(shadowed_rules(rules))


def trace(rules, ak):
    """How many flows each rule matched, and how many fell to implicit deny."""
    counts, denied = [0] * len(rules), 0
    for a in ak:
        for i, (_, criteria) in enumerate(rules):
            if all(a[k] == v for k, v in criteria.items()):
                counts[i] += 1
                break
        else:
            denied += 1
    return counts, denied


NARROW = [(True, {"source": "ext", "dest": "dmz", "port": 80}),
          (True, {"source": "ext", "dest": "dmz", "port": 443}),
          (True, {"source": "dmz", "dest": "int", "port": 3306}),
          (True, {"source": "mgmt"}),
          (True, {"source": "int", "dest": "dmz"}),
          (True, {"source": "int", "dest": "ext"}),
          (True, {"source": "int", "dest": "int"})]

WIDE = [(True, {"source": "ext", "dest": "dmz"})] + NARROW[2:]
MISORDERED = [(True, {"source": "dmz"})] + NARROW
TIGHTENED = NARROW[:5] + [(True, {"source": "int", "dest": "ext", "port": 443})] + NARROW[6:]

SETS = (("narrow", NARROW), ("wide", WIDE),
        ("misordered", MISORDERED), ("tightened", TIGHTENED))
AK = flows()

print(f"flow {len(AK)} | intent passes {sum(1 for a in AK if policy(a))} "
      f"| intent blocks {sum(1 for a in AK if not policy(a))}")
print()
print(f"{'rule set':<14s} {'rules':>5s} {'shadowed':>8s} {'implicit deny':>13s} "
      f"{'correct':>7s} {'false admit':>11s} {'false deny':>11s}")
for name, k in SETS:
    d = diff(AK, policy, rule_chain(k))
    _, denied = trace(k, AK)
    print(f"{name:<14s} {len(k):5d} {shadowed_count(k):8d} {denied:13d} "
          f"{d['correct_pass'] + d['correct_block']:7d} {d['false_admit']:11d} "
          f"{d['false_deny']:11d}")

print()
print("narrow set, matches per rule:", trace(NARROW, AK)[0])
print("misordered set, matches per rule:")
counts, denied = trace(MISORDERED, AK)
shadow = shadowed_rules(MISORDERED)
for i, ((_, criteria), n) in enumerate(zip(MISORDERED, counts)):
    print(f"  {i + 1}. {str(criteria):<48s} {n:3d}"
          f"  {'shadowed' if i in shadow else ''}".rstrip())
print(f"  implicit deny{'':40s} {denied:3d}")

print()
for name, k in SETS[1:]:
    d = rule_chain(k)
    print(f"{name}: false admit",
          [(a["no"], a["source"], a["dest"], a["port"])
           for a in AK if d(a) and not policy(a)],
          "| false deny",
          [(a["no"], a["source"], a["dest"], a["port"])
           for a in AK if policy(a) and not d(a)])
flow 40 | intent passes 22 | intent blocks 18

rule set       rules shadowed implicit deny correct false admit  false deny
narrow             7        0            18      40           0           0
wide               6        0            16      38           2           0
misordered         8        1            16      38           2           0
tightened          7        0            20      38           0           2

narrow set, matches per rule: [0, 1, 0, 11, 4, 3, 3]
misordered set, matches per rule:
  1. {'source': 'dmz'}                                  2
  2. {'source': 'ext', 'dest': 'dmz', 'port': 80}       0
  3. {'source': 'ext', 'dest': 'dmz', 'port': 443}      1
  4. {'source': 'dmz', 'dest': 'int', 'port': 3306}     0  shadowed
  5. {'source': 'mgmt'}                                11
  6. {'source': 'int', 'dest': 'dmz'}                   4
  7. {'source': 'int', 'dest': 'ext'}                   3
  8. {'source': 'int', 'dest': 'int'}                   3
  implicit deny                                          16

wide: false admit [(3, 'ext', 'dmz', 3306), (18, 'ext', 'dmz', 8080)] | false deny []
misordered: false admit [(23, 'dmz', 'mgmt', 8080), (39, 'dmz', 'mgmt', 8080)] | false deny []
tightened: false admit [] | false deny [(30, 'int', 'ext', 3306), (35, 'int', 'ext', 3306)]

Counting Rules Does Not Measure Policy

Reading the four rows by rule count gives the order 6, 7, 7, 8. Reading them by correct count gives 38, 40, 38, 38. There is no link between the two orderings.

Seven rules were written twice and gave two different results. The narrow set is 40/40 correct; the tightened set, with the same rule count, gets 38. Eight rules are not better than seven: the misordered set is one rule longer and stayed at 38. Six rules are not simpler than seven: the wide set is one rule shorter and is also 38. A set’s length says nothing about its deviation from intent.

The direction of the deviation cannot be read from the set either. All three flawed sets give 38 correct, but:

Wide set: 2 false admit, 0 false deny. A rule’s port field was deleted, and two flows going from ext to dmz — one on 3306, one on 8080 — passed although intent would have blocked them. Rule count dropped by one; the open surface grew by two flows.

Misordered set: 2 false admit, 0 false deny. The rule added to the front of the chain permits everything sourced from dmz; two flows from dmz to the management zone leaked through this way. Yet this was intent’s most definite statement: nobody from outside to the management zone.

Tightened set: 0 false admit, 2 false deny. No leak here at all — flawless on paper. The cost shows up in the other column: two flows going from int to ext were cut because their port was not 443, and intent would have passed both. An audit that only counts leaks cannot tell this set apart from the narrow set.

Two flows each are a share of 0.050 in a set of forty; since the smallest measurable difference is 0.025, both are within the measurement band.

Zero Matches Says Two Different Things

The lower table shows the chain’s operation rule by rule. Two rules in the misordered set match 0 flows: the second rule (ext dmz 80) and the fourth rule (dmz int 3306). The counter writes the same thing for both. Their reasons are not the same.

The second rule is reachable; none of the forty flows happens to match it. It would match if the traffic changed. The fourth rule, however, is unreachable: every flow that could reach it is cut off at the first rule. It will not match no matter what the traffic is. The counter cannot tell these two apart; static scanning can, and it flags only the fourth one in the last column. In the narrow set, too, the third rule matches 0 flows, and there is no shadowed rule there — the same observation, a different reason.

Had the shadowed rule’s action been different from the one shadowing it, the result would not have been this quiet. All four sets in this lesson had every rule carry the permit action (NS11). In a mixed chain, a deny rule written underneath a wide permit rule is never applied even though it was written; the chain keeps open a path its author thought was closed. Static scanning flags both cases the same way — comparing actions is not the scan’s job — but the weight of the flag is not the same: in one case only readability suffers, in the other, policy itself breaks.

The shadowed rule itself does no harm in this measurement. It carries the same action as the rule shadowing it, so deleting it changes no decision across the forty flows. The rule doing the harm is the shadowing one, and its cost is paid elsewhere: in the two flows leaking into the management zone. The shadowed rule is a symptom, not the leak itself. What the symptom says is this: someone reading the chain sees the line “dmz to int, only the data entry is open” and believes it, when two lines above, that zone has already been opened entirely. The rule chain does not run the way it reads.

Surfaces Left Open, and Narrowings

First surface — a deleted criteria field. In the wide set, deleting the port field saved one rule and leaked two flows. Narrowing: write the criterion. The cost is exactly one rule, and in exchange false admit drops from 2 to 0. Simplification achieved by deleting a criteria field looks like an improvement to an audit that reads rule count.

Second surface — a wide rule placed at the front. When a rule opening an entire zone is placed at the very front, it neutralizes every narrowing rule that follows it. Narrowing: place rules by criteria scope, not by insertion order — narrowly-scoped rules first, wide ones later — and run the chain through static scanning after every change. The scan catches the shadowed rule in this set and points at the wide rule.

Third surface — the silence of implicit deny. In the tightened set, implicit deny cuts 20 flows; 18 of them are flows intent also cuts, 2 are false denies. Both are cut from the same place, a place with no name, and neither has a rule name standing against it. Narrowing: write a rule at the end of the chain whose action is deny and that leaves every criterion open. It changes no decision — implicit deny was already doing the same thing — but it makes the blocked flow countable. False deny can only be measured where it can be counted.

Summary

  • An access control list’s meaning does not come from its criteria alone, but from the chain the criteria and the evaluation order build together; the chain ends in an unwritten implicit deny.
  • On the same forty flows, the narrow set gives 40/40 correct with 7 rules; the wide set produces 2 false admits with 6 rules; the misordered set produces 2 false admits and 1 shadowed rule with 8 rules; the tightened set produces 2 false denies with 7 rules.
  • There is no link between rule count and deviation from intent: seven rules were written twice, one gave 40 correct, the other 38. Counting rules does not measure policy.
  • The direction of the deviation cannot be read from the set either; all three flawed sets give 38 correct, but two of them false-admit and one false-denies.
  • Zero matches comes from two different causes — an unused rule and an unreachable rule — and the counter cannot tell them apart; rule shadowing is visible only through static scanning.
  • A shadowed rule is a symptom; what produces the leak is the wide rule shadowing it.

Next Step

Every rule in this lesson was written over four names: ext, dmz, int, mgmt. These names came from the fixture and were never questioned — as if the network’s zones were just given. But the number of zones is a design decision: the same network can be split into two zones, or six. The next lesson derives rules from a fixed intent and changes the zone graph, measuring two things at once: how many rules are needed as the zone count grows, and which direction merging or splitting zones shifts the deviation from intent.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close