Files

256 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
# The firewall page answers "what can another machine reach?", and that answer
# needs both halves at once.
#
# A port is reachable only when something is LISTENING on a network address AND
# the firewall permits it. Either half alone is not exposure -- which is exactly
# how a tidy rules list coexists with an open database, as it does on this
# machine.
#
# The rules:
#
# 1. Exposure is the crossing, not either half. A listener the firewall blocks
# is not exposed, and an allowed port nothing listens on is not either.
# 2. Ephemeral client sockets are not services. A browser's outbound UDP port
# looks identical to a service in `ss`, and listing twenty of them buries
# the two rows that matter.
# 3. Nothing destructive happens without saying what it cuts off, by name.
# 4. The page never states what it has not checked. It said "firewalld is
# stopped" for the seconds before its first read returned.
# 5. Rich rules are shown and never edited: a syntax is not a setting, but
# hiding it would misrepresent the configuration.
#
# Read-only. It never changes a firewall rule.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-firewall"
service="$repo_dir/config/dot/quickshell/services/Firewall.qml"
page="$repo_dir/config/dot/quickshell/modules/settings/FirewallPage.qml"
fail() {
printf 'firewall contract: %s\n' "$1" >&2
exit 1
}
for path in "$helper" "$service" "$page"; do
[[ -r "$path" ]] || fail "missing $path"
done
[[ -x "$helper" ]] || fail 'panama-firewall is not executable'
# ── 1. Exposure is the crossing ─────────────────────────────────────────────
grep -q 'def listeners' "$helper" || fail 'nothing enumerates what is listening'
grep -q 'def allowed_ports' "$helper" || fail 'nothing enumerates what the firewall permits'
# Checked from the DATA, not from the source. An earlier version grepped for
# the guard line and passed with it deleted, because the same words appear on an
# unrelated line a few lines below -- so the check was matching itself into a
# false pass while every listener was being reported as exposed.
# The crossing itself, against a recorded firewall. It cannot be tested against
# this machine: its zone permits every port above 1024, so "listening" and
# "listening AND permitted" produce identical answers, and the blocked case
# needs a listener below port 1024, which needs root to create.
work="$(mktemp -d /tmp/panama-firewall.XXXXXX)"
trap 'rm -rf "$work"' EXIT
cat >"$work/fixture.json" <<'FIXTURE'
{
"zones": [{"name": "test", "interfaces": ["eth0"], "services": ["ssh"],
"ports": ["8000-8999/tcp"], "richRules": [], "target": "default"}],
"servicePorts": {"ssh": ["22/tcp"]},
"listeners": [
{"port": 22, "protocol": "tcp", "process": "sshd"},
{"port": 8080, "protocol": "tcp", "process": "webserver"},
{"port": 5432, "protocol": "tcp", "process": "postgres"},
{"port": 631, "protocol": "tcp", "process": "cupsd"}
]
}
FIXTURE
crossed="$(PANAMA_FIREWALL_FIXTURE="$work/fixture.json" "$helper" snapshot 2>/dev/null)" \
|| fail 'the recorded firewall could not be read'
reachable="$(jq -r '[.exposed[].port] | sort | join(",")' <<<"$crossed")"
# 22 is allowed by the ssh service; 8080 falls in the open range. 5432 and 631
# are listening and NOT permitted, so they are not exposure.
[[ "$reachable" == "22,8080" ]] \
|| fail "the crossing is wrong: reachable ports were [$reachable], expected [22,8080] -- 5432 and 631 are listening but not permitted"
jq -e '[.exposed[] | select(.port == 22) | .allowedBy] | .[0] == "the ssh service"' <<<"$crossed" >/dev/null \
|| fail 'a port allowed by a named service is not attributed to that service'
jq -e '[.exposed[] | select(.port == 8080) | .allowedBy] | .[0] == "the open port range"' <<<"$crossed" >/dev/null \
|| fail 'a port allowed by a range is not attributed to the range'
# ── 2. Ephemeral sockets are excluded ───────────────────────────────────────
grep -q 'EPHEMERAL_FLOOR' "$helper" \
|| fail 'ephemeral client sockets are not distinguished from services'
# ── 3. Destructive actions name their consequences ──────────────────────────
page_code="$(grep -vE '^\s*//' "$page")"
grep -q 'rangeDependents' "$service" \
|| fail 'nothing computes what closing the port range would cut off'
grep -q 'Closing this cuts off' <<<"$page_code" \
|| fail 'closing the port range does not say what it cuts off'
grep -q 'confirmingRange' <<<"$page_code" \
|| fail 'the port range can be closed without confirming'
grep -q 'confirmingRemoval' <<<"$page_code" \
|| fail 'a service can be removed without confirming'
# Removing ssh while someone is connected over it ends their session.
grep -q 'sshSessions' "$service" || fail 'the service does not know about live SSH sessions'
grep -q 'connected over SSH right now' <<<"$page_code" \
|| fail 'removing ssh does not warn when someone is connected over it'
# ── 4. The page does not answer before it has looked ────────────────────────
grep -q 'Firewall.scanned' <<<"$page_code" \
|| fail 'the page reports firewall state before its first read has returned'
grep -qE 'Checking' <<<"$page_code" \
|| fail 'there is no state for "not read yet", so it must be claiming one of the answers'
# ── 5. Rich rules are shown, not edited ─────────────────────────────────────
grep -q 'richRules' "$helper" || fail 'rich rules are not read, so the page would hide them'
grep -q 'richRules' <<<"$page_code" || fail 'rich rules are not shown'
grep -qiE 'addRichRule|removeRichRule|--add-rich-rule' "$helper" "$page" \
&& fail 'the page edits rich rules, which are a syntax rather than a setting'
# ── 6. Opening something is not the same act as closing it ──────────────────
#
# The page gained its add side long after its remove side, and the temptation
# was to give both the same confirm-then-act shape for symmetry. That would be
# wrong, and wrong in the direction that matters: a confirmation dialog is how
# this page says "this has a consequence you cannot see from here". Allowing a
# port has exactly one consequence, and it is the sentence the user just read on
# the button. Spending a confirm on it teaches people to click through the ones
# that mean something.
#
# So: additions go straight through, removals and zone changes do not.
grep -q 'Firewall.addService\|addService(' <<<"$page_code" \
|| fail 'the page cannot allow a named service, so the firewall is still read-only from here'
grep -q 'Firewall.addPort\|addPort(' <<<"$page_code" \
|| fail 'the page cannot allow a port'
grep -qE 'confirming(Add|Allow|Service|Port)\b' <<<"$page_code" \
&& fail 'allowing something asks for a confirmation; that ceremony belongs to the actions that cut people off'
# What it must say instead, because both facts are invisible from the button:
# the rule outlives a reboot, and firewalld will raise a polkit prompt.
grep -qi 'permanent' <<<"$page_code" \
|| fail 'the add flow never says the rule is permanent'
grep -qi 'ask for your password' <<<"$page_code" \
|| fail 'the add flow never warns that the system will ask for a password'
# A zone change IS consequence-bearing, and its consequence is specific: it
# changes which rules apply to one named interface, and every other interface
# keeps the zone it had. A confirm that says "change zone?" tells the user
# nothing they did not already know, so this pins that the interface is named.
grep -q 'setZone' <<<"$page_code" \
|| fail 'the page cannot change a connection zone'
grep -q 'setDefaultZone' <<<"$page_code" \
|| fail 'the page cannot change the default zone'
grep -qE '(confirming|pending)(Zone|Interface)' <<<"$page_code" \
|| fail 'a connection zone can be changed without confirming, and it decides which rules apply to that link'
python3 - "$page" <<'PY' || fail 'the zone-change confirmation does not name the interface it applies to'
import re
import sys
lines = open(sys.argv[1], encoding="utf-8").read().splitlines()
anchors = [i for i, line in enumerate(lines)
if re.search(r"(confirming|pending)(Zone|Interface)", line)]
if not anchors:
raise SystemExit(1)
# Any one of the two-stage anchors may be the one carrying the prose; the
# declaration of the state is usually not.
for anchor in anchors:
window = "\n".join(lines[max(0, anchor - 20):anchor + 60])
named = re.search(r"(interface|iface)", window, re.I)
interpolated = re.search(r"\$\{|\" \+ |\+ \"", window)
if named and interpolated:
raise SystemExit(0)
raise SystemExit(1)
PY
# ── 7. The zone browser reads and does not write ────────────────────────────
#
# `zone-info` exists so somebody can look at what a zone would do before moving
# an interface into it. A read that can write is not a browser, it is a foot-gun
# with a magnifying glass on it.
grep -q 'zone-info' "$helper" \
|| fail 'the helper cannot describe a zone, so the zone browser has nothing to show'
grep -q 'zoneInfo' "$service" \
|| fail 'the service does not expose zone descriptions'
python3 - "$helper" <<'PY' || fail 'the zone-info path can change the firewall'
import re
import sys
source = open(sys.argv[1], encoding="utf-8").read()
match = re.search(r"\ndef zone_info\b.*?(?=\ndef |\Z)", source, re.S)
if not match:
raise SystemExit(1)
body = match.group(0)
# --info-zone and --list-* are reads. Anything that adds, removes, changes or
# makes permanent is not.
if re.search(r"--(add|remove|change|set|permanent|reload)", body):
print(body[:400], file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
command -v jq >/dev/null 2>&1 || { printf 'firewall contract: SKIP (no jq)\n'; exit 0; }
state="$("$helper" snapshot 2>/dev/null)" || fail 'snapshot failed'
jq -e '(.exposed | type == "array") and (.zones | type == "array")' <<<"$state" >/dev/null \
|| fail 'the snapshot is missing exposure or zones'
if [[ "$(jq -r '.available' <<<"$state")" == "true" ]]; then
# Everything reported as exposed must name a rule THIS ZONE ACTUALLY HAS.
# A permissive stand-in like "assumed" satisfies "non-empty" while meaning
# the crossing was never performed, so the reason is matched against the
# zone's real services and port ranges.
allowed_reasons="$(jq -r '
(.zones[0].services // [] | map("the \(.) service"))
+ (if ((.zones[0].ports // []) | length) > 0 then ["the open port range"] else [] end)
| .[]' <<<"$state" | sort -u)"
[[ -n "$allowed_reasons" ]] || fail 'the zone reports no services and no ports, so nothing could be permitted'
while read -r reason; do
[[ -n "$reason" ]] || continue
grep -qxF "$reason" <<<"$allowed_reasons" \
|| fail "something is reported as reachable via \"$reason\", which is not a rule this zone has"
done < <(jq -r '.exposed[].allowedBy' <<<"$state" | sort -u)
# And must be a real port.
jq -e '[.exposed[] | (.port > 0 and .port < 65536)] | all' <<<"$state" >/dev/null \
|| fail 'an exposed entry has no valid port'
# Loopback-only listeners are not exposure and must never appear.
jq -e '[.exposed[] | select(.name == "loopback")] | length == 0' <<<"$state" >/dev/null \
|| fail 'a loopback-only listener is reported as reachable'
fi
# ── zone-info describes a zone and leaves it exactly as it found it ─────────
if [[ "$(jq -r '.available' <<<"$state")" == "true" ]]; then
zone_name="$(jq -r '.zones[0].name // ""' <<<"$state")"
if [[ -n "$zone_name" ]]; then
info="$("$helper" zone-info "$zone_name" 2>/dev/null)" \
|| fail "zone-info failed for the zone this machine is actually in ($zone_name)"
jq -e '(.services | type == "array") and (.ports | type == "array") and has("summary")' \
<<<"$info" >/dev/null \
|| fail "zone-info does not describe services, ports and a summary: $info"
after="$("$helper" snapshot 2>/dev/null)" || fail 'the snapshot after zone-info failed'
[[ "$(jq -cS '.zones' <<<"$after")" == "$(jq -cS '.zones' <<<"$state")" ]] \
|| fail 'reading a zone changed the firewall, which is the one thing a browser must not do'
fi
fi
# ── Refusals ────────────────────────────────────────────────────────────────
refusal() { "$helper" "$@" 2>/dev/null | jq -r '.error // ""'; }
[[ -n "$(refusal zone-info 'public; reboot')" ]] || fail 'a bad zone name was accepted by zone-info'
[[ -n "$(refusal zone-info '')" ]] || fail 'an empty zone name was accepted by zone-info'
for bad in "ssh; rm -rf /" "../escape" "" "UPPER CASE"; do
[[ -n "$(refusal add-service "$bad")" ]] || fail "a bad service name was accepted: $bad"
done
for bad in "22" "22/sctp" "70000/tcp" "abc/tcp"; do
[[ -n "$(refusal add-port "$bad")" ]] || fail "a bad port specification was accepted: $bad"
done
[[ -n "$(refusal set-zone 'eth0; reboot' public)" ]] || fail 'a bad interface name was accepted'
[[ -n "$(refusal bogus)" ]] || fail 'an unknown command was accepted'
printf 'firewall contract: PASS (%s reachable, %s of them data stores)\n' \
"$(jq '.exposed | length' <<<"$state")" \
"$(jq '.exposedDataStores | length' <<<"$state")"