615 lines
28 KiB
Bash
Executable File
615 lines
28 KiB
Bash
Executable File
#!/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'
|
|
|
|
# ── 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 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 ─────────────────────────────────────────────────────────
|
|
|
|
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" <<STUB
|
|
#!/usr/bin/env bash
|
|
state="$state_dir"
|
|
printf 'nmcli %s\n' "\$*" >>"\$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'
|
|
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
|
|
*"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" <<STUB
|
|
#!/usr/bin/env bash
|
|
printf 'gsettings %s\n' "\$*" >>"$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" <<STUB
|
|
#!/usr/bin/env bash
|
|
printf 'rfkill %s\n' "\$*" >>"$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" <<STUB
|
|
#!/usr/bin/env bash
|
|
printf '$blocked %s\n' "\$*" >>"$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)"
|
|
|
|
# ── 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 '[email protected]' 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 2>/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 2>/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"; do
|
|
leaked="$(leak_in_scratch "$secret")"
|
|
[[ -z "$leaked" ]] || fail "a secret was left behind in $leaked"
|
|
done
|
|
|
|
printf 'network tools contract: PASS (details, forget, autoconnect, MAC, import, hotspot, enterprise, proxy, airplane)\n'
|