Bound the LAN over IPv6, and over the range a tailnet uses
The node firewall's DROPs named IPv4 ranges only, so the control this project exists to provide did not apply to IPv6 at all. That is open by default on every network including ones with no IPv6 in them: every Linux host brings up an fe80:: address on every interface whether or not anything routes v6, and a daemon bound to :: answers on it. Neighbours were reachable across the segment with all four DROPs in force, `ip -4 route` showed nothing unusual, and `dejima-devbox-audit reach` reported PASS. Dropped whole rather than enumerated, because IPv6 has no RFC 1918. A v6 network is numbered out of a globally routable delegated prefix, so no equivalent range list can be written here - fc00::/7 catches ULAs most networks do not use and misses the prefix a neighbour is actually on. On the node rather than in the guest, for the same reason as every other rule here: a sysctl is a setting root on the guest puts back. Also drops 100.64.0.0/10. A tailnet is numbered there and is none of the three RFC 1918 ranges, so a devbox on one reached every machine on the overlay. The audit gets a matching check: it asks ff02::1 who is on the link and counts the replies that are not its own. No positive control, because both reasons for v6 silence are the outcome wanted - unlike the v4 case, where a working firewall and a broken network look identical. It does report INDETERMINATE when the probe cannot run at all, which is why bootstrap now installs iputils-ping; without it the check would have passed vacuously, which is the one thing this program is built not to do. Docs: the threat model gains a "Not bounded" list collecting the negative space in one place, a section on the two address families, two new entries under "Rejected, with reasons", and a quarterly review cadence with the conditions to check rather than re-argue. Tested with `bats test/` (114 passing) and by exercising the probe against recorded ping output, including its own reply and DUP! lines.
This commit is contained in:
@@ -71,6 +71,11 @@ layer grants is [docs/threat-model.md](docs/threat-model.md), including the
|
||||
[blast radius by layer](docs/threat-model.md#blast-radius-by-layer) and the
|
||||
options that were [rejected, with reasons](docs/threat-model.md#rejected-with-reasons).
|
||||
|
||||
The shortest way to find out whether this is the tool you want is
|
||||
[Not bounded](docs/threat-model.md#not-bounded), which is the same page's
|
||||
negative space collected in one list rather than left to be inferred from what
|
||||
each section happens to discuss.
|
||||
|
||||
Container escape is deliberately deprioritised. An escape gains root, which is
|
||||
more than the container has, and root on this VM is already a row in that table,
|
||||
bounded by the same hypervisor firewall however it was reached. So
|
||||
@@ -131,6 +136,15 @@ is not, and inside the ranges the firewall drops.
|
||||
A check that reports success when it does not know is worse than no check,
|
||||
because it gets believed.
|
||||
|
||||
**The IPv6 half has the same three states for different reasons.** It asks the
|
||||
segment who is there over IPv6 and counts the answers, and there a silence is
|
||||
unambiguous: the node dropped the family, or the guest never had it, and both
|
||||
are the outcome wanted. So silence is a plain `PASS` with no positive control.
|
||||
`INDETERMINATE` is reserved for the probe not running at all, which is still not
|
||||
a pass. It is a separate check because the node's other rules name IPv4 ranges
|
||||
and match none of that traffic - see
|
||||
[the LAN boundary is two boundaries](docs/threat-model.md#the-lan-boundary-is-two-boundaries).
|
||||
|
||||
**A rule that REJECTs rather than DROPs reports `FAIL` even though it is
|
||||
working.** `bash`'s `/dev/tcp` cannot tell a rejection from a live host
|
||||
refusing. That is the safe direction to be wrong in, and it is why the template
|
||||
|
||||
+157
-2
@@ -19,10 +19,17 @@
|
||||
# it must be unreachable as a destination, while traffic *through* it to
|
||||
# the internet is untouched.
|
||||
#
|
||||
# That covers IPv4. The second target is ff02::1, the all-nodes link-local
|
||||
# multicast address, because the node's DROPs name IPv4 ranges and a guest with
|
||||
# IPv6 reach onto the LAN satisfies every check above without any of them
|
||||
# noticing. See "the other address family" below.
|
||||
#
|
||||
# Nothing here is persisted. A file listing what this VM must not reach would
|
||||
# be a map of valuable targets kept on the machine most likely to be owned, and
|
||||
# would describe topology not otherwise obtainable from here without loud
|
||||
# scanning. There is no such file and there should never be one.
|
||||
# scanning. There is no such file and there should never be one. Neither target
|
||||
# is such a list: the gateway is already in the routing table, and ff02::1 is
|
||||
# the same address on every network there has ever been.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -95,6 +102,120 @@ gateway_verdict() {
|
||||
fi
|
||||
}
|
||||
|
||||
# --- the other address family -----------------------------------------------
|
||||
#
|
||||
# Everything above is IPv4 by construction: `ip -4 route`, an IPv4 gateway, and
|
||||
# IPv4 ranges dropped on the node. A guest that can reach the LAN over IPv6
|
||||
# passes every one of those checks with nothing to show for it, so the question
|
||||
# gets asked separately here.
|
||||
#
|
||||
# It is a different shape of check and deliberately so. There is no positive
|
||||
# control, because unlike the v4 case both ways of passing are outcomes worth
|
||||
# having - v6 dropped at the node, or v6 never configured at all - and no
|
||||
# service a devbox needs lives on this family for the silence to be hiding.
|
||||
# What is ruled out is the third case: working v6 reach with rules written only
|
||||
# for v4. dejima-ci-hardener argues the same way in its own runner tests.
|
||||
|
||||
# Which interface faces the LAN.
|
||||
#
|
||||
# Read from the v4 default route rather than the v6 one, because where this
|
||||
# check is working correctly there is no v6 default route to read - and that is
|
||||
# precisely the case it has to keep working in.
|
||||
lan_iface() {
|
||||
local dev
|
||||
dev=$(ip -4 route show default 2>/dev/null \
|
||||
| awk '{for (i = 1; i < NF; i++) if ($i == "dev") { print $(i + 1); exit }}')
|
||||
[ -n "$dev" ] || return 1
|
||||
printf '%s\n' "$dev"
|
||||
}
|
||||
|
||||
# An IPv6 address on this host that is not link-local, or nothing.
|
||||
#
|
||||
# Link-local is excluded because it is always there. The kernel brings up an
|
||||
# fe80:: address on every interface whether or not anything routes v6, so its
|
||||
# presence says nothing and reading it as reach would fail every correctly
|
||||
# contained VM.
|
||||
#
|
||||
# A global or unique-local address is different. It arrived by SLAAC or DHCPv6,
|
||||
# which means a router advertisement reached this guest and a reply left it, so
|
||||
# v6 is demonstrably not being dropped at the node. No probe needed: the address
|
||||
# is the evidence.
|
||||
ipv6_routable_addr() {
|
||||
ip -6 -o addr show scope global 2>/dev/null | awk '{print $4; exit}'
|
||||
}
|
||||
|
||||
# Reply addresses in ping's output that are not ours, counted.
|
||||
#
|
||||
# Split out from the probe so the decision can be tested without a fake ping on
|
||||
# PATH, which is this suite's rule. Excluding our own addresses is the whole of
|
||||
# the decision: we answer our own multicast, and counting that would report
|
||||
# every contained VM as a failure.
|
||||
ipv6_replies_from_others() { # ipv6_replies_from_others PING_OUTPUT OWN_ADDRS
|
||||
printf '%s\n' "$1" \
|
||||
| sed -n 's/.*bytes from \([0-9a-fA-F:]\{2,\}\).*/\1/p' \
|
||||
| sort -u \
|
||||
| grep -vxF "${2:-::}" \
|
||||
| grep -c . || true
|
||||
}
|
||||
|
||||
# How many other hosts answer on the link over IPv6.
|
||||
#
|
||||
# ff02::1 is the all-nodes link-local multicast address and every IPv6 host
|
||||
# answers it. Every Linux host is an IPv6 host: the fe80:: address above exists
|
||||
# on networks that route no v6 at all, and a daemon bound to :: answers on it.
|
||||
# So this finds neighbours that a v4 reading of the network says are not there.
|
||||
#
|
||||
# It is not the target list this program refuses to keep. ff02::1 is the same
|
||||
# address on every network there has ever been, so writing it down describes no
|
||||
# topology and leaks nothing about this one.
|
||||
#
|
||||
# Returns non-zero when the question could not be asked at all, which the caller
|
||||
# turns into INDETERMINATE. Printing 0 instead would be the same lie as reading a
|
||||
# timeout as a pass: nobody answered because nobody was asked.
|
||||
ipv6_neighbour_replies() {
|
||||
local iface out own
|
||||
iface=$(lan_iface) || return 1
|
||||
command -v ping >/dev/null 2>&1 || return 1
|
||||
out=$(timeout "$PROBE_TIMEOUT" ping -6 -n -c 2 -W 1 -I "$iface" ff02::1 2>/dev/null || true)
|
||||
own=$(ip -6 -o addr show 2>/dev/null | awk '{sub(/\/.*/, "", $4); print $4}')
|
||||
ipv6_replies_from_others "$out" "$own"
|
||||
}
|
||||
|
||||
# FAIL | PASS | INDETERMINATE
|
||||
#
|
||||
# Three states, but not the three above, and the difference is the point.
|
||||
#
|
||||
# There, INDETERMINATE is silence this program cannot interpret: a working
|
||||
# firewall and a broken network look identical from in here. That ambiguity does
|
||||
# not exist on this family. Both of the reasons a segment stays silent over IPv6
|
||||
# - dropped at the node, or never configured in the guest - are the outcome
|
||||
# wanted, and no service a devbox needs lives here for the silence to be hiding
|
||||
# one. So a silent segment is a plain PASS and needs no positive control.
|
||||
#
|
||||
# What INDETERMINATE means here is that the question was never asked: no `ping`
|
||||
# installed, or no interface to ask on. That is not containment, it is an unrun
|
||||
# check, and reporting it as a pass is the failure this whole program is built
|
||||
# around not committing.
|
||||
ipv6_verdict() {
|
||||
local addr replies
|
||||
addr=$(ipv6_routable_addr)
|
||||
# Checked first because it needs no probe. An address is evidence on its own,
|
||||
# so a host with one is FAIL even where nothing could have been asked.
|
||||
if [ -n "$addr" ]; then
|
||||
printf 'FAIL\n'
|
||||
return 0
|
||||
fi
|
||||
if ! replies=$(ipv6_neighbour_replies); then
|
||||
printf 'INDETERMINATE\n'
|
||||
return 0
|
||||
fi
|
||||
if [ "${replies:-0}" -gt 0 ]; then
|
||||
printf 'FAIL\n'
|
||||
else
|
||||
printf 'PASS\n'
|
||||
fi
|
||||
}
|
||||
|
||||
cfg_get() {
|
||||
local f="$CONF_DIR/config" v=""
|
||||
[ -r "$f" ] && v=$(sed -n "s/^$1=//p" "$f" | tail -1)
|
||||
@@ -200,7 +321,7 @@ positive_controls_ok() {
|
||||
}
|
||||
|
||||
cmd_reach() {
|
||||
local verdict rc=0
|
||||
local verdict v6 rc=0
|
||||
verdict=$(gateway_verdict) || return $?
|
||||
case $verdict in
|
||||
PASS)
|
||||
@@ -224,6 +345,40 @@ cmd_reach() {
|
||||
rc=1
|
||||
;;
|
||||
esac
|
||||
|
||||
v6=$(ipv6_verdict)
|
||||
case $v6 in
|
||||
PASS)
|
||||
say "PASS: nothing on the LAN answers this VM over IPv6."
|
||||
;;
|
||||
FAIL)
|
||||
say "FAIL: this VM reaches the LAN over IPv6."
|
||||
say " The DROPs on the node name IPv4 ranges and match none of this"
|
||||
say " traffic, so the v4 result above says nothing about it."
|
||||
say " Add the outbound IPv6 rule - proxmox/README.md section 3, rule"
|
||||
say " 12 - which drops the family whole because IPv6 has no range"
|
||||
say " list to enumerate the way RFC 1918 does."
|
||||
say " Disabling IPv6 in the guest is not the same control: that is a"
|
||||
say " setting root here can put back, and root here is the case."
|
||||
# Only overwrite a pass. INDETERMINATE already means "not a pass" and
|
||||
# is the more specific thing to have said, so it is left standing.
|
||||
if [ "$verdict" = PASS ]; then
|
||||
verdict=FAIL
|
||||
fi
|
||||
rc=1
|
||||
;;
|
||||
INDETERMINATE)
|
||||
say "INDETERMINATE: the IPv6 question could not be asked."
|
||||
say " Not a pass. Nothing answered because nothing was sent."
|
||||
say " Install iputils-ping and run again. dejima-devbox-bootstrap"
|
||||
say " installs it; a host set up before it did will not have it."
|
||||
if [ "$verdict" = PASS ]; then
|
||||
verdict=INDETERMINATE
|
||||
fi
|
||||
rc=1
|
||||
;;
|
||||
esac
|
||||
|
||||
printf '%s\n' "$verdict"
|
||||
return $rc
|
||||
}
|
||||
|
||||
@@ -109,9 +109,15 @@ apt-get update -qq
|
||||
# python3 and jq are dejima's own prerequisites: the host-side credential proxy
|
||||
# is python3, and its installer reads settings.json with jq. git is here for
|
||||
# the clone below.
|
||||
#
|
||||
# iputils-ping is the audit's, and it is named rather than assumed. A standard
|
||||
# Debian install has it and a minimal or cloud image often does not, and its
|
||||
# absence has no symptom: without it the audit cannot ask the segment who is
|
||||
# reachable over IPv6, which is a check it must then report as INDETERMINATE
|
||||
# rather than pass. Cheaper to install than to diagnose.
|
||||
apt-get install -y -qq --no-install-recommends \
|
||||
docker.io docker-cli \
|
||||
python3 jq git ca-certificates curl
|
||||
python3 jq git ca-certificates curl iputils-ping
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
{
|
||||
|
||||
@@ -133,6 +133,17 @@ read `none found` on a box full of credentials.
|
||||
tell them apart on its own. Reporting it as success would be the one failure
|
||||
that matters: a check believed when it does not know.
|
||||
|
||||
**`reach` runs a second check, on IPv6, and reports it on its own line.** The
|
||||
node's DROPs name IPv4 ranges and match none of that traffic, so a VM that
|
||||
reaches the whole LAN over IPv6 passes every check above with nothing to show
|
||||
for it. This one asks the segment who is there and counts the answers. It uses
|
||||
`ping`, from `iputils-ping`, which §5's bootstrap installs — without it the
|
||||
check reports `INDETERMINATE`, which is again not a pass.
|
||||
|
||||
If it says `FAIL`, the fix is rule 12 on the node, not a sysctl in here. See
|
||||
[the threat model](threat-model.md#the-lan-boundary-is-two-boundaries) for why
|
||||
that distinction is not pedantry.
|
||||
|
||||
## 7. Keep the credentials off this box
|
||||
|
||||
Push with a **deploy token scoped to the repositories this box works on**, not
|
||||
|
||||
+44
-1
@@ -40,6 +40,49 @@ routing failure can produce that.
|
||||
refusing and produces `FAIL`. That is the safe direction to be wrong in, and it
|
||||
is why the template specifies DROP.
|
||||
|
||||
## The IPv6 check
|
||||
|
||||
`dejima-devbox-audit reach` runs a second check on the other address family and
|
||||
reports on it separately. Anything but `PASS` there fails the whole run, even
|
||||
when the gateway probe passed.
|
||||
|
||||
| Verdict | Means | Exit |
|
||||
| --- | --- | --- |
|
||||
| `FAIL` | A global or unique-local IPv6 address is configured here, **or** something that was not this VM answered `ff02::1` | 1 |
|
||||
| `PASS` | Neither, and the probe ran | 0 |
|
||||
| `INDETERMINATE` | The probe could not run: no `ping`, or no interface to ask on | 1 |
|
||||
|
||||
**The three states are not the three above.** There, `INDETERMINATE` is silence
|
||||
this program cannot interpret, because a working firewall and a broken network
|
||||
look identical from inside the VM. That ambiguity does not exist here. Both
|
||||
reasons a segment stays silent over IPv6 - dropped at the node, or never
|
||||
configured in the guest - are the outcome wanted, and no service a devbox needs
|
||||
lives on this family for the silence to be hiding one. So a silent segment is a
|
||||
plain `PASS` and there is no positive control, because there is nothing to
|
||||
disambiguate.
|
||||
|
||||
`INDETERMINATE` here means the question was never asked. `ping` comes from
|
||||
`iputils-ping`, which `dejima-devbox-bootstrap` installs and a host set up
|
||||
before it did will not have. Nobody answering because nobody was asked is not
|
||||
containment, and the one thing this program will not do is call it a pass.
|
||||
|
||||
**Why an address is enough on its own.** A global or unique-local address got
|
||||
here by SLAAC or DHCPv6, so an advertisement reached this guest and a reply left
|
||||
it. That is the node not dropping v6, evidenced rather than inferred. Link-local
|
||||
is excluded because the kernel brings one up regardless and its presence says
|
||||
nothing.
|
||||
|
||||
**Why `ff02::1`.** It is the all-nodes link-local multicast address and every
|
||||
IPv6 host answers it - and every Linux host is an IPv6 host, on a network that
|
||||
routes no v6 at all. So it finds neighbours a v4 reading says are not there. Our
|
||||
own replies are excluded; counting them would fail every contained VM. Like the
|
||||
gateway, it is not a target worth writing down: it is the same address on every
|
||||
network there has ever been.
|
||||
|
||||
**Why this is not the gateway probe with a `-6`.** Where the rule is working
|
||||
there is no IPv6 default route to probe, so the check has to work without one.
|
||||
It takes its interface from the v4 default route instead.
|
||||
|
||||
## The positive controls
|
||||
|
||||
Either one is sufficient:
|
||||
@@ -53,7 +96,7 @@ Requiring both would turn an unrelated outage into a report that gets ignored.
|
||||
|
||||
| Mode | Boundary cost | Maintenance |
|
||||
| --- | --- | --- |
|
||||
| Static in the guest | None. Four DROPs plus the named exceptions, and no DHCP rules | The address is asserted in two places |
|
||||
| Static in the guest | None. Six DROPs plus the named exceptions, and no DHCP rules | The address is asserted in two places |
|
||||
| Reserved at the router | Needs both halves of the DHCP pair | One source of truth |
|
||||
| Plain DHCP | Same DHCP pair | None, but the address moves |
|
||||
|
||||
|
||||
+119
-1
@@ -159,6 +159,41 @@ changes.
|
||||
What dejima cannot bound is the devbox VM's own network access, because dejima
|
||||
runs inside that VM. That gap is this project's entire subject.
|
||||
|
||||
## The LAN boundary is two boundaries
|
||||
|
||||
The control aimed at lateral movement is a list of ranges dropped on the
|
||||
hypervisor, and a list bounds only what it names. This one named IPv4 ranges
|
||||
only, until it was found not to.
|
||||
|
||||
That is not the kind of gap where a control turns out weaker than advertised.
|
||||
It is the kind where the control does not apply at all, on a path that is open
|
||||
by default on every network - including networks with no IPv6 anywhere in them.
|
||||
Every Linux host brings up an `fe80::` address on every interface whether or not
|
||||
anything routes v6, and a daemon bound to `::` answers on it. So the machine
|
||||
beside this one was reachable across the segment with all four DROPs in force,
|
||||
`ip -4 route` showed nothing unusual, and `dejima-devbox-audit reach` said
|
||||
`PASS`.
|
||||
|
||||
**IPv6 has no RFC 1918.** The v4 list works because three ranges cover every
|
||||
private network there is. A v6 network is numbered out of a globally routable
|
||||
prefix delegated by an ISP, so no equivalent list can be written down here:
|
||||
`fc00::/7` catches unique-local addresses most networks do not use and misses
|
||||
the prefix a neighbour is actually on. The set cannot be enumerated, so the
|
||||
family is dropped whole, on the node, as rule 12 of the security group.
|
||||
|
||||
This costs nothing that was being claimed. Internet egress stays unbounded and
|
||||
stays over IPv4, for the reasons argued in `proxmox/firewall.fw.template`. What
|
||||
rule 12 removes is a second route to the internet and a first route to the LAN.
|
||||
|
||||
The audit verifies it the way it verifies the rest, by behaviour: it asks
|
||||
`ff02::1` who is on the link and counts the replies that are not its own. A v4
|
||||
timeout is ambiguous, because a working firewall and a broken network look alike
|
||||
from in here. A v6 silence is not, because both of its causes - dropped at the
|
||||
node, or never configured at all - are the outcome wanted, and no service a
|
||||
devbox needs lives on that family for the silence to be hiding one. So that half
|
||||
needs no positive control. What it does still report separately is a probe that
|
||||
could not run at all, which is not containment and is not allowed to read as it.
|
||||
|
||||
## Blast radius by layer
|
||||
|
||||
| Layer | Reached by | Radius |
|
||||
@@ -166,7 +201,7 @@ runs inside that VM. That gap is this project's entire subject.
|
||||
| Container | normal operation | the forge credential's scope, mounted paths, and egress to allowlisted domains only |
|
||||
| Devbox VM, unprivileged | container escape | everything on the VM the account can read, and **unrestricted egress to the public internet** |
|
||||
| Devbox VM, root | escape plus local escalation, or misconfiguration | the whole VM, the same unrestricted internet egress, and **the model API credential** |
|
||||
| LAN | any of the above, if unbounded | every host the VM can route to |
|
||||
| LAN | any of the above, if unbounded | every host the VM can route to, in either address family |
|
||||
| Hypervisor | out of scope | named for completeness |
|
||||
|
||||
The jump from row one to row two is the one to read twice, and it is not only
|
||||
@@ -181,6 +216,38 @@ spread to, rather than whether it can talk. And it makes every row verifiable
|
||||
rather than asserted. `dejima-devbox-audit` is the
|
||||
verification: rows four and five by behaviour, rows two and three by inventory.
|
||||
|
||||
## Not bounded
|
||||
|
||||
Everything above is organised by control, which leaves the reader to assemble
|
||||
the negative space themselves. Most will not. So, in one place, what a devbox
|
||||
running this is *not* protected from. Each line has its argument somewhere above
|
||||
or below; this list exists so that nobody has to go looking to find out whether
|
||||
there is one.
|
||||
|
||||
- **Exfiltration, and command-and-control.** Egress to the internet is
|
||||
unbounded from the VM, deliberately. A payload out on the VM fetches what it
|
||||
likes and reports to whom it likes. dejima's allowlist bounds this inside the
|
||||
container, and an escape is precisely what leaves it behind.
|
||||
- **Container escape.** Deprioritised on purpose. Nothing here separates an
|
||||
escape from root on the VM, and the rejected alternatives are below.
|
||||
- **Root on the devbox.** Not prevented. Bounded, stated, and checkable, which
|
||||
is a different claim.
|
||||
- **The model API credential.** On this box, and it cannot be moved off,
|
||||
because the proxy that holds it has to read it here. An escape converts "can
|
||||
use the API while this session lasts" into "holds the token".
|
||||
- **The two LAN exceptions.** The forge and the resolver are reachable from
|
||||
this VM on the ports listed, by anything on it. "No LAN access" is not
|
||||
literally true and never was.
|
||||
- **Inbound exposure.** The node's inbound list is yours to keep short and
|
||||
nothing here checks it. It can only be checked from outside the VM, and this
|
||||
tool does not go looking for things to connect to.
|
||||
- **Other people's work.** Out of spec. One person's work per devbox is a
|
||||
condition, not an observation.
|
||||
- **History.** The audit reports state. There is no detection and no forensics
|
||||
here, so it will tell you a rule is missing and never that it once was.
|
||||
- **The hypervisor itself.** Out of scope, and it is the layer everything above
|
||||
is standing on.
|
||||
|
||||
## Rejected, with reasons
|
||||
|
||||
Recorded so they are not re-proposed without new information. Each of these is
|
||||
@@ -255,6 +322,27 @@ The half worth closing is agent memory, and a Unix account boundary is not what
|
||||
closes it. dejima scopes the `projects` and `file-history` mounts, which is
|
||||
where that control belongs: in the repository that owns the mount.
|
||||
|
||||
**Mirroring the LAN DROPs into IPv6 instead of dropping the family.** Rejected
|
||||
because the list cannot be written. There is no v6 range that means "private",
|
||||
so the closest anyone can get is dropping `fc00::/7` and leaving the guest able
|
||||
to reach every neighbour on the prefix the ISP delegated. A rule set that looks
|
||||
symmetrical and covers nothing is worse than the blanket drop, because it reads
|
||||
as handled by anyone reviewing it later.
|
||||
|
||||
Revisit if the devbox ever genuinely needs IPv6 egress. The work then is to find
|
||||
out which prefix your own LAN is on and drop that instead, and to know that the
|
||||
prefix changes when the delegation does - so the rule acquires a way to go stale
|
||||
that none of the v4 rules have.
|
||||
|
||||
**Disabling IPv6 in the guest, with `net.ipv6.conf.all.disable_ipv6`.** Rejected
|
||||
for the reason every other control here sits on the node: it is a setting on a
|
||||
machine this model assumes an attacker holds root on, and root sets it back.
|
||||
|
||||
It is worse than merely ineffective. It would also make the audit's own IPv6
|
||||
check pass, so the guest would be certifying its own containment on the strength
|
||||
of a setting the guest controls - which is the exact failure the whole
|
||||
verify-by-behaviour design exists to avoid.
|
||||
|
||||
**Scheduled rollback.** Rejected because it destroys uncommitted work, which is
|
||||
the thing a devbox exists to hold. This is the largest single difference from
|
||||
the sister project, whose entire containment story is that a runner is rebuilt
|
||||
@@ -267,6 +355,36 @@ reports state, not history.
|
||||
controls belong in dejima. Where this project needs something from there, it is
|
||||
raised there rather than worked around here.
|
||||
|
||||
## This page has a date on it
|
||||
|
||||
Every argument above is reasoning about a particular idea of what the adversary
|
||||
can do, and that idea does not keep. The controls are aimed at a payload nobody
|
||||
aimed at this machine and at an agent that can be talked into things, and
|
||||
neither of those descriptions has been getting more reassuring.
|
||||
|
||||
Hardening designed against the agent of two years ago is not obviously adequate
|
||||
against the agent of today, and the honest position is that nobody here can tell
|
||||
you where the line is. What can be done is to re-read the reasoning on a
|
||||
schedule rather than when something goes wrong.
|
||||
|
||||
**Quarterly**, matching dejima's own cadence, and the argument applies harder
|
||||
here than there: this is the layer that catches what the layer above fails to
|
||||
hold, so it is the one where a stale assumption goes unnoticed longest.
|
||||
|
||||
What to check, rather than re-argue:
|
||||
|
||||
- the revisit conditions on each entry in "Rejected, with reasons". They are
|
||||
written as facts to look up, not judgements to re-make, so the review is
|
||||
cheap and has an answer;
|
||||
- whether the sibling directories dejima mounts host-wide - `tasks`, `plans`,
|
||||
`sessions`, `backups` - have started being loaded into a session's context at
|
||||
startup, which is the property that would make them worth scoping;
|
||||
- whether anything has changed about what an escape is worth, since that is the
|
||||
premise the deprioritisation rests on and not a conclusion of it;
|
||||
- whether the address families, ports and ranges named on the node still cover
|
||||
what a neighbour of this VM can be addressed by. IPv6 was missing from that
|
||||
list for as long as the list existed, and nothing failed in the meantime.
|
||||
|
||||
## What this repository does not claim
|
||||
|
||||
It does not prevent root on the devbox. Nothing here tries to.
|
||||
|
||||
+86
-15
@@ -156,19 +156,19 @@ running.
|
||||
|
||||
## 3. The firewall rules
|
||||
|
||||
Ten rules, built once as a **security group** and applied to each devbox. Even
|
||||
with one VM, build the group: hand-entered copies are two chances to get the
|
||||
ordering wrong and one of them to drift later.
|
||||
Twelve rules, built once as a **security group** and applied to each devbox.
|
||||
Even with one VM, build the group: hand-entered copies are two chances to get
|
||||
the ordering wrong and one of them to drift later.
|
||||
|
||||
This is the primary control. Everything else in this repository exists to
|
||||
verify it or to explain it.
|
||||
|
||||
### What each part is for
|
||||
|
||||
The outbound half is short and does the work. Four DROPs cover every private
|
||||
range, and above them sit exceptions for the two hosts this VM genuinely needs:
|
||||
your forge and your resolver. That is the bound this whole project states, and
|
||||
it does not change however long the inbound list gets.
|
||||
The outbound half is short and does the work. Six DROPs cover every address a
|
||||
neighbour of this VM can hold, and above them sit exceptions for the two hosts
|
||||
it genuinely needs: your forge and your resolver. That is the bound this whole
|
||||
project states, and it does not change however long the inbound list gets.
|
||||
|
||||
The inbound half is the part that grows, because unlike a CI runner a devbox
|
||||
offers services you want to reach. Keep it legible rather than short: every
|
||||
@@ -191,6 +191,8 @@ subnet named here, every port stated, and no rule added anywhere else.
|
||||
| 8 | out | DROP | — | — | **Dest:** `172.16.0.0/12` | |
|
||||
| 9 | out | DROP | — | — | **Dest:** `192.168.0.0/16` | The three that matter |
|
||||
| 10 | out | DROP | — | — | **Dest:** `169.254.0.0/16` | Link-local, incl. metadata services |
|
||||
| 11 | out | DROP | — | — | **Dest:** `100.64.0.0/10` | Carrier-grade NAT, where a tailnet lives |
|
||||
| 12 | out | DROP | — | — | **Dest:** `::/0` | IPv6, the whole family |
|
||||
|
||||
**Rules 1 and 2 are added once per client subnet.** Phones and laptops sit on
|
||||
different wireless networks often enough that assuming one is the wrong
|
||||
@@ -204,6 +206,47 @@ years before it is suddenly very wrong.
|
||||
it and `git push` hangs against rule 7 rather than failing, which reads as the
|
||||
forge being down.
|
||||
|
||||
**Rule 11 is for a tailnet.** Tailscale numbers its hosts out of
|
||||
`100.64.0.0/10`, which is none of the three RFC 1918 ranges, so without this
|
||||
rule a devbox on your tailnet reaches every machine you own anywhere — which is
|
||||
a wider network than the one rules 7 to 9 were drawn around, not a narrower one.
|
||||
Add it whether or not you run Tailscale today: it costs nothing on a devbox that
|
||||
has no overlay, and the day someone installs one is not a day anyone revisits
|
||||
the firewall.
|
||||
|
||||
#### Rule 12, and why IPv6 is dropped whole
|
||||
|
||||
Rules 7 to 11 work because the protected set is enumerable. RFC 1918 named three
|
||||
ranges, your LAN is inside them, and the list is short enough to read.
|
||||
|
||||
**IPv6 has no RFC 1918.** A v6 network is numbered out of a globally routable
|
||||
prefix delegated by your ISP, so there is no equivalent list to write down.
|
||||
Dropping `fc00::/7` catches unique-local addresses most networks do not use and
|
||||
misses the prefix your actual neighbour is on. Since the set cannot be
|
||||
enumerated, the family goes.
|
||||
|
||||
This costs a devbox nothing. The internet is still reached over IPv4 by the
|
||||
default policy, unbounded and on purpose. Rule 12 removes a second path to the
|
||||
same place, not the only path.
|
||||
|
||||
It is worth having even if you are certain your network has no IPv6. Every Linux
|
||||
host brings up an `fe80::` address on every interface whether or not anything
|
||||
routes v6, and a daemon bound to `::` answers on it. Without rule 12, rules 7 to
|
||||
11 can all be in force and the machine beside this one is still one
|
||||
`ssh fe80::...%eth0` away. That path does not show up in `ip -4 route`, does not
|
||||
involve your router, and is invisible to anyone checking the v4 rules are right.
|
||||
|
||||
**Do not do this in the guest instead.** `net.ipv6.conf.all.disable_ipv6=1` looks
|
||||
equivalent and is not: it is a setting on a machine this whole file assumes an
|
||||
attacker holds root on, and root can set it back. The rule belongs on the node
|
||||
for the same reason every other rule here does.
|
||||
|
||||
If the console rejects `::/0`, the same coverage is four rules — `fe80::/10`,
|
||||
`fc00::/7`, `ff00::/8`, `2000::/3` — placed together in rule 12's position.
|
||||
Prefer the single line: four entries are four chances to enter three.
|
||||
|
||||
`dejima-devbox-audit reach` checks this one by behaviour, like the rest.
|
||||
|
||||
#### Why the front door gets the narrower list
|
||||
|
||||
The intuition that SSH is the more dangerous of the two is backwards.
|
||||
@@ -231,7 +274,7 @@ construction:
|
||||
#### What this list does not bound
|
||||
|
||||
Adding a client subnet widens **who can reach this VM**. It does not widen what
|
||||
this VM can reach, which is the bound rules 7 to 10 fix and the one this
|
||||
this VM can reach, which is the bound rules 7 to 12 fix and the one this
|
||||
project exists to state. The two are different axes and conflating them leads
|
||||
to refusing a harmless convenience.
|
||||
|
||||
@@ -389,15 +432,24 @@ nothing:
|
||||
# what is actually loaded right now
|
||||
sudo iptables -S | grep "tap<vmid>i0"
|
||||
|
||||
# the same, for the other address family. Rule 12 lands here and nowhere else
|
||||
sudo ip6tables -S | grep "tap<vmid>i0"
|
||||
|
||||
# what Proxmox would generate from the current config
|
||||
sudo pve-firewall compile | grep -B2 -A20 "tap<vmid>i0"
|
||||
```
|
||||
|
||||
**Run both, not just the first.** A rule with an IPv6 destination is compiled
|
||||
into `ip6tables` and is absent from `iptables -S` — so checking only the v4
|
||||
table shows rule 12 missing whether it is there or not, and shows it identically
|
||||
either way.
|
||||
|
||||
**What you are looking for.** Proxmox names the per-VM chains after the tap
|
||||
interface — `tap<vmid>i0-IN` and `tap<vmid>i0-OUT` for the first NIC — and
|
||||
your rules appear inside them, in the order you added them. In the `-OUT`
|
||||
chain that means the four pinhole ACCEPTs first, then the four LAN DROPs,
|
||||
then whatever Proxmox appends for the policy.
|
||||
your rules appear inside them, in the order you added them. In the v4 `-OUT`
|
||||
chain that means the four pinhole ACCEPTs first, then the five LAN DROPs, then
|
||||
whatever Proxmox appends for the policy. The v6 `-OUT` chain holds rule 12 and
|
||||
none of the pinholes, because every one of them names an IPv4 address.
|
||||
|
||||
**What each failure looks like.**
|
||||
|
||||
@@ -407,17 +459,18 @@ then whatever Proxmox appends for the policy.
|
||||
| Chains exist but hold none of your rules | The rules are on a different VM id, or were added at datacenter or node level rather than to this VM |
|
||||
| A LAN DROP above a pinhole ACCEPT | The ordering trap. The VM will not reach the forge or resolve anything; drag the pinholes above the DROPs |
|
||||
| Rules present and correct, DNS still failing | The address in rules 5–6 is not the VM's nameserver. The gateway is the usual wrong answer — see "Values that must agree" |
|
||||
| v4 chain right, `ip6tables -S` empty | Rule 12 was not entered, or the console rejected `::/0` and the error was missed. Nothing about the v4 half will look wrong |
|
||||
|
||||
`pve-firewall status` is the quicker sanity check of whether the service is
|
||||
running and enabled at all, but it says nothing about *these* rules — it is
|
||||
the datacenter switch it reports on, not your ten lines.
|
||||
the datacenter switch it reports on, not your twelve lines.
|
||||
|
||||
**Both commands describe configuration.** They are worth reading, and they are
|
||||
not proof: a rule that is loaded and a rule that does what you meant are
|
||||
different claims. The checks from inside the VM below are the ones that test
|
||||
behaviour, which is why they follow rather than replace this.
|
||||
|
||||
and from inside the VM, once it is up. Three of these must succeed and one
|
||||
and from inside the VM, once it is up. Three of these must succeed and two
|
||||
must fail, which is what makes the set diagnostic rather than reassuring:
|
||||
|
||||
```sh
|
||||
@@ -425,16 +478,31 @@ try() { timeout 5 bash -c "exec 3<>/dev/tcp/$1/$2" 2>/dev/null \
|
||||
&& echo " CONNECTED $1:$2" || echo " blocked $1:$2"; }
|
||||
|
||||
gw=$(ip -4 route show default | awk '{print $3; exit}')
|
||||
dev=$(ip -4 route show default | awk '{for (i=1;i<NF;i++) if ($i=="dev") print $(i+1); exit}')
|
||||
|
||||
try "$gw" 443 # must be blocked - your own first hop
|
||||
try <forge-ip> 443 # must connect
|
||||
try deb.debian.org 80 # must connect - the public internet
|
||||
getent hosts deb.debian.org && echo " resolved" || echo " DNS FAILED"
|
||||
|
||||
# must find nobody but this VM - rule 12
|
||||
sudo ping -6 -n -c 2 -W 1 -I "$dev" ff02::1
|
||||
```
|
||||
|
||||
Nothing here needs installing: `bash` opens a TCP connection on its own and
|
||||
`getent` is part of libc, which matters on a machine you have not finished
|
||||
setting up yet.
|
||||
setting up yet. `ping` is `iputils-ping`, which Debian has by default.
|
||||
|
||||
**The last one asks the whole LAN who is there, over IPv6.** `ff02::1` is the
|
||||
all-nodes link-local multicast address and every IPv6 host answers it — and
|
||||
every Linux host is an IPv6 host, because the `fe80::` address exists whether or
|
||||
not the network routes v6. Replies from an address that is not this VM's own
|
||||
mean rule 12 is not in force and the segment is reachable over a family none of
|
||||
the v4 checks above touch.
|
||||
|
||||
Like the gateway probe, it writes down no target: `ff02::1` is the same address
|
||||
on every network there has ever been, so naming it describes nothing about
|
||||
yours.
|
||||
|
||||
**The first probe targets your gateway, deliberately, and not some other host
|
||||
on your LAN.** Naming another host would mean writing down something worth
|
||||
@@ -445,12 +513,15 @@ it sits inside the ranges rules 7 to 10 drop, so with the rules applied it must
|
||||
be unreachable *as a destination* while traffic *through* it to the internet is
|
||||
untouched.
|
||||
|
||||
Read the four together, because the pattern says which thing is wrong:
|
||||
Read them together, because the pattern says which thing is wrong:
|
||||
|
||||
- **First one connects** → the firewall is not in force at all. Check the
|
||||
Network device's Firewall checkbox and the datacenter switch.
|
||||
- **Second or third fails while the fourth works** → a pinhole is below the LAN
|
||||
DROPs. Check the rule order.
|
||||
- **Only the last one finds neighbours** → the v4 half is right and rule 12 is
|
||||
missing. This is the failure that looks like success from every other angle,
|
||||
which is why it has its own probe.
|
||||
- **Everything fails** → the VM has no working route or DNS, which is a
|
||||
networking problem rather than a firewall one. Note that this also makes the
|
||||
first probe's "blocked" meaningless: silence proves nothing when nothing is
|
||||
|
||||
@@ -36,6 +36,10 @@ enable: 1
|
||||
# Inbound denies by default. Unlike a CI runner, this VM does offer services
|
||||
# somebody should reach - you work on it - so the exceptions below are longer
|
||||
# than a runner's and are the part of this file that grows.
|
||||
#
|
||||
# It covers both address families and needed nothing said about IPv6, because a
|
||||
# default policy is not a list of ranges and so cannot leave one out. Compare
|
||||
# the outbound half, where the same coverage costs an extra rule.
|
||||
policy_in: DROP
|
||||
|
||||
# Outbound ACCEPTs by default, and the LAN is denied explicitly below.
|
||||
@@ -54,9 +58,12 @@ policy_in: DROP
|
||||
# not them.
|
||||
#
|
||||
# Deny-by-default earns its keep where the protected set is enumerable, which
|
||||
# is what the four DROPs below are. It does not apply to the internet, which
|
||||
# is what the IPv4 DROPs below are. It does not apply to the internet, which
|
||||
# is not enumerable, and treating it as though it were gets you a rule set
|
||||
# that looks strict and only gets in the way.
|
||||
#
|
||||
# The cost of default-ACCEPT is that every range to be denied has to be named,
|
||||
# and IPv6 is the one it is easiest to forget to name. See the last rule.
|
||||
policy_out: ACCEPT
|
||||
|
||||
log_level_in: nolog
|
||||
@@ -140,12 +147,47 @@ OUT ACCEPT -dest __RESOLVER_IP__ -p tcp -dport 53 -log nolog
|
||||
OUT ACCEPT -p udp -dport 67 -log nolog
|
||||
|
||||
# --- everything else on your network ----------------------------------------
|
||||
# The four lines the whole VM exists to make true.
|
||||
# The six lines the whole VM exists to make true.
|
||||
OUT DROP -dest 10.0.0.0/8 -log nolog
|
||||
OUT DROP -dest 172.16.0.0/12 -log nolog
|
||||
OUT DROP -dest 192.168.0.0/16 -log nolog
|
||||
# Link-local, which includes cloud metadata services at 169.254.169.254.
|
||||
OUT DROP -dest 169.254.0.0/16 -log nolog
|
||||
# Carrier-grade NAT, which is where Tailscale numbers a tailnet. Without this
|
||||
# line "your network" quietly means the LAN and not the overlay, and the
|
||||
# overlay is the one that reaches the machines you own somewhere else.
|
||||
OUT DROP -dest 100.64.0.0/10 -log nolog
|
||||
|
||||
# IPv6, all of it, and the reason it is one blanket line rather than four more
|
||||
# mirroring the ones above.
|
||||
#
|
||||
# Those work because the protected set is enumerable: RFC 1918 named three
|
||||
# ranges and your LAN is inside them. **IPv6 has no RFC 1918.** A v6 network is
|
||||
# numbered out of a globally routable prefix your ISP delegates, so "the private
|
||||
# ranges" is not a list that can be written here - fc00::/7 covers unique-local
|
||||
# addresses most networks do not use, and misses the prefix the machine next
|
||||
# door is actually addressed on. Enumerating is not available, so the family
|
||||
# goes.
|
||||
#
|
||||
# This is not the trade policy_out declines above. That argument is about the
|
||||
# internet, and it still holds: the internet is reached over IPv4 by the default
|
||||
# policy, unbounded and deliberately. What is dropped here is a second path to
|
||||
# the same place, and a devbox loses nothing by not having it.
|
||||
#
|
||||
# It also closes something that is open on a network with no IPv6 at all. Every
|
||||
# Linux host brings up a fe80:: address on every interface whether or not
|
||||
# anything routes v6, and a daemon bound to :: answers on it. Without this line
|
||||
# the five DROPs above can all be in force and the machine beside this one is
|
||||
# still one `ssh fe80::...%eth0` away.
|
||||
#
|
||||
# Do this here rather than by disabling IPv6 in the guest. The guest's own
|
||||
# settings are administered by whoever holds root on the guest, and this whole
|
||||
# file exists because that is assumed to be an attacker.
|
||||
#
|
||||
# If the console rejects `::/0`, the same coverage is fe80::/10, fc00::/7,
|
||||
# ff00::/8 and 2000::/3. Prefer the one line: four are four chances to enter
|
||||
# three.
|
||||
OUT DROP -dest ::/0 -log nolog
|
||||
|
||||
# Nothing follows. The public internet is reached by the default policy, not
|
||||
# by a list of ports - see the note on policy_out above.
|
||||
|
||||
+11
-5
@@ -21,10 +21,15 @@
|
||||
# and cannot be mistaken for a working value
|
||||
# - RFC 5737 documentation ranges: 192.0.2.0/24, 198.51.100.0/24,
|
||||
# 203.0.113.0/24, which exist for exactly this and route nowhere
|
||||
# - an RFC1918 literal ONLY where the example has to be private to make
|
||||
# sense, which here means the four ranges the node firewall drops and
|
||||
# - a private-range literal ONLY where the example has to be private to make
|
||||
# sense, which here means the five ranges the node firewall drops and
|
||||
# nothing else. Those name every LAN rather than anybody's.
|
||||
#
|
||||
# IPv6 literals are not checked. The one this repository commits is ::/0, which
|
||||
# names no network at all, and there is no v6 equivalent of the RFC 1918 shape
|
||||
# this guard looks for - which is itself the reason rule 12 drops the family
|
||||
# whole rather than enumerating it.
|
||||
#
|
||||
# This repository is private today and public later, which changes nothing:
|
||||
# redaction before the first commit is free, and redaction afterwards means
|
||||
# rewriting history and living with whatever was already cloned.
|
||||
@@ -42,10 +47,11 @@ setup() {
|
||||
255.255.255.255 # limited broadcast, in the DHCP rebind note
|
||||
169.254.169.254 # the cloud metadata address, named as a target
|
||||
|
||||
10.0.0.0/8 # the four RFC1918 + link-local ranges the node
|
||||
172.16.0.0/12 # firewall drops. Ranges, not hosts: they name
|
||||
192.168.0.0/16 # every LAN rather than anybody's
|
||||
10.0.0.0/8 # the five ranges the node firewall drops. Ranges,
|
||||
172.16.0.0/12 # not hosts: they name every LAN rather than
|
||||
192.168.0.0/16 # anybody's
|
||||
169.254.0.0/16
|
||||
100.64.0.0/10 # carrier-grade NAT, where a tailnet is numbered
|
||||
|
||||
192.0.2.0/24 # the three RFC 5737 documentation ranges, named
|
||||
198.51.100.0/24 # in the guidance above as what to reach for
|
||||
|
||||
+127
@@ -17,6 +17,14 @@ setup() {
|
||||
REPO="$(cd "$BATS_TEST_DIRNAME/.." && pwd)"
|
||||
source "$REPO/bin/dejima-devbox-audit"
|
||||
default_gateway() { printf '192.0.2.1\n'; }
|
||||
# The two IPv6 functions that touch the network, stubbed to the contained
|
||||
# answer for the same reason default_gateway is: every test would otherwise
|
||||
# ping the segment it happens to be running on.
|
||||
#
|
||||
# Their caller, ipv6_verdict, is deliberately NOT stubbed - see the note
|
||||
# below on positive_controls_ok for what that mistake looks like.
|
||||
ipv6_routable_addr() { printf ''; }
|
||||
ipv6_neighbour_replies() { printf '0\n'; }
|
||||
# positive_controls_ok is deliberately NOT stubbed here. Stubbing it in
|
||||
# setup shadowed the real function for the tests that exist to check it,
|
||||
# and they passed against the stub. Each test that needs it faked says so.
|
||||
@@ -129,6 +137,125 @@ setup() {
|
||||
[[ "$output" == *REJECT* ]]
|
||||
}
|
||||
|
||||
# --- the other address family ------------------------------------------------
|
||||
#
|
||||
# The v4 checks above cannot see this at all: a guest reaching the LAN over IPv6
|
||||
# has an unreachable v4 gateway and a reachable forge, which is exactly what a
|
||||
# pass looks like.
|
||||
|
||||
@test "a global IPv6 address is a failure on its own, with no probe needed" {
|
||||
# It got there by SLAAC or DHCPv6, so an advertisement reached this guest and
|
||||
# a reply left it. That is the node not dropping v6, evidenced rather than
|
||||
# inferred.
|
||||
ipv6_routable_addr() { printf '2001:db8::5/64\n'; }
|
||||
run ipv6_verdict
|
||||
[ "$output" = FAIL ]
|
||||
}
|
||||
|
||||
@test "a link-local address alone is not a failure, because it is always there" {
|
||||
# The kernel brings one up whether or not anything routes v6. Reading it as
|
||||
# reach would fail every correctly contained VM, which is why the address
|
||||
# check is scoped to global.
|
||||
run ipv6_verdict
|
||||
[ "$output" = PASS ]
|
||||
}
|
||||
|
||||
@test "a neighbour answering on the link fails even with no v6 address here" {
|
||||
# The case a v4 reading misses entirely: nothing routes v6, every host on the
|
||||
# segment is still reachable at fe80:: and answering for it.
|
||||
ipv6_neighbour_replies() { printf '2\n'; }
|
||||
run ipv6_verdict
|
||||
[ "$output" = FAIL ]
|
||||
}
|
||||
|
||||
@test "our own reply to our own multicast is not a neighbour" {
|
||||
# We answer ff02::1 too. Counting that reports every contained VM as a
|
||||
# failure, and it is the first thing this probe gets wrong if left alone.
|
||||
out='64 bytes from fe80::1%eth0: icmp_seq=1 ttl=64 time=0.052 ms'
|
||||
run ipv6_replies_from_others "$out" 'fe80::1'
|
||||
[ "$output" = 0 ]
|
||||
}
|
||||
|
||||
@test "a neighbour's reply alongside our own is counted" {
|
||||
out='64 bytes from fe80::1%eth0: icmp_seq=1 ttl=64 time=0.052 ms
|
||||
64 bytes from fe80::99%eth0: icmp_seq=1 ttl=64 time=1.21 ms (DUP!)'
|
||||
run ipv6_replies_from_others "$out" 'fe80::1'
|
||||
[ "$output" = 1 ]
|
||||
}
|
||||
|
||||
@test "silence from the whole segment counts nobody" {
|
||||
run ipv6_replies_from_others '' 'fe80::1'
|
||||
[ "$output" = 0 ]
|
||||
}
|
||||
|
||||
@test "a v6 failure fails the whole reach check even when the v4 half passes" {
|
||||
gateway_verdict() { printf 'PASS\n'; }
|
||||
ipv6_neighbour_replies() { printf '1\n'; }
|
||||
run cmd_reach
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *FAIL* ]]
|
||||
}
|
||||
|
||||
@test "an INDETERMINATE v4 half is not overwritten by a v6 failure" {
|
||||
# Both are non-passes and both are reported, but INDETERMINATE is the more
|
||||
# specific thing to have said about the v4 half and survives as the verdict.
|
||||
gateway_verdict() { printf 'INDETERMINATE\n'; }
|
||||
ipv6_neighbour_replies() { printf '1\n'; }
|
||||
run cmd_reach
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *INDETERMINATE* ]]
|
||||
}
|
||||
|
||||
@test "the v6 remedy points at the node, not at a sysctl in the guest" {
|
||||
# Someone reading this failure will reach for disable_ipv6 first. It is the
|
||||
# wrong layer for the same reason the rest of the firewall is not in here.
|
||||
gateway_verdict() { printf 'PASS\n'; }
|
||||
ipv6_neighbour_replies() { printf '1\n'; }
|
||||
run cmd_reach
|
||||
[[ "$output" == *guest* ]]
|
||||
}
|
||||
|
||||
@test "a probe that could not run is indeterminate, not a pass" {
|
||||
# No ping installed, or no interface to ask on. Nobody answered because
|
||||
# nobody was asked, and the two are not the same reading.
|
||||
ipv6_neighbour_replies() { return 1; }
|
||||
run ipv6_verdict
|
||||
[ "$output" = INDETERMINATE ]
|
||||
}
|
||||
|
||||
@test "an unrunnable probe still fails a host that has a global v6 address" {
|
||||
# The address needs no probe: it is evidence rather than inference, so the
|
||||
# answer is definite even where nothing could be sent.
|
||||
ipv6_routable_addr() { printf '2001:db8::5/64\n'; }
|
||||
ipv6_neighbour_replies() { return 1; }
|
||||
run ipv6_verdict
|
||||
[ "$output" = FAIL ]
|
||||
}
|
||||
|
||||
@test "an unrunnable v6 probe fails the reach check rather than passing it" {
|
||||
gateway_verdict() { printf 'PASS\n'; }
|
||||
ipv6_neighbour_replies() { return 1; }
|
||||
run cmd_reach
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *INDETERMINATE* ]]
|
||||
}
|
||||
|
||||
@test "an unrunnable v6 probe names the package that would fix it" {
|
||||
gateway_verdict() { printf 'PASS\n'; }
|
||||
ipv6_neighbour_replies() { return 1; }
|
||||
run cmd_reach
|
||||
[[ "$output" == *iputils-ping* ]]
|
||||
}
|
||||
|
||||
@test "a v6 FAIL outranks a v6-probe INDETERMINATE for the reported verdict" {
|
||||
# Both are non-passes, but FAIL is definite and INDETERMINATE is not, so a
|
||||
# run that found a neighbour must not be softened by anything.
|
||||
gateway_verdict() { printf 'PASS\n'; }
|
||||
ipv6_neighbour_replies() { printf '1\n'; }
|
||||
run cmd_reach
|
||||
[[ "$output" == *FAIL* ]]
|
||||
}
|
||||
|
||||
# --- radius report ----------------------------------------------------------
|
||||
#
|
||||
# This one inventories the box rather than the network, and the distinction is
|
||||
|
||||
+19
-3
@@ -28,12 +28,27 @@ lineno() { grep -nE "$1" "$TPL" | head -1 | cut -d: -f1; }
|
||||
[ "$last_accept" -lt "$first_drop" ]
|
||||
}
|
||||
|
||||
@test "all four dropped ranges are present" {
|
||||
for r in '10\.0\.0\.0/8' '172\.16\.0\.0/12' '192\.168\.0\.0/16' '169\.254\.0\.0/16'; do
|
||||
@test "all six dropped ranges are present" {
|
||||
for r in '10\.0\.0\.0/8' '172\.16\.0\.0/12' '192\.168\.0\.0/16' \
|
||||
'169\.254\.0\.0/16' '100\.64\.0\.0/10' '::/0'; do
|
||||
grep -qE "^OUT DROP -dest $r" "$TPL"
|
||||
done
|
||||
}
|
||||
|
||||
@test "IPv6 is dropped, because none of the v4 ranges above match any of it" {
|
||||
# The failure this pins is silent from every other angle: the v4 rules can be
|
||||
# entered perfectly and every host on the segment stays reachable at fe80::,
|
||||
# on a network that routes no IPv6 at all.
|
||||
grep -qE '^OUT DROP -dest ::/0' "$TPL"
|
||||
}
|
||||
|
||||
@test "the outbound policy is the only one that needed IPv6 spelled out" {
|
||||
# policy_in: DROP is a policy rather than a list, so it cannot leave an
|
||||
# address family out. policy_out: ACCEPT can, and did.
|
||||
grep -qE '^policy_in: DROP' "$TPL"
|
||||
! grep -qE '^IN DROP -dest ::/0' "$TPL"
|
||||
}
|
||||
|
||||
@test "the forge is reachable on both 443 and 22" {
|
||||
# 22 is the one a CI runner never needed. A devbox pushes over SSH, and
|
||||
# without this rule that push hangs against the DROPs below.
|
||||
@@ -58,10 +73,11 @@ lineno() { grep -nE "$1" "$TPL" | head -1 | cut -d: -f1; }
|
||||
# Placeholders only. addresses.bats catches leaks repository-wide; this
|
||||
# catches a rule that works on the network it was written on and silently
|
||||
# on no other.
|
||||
run grep -nE '^(IN|OUT) (ACCEPT|DROP).*-(source|dest) [0-9]' "$TPL"
|
||||
run grep -nE '^(IN|OUT) (ACCEPT|DROP).*-(source|dest) [0-9:]' "$TPL"
|
||||
for line in "${lines[@]}"; do
|
||||
case "$line" in
|
||||
*10.0.0.0/8*|*172.16.0.0/12*|*192.168.0.0/16*|*169.254.0.0/16*) ;;
|
||||
*100.64.0.0/10*|*::/0*) ;;
|
||||
*) echo "rule names a literal address: $line"; false ;;
|
||||
esac
|
||||
done
|
||||
|
||||
Reference in New Issue
Block a user