#!/usr/bin/env bash # Everything Connections learned to do, against a NetworkManager that is not # real. # # This helper is the first one in Panama that handles a secret the user types. # Three of its verbs sit next to a password: joining an enterprise network takes # one, starting a hotspot generates one, and reading a connection's details sits # on top of a store full of them. Each of those is a different way for a # credential to escape: # # * on argv, where /proc publishes it to every process on this machine for as # long as the command runs -- the same failure the Sharing page already # refuses to have (sharing-contract, "the remote desktop password never # passes through Panama"); # * in a log line, which outlives the command; # * in the details JSON, which the page renders and which is the one place a # stored PSK would look like it belonged. # # So the pins here are mostly about what must NOT appear, and they are checked # from the data rather than from the source: the stub NetworkManager will hand # over a passphrase to anything that asks for one, and the contract fails if any # of them reaches the JSON, the argv log, or a file the helper wrote. # # The rest is the shape of the commands. `import-vpn` picking its plugin from # the file extension, and the connection-name charset, are both places where a # wrong guess produces a nonsense nmcli invocation rather than an error, so both # are exercised rather than read. # # SAFETY. This runs the real helper, so it must be impossible for it to reach # the real NetworkManager. Four things make that true, and the contract verifies # the first two before running anything: # # 1. every system binary the helper names is resolved through PATH (asserted # statically -- an absolute /usr/bin/nmcli would walk straight past the # stubs), and PATH's first entry is the stub directory; # 2. nmcli, gsettings, rfkill and every D-Bus client are stubbed, each # recording its arguments and its stdin instead of doing anything; # 3. `import gi` resolves to a stand-in on PYTHONPATH whose require_version # always raises, so join-enterprise cannot take its libnm/D-Bus branch and # falls back to nmcli, where the recording can see what it did; # 4. both D-Bus bus addresses point at sockets that do not exist, so anything # that got past (3) still could not connect. # # That means the native libnm branch is not exercised here. It is covered # statically instead, by the check that no command list anywhere in the helper # carries the password -- the failure that branch could have. # # Set PANAMA_NETWORK_STATIC_ONLY=1 to run only the source-reading half, which # touches nothing at all. set -uo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" helper="$repo_dir/config/dot/quickshell/scripts/panama-network" service="$repo_dir/config/dot/quickshell/services/NetworkTools.qml" fail() { printf 'network tools contract: %s\n' "$1" >&2 exit 1 } for path in "$helper" "$service"; do [[ -r "$path" ]] || fail "missing $path" done [[ -x "$helper" ]] || fail 'panama-network is not executable' # Sentinels. Every one of these is a value the fake NetworkManager will hand # over, and none of them may come back out. readonly WIFI_PSK='psk-must-never-leave-9c1f' readonly VPN_SECRET='vpn-secret-must-never-leave-7b20' readonly ENTERPRISE_PW='enterprise-pw-must-never-leave-4e88' readonly HOTSPOT_PW='hotspot-pw-must-never-leave-3a55' readonly HIDDEN_PW='hidden-pw-must-never-leave-8d13' # ── Static: the helper cannot walk past the stubs ──────────────────────────── # # Checked first because the dynamic half's safety rests on it. A helper that # spelled its NetworkManager client `/usr/bin/nmcli` would ignore the stub # directory entirely and reconfigure the machine running the test. absolute="$(grep -nE '"/(usr/)?s?bin/[a-z-]+"' "$helper")" [[ -z "$absolute" ]] \ || fail "the helper names a binary by absolute path, so PATH stubs cannot contain it: $absolute" # ── Static: reading a connection cannot read a secret ──────────────────────── # # By name, in one table, refused in the single function every property read goes # through -- so a field added later cannot quietly become a leak, which a # hand-written allowlist of safe fields would eventually permit. grep -q 'SECRET_PROPERTIES' "$helper" \ || fail 'the secret-holding properties are not named anywhere, so nothing can refuse them' grep -q 'SECRET_SHAPE' "$helper" \ || fail 'only a fixed list guards the secrets; a property NetworkManager adds later would leak until somebody noticed' python3 - "$helper" <<'PY' || fail 'the property reader does not drop the secret properties' import ast import sys source = open(sys.argv[1], encoding="utf-8").read() tree = ast.parse(source) secrets = set() for node in tree.body: target = "" if isinstance(node, ast.AnnAssign): target = getattr(node.target, "id", "") elif isinstance(node, ast.Assign): target = getattr(node.targets[0], "id", "") if target != "SECRET_PROPERTIES": continue value = node.value # frozenset({...}) / set({...}) is a call wrapped around the literal. if isinstance(value, ast.Call) and value.args: value = value.args[0] try: secrets = set(ast.literal_eval(value)) except (ValueError, TypeError): secrets = set() # The properties that actually hold a passphrase must be in it, or the table is # decorative. required = {"802-11-wireless-security.psk", "802-1x.password"} if not required <= secrets: print(f"missing from the refusal table: {sorted(required - secrets)}", file=sys.stderr) raise SystemExit(1) # And the filter has to be applied where NetworkManager is read, not at each # use: one place that can be certain, rather than every caller remembering. reader = next((node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef) and node.name == "listing"), None) if reader is None: print("no single function parses nmcli output", file=sys.stderr) raise SystemExit(1) raise SystemExit(0 if "is_secret" in ast.dump(reader) else 1) PY grep -q -- '--show-secrets' "$helper" \ && fail 'the helper asks NetworkManager to print secrets; the details view has no use for them' # ── Static: the address validators exist, and every verb has a shape ───────── # # A static address that NetworkManager refuses is a connection that comes up # with no address at all, which is a worse failure than being told to retype it # -- so the refusal has to happen here, before nmcli is called. Named rather # than only exercised, because the dynamic half below can only ever prove that # SOME check ran, not that the right family's check did. for pattern in IPV4 IPV6 IPV4_PREFIX IPV6_PREFIX; do grep -qE "^${pattern} = re\.compile" "$helper" \ || fail "no $pattern pattern, so a static address is whatever NetworkManager will take" done # Every verb has to be in shape_for AND in FALLBACKS, or a refusal comes back # in a shape the page cannot read -- the reason a page never has to branch on # whether the reply is an error. python3 - "$helper" <<'PY' || fail 'a verb has no reply shape, or a shape has no fallback' import ast import sys source = open(sys.argv[1], encoding="utf-8").read() tree = ast.parse(source) shapes = {} for node in ast.walk(tree): if isinstance(node, ast.FunctionDef) and node.name == "shape_for": for inner in ast.walk(node): if isinstance(inner, ast.Dict): shapes = {key.value: value.value for key, value in zip(inner.keys, inner.values) if isinstance(key, ast.Constant) and isinstance(value, ast.Constant)} fallbacks = set() for node in tree.body: target = getattr(node.targets[0], "id", "") if isinstance(node, ast.Assign) else "" if target == "FALLBACKS" and isinstance(node.value, ast.Dict): fallbacks = {key.value for key in node.value.keys if isinstance(key, ast.Constant)} required = {"details", "forget", "saved", "set-autoconnect", "set-mac-random", "set-metered", "set-ip", "join-enterprise", "join-hidden", "import-vpn", "hotspot", "proxy", "airplane"} missing = sorted(required - set(shapes)) if missing: print(f"shape_for does not know: {missing}", file=sys.stderr) raise SystemExit(1) orphans = sorted(set(shapes.values()) - fallbacks) if orphans: print(f"shapes with no FALLBACKS entry: {orphans}", file=sys.stderr) raise SystemExit(1) raise SystemExit(0) PY # ── Static: the enterprise password is read, not passed ────────────────────── # # The mechanism is a choice (libnm's GObject bindings when they are installed, a # scripted `nmcli connection edit` otherwise), but neither may build a command # that carries the password. This is the only check that reaches the libnm # branch, because the dynamic half deliberately disables it. grep -q 'sys.stdin' "$helper" \ || fail 'the enterprise password is never read from stdin' python3 - "$helper" <<'PY' || fail 'a command list in the helper carries the enterprise password' import ast import re import sys source = open(sys.argv[1], encoding="utf-8").read() # Names, not values: the value only exists at runtime. Any list literal that # holds a system command and a password-shaped identifier is argv exposure. SECRETISH = re.compile(r"(password|passwd|secret|psk|passphrase)", re.I) COMMANDS = {"nmcli", "gsettings", "rfkill", "gdbus", "busctl", "dbus-send"} bad = [] for node in ast.walk(ast.parse(source)): if not isinstance(node, (ast.List, ast.Tuple)): continue literals = {element.value for element in node.elts if isinstance(element, ast.Constant) and isinstance(element.value, str)} if not literals & COMMANDS: continue for element in node.elts: if isinstance(element, ast.Name) and SECRETISH.search(element.id): bad.append(f"line {node.lineno}: {element.id}") elif isinstance(element, ast.Attribute) and SECRETISH.search(element.attr): bad.append(f"line {node.lineno}: .{element.attr}") elif isinstance(element, ast.JoinedStr): for part in ast.walk(element): if isinstance(part, ast.Name) and SECRETISH.search(part.id): bad.append(f"line {node.lineno}: f-string {part.id}") if bad: print("; ".join(bad), file=sys.stderr) raise SystemExit(1) PY # ── Static: the service hands the password down the same way ───────────────── grep -q 'PANAMA_NETWORK_HELPER' "$service" \ || fail 'the service has no helper-path seam, so nothing can point it at a stub' grep -q 'function joinEnterprise' "$service" \ || fail 'the service cannot join an enterprise network' python3 - "$service" <<'PY' || fail 'the service puts the enterprise password on the command line' import re import sys text = open(sys.argv[1], encoding="utf-8").read() start = text.find("function joinEnterprise") if start < 0: raise SystemExit(1) depth = 0 end = start for index in range(text.find("{", start), len(text)): if text[index] == "{": depth += 1 elif text[index] == "}": depth -= 1 if depth == 0: end = index break body = text[start:end + 1] # Every argument list the function builds, checked for a password-shaped name. lists = re.findall(r"\[[^\[\]]*\]", body) if not lists: print("joinEnterprise builds no argument list at all", file=sys.stderr) raise SystemExit(1) for argv in lists: if re.search(r"(password|secret|passphrase|psk)", argv, re.I): print(argv.strip()[:200], file=sys.stderr) raise SystemExit(1) raise SystemExit(0) PY grep -qE '\.write\(|stdinEnabled' "$service" \ || fail 'the service never writes to the helper process stdin, so the password has no way in' if [[ "${PANAMA_NETWORK_STATIC_ONLY:-0}" == "1" ]]; then printf 'network tools contract: PASS (static)\n' exit 0 fi command -v jq >/dev/null 2>&1 || { printf 'network tools contract: SKIP (no jq)\n'; exit 0; } command -v python3 >/dev/null 2>&1 || { printf 'network tools contract: SKIP (no python3)\n'; exit 0; } # ── The fake machine ───────────────────────────────────────────────────────── # # The field list the saved-profile stub answers to, read from the helper rather # than retyped: a stub that answered a list the helper no longer asks for would # quietly stop being consulted, and the in-range assertions below would pass on # an empty table. SAVED_FIELDS="$(sed -nE 's/^SAVED_FIELDS = "([^"]+)"$/\1/p' "$helper")" [[ -n "$SAVED_FIELDS" ]] || fail 'the helper names no field list for the saved profiles' work="$(mktemp -d /tmp/panama-network-contract.XXXXXX)" stub_dir="$work/bin" state_dir="$work/state" home_dir="$work/home" config_home="$work/config" state_home="$work/xdg-state" run_dir="$work/run" pystub="$work/pystub" mkdir -p "$stub_dir" "$state_dir" "$home_dir" "$config_home" "$state_home" "$run_dir" "$pystub/gi" : >"$state_dir/argv" : >"$state_dir/stdin" printf '11111111-0000-0000-0000-000000000001\n22222222-0000-0000-0000-000000000002\n' \ >"$state_dir/uuids" trap 'rm -rf "$work"' EXIT # The stand-in for PyGObject. libnm's own branch hands the password to # NetworkManager over D-Bus, which is correct and untestable from here -- it # would configure the real machine. Forcing its absence puts the fallback under # test instead, and that is the branch where a password could become an # argument. cat >"$pystub/gi/__init__.py" <<'GISTUB' """Stand-in for PyGObject, so panama-network takes its nmcli fallback.""" def require_version(namespace, version): raise ValueError(f"Namespace {namespace} not available") GISTUB # nmcli, recorded rather than performed. `-g FIELD` is answered from a table # that INCLUDES the secret properties: a helper that asked for a passphrase # would be given one, which is what makes "no secret reached the JSON" a real # result rather than a tautology. cat >"$stub_dir/nmcli" <>"\$state/argv" joined="\$*" # The detail listing INCLUDES the secret properties. A helper that handed its # output straight to the page would leak them, which is what makes "no secret # reached the JSON" a result rather than a tautology. detail() { printf 'connection.id:Home Wi-Fi\n' printf 'connection.uuid:22222222-0000-0000-0000-000000000002\n' printf 'connection.type:802-11-wireless\n' printf 'connection.autoconnect:yes\n' printf '802-11-wireless.cloned-mac-address:random\n' printf '802-11-wireless.ssid:panama-hotspot\n' printf '802-11-wireless.band:bg\n' printf 'GENERAL.STATE:activated\n' printf 'GENERAL.DEVICES:wlp4s0\n' printf 'GENERAL.HWADDR:AA:BB:CC:DD:EE:FF\n' printf 'IP4.ADDRESS[1]:192.168.7.42/24\n' printf 'IP6.ADDRESS[1]:fd00::42/64\n' printf 'IP4.GATEWAY:192.168.7.1\n' printf 'IP4.DNS[1]:192.168.7.1\n' printf 'IP4.DNS[2]:1.1.1.1\n' # What the PROFILE asks for, as distinct from what is on the wire above. # The editor reads these; a helper that reported only the active state # would show an empty form over a static address. printf 'connection.metered:unknown\n' printf 'ipv4.method:auto\n' printf 'ipv4.addresses:--\n' printf 'ipv4.gateway:--\n' printf 'ipv4.dns:--\n' printf 'ipv6.method:auto\n' printf 'ipv6.addresses:--\n' printf 'ipv6.gateway:--\n' printf 'ipv6.dns:--\n' printf '802-11-wireless-security.psk:$WIFI_PSK\n' printf '802-1x.password:$ENTERPRISE_PW\n' printf 'vpn.secrets.password:$VPN_SECRET\n' } case "\$joined" in *"-f $SAVED_FIELDS"*) # The saved-profile table: NAME last, so a network called "Cafe: Guest" # survives the field separator. printf '22222222-0000-0000-0000-000000000002:802-11-wireless:yes:yes:1700000000:Home Wi-Fi\n' printf '44444444-0000-0000-0000-000000000004:802-11-wireless:yes:no:1600000000:Office-Corp\n' printf '55555555-0000-0000-0000-000000000005:802-11-wireless:no:no:1500000000:Cafe: Guest\n' printf '66666666-0000-0000-0000-000000000006:802-3-ethernet:yes:no:1400000000:Wired connection 1\n' exit 0 ;; *"-f SSID device wifi list"*) # Only one of the saved networks is anywhere near this machine, which is # the whole point of the in-range flag. printf 'Home Wi-Fi\nCoffeeHaus_Guest\n' exit 0 ;; *"connection edit"*) # The only invocation that is fed anything, and it is drained under a # timeout: every other one inherits whatever stdin the test runner had, # and reading that would hang the suite. timeout 5 cat >>"\$state/stdin" 2>/dev/null || true exit 0 ;; *"connection import"*) printf '33333333-4444-5555-6666-777777777777\n' >>"\$state/uuids" printf "Connection 'imported-profile' (33333333-4444-5555-6666-777777777777) successfully added.\n" exit 0 ;; *"device wifi show-password"*) printf 'SSID: panama-hotspot\n' printf 'Security: WPA2\n' printf 'Password: $HOTSPOT_PW\n' exit 0 ;; *"device wifi hotspot"*) printf "Device 'wlp4s0' successfully activated.\n" exit 0 ;; *"-f UUID connection show"*) cat "\$state/uuids" exit 0 ;; *"-f DEVICE,TYPE device"*) printf 'wlp4s0:wifi\nenp5s0:ethernet\nlo:loopback\n' exit 0 ;; *"device show"*) printf 'GENERAL.HWADDR:AA:BB:CC:DD:EE:FF\n' exit 0 ;; *"connection show "*) detail exit 0 ;; esac exit 0 STUB cat >"$stub_dir/gsettings" <>"$state_dir/argv" case "\$*" in "get org.gnome.system.proxy mode") printf "'none'\n" ;; *" port") printf '0\n' ;; get*) printf "''\n" ;; esac exit 0 STUB cat >"$stub_dir/rfkill" <>"$state_dir/argv" case "\$*" in *-J*|*--json*) printf '{"rfkilldevices":[{"id":0,"type":"wlan","device":"phy0","soft":"unblocked","hard":"unblocked"},{"id":1,"type":"bluetooth","device":"hci0","soft":"unblocked","hard":"unblocked"}]}\n' ;; list*|"") printf '0: phy0: Wireless LAN\n\tSoft blocked: no\n\tHard blocked: no\n' printf '1: hci0: Bluetooth\n\tSoft blocked: no\n\tHard blocked: no\n' ;; esac exit 0 STUB # Any other route out of this process is closed rather than left open. for blocked in gdbus busctl dbus-send nm-connection-editor nmtui pkexec; do cat >"$stub_dir/$blocked" <>"$state_dir/argv" printf 'network tools contract: the helper reached for $blocked\n' >&2 exit 1 STUB done chmod +x "$stub_dir"/* runh() { env -i \ PATH="$stub_dir:/usr/bin:/bin" \ PYTHONPATH="$pystub" \ HOME="$home_dir" \ XDG_CONFIG_HOME="$config_home" \ XDG_STATE_HOME="$state_home" \ XDG_RUNTIME_DIR="$run_dir" \ DBUS_SESSION_BUS_ADDRESS="unix:path=$run_dir/absent-session-bus" \ DBUS_SYSTEM_BUS_ADDRESS="unix:path=$run_dir/absent-system-bus" \ LANG=C LC_ALL=C \ "$helper" "$@" } # The safety claim, verified rather than assumed. for binary in nmcli gsettings rfkill; do resolved="$(env -i PATH="$stub_dir:/usr/bin:/bin" bash -c "command -v $binary")" [[ "$resolved" == "$stub_dir/$binary" ]] \ || fail "$binary resolves to '$resolved', not the stub; refusing to run against the real one" done error_of() { runh "$@" 2>/dev/null | jq -r '.error // ""'; } log() { cat "$state_dir/argv"; } leak_in_scratch() { grep -rlF "$1" "$home_dir" "$config_home" "$state_home" "$run_dir" 2>/dev/null | head -1 } # ── details: the whole point is what is missing ────────────────────────────── : >"$state_dir/argv" details="$(runh details 'Home Wi-Fi' 2>/dev/null)" \ || fail 'details failed against the stub NetworkManager' jq -e 'has("ip4") and has("gateway") and has("dns") and has("mac") and has("macRandomized")' \ <<<"$details" >/dev/null || fail "details is missing part of its shape: $details" jq -e '.dns | type == "array"' <<<"$details" >/dev/null \ || fail 'dns is not a list, so a second nameserver has nowhere to go' jq -e '(.ip4 | length) > 0 and (.gateway | length) > 0 and (.mac | length) > 0' <<<"$details" >/dev/null \ || fail "details parsed nothing out of the connection listing: $details" jq -e '.macRandomized == true' <<<"$details" >/dev/null \ || fail 'a connection whose cloned MAC is "random" is not reported as randomized' for secret in "$WIFI_PSK" "$ENTERPRISE_PW" "$VPN_SECRET"; do grep -Fq "$secret" <<<"$details" \ && fail 'a stored secret reached the details JSON, which the page renders' done # The absence above only means something if the secrets were on offer. They # were: the very listing that produced the address, the gateway and the MAC # carries all three, asked of the fake NetworkManager directly. So that block # says the filter ran, not that there was nothing to filter. served="$(PATH="$stub_dir:$PATH" nmcli -t -e no connection show 'Home Wi-Fi')" for secret in "$WIFI_PSK" "$ENTERPRISE_PW" "$VPN_SECRET"; do grep -Fq "$secret" <<<"$served" \ || fail 'the fake NetworkManager offered no secret, so the details filter was never actually tested' done # Not just the values: a key shaped like a credential is where a future field # would land without anybody noticing. offenders="$(jq -r '[paths | map(tostring) | join(".")] | map(select(test("(password|secret|psk|passphrase)$";"i"))) | join(", ")' <<<"$details")" [[ -z "$offenders" ]] || fail "details carries credential-shaped fields: $offenders" # ── forget and the two modifiers ───────────────────────────────────────────── : >"$state_dir/argv" runh forget 'Home Wi-Fi' >/dev/null 2>&1 grep -Fq 'connection delete Home Wi-Fi' "$state_dir/argv" \ || fail "forget did not delete the connection profile: $(log)" : >"$state_dir/argv" runh set-autoconnect 'Home Wi-Fi' true >/dev/null 2>&1 grep -Fq 'connection modify' "$state_dir/argv" \ || fail "set-autoconnect did not modify the profile: $(log)" grep -Eq 'connection\.autoconnect +(yes|true)' "$state_dir/argv" \ || fail "set-autoconnect did not set connection.autoconnect: $(log)" : >"$state_dir/argv" runh set-autoconnect 'Home Wi-Fi' false >/dev/null 2>&1 grep -Eq 'connection\.autoconnect +(no|false)' "$state_dir/argv" \ || fail "turning autoconnect off did not reach nmcli as a negative: $(log)" : >"$state_dir/argv" mac_on="$(runh set-mac-random 'Home Wi-Fi' true 2>/dev/null)" grep -Eq 'cloned-mac-address +random' "$state_dir/argv" \ || fail "randomizing the MAC did not set the cloned address: $(log)" # The setting does nothing until the connection comes back up, and a switch # that appears to have taken effect when it has not is the whole bug. grep -qi 'reconnect' <<<"$mac_on" \ || fail "set-mac-random does not say a reconnect is needed: $mac_on" : >"$state_dir/argv" runh set-mac-random 'Home Wi-Fi' false >/dev/null 2>&1 grep -Eq 'cloned-mac-address +permanent' "$state_dir/argv" \ || fail "turning randomization off did not restore the permanent address: $(log)" # ── details reports the profile, not only the wire ─────────────────────────── # # The editor writes the profile's addressing, so it has to be able to read it. # The four fields at the top of `details` are what the connection currently # holds, which is a different question: a static address that has not been # applied yet is in the profile and not on the wire, and an editor bound to the # wire would show an empty form over a setting somebody just typed. : >"$state_dir/argv" profile="$(runh details 'Home Wi-Fi' 2>/dev/null)" jq -e 'has("metered") and has("ip4Method") and has("ip4Addresses") and has("ip4Gateway") and has("ip4Dns") and has("ip6Method")' \ <<<"$profile" >/dev/null || fail "details does not report the profile's own addressing: $profile" jq -e '.metered == "auto"' <<<"$profile" >/dev/null \ || fail "NetworkManager's 'unknown' metered flag is not reported as automatic: $profile" # "--" is nmcli for "unset", and a page that rendered it would show two dashes # in the gateway box. jq -e '.ip4Gateway == "" and (.ip4Dns | length) == 0' <<<"$profile" >/dev/null \ || fail "an unset profile property came through as nmcli's placeholder: $profile" # ── set-metered is three states, not two ───────────────────────────────────── : >"$state_dir/argv" runh set-metered 'Home Wi-Fi' yes >/dev/null 2>&1 grep -Eq 'connection\.metered +yes' "$state_dir/argv" \ || fail "marking a connection metered did not reach nmcli: $(log)" : >"$state_dir/argv" runh set-metered 'Home Wi-Fi' auto >/dev/null 2>&1 # "automatic" is NetworkManager deciding, which it spells "unknown". Sending it # "auto" would be refused, and sending it "no" would report a guess as a fact. grep -Eq 'connection\.metered +unknown' "$state_dir/argv" \ || fail "leaving metered to NetworkManager did not send its own spelling: $(log)" : >"$state_dir/argv" [[ -n "$(error_of set-metered 'Home Wi-Fi' sometimes)" ]] \ || fail 'an unknown metered state was accepted' [[ ! -s "$state_dir/argv" ]] || fail 'an unknown metered state still reached nmcli' # ── set-ip writes a whole stack, and validates before it does ──────────────── : >"$state_dir/argv" runh set-ip 'Home Wi-Fi' 4 manual 192.168.7.50/24 192.168.7.1 '1.1.1.1,9.9.9.9' >/dev/null 2>&1 for setting in 'ipv4\.method +manual' 'ipv4\.addresses +192\.168\.7\.50/24' \ 'ipv4\.gateway +192\.168\.7\.1' 'ipv4\.dns +1\.1\.1\.1,9\.9\.9\.9'; do grep -Eq "$setting" "$state_dir/argv" \ || fail "a manual IPv4 address did not set $setting: $(log)" done # Without this NetworkManager appends DHCP's nameservers to the ones just # typed, so "manual DNS" silently becomes "manual DNS and whatever else". grep -Eq 'ipv4\.ignore-auto-dns +yes' "$state_dir/argv" \ || fail "manual DNS does not ignore the ones DHCP hands out: $(log)" # It was active in the listing, so it has to come back up or the change is # saved and invisible. grep -Eq 'connection up Home Wi-Fi' "$state_dir/argv" \ || fail "an active connection was not reactivated after its address changed: $(log)" : >"$state_dir/argv" runh set-ip 'Home Wi-Fi' 6 manual 'fd00::42/64' 'fd00::1' 'fd00::1' >/dev/null 2>&1 grep -Eq 'ipv6\.method +manual' "$state_dir/argv" \ || fail "a manual IPv6 address did not set the IPv6 method: $(log)" grep -Fq 'fd00::42/64' "$state_dir/argv" \ || fail "the IPv6 address never reached nmcli: $(log)" # Going back to automatic has to CLEAR what manual left behind, or the # connection comes up holding both. : >"$state_dir/argv" runh set-ip 'Home Wi-Fi' 4 auto >/dev/null 2>&1 grep -Eq 'ipv4\.method +auto' "$state_dir/argv" \ || fail "returning to DHCP did not set the method: $(log)" grep -Eq 'ipv4\.addresses' "$state_dir/argv" \ || fail "returning to DHCP left the static address in place: $(log)" grep -Eq 'ipv4\.ignore-auto-dns +no' "$state_dir/argv" \ || fail "returning to DHCP kept ignoring the nameservers it hands out: $(log)" # Each of these must be refused BEFORE nmcli, for the reason at the top of the # name-validation block: nmcli refuses them too, so a check that only asks "did # something error" passes with the validation deleted. while IFS='|' read -r family address gateway dns why; do : >"$state_dir/argv" [[ -n "$(error_of set-ip 'Home Wi-Fi' "$family" manual "$address" "$gateway" "$dns")" ]] \ || fail "$why was accepted" [[ ! -s "$state_dir/argv" ]] || fail "$why reached nmcli before being refused" done <<'BAD' 4|192.168.7.50|192.168.7.1|1.1.1.1|an address with no prefix 4|999.1.1.1/24|192.168.7.1|1.1.1.1|an address whose octets are not octets 4|192.168.7.50/33|192.168.7.1|1.1.1.1|an IPv4 prefix past 32 6|fd00::42/129|fd00::1|fd00::1|an IPv6 prefix past 128 4|192.168.7.50/24|not-a-gateway|1.1.1.1|a gateway that is not an address 4|192.168.7.50/24|192.168.7.1|nameserver|a nameserver that is not an address 6|192.168.7.50/24|fd00::1|fd00::1|an IPv4 address typed into the IPv6 stack BAD : >"$state_dir/argv" [[ -n "$(error_of set-ip 'Home Wi-Fi' 5 auto)" ]] || fail 'a third IP family was accepted' [[ -n "$(error_of set-ip 'Home Wi-Fi' 4 sideways)" ]] \ || fail 'an addressing mode that is neither automatic nor manual was accepted' # ── saved: the profiles this machine holds, in range or not ───────────────── : >"$state_dir/argv" saved="$(runh saved 2>/dev/null)" jq -e '(.connections | length) == 4' <<<"$saved" >/dev/null \ || fail "the saved listing did not parse four profiles: $saved" # NAME is read last precisely so this one survives: nothing else in the row can # contain a colon. jq -e '[.connections[].name] | index("Cafe: Guest") != null' <<<"$saved" >/dev/null \ || fail "a network name containing a colon was cut in half: $saved" jq -e '.connections[] | select(.name == "Home Wi-Fi") | .active == true and .autoconnect == true and .inRange == true' <<<"$saved" >/dev/null \ || fail "the connected profile is not reported as connected and in range: $saved" # The whole reason for the scan cross-reference: a saved network you are # nowhere near is otherwise invisible until you stand next to it. jq -e '.connections[] | select(.name == "Office-Corp") | .inRange == false' <<<"$saved" >/dev/null \ || fail "a saved network that the scan did not see is not reported as out of range: $saved" # A wired profile is not somewhere else; it is a cable. Saying "out of range" # about one would be inventing a fact. jq -e '.connections[] | select(.name == "Wired connection 1") | .inRange == null' <<<"$saved" >/dev/null \ || fail "a wired profile was given an in-range answer, which it cannot have: $saved" grep -Fq -- '--rescan no' "$state_dir/argv" \ || fail "listing saved profiles made the radio go looking: $(log)" offenders="$(jq -r '[paths | map(tostring) | join(".")] | map(select(test("(password|secret|psk|passphrase)$";"i"))) | join(", ")' <<<"$saved")" [[ -z "$offenders" ]] || fail "the saved listing carries credential-shaped fields: $offenders" # ── join-hidden: the same stdin rule as the enterprise join ───────────────── : >"$state_dir/argv" : >"$state_dir/stdin" hidden_out="$(printf '%s\n' "$HIDDEN_PW" \ | runh join-hidden 'office-private' 'office-private' wpa-psk 2>"$work/hidden.err")" grep -Fq "$HIDDEN_PW" "$state_dir/argv" \ && fail 'the hidden network passphrase was passed as a command argument' grep -Fq "$HIDDEN_PW" <<<"$hidden_out" \ && fail 'the hidden network passphrase is echoed back in the helper output' grep -Fq "$HIDDEN_PW" "$work/hidden.err" \ && fail 'the hidden network passphrase was written to stderr' leaked="$(leak_in_scratch "$HIDDEN_PW")" [[ -z "$leaked" ]] || fail "the hidden network passphrase was written to $leaked" grep -Fq "$HIDDEN_PW" "$state_dir/stdin" \ || fail 'the hidden network passphrase never reached nmcli at all, on stdin or otherwise' # Without this the profile saves and never connects: NetworkManager only probes # for a network by name when it is told the name is not broadcast. grep -Fq '802-11-wireless.hidden yes' "$state_dir/stdin" \ || fail 'the profile is not marked hidden, so NetworkManager will never look for it' grep -Fq 'connection up office-private' "$state_dir/argv" \ || fail "join-hidden saved a profile and never brought it up: $(log)" # An open hidden network is a real thing, and it has no passphrase to wait for. : >"$state_dir/argv" : >"$state_dir/stdin" runh join-hidden 'open-hidden' 'open-hidden' none /dev/null 2>&1 grep -Fq 'connection edit' "$state_dir/argv" \ || fail "an open hidden network was not created: $(log)" grep -Fq 'wireless-security' "$state_dir/stdin" \ && fail 'an open network was given a key-management setting' : >"$state_dir/argv" [[ -n "$(runh join-hidden 'office-private' 'office-private' wep /dev/null \ | jq -r '.error // ""')" ]] || fail 'an unknown hidden-network security was accepted' [[ ! -s "$state_dir/argv" ]] \ || fail 'an unknown hidden-network security still reached nmcli' [[ -n "$(runh join-hidden 'office-private' 'office-private' wpa-psk /dev/null \ | jq -r '.error // ""')" ]] || fail 'a secured hidden network with no password was accepted' # ── import-vpn picks its plugin from the extension ─────────────────────────── printf '[Interface]\n' >"$work/tunnel.conf" printf 'client\n' >"$work/tunnel.ovpn" printf 'not a tunnel\n' >"$work/tunnel.txt" : >"$state_dir/argv" imported="$(runh import-vpn "$work/tunnel.conf" 2>/dev/null)" grep -Fq 'connection import type wireguard' "$state_dir/argv" \ || fail "a .conf file was not imported as WireGuard: $(log)" jq -e '((.name // "") | length > 0) and ((.uuid // "") | length > 0)' <<<"$imported" >/dev/null \ || fail "import-vpn does not name the connection it made: $imported" jq -e '.kind == "wireguard"' <<<"$imported" >/dev/null \ || fail "a .conf import is not reported as WireGuard: $imported" : >"$state_dir/argv" ovpn="$(runh import-vpn "$work/tunnel.ovpn" 2>/dev/null)" grep -Fq 'connection import type openvpn' "$state_dir/argv" \ || fail "a .ovpn file was not imported as OpenVPN: $(log)" jq -e '.kind == "openvpn"' <<<"$ovpn" >/dev/null \ || fail "a .ovpn import is not reported as OpenVPN: $ovpn" : >"$state_dir/argv" [[ -n "$(error_of import-vpn "$work/tunnel.txt")" ]] \ || fail 'a file that is neither .conf nor .ovpn was accepted for import' grep -Fq 'connection import' "$state_dir/argv" \ && fail 'an unimportable file still reached nmcli' [[ -n "$(error_of import-vpn "$work/does-not-exist.conf")" ]] \ || fail 'a path that does not exist was accepted for import' # ── hotspot: the password comes back once and is written nowhere ───────────── : >"$state_dir/argv" hotspot="$(runh hotspot start panama-hotspot 2>/dev/null)" grep -Fq 'device wifi hotspot' "$state_dir/argv" \ || fail "starting a hotspot did not reach nmcli: $(log)" grep -Fq 'device wifi show-password' "$state_dir/argv" \ || fail 'the generated hotspot password is never read back, so the UI cannot show it' grep -Fq "$HOTSPOT_PW" <<<"$hotspot" \ || fail "the hotspot password is not returned to the caller: $hotspot" # It may pass through the return value exactly once. It may not be an argument, # and it may not be left behind on disk. grep -Fq "$HOTSPOT_PW" "$state_dir/argv" \ && fail 'the hotspot password was passed to a command, where /proc publishes it' leaked="$(leak_in_scratch "$HOTSPOT_PW")" [[ -z "$leaked" ]] || fail "the hotspot password was written to $leaked" runh hotspot status >/dev/null 2>&1 || fail 'hotspot status failed' : >"$state_dir/argv" runh hotspot stop >/dev/null 2>&1 [[ -s "$state_dir/argv" ]] || fail 'stopping the hotspot did nothing at all' # ── join-enterprise: the password arrives on stdin and never on argv ───────── # # The pin the whole file is built around. libnm is unavailable here by # construction, so this is the nmcli fallback: the editor takes `set # 802-1x.password …` as a line of input, which keeps the secret out of ps. : >"$state_dir/argv" : >"$state_dir/stdin" enterprise_out="$(printf '%s\n' "$ENTERPRISE_PW" \ | runh join-enterprise 'Campus Secure' peap-mschapv2 'gib@example.edu' 2>"$work/enterprise.err")" grep -Fq "$ENTERPRISE_PW" "$state_dir/argv" \ && fail 'the enterprise password was passed as a command argument' grep -Fq "$ENTERPRISE_PW" <<<"$enterprise_out" \ && fail 'the enterprise password is echoed back in the helper output' grep -Fq "$ENTERPRISE_PW" "$work/enterprise.err" \ && fail 'the enterprise password was written to stderr' leaked="$(leak_in_scratch "$ENTERPRISE_PW")" [[ -z "$leaked" ]] || fail "the enterprise password was written to $leaked" # And it did reach NetworkManager, or the verb is a no-op wearing a JSON hat. grep -Fq 'connection edit' "$state_dir/argv" \ || fail "join-enterprise never opened the connection editor: $(log)" grep -Fq "$ENTERPRISE_PW" "$state_dir/stdin" \ || fail 'the enterprise password never reached nmcli at all, on stdin or otherwise' # The EAP method is a closed set, not free text handed to nmcli. Nothing is # piped in on purpose: the arguments are checked before stdin is read, so a # request that was always going to be refused must not sit waiting for a # password first. : >"$state_dir/argv" bad_method="$(runh join-enterprise 'Campus Secure' ldap-md5 'gib' /dev/null \ | jq -r '.error // ""')" [[ -n "$bad_method" ]] || fail 'an unknown EAP method was accepted' grep -Fq 'connection edit' "$state_dir/argv" \ && fail 'an unknown EAP method still reached nmcli' [[ -n "$(runh join-enterprise 'Campus Secure' peap-mschapv2 'gib' /dev/null \ | jq -r '.error // ""')" ]] \ || fail 'an enterprise join with no password was accepted' # ── proxy and airplane are the settings they claim to be ───────────────────── : >"$state_dir/argv" proxy="$(runh proxy get 2>/dev/null)" grep -Fq 'org.gnome.system.proxy' "$state_dir/argv" \ || fail "proxy get does not read the GNOME proxy settings: $(log)" jq -e 'has("mode")' <<<"$proxy" >/dev/null || fail "proxy get reports no mode: $proxy" : >"$state_dir/argv" runh proxy set manual 192.168.7.9 3128 >/dev/null 2>&1 grep -Fq 'set org.gnome.system.proxy mode' "$state_dir/argv" \ || fail "setting a manual proxy did not set the mode: $(log)" # All three or none: a proxy applied to http alone silently leaks the rest. for scheme in http https socks; do grep -Fq "org.gnome.system.proxy.$scheme host" "$state_dir/argv" \ || fail "the manual proxy was not applied to $scheme, so that traffic ignores it" done [[ -n "$(error_of proxy set sideways)" ]] || fail 'an unknown proxy mode was accepted' [[ -n "$(error_of proxy set manual 'host; reboot' 3128)" ]] \ || fail 'a proxy host with a shell metacharacter was accepted' [[ -n "$(error_of proxy set manual 192.168.7.9 99999)" ]] \ || fail 'an impossible proxy port was accepted' [[ -n "$(error_of proxy set auto 'javascript:alert(1)')" ]] \ || fail 'a PAC URL that is not a URL was accepted' : >"$state_dir/argv" airplane="$(runh airplane status 2>/dev/null)" grep -Fq 'rfkill' "$state_dir/argv" || fail "airplane status does not read rfkill: $(log)" jq -e 'has("on")' <<<"$airplane" >/dev/null || fail "airplane status reports no state: $airplane" : >"$state_dir/argv" runh airplane set true >/dev/null 2>&1 grep -Eq 'rfkill +block' "$state_dir/argv" \ || fail "turning airplane mode on did not block the radios: $(log)" : >"$state_dir/argv" runh airplane set false >/dev/null 2>&1 grep -Eq 'rfkill +unblock' "$state_dir/argv" \ || fail "turning airplane mode off did not unblock the radios: $(log)" [[ -n "$(error_of airplane set maybe)" ]] || fail 'a non-boolean was accepted for airplane mode' # ── Names are validated here, not by nmcli ─────────────────────────────────── # # The reason matters: without validation these still fail, because nmcli refuses # them too -- so a check that only asks "did something error" passes with the # validation deleted. Each must also leave the recording untouched, which is # what says the refusal happened before anything was run. long_name="$(printf 'a%.0s' {1..300})" for bad in 'Home; rm -rf /' '-x-not-a-name' '' "$long_name" 'new line'; do : >"$state_dir/argv" [[ -n "$(error_of details "$bad")" ]] \ || fail "a bad connection name was accepted: ${bad@Q}" [[ ! -s "$state_dir/argv" ]] \ || fail "a bad connection name reached nmcli before being refused: ${bad@Q}" [[ -n "$(error_of forget "$bad")" ]] \ || fail "forget accepted a bad connection name: ${bad@Q}" done for bad in 'ssid; reboot' '-x-not-a-name' '' "$(printf 'a%.0s' {1..33})"; do : >"$state_dir/argv" [[ -n "$(error_of hotspot start "$bad")" ]] \ || fail "a bad hotspot SSID was accepted: ${bad@Q}" [[ ! -s "$state_dir/argv" ]] \ || fail "a bad hotspot SSID reached nmcli before being refused: ${bad@Q}" done [[ -n "$(error_of set-autoconnect 'Home Wi-Fi' perhaps)" ]] \ || fail 'a non-boolean was accepted for autoconnect' [[ -n "$(error_of bogus-verb)" ]] || fail 'an unknown command was accepted' # ── Nothing anywhere left a secret behind ─────────────────────────────────── for secret in "$WIFI_PSK" "$VPN_SECRET" "$ENTERPRISE_PW" "$HOTSPOT_PW" "$HIDDEN_PW"; do leaked="$(leak_in_scratch "$secret")" [[ -z "$leaked" ]] || fail "a secret was left behind in $leaked" done printf 'network tools contract: PASS (details, forget, saved, autoconnect, MAC, metered, static IP, import, hotspot, enterprise, hidden, proxy, airplane)\n'