Roman

Roman

If you are distressed by anything external, the pain is not due to the thing itself, but to your estimate of it; and this you have the power to revoke at any moment ― Marcus Aurelius, Meditations

Anabasis, Book 7 - Τὰ δεδογμένα

Veröffentlicht am in Posts
Aktualisiert am
Kurz-Link: https://rospies.dev/blog/s/f
Serie: AnabasisTeil 7 von 8
  1. 1Θάλαττα! Θάλαττα!
  2. 2Σταθμοὺς τρεῖς, παρασάγγας πεντεκαίδεκα
  3. 3Ἔστησαν ἀγνοοῦντες
  4. 4ὁ δʼ ἑρμηνεὺς εἶπε περσιστὶ
  5. 5Οὐκ ἄβατόν ἐστι τὸ ὄρος
  6. 6Καὶ διηγοῦνται ταὐτά
  7. 7Τὰ δεδογμένα
  8. 8Δίκην ὑποσχεῖν
Diesen Beitrag teilen
A ⇄ 文

ὅτῳ δοκεῖ ταῦτα, ἀνατεινάτω τὴν χεῖρα. ἔδοξε ταῦτα. νῦν τοίνυν, ἔφη, ἀπιόντας ποιεῖν δεῖ τὰ δεδογμένα. - “Those in favour of this resolution, hold up their hands.” The resolution was carried. “And now,” said he, “it would be well to separate and carry out what we have decreed.”

Book three, chapter two. The generals have been seized under safe conduct, Clearchus is dead, and the army that woke that morning is an army bereft of anyone to command it, whereupon it does what Greeks do in extremity and holds an assembly, and the Athenian who until that week had held no command whatsoever rises to speak. He speaks at length, and much of it is what one would anticipate from the occasion - courage, the gods, the enemy’s perjury and the Greeks’ fidelity to their oaths. What I find remarkable lies near the close, and it has nothing whatever to do with courage: it is an administrative proposal. March in a hollow square with the baggage in the middle; let Cheirisophus lead, being a Lacedaemonian; the two eldest generals take the flanks, he and Timasion, the two youngest, the rear - and settle all of it now, so that, when the enemy appears, we shall not need to deliberate, but can at once set in motion the machinery in existence.

He then appends two sentences which, for a speech delivered to a leaderless army in hostile country, are almost disarmingly modest: we can but make experiment of this arrangement, and alter it with deliberation, as from time to time any improvement suggests itself. If any one has a better plan to propose, let him do so. Nobody has. He calls for a show of hands - the second of the morning, the first having been for a vow to the gods - the motion carries, and he dismisses them to carry out what they have decreed.

Settled beforehand rather than in the instant the cavalry appears, laid before the assembly - where anyone with a better notion was at liberty to say so -, amended with deliberation and not on caprice, carried by a show of hands, and thereafter executed - by people spared the labour of re-deriving the plan each time something came over the hill. If a better account exists of what a policy specification ought to be, I have not met it, and this post concerns ours.

A policy is not the sum of its rules

The first post of this series maintained that a list of rules is not a policy, and the five since have been given over to the machinery by which a list of rules becomes a set of packets. That set is what the firewall does. What it is meant to do must be supplied from elsewhere, and set down in a form indifferent to the phrasing of the rules - otherwise, one has merely transcribed the ruleset a second time, its every accident faithfully preserved.

The specification I run against the production dump of Book one opens with a comment that states the matter more economically than I am about to:

What the host’s operator meant this firewall to do, stated as flows rather than as rules. The point of writing it this way is that none of it depends on how the ruleset is phrased: reorder the rules, replace a chain with a verdict map, move the blocklist into a set, and every assertion below still means the same thing.

That is the design constraint entire. A specification speaks of packets, never of rules; of who reaches what, never of which line says so. The remainder of the language is what follows from taking that constraint at its word.

Three addresses and a port

Book six closed on a promise: a post on how one commits “only these three addresses may reach SSH” to a form a machine can decide and a human can review in a pull request. Let us begin there, then, and with a firewall that gets it wrong.

The ruleset under examination is as small as realism permits: an ip filter table, an input chain with policy drop, established and related traffic accepted, SSH accepted from a set of source addresses, HTTPS accepted from anywhere. The set of sources holds the three administrators, 192.0.2.10 through .12 - and a fourth, 198.51.100.7, which somebody added and everyone else forgot about. Everyone has met that notorious fourth address - the invariably temporary device that nobody ever remembered to remove.

Here, then, is the specification:

apiVersion: anabasis/v1
kind: Profile

metadata:
  name: ssh-admins
  description: Three administrators reach SSH, and nobody else does.

addresses:
  admins: [192.0.2.10, 192.0.2.11, 192.0.2.12]

services:
  ssh: {proto: tcp, dport: [22]}
  https: {proto: tcp, dport: [443]}

defaults:
  ct_state: [new]
  severity: error

assertions:
  # Nobody but the administrators reaches SSH.
  - id: ssh-only-from-admins
    kind: deny
    flow: {family: ip, path: input, service: ssh, src: {not: [admins]}}

  # And the administrators actually do: a firewall that drops everything
  # satisfies the assertion above just as well.
  - id: admins-reach-ssh
    kind: allow
    flow: {family: ip, path: input, service: ssh, src: [admins]}

  # Nothing over TCP beyond these two flows, whatever the port.
  - id: nothing-else-over-tcp
    kind: only
    flow: {family: ip, path: input, proto: tcp}
    flows:
      - {service: ssh, src: [admins]}
      - {service: https}

Three assertions, of which the first two ought to be read as a pair before they are read singly. The deny holds that nobody outside the address book reaches port 22, and a firewall that discards every packet it ever sees satisfies it to perfection. That is no deficiency in deny; it is what the word means. The claim “only these three may reach SSH” is in truth two claims soldered together - that the others may not, and that these three may - and the language obliges you to write both, since a tool that tacitly read one as entailing the other would be conjecturing your intent, and conjecture is the one thing this series has been at pains to renounce.

The third assertion is a claim of another order, to which I shall presently return. First the run, with nothing omitted but the file path and the fingerprint lines:

Anabasis compliance report
  profile: ssh-admins
  nft:     1.1.7
  host:    assumed (not read)

Violations
  [fail] nothing-else-over-tcp (only input, ip)
      traffic outside the permitted flows is accepted
      example: ip tcp 198.51.100.7:0 -> 0.0.0.0:22 ct state new iif <other>
      traffic: ct state new l4proto tcp saddr 198.51.100.7 dport 22
      packets: 7.92e+28
      decided by ip/filter/input handle 3: accept [match saddr == 192.0.2.10 192.0.2.11 192.0.2.12 198.51.100.7 match(implicit) l4proto == 6 match dport == 22 verdict accept]
  [fail] ssh-only-from-admins (deny input, ip)
      traffic the specification forbids is accepted
      example: ip tcp 198.51.100.7:0 -> 0.0.0.0:22 ct state new iif <other>
      traffic: ct state new l4proto tcp saddr 198.51.100.7 dport 22
      packets: 7.92e+28
      decided by ip/filter/input handle 3: accept [match saddr == 192.0.2.10 192.0.2.11 192.0.2.12 198.51.100.7 match(implicit) l4proto == 6 match dport == 22 verdict accept]

Assumptions
  the input path: the host's routing table, forwarding switches, reverse-path filtering and bridge netfilter state were not read, so every packet was taken to be deliverable and forwardable

2 failed, 0 undecided, 1 satisfied, 0 not applicable

Both the deny and the only catch the forgotten-about laptop, name the rule that admits it, and produce a concrete packet in evidence. Strike 198.51.100.7 from the set and the report contracts to its assumptions and a single line - 0 failed, 0 undecided, 3 satisfied, 0 not applicable - exit code 0.

Now leave the SSH set as it stands and have somebody add tcp dport 8080 accept for a service they were testing:

Violations
  [fail] nothing-else-over-tcp (only input, ip)
      traffic outside the permitted flows is accepted
      example: ip tcp 0.0.0.0:0 -> 0.0.0.0:8080 ct state new iif <other>
      traffic: ct state new l4proto tcp dport 8080
      packets: 3.4e+38
      decided by ip/filter/input handle 5: accept [match(implicit) l4proto == 6 match dport == 8080 verdict accept]

1 failed, 0 undecided, 2 satisfied, 0 not applicable

The deny is satisfied, and rightly: nobody but the administrators reaches SSH, which is all it ever asserted. The only fails, because its assertion was of a far stronger kind - that on this path, over this protocol, nothing is accepted beyond the flows enumerated. “Nobody else reaches this service” and “nothing else is reachable” are distinct propositions, and an audit is usually in pursuit of the second, which is precisely the one nobody troubles to write down, for the simple reason that writing it requires one to know the whole of one’s intent rather than a fragment of it, which is difficult enough in current-day teamwork environments.

What a flow denotes

Book two argued that a packet is a point in a space Σ of some hundred and fifty boolean dimensions, and that anything worth asking of a firewall is a question about subsets of that space. A flow descriptor is the specification’s means of naming such a subset. Each key restricts one field; the flow is whatever satisfies every restriction at once; a field the flow leaves unmentioned it leaves unrestricted.

Selectors come in two forms, and the header that declares them prescribes how they are to be read:

The two forms read as they would be said: src: [bastion] is “from the bastion”, src: {not: [bastion]} is “from anywhere but the bastion”.

The second form merits a pause, “not” being the place where hand-written firewall reasoning most reliably comes to grief. {not: [admins]} does not mean “anything whatever, provided it is not an administrator”; it means the complement of the administrators within the domain of that single field, intersected with everything else the flow stipulates. Written out, with D_f the domain of field f and B(n) the addresses for which an address-book entry n stands:

𝒮︀ f ( ¬ N ) = D f ∖ ⋃ n ∈ N ℬ︀ ( n ) , ℱ︀ ( 𝑓 𝑙 𝑜 𝑤 ) = ⋂ f { p ∈ Σ : p f ∈ 𝒮︀ f } \mathcal{S}_f(\neg N) \;=\; D_f \setminus \bigcup_{n \in N} \mathcal{B}(n), \qquad \mathcal{F}(\mathit{flow}) \;=\; \bigcap_{f} \{\, p \in \Sigma \;:\; p_f \in \mathcal{S}_f \,\}

Hence src: {not: [admins]} together with service: ssh denotes every packet bound for TCP port 22 whose source is any of the 2³² − 3 remaining IPv4 addresses - in principle, at any rate - and not a packet more. For addresses and ports, D_f is the evident numeric range. For interfaces the matter is more interesting, interface names not being numbers: the domain comprises every interface the ruleset names, together with the host’s when the host was read, and one further symbol standing for every interface nobody names. That symbol is the iif <other> of the examples above, and it is how the tool may say “some interface this ruleset never mentions” without inventing one.

The address and service books are no mere convenience laid over this; they are what keeps a specification reviewable. They are expanded when the flow is evaluated and not an instant before, so that the document on disk continues to say admins rather than three dotted quads. The design notes state the reason in a single phrase: the diff should say that “mgmt grew by one prefix”, not present a wall of numeric noise that happens to mean the same. The language accordingly treats an address-book entry much as a programming language treats a named constant, declared once and referred to by name thereafter.

defaults is the one place a flow inherits what it did not say. The specification above sets ct_state: [new], which is why every finding implicitly carries ct state new: a firewall that accepts established traffic from all quarters is behaving exactly as it should, and a specification that neglected to exclude it would fail on every host it was ever pointed at. Only the connection state and the severity admit of a default, and the code’s reason is brief enough to quote whole:

Only fields whose absence means a default are worth inheriting; a field whose absence means “anything” is already as wide as the scope that contains it.

Assertion language

The language recognises seven kinds of assertion, and they divide into two families answering questions of fundamentally different character.

Four concern what the firewall does, and they are the ones this series has been building towards: deny, allow, allow-some and only. Book three set out the judge behind them - two set expressions per kind, one for what certainly happens and one for what possibly does - and I shall not rehearse the table. allow-some is the sole member of the four not yet encountered; it requires that at least one packet of the flow be accepted, and the header names it for what it is: “A reachability smoke test: ’the service replies to someone’, without claiming who.”

The remaining three - require, forbid and assert - concern how the policy is written. require demands that some rule matching a pattern exist, forbid that none does, and assert that the objects a pattern selects possess stated properties, at least one such object being present. They exist because certain questions are irreducibly syntactic. “Is the input chain’s policy drop?” is a question about a chain, not about a set of packets, and “does anything hand packets to a userspace queue?” is a question about the presence of a verdict. The production specification carries both:

  - id: input-policy-is-drop
    kind: assert
    references: ["CIS 3.5.1"]
    base_chain:
      family: [ip, ip6, inet]
      hook: input
      policy: drop

  - id: nothing-is-handed-to-user-space
    kind: forbid
    pattern:
      verdict: queue

Syntactic assertions have a blind spot, and I would sooner declare it here than have it discovered in an incident review. A rule may exist and accomplish nothing whatever, an earlier rule having already claimed every packet it could have matched; require is satisfied by such a rule nonetheless, since it asks whether the rule is present, not whether it is of any significance. The doc comment on the enum says as much without hedging:

A statement about how the policy is written, not about what it achieves in a given environment - and one that a shadowed rule satisfies while doing nothing. Pair it with a deny for the same intent.

The production specification heeds its own advice. Its require - that the CrowdSec blocklist be consulted by a drop rule on the input hook - stands beside a comment naming the flow assertion that says what the rule must accomplish:

Paired with blocklisted-sources-reach-no-published-service, which asserts that the blocklist actually achieves what it sets out to achieve.

assert has a second province altogether, and its origins lie in Book five. There the host beneath the ruleset - routing, forwarding switches, reverse-path filtering, bridge netfilter - proved to decide which packets ever reach a chain at all, and the report lists those facts as assumptions whenever a verdict rests on them. An assert with a host: block converts such an assumption into something that fails the moment it ceases to hold: host: {reverse_path: {interface: wan0, mode: strict}}. And should the host not have been consulted at all, the claim is not silently scored as satisfied; it returns inconclusive, with the explanation that “the host was not read, so what it does is not established either way”.

Reviewable, which is to say boring to diff

The second half of Book six’s promise was that a developer could review this in a pull request, and that proves to be as much a question of form as of content. Two people writing the same intent will write it differently - keys in another order, addresses unsorted, a prefix with its host bits still set - and the reviewer is then left to determine whether a diff changes anything at all, which is precisely the labour a specification was meant to spare.

Every specification therefore has a canonical form, and anabasis spec fmt prints it. Here is one written as specifications customarily are:

kind: Profile
apiVersion: anabasis/v1
metadata: {name: untidy}

addresses:
  admins: [192.0.2.12, 192.0.2.10, 192.0.2.11, 192.0.2.10]
  mgmt: [10.20.30.40/16]

services:
  ssh: {proto: tcp, dport: [22]}

assertions:
  - id: ssh-only-from-admins
    kind: deny
    flow: {family: ip, path: input, service: ssh, src: {not: [mgmt, admins]}}
  - id: admins-reach-ssh
    kind: allow
    flow: {path: input, family: ip, src: [admins], service: ssh}

and here is what returns:

apiVersion: anabasis/v1
kind: Profile
metadata:
  name: untidy
  description: ''
  references: []
extends: []
addresses:
  admins:
    - '192.0.2.10'
    - '192.0.2.11'
    - '192.0.2.12'
  mgmt:
    - '10.20.0.0/16'
services:
  ssh:
    proto: tcp
    dport:
      - 22
interfaces: {}
defaults:
  ct_state: []
assertions:
  - id: admins-reach-ssh
    kind: allow
    flow:
      family: ip
      path: input
      service: ssh
      src:
        - admins
    references: []
  - id: ssh-only-from-admins
    kind: deny
    flow:
      family: ip
      path: input
      service: ssh
      src:
        not:
          - admins
          - mgmt
    references: []
disable: []
suppressions: []

Assertions sorted by id; the duplicated administrator gone; the addresses sorted; 10.20.30.40/16 normalised to 10.20.0.0/16, a prefix with host bits set being either a typographical error or a misapprehension, and in neither case something a reviewer ought to have to detect; the names within not sorted likewise; and the names themselves untouched, since the diff must remain symbolic. It is complete rather than minimal, moreover: every key is written out, empty or otherwise, so that a field added later appears as a changed line rather than as a line materialising from nowhere.

The comment at the head is gone, and that is the formatter’s deliberate doing, and the reason it prints to standard output rather than rewriting the file in place. From its header:

Deliberately returns the text rather than writing it back: the document model holds no comments whatsoever, and a formatter that rewrote the file in place would delete the reasoning profiles are mostly made of.

For CI there is --check, which compares meanings rather than bytes:

[error] untidy.yaml: not in canonical form at line 10 of the canonical form
  written:       - '192.0.2.12'
  canonical:     - '192.0.2.10'

exit code 1, and 0 once the file is canonical. The header explains why the comparison runs in that direction:

Comments, blank lines, key order and quoting are not differences: both sides of the comparison are rendered, so only what the document means can diverge. A textual comparison would alert on every profile containing a single comment, which is not the purpose of this exercise.

That last clause is one I return to often, and it will reappear below, for it is the whole theory of suppressions as well.

Inheritance, and who wins

Real fleets comprise more than one host, and most share a baseline. A profile may therefore extends: others, and the rule of composition is the one you would expect: bases are read depth first, in the order listed; the document itself is merged last and prevails; and assertions are matched by id, so that a child declaring ssh-only-from-admins supplants the base’s assertion of that name outright rather than being merged with it field by field, which would manufacture a third assertion nobody had signed off on. disable: [id] removes an assertion, and is applied once, after all merging is done.

With ◁ for “right prevails on a shared id” and A₁ … A_k the assertion lists of the bases, in order, followed by the document’s own A_d:

A ◃ B = B ∪ { a ∈ A : i d ( a ) ∉ i d ( B ) } , A 𝑚 𝑒 𝑟 𝑔 𝑒 𝑑 = ( A 1 ◃ ⋯ ◃ A k ◃ A d ) ∖ { a : i d ( a ) ∈ 𝑑 𝑖 𝑠 𝑎 𝑏 𝑙 𝑒 } A \triangleleft B \;=\; B \,\cup\, \{\, a \in A \;:\; \mathrm{id}(a) \notin \mathrm{id}(B) \,\}, \qquad A_{\mathit{merged}} \;=\; \big(A_1 \triangleleft \cdots \triangleleft A_k \triangleleft A_d\big) \setminus \{\, a \;:\; \mathrm{id}(a) \in \mathit{disable} \,\}

anabasis spec fmt --merged prints the result, since the effective policy of a host assembled from several profiles is the merge and not any one of its constituent files, and it is the merge to which a reviewer must in the end assent.

One particular from that quarter. Every file carries an apiVersion, and it is checked for each file, not merely on the merged result. A base left lying about since an earlier version of the format, beneath a child that says anabasis/v1, is refused by name:

[error] base-old.yaml: unsupported apiVersion: 'anabasis/v1alpha1' is not 'anabasis/v1'

A misconfiguration of this kind yields exit code 3; to check only the merged document would have been to let the old base be inherited in silence, under a version string it never subscribed to.

Overriding a finding

Sooner or later every compliance tool must confront the question of the laptop. The fourth address is known, it is temporary, somebody has signed off on it, and the pipeline has been red for a week. If the tool affords no way of accepting that finding, the tool is switched off - not out of malice but as a matter of course, the alternative being that nobody can merge anything, or that Mimir keeps raising an alert which somebody silences within the hour. A gate that cannot be quieted selectively is a gate that some weary engineer switches off in self-defence, and the header on suppressions says as much.

What matters is what one accepts, or rather, suppresses. To accept the assertion - “cease checking ssh-only-from-admins” - would be to accept the engineer’s laptop, and then the next address somebody adds, and the one after, none of them having been looked at. A suppression is accordingly keyed on the finding’s fingerprint, which the report prints beneath every finding:

Keying on the fingerprint rather than on the assertion is deliberate: an assertion covers every packet it describes, and accepting one violation of it must not silently accept the next one.

The fingerprint is a SHA-256 over a fixed prefix (anabasis/assertion/v1), the assertion’s id, the family, the path, the status, and the rendered witness. The witness is what distinguishes one violation from the next, and the runs above bear it out: the only assertion failed twice, once for the laptop and once for port 8080, and it carried two different fingerprints, 788ae76a… and d6a551ce…. Accept the first, and the second fails still.

The reader may by now be wondering how this can hold, given the untold number of candidate witnesses Satisfy-one might return for any one region. The implementation anticipates the question, and explains why the stability of the witness is a matter of design rather than of chance:

Deterministic by construction: at every node the zero branch is preferred, and skipped levels are assigned zero. Counterexamples therefore stay identical across runs and machines, which is what lets a finding fingerprint be stable.

A suppression then requires three things: the fingerprint entire, a reason, and optionally a date on which it lapses. The fingerprint entire, because a prefix that chanced to match two findings would accept one that nobody examined; a reason, to spare the next engineer who opens the file a premature greying; a date, lest a forgotten suppression harden into a permanent gap in the monitoring:

suppressions:
  - fingerprint: a63af289f7d2ab508656c20377c761bd6ca38de53611f2843b0ada1637771785
    reason: the fourth address is the on-call laptop until the VPN migration lands
    expires: "2026-12-31"
  - fingerprint: 0000000000000000000000000000000000000000000000000000000000000000
    reason: accepted a finding that has since been fixed

The deny finding remains listed - accepted, not concealed - but it no longer counts as a failure:

  [fail] accepted ssh-only-from-admins (deny input, ip)
      traffic the specification forbids is accepted
      accepted: the fourth address is the on-call laptop until the VPN migration lands (until 2026-12-31)
      ...

Acceptances that no longer hold
  matches nothing: accepted a finding that has since been fixed (0000000000000000000000000000000000000000000000000000000000000000)

1 failed, 0 undecided, 1 satisfied, 0 not applicable, 1 accepted

The pipeline stays red regardless, the only assertion having a finding of its own with a fingerprint of its own that nobody accepted - which is exactly as it should be, for to accept the laptop in respect of one claim is not to accept it in respect of all. The second suppression is reported as matching nothing: the finding it was written for has since been fixed, and a suppression that suppresses nothing is clutter at best and, at worst, a fingerprint lying in wait for a coincidence.

Now move the date into the past - the migration overran, as migrations do - and the acceptance simply lapses:

  [fail] ssh-only-from-admins (deny input, ip)
      traffic the specification forbids is accepted
      ...

Acceptances that no longer hold
  expired on 2026-09-01: the fourth address is the on-call laptop until the VPN migration lands (a63af289f7d2ab508656c20377c761bd6ca38de53611f2843b0ada1637771785)
  matches nothing: accepted a finding that has since been fixed (0000000000000000000000000000000000000000000000000000000000000000)

2 failed, 0 undecided, 1 satisfied, 0 not applicable

The finding is back, marked failed once more, with the reason given at the time still attached, so that whoever reads it the morning after is spared an archaeological expedition through the Git history to learn what was agreed and on what grounds.

Book one closed its complaint about byte-comparing drift detection with the observation that upgrading nft makes “every suppression you’ve ever written” lapse at a stroke. This is the obverse of that complaint. A fingerprint composed of an id, a path and a packet contains nothing an nft upgrade can disturb: reorder the rules, re-spell the sets, fold the chain into a verdict map, and the laptop’s fingerprint remains what it was, because the laptop remains the same packet, and Satisfy-one is bound to the same discipline.

The v1 promise, and what it costs

A specification is a file somebody commits, and a committed file typically outlives the version of the tool that first read it. anabasis/v1 is therefore a contract, and the constant that carries it is documented as one:

Frozen: within v1 a field, an assertion kind or a host-claim fact may be added, and nothing may be renamed, removed or given a different meaning.

Talk, as Torvalds observed, is cheap, so two tests exist to make this particular contract expensive to break. A committed JSON schema is compared against the structs the parser reads into, and fails when the two drift apart. That alone will not suffice - rename a field in the struct, regenerate the schema, and both sides move in concert and agree with one another perfectly, the same shared-misreading problem for which the kernel oracle of Book six exists. The other half is therefore a file called surface-v1.yaml, which exercises every key of the v1 surface at least once, its canonical form committed alongside. Its header:

Not a profile anybody would run: it exists so that removing or renaming a field breaks a test rather than a user.

The promise has a price, and it is worth being exact about the direction in which it runs. The parser rejects keys it does not recognise, so a typographical error in a key name is an error and not a field silently ignored:

[error] ssh-typo.yaml: the specification does not parse: 18:14: unknown_key
     serverity: warning
                ^

Exit code 3 once more. I would not have it otherwise - a misspelt severity that passes unnoticed is a warning that was meant to be an error, and an error that nobody will ever see - yet it follows that a document using a field introduced in a later v1 release is rejected by an older build in precisely the same fashion. Additive change means that a newer build reads every older document; it does not mean that an older build reads every newer one. Whoever pins the tool to a version needs to know as much, and it is better said here than learnt the hard way.

The output

A verdict of “fail” unaccompanied is a work item without a description, and the report is built to proffer as much as can be established about each finding.

The example is the witness: one concrete packet from the violating set, the lexicographically least, which is the quotation above at work. With the fields laid out most significant bit first, “least” means the lowest address and the lowest port the violating set contains, whence the examples read 198.51.100.7:0 -> 0.0.0.0:22 - source port 0 and destination 0.0.0.0 are not claims about real traffic, merely what results when every field the violation is indifferent to is left at zero. The field that is not zero is the one carrying the bits that matter.

The traffic line is the whole violating region, rendered as boxes - one conjunction of field ranges per line - rather than as a lone packet. The region code merges them where it can; the header phrases it as “two boxes that differ in a single bit of a single field describe one box with that bit freed”. Here is explain --flow on the corrected ruleset, asking about new TCP to port 22 without reference to any assertion:

Flow on the input path, ip

Accepted (2.38e+29 packets)
  ct state new l4proto tcp saddr 192.0.2.10/31 dport 22
  ct state new l4proto tcp saddr 192.0.2.12 dport 22
  example: ip tcp 192.0.2.10:0 -> 0.0.0.0:22 ct state new iif <other>
  decided by ip/filter/input handle 3: accept [match saddr == 192.0.2.10 192.0.2.11 192.0.2.12 match(implicit) l4proto == 6 match dport == 22 verdict accept]

Refused (3.4e+38 packets)
  ct state new l4proto tcp saddr 0.0.0.0/1 dport 22
  ct state new l4proto tcp saddr 128.0.0.0/2 dport 22
  ct state new l4proto tcp saddr 224.0.0.0/3 dport 22
  ct state new l4proto tcp saddr 208.0.0.0/4 dport 22
  (further regions not listed)
  example: ip tcp 0.0.0.0:0 -> 0.0.0.0:22 ct state new iif <other>
  decided by ip/filter/input: policy drop

.10 and .11 differ in their final bit and have coalesced into a /31; .12 has no such neighbour and stands alone. The refused side is the remainder of the address space carved into prefixes, and a great many would be needed to describe it exhaustively, so the listing is cut at --max-boxes and says so - rather than printing four screens of prefixes or, worse, falling silent without remark.

The packets line is Satisfy-count from Book two, and it is easily misread, so let me be exact. It is the number of points of the modelled packet space that fall within the region: every bit the flow leaves open doubles it. 7.92e+28 is 2⁹⁶, which is what remains once source address, protocol, destination port and connection state are fixed; the corrected ruleset’s accepted SSH region is thrice that, one 2⁹⁶ per administrator, whence the 2.38e+29 above. It is not a count of packets anyone will ever send, and it is not a measure of risk. What it is good for is ranking: a finding whose region is one source address wide and one whose region is half the Internet are different problems altogether, and the count tells them apart at a glance.

Decided by is the trace, reduced to the step that settled the packet: the chain, the handle, and the rule as it reads.

An export for the dashboard

A report on the terminal is for the person who ran it. Code-scanning platforms want SARIF, and Book three has already covered the one mapping that called for thought - an inconclusive finding becomes kind: review. What remains is the question of where a finding is located, and here I made a choice worth defending: the primary location is the specification file and the line of the assertion, not the ruleset. The ruleset is a dump from a running kernel; the specification is the artefact under version control, the one a pull request touches and a reviewer annotates, and a code-scanning platform can annotate only the files it possesses. The nft objects go into logical locations instead:

{
  "ruleId": "spec:ssh-only-from-admins",
  "kind": "fail",
  "level": "error",
  "message": {
    "text": "traffic the specification forbids is accepted (ip, input)"
  },
  "locations": [
    {
      "physicalLocation": {
        "artifactLocation": { "uri": "ssh.yaml" },
        "region": { "startLine": 21 }
      }
    }
  ],
  "relatedLocations": [
    {
      "logicalLocations": [
        {
          "name": "ip/filter/input",
          "fullyQualifiedName": "ip/filter/input/handle:3",
          "kind": "rule"
        }
      ],
      "message": { "text": "accept" }
    }
  ],
  "partialFingerprints": {
    "anabasis/assertion/v1": "a63af289f7d2ab508656c20377c761bd6ca38de53611f2843b0ada1637771785"
  },
  "properties": {
    "region": [ "ct state new l4proto tcp saddr 198.51.100.7 dport 22" ],
    "witness": "ip tcp 198.51.100.7:0 -> 0.0.0.0:22 ct state new iif <other>",
    "volume": "7.92e+28",
    "region_truncated": false
  }
}

(Trimmed: the absolute path shortened, and the trace and undecidable lists taken out of properties.)

The fingerprint travels in partialFingerprints, which is what permits a platform to recognise the same finding across runs and keep its triage history attached. An accepted finding carries its reason as the SARIF suppression’s justification; an expired or orphaned acceptance becomes a tool notification, so that “the acceptance has run out” reaches the dashboard and not merely the terminal.

Carrying out what was decreed

Xenophon’s arrangement was tried almost at once. The army crosses the Zapatas “marching in regular order, with the beasts and mob of the army in the middle”, exactly as voted; Mithridates appears with a couple of hundred horse and twice as many bowmen and slingers, and the rearguard - Xenophon’s own - “suffered for a while severely without being able to retaliate”, nothing in the arrangement reaching as far as a Persian sling. A finding, in short, and one the plan had not foreseen. What follows is the part I like best: nobody reopens the question of who leads the square. Xenophon proposes slingers from among the Rhodians in the ranks and a troop of horse from the baggage animals, and then, in Dakyns’ rendering, “these proposals were carried, and that night two hundred slingers were enrolled”. Altered with deliberation, as from time to time any improvement suggests itself - exactly as promised.

A specification is that arrangement for a firewall. It is decided in advance, in a pull request where anyone with a better plan may say so; it is amended with deliberation rather than at three in the morning, and the canonical form renders every amendment legible; it is carried when the pull request merges; and thereafter every run of the check is the army carrying out what it decreed, against a kernel at which Docker, CrowdSec and the colleague at two in the morning have each since taken their turn. Where the two disagree, the report says where, with a packet in evidence, and where the disagreement has been knowingly accepted, it says why, and until when.

The tool is called Anabasis. It is C++23, it is MIT licensed, and its release is not far off. Before that day arrives, however, there is one more post to be got through.

Tags: , , , , , ,

Roman Spies
Interaktionen & Kommentare