Files
dejima-ci-hardener/test/allowlist.bats
T
branderson e4e0816f6f
tests / runner-env (confined) (pull_request_target) Successful in 41s
tests / tests (pull_request_target) Successful in 1m31s
tests / compose-schema (pull_request_target) Successful in 49s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
Give each runner pool the allowlist it could actually run
Every job on the confined tier resolved against one list and was filtered by one
ipset, so a name any trusted repository declared was reachable from every
repository's jobs. One project's declaration widened all of them.

THE SET IS WHAT HAD TO BE SCOPED, not the resolver. It holds addresses with no
memory of which name put them there, and every bridge matched the same one, so
the union was at the address layer: a job that could not resolve another
project's name could still connect to the address that name resolved to, and
declarations are public.

A SET PER RUNNER POOL, holding the union of every declaration that pool could be
made to run. That is the only union that is safe. A pool takes jobs from the
repositories it is registered for, so a hostile branch in one of them can
already put a job on it, and its names being in that pool's set gives away
nothing the branch did not already have. What the set leaves out is every
repository the pool cannot run at all.

An earlier draft scoped per project and leaned on labels to route jobs, which is
not a boundary: a branch writes its own runs-on, so a label is a request the job
makes rather than a property the operator sets. What a runner may take is
decided by its registration, which a branch cannot change. So the set is built
from the registration - one repository, or an owner - and labels are left alone.
Pools may share them.

Registering a pool to one repository is how its union gets small, and is the
recommendation rather than the mechanism. An owner-scoped pool is not a
misconfiguration: it is a pool that genuinely can run any of those
repositories' jobs, holding exactly what that implies. So the dejima
organisation's repositories can keep one org-scoped pool and one union, a
user-scoped pool keeps a small one, and a repository needing an unusual domain
can have a pool of its own without that domain reaching anybody else's.

No shared fallback. A declaration no pool can run refuses the render, naming
what is missing: that is almost always a repository trusted to widen the
allowlist and never given a runner, which is a job queueing rather than a
security decision, and rendering its names into nothing would hide it. Refusing
installs nothing, so the allowlist already in force stays in force.

dnsmasq places a name's addresses in several sets at once, so one resolver fills
each pool's set and no others. The firewall matches each bridge against its
pool's set and a base set every pool shares, and takes rules for a pool's bridge
whether or not the config's bridge list knew about it.

Three things caught while doing it. The name count was lines minus the header,
which doubled the moment a name emitted two lines. A guest-layer note must
require a snapshot, or this tier's scheduled rollback quietly undoes the
install. And the revocation flush from the previous change flushed the set named
by the config key, which is a prefix now and holds nothing - it would have
emptied an unused set and reported a revocation that had not happened.

Capturing fetch_repo into a variable also dropped its exit status, so a forge
that rejected the token became a repository that declared nothing. It was the
last command of a pipeline before, where pipefail carried the failure out.
2026-09-01 13:34:15 -07:00

883 lines
33 KiB
Bash

#!/usr/bin/env bats
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Brad Anderson
#
# Drives bin/dejima-ci-allowlist against a stubbed curl and dnsmasq, so the
# properties being checked are the ones that decide whether this program is
# worth running: where it reads from, and which failures leave the previous
# allowlist alone.
setup() {
T="$(mktemp -d)"
BIN="${BATS_TEST_DIRNAME}/../bin/dejima-ci-allowlist"
mkdir -p "$T/stub" "$T/out"
# curl stub: prints whatever the fixture for this repo holds and reports
# the status the test asked for. Records the URL it was given, since half
# of what matters here is the ref in that URL.
cat > "$T/stub/curl" <<'EOF'
#!/usr/bin/env bash
out=""; url=""
while [ $# -gt 0 ]; do
case "$1" in
-o) out="$2"; shift ;;
# Recorded so a test can assert on what actually went over the wire,
# which is the only place token mangling is visible.
-H) printf '%s\n' "$2" >> "$STUB_DIR/last-headers"; shift ;;
http*) url="$1" ;;
esac
shift
done
echo "$url" >> "$STUB_URLS"
repo=$(sed -E 's#.*/repos/([^/]+/[^/]+)/raw.*#\1#' <<< "$url")
status=$(cat "$STUB_DIR/$repo.status" 2>/dev/null || echo 200)
[ -f "$STUB_DIR/$repo.body" ] && cat "$STUB_DIR/$repo.body" > "$out" || : > "$out"
echo -n "$status"
EOF
# dnsmasq stub: --test passes unless a test says otherwise.
cat > "$T/stub/dnsmasq" <<'EOF'
#!/usr/bin/env bash
[ -n "${STUB_DNSMASQ_FAIL:-}" ] && exit 1
exit 0
EOF
chmod +x "$T/stub/curl" "$T/stub/dnsmasq"
export PATH="$T/stub:$PATH"
export STUB_DIR="$T/fixtures" STUB_URLS="$T/urls"
mkdir -p "$STUB_DIR"
echo token > "$T/token"
echo "owner/repo" > "$T/repos"
: > "$T/base"
# A correctly configured host: a repository that declares names has a set of
# its own. Without one the render refuses, which is its own test rather than
# the state every other test starts from.
echo "owner/repo 1" > "$T/pools"
cat > "$T/config" <<EOF
forge_api = https://gitea.example.com/api/v1
token_file = $T/token
repos_file = $T/repos
pools_file = $T/pools
base_file = $T/base
upstream = 198.51.100.53
out = $T/out/ci-allow.conf
reload = true
EOF
}
teardown() { rm -rf "$T"; }
run_tool() { run "$BIN" "$@" --config "$T/config"; }
@test "renders one server= line per declared domain" {
mkdir -p "$STUB_DIR/owner"
printf 'example.com\ndeb.debian.org\n' > "$STUB_DIR/owner/repo.body"
run_tool render
[ "$status" -eq 0 ]
grep -q '^server=/example.com/198.51.100.53$' "$T/out/ci-allow.conf"
grep -q '^server=/deb.debian.org/198.51.100.53$' "$T/out/ci-allow.conf"
}
@test "reads main by default, and the ref is in the request" {
mkdir -p "$STUB_DIR/owner"; echo example.com > "$STUB_DIR/owner/repo.body"
run_tool render
[ "$status" -eq 0 ]
grep -q 'ref=main' "$T/urls"
}
# The whole point of the program. A ref a pull request can write is not a
# source of truth, so this is worth a test rather than a comment.
@test "the ref is configurable but defaults to the operator's branch" {
mkdir -p "$STUB_DIR/owner"; echo example.com > "$STUB_DIR/owner/repo.body"
echo "ref = release" >> "$T/config"
run_tool render
grep -q 'ref=release' "$T/urls"
}
@test "a repository with no declaration is skipped, not fatal" {
mkdir -p "$STUB_DIR/owner"
echo 404 > "$STUB_DIR/owner/repo.status"
echo example.com > "$T/base"
run_tool render
[ "$status" -eq 0 ]
grep -q '^server=/example.com/' "$T/out/ci-allow.conf"
}
@test "an unexpected HTTP status is fatal and installs nothing" {
mkdir -p "$STUB_DIR/owner"
echo 500 > "$STUB_DIR/owner/repo.status"
echo example.com > "$T/base"
run_tool render
[ "$status" -ne 0 ]
[ ! -f "$T/out/ci-allow.conf" ]
[[ "$output" == *"HTTP 500"* ]]
}
@test "a rejected token says so, rather than reporting an HTTP number" {
# 401 is the one an operator actually meets, and "unexpected HTTP 401" sends
# nobody to the token. It has to name the file and say that a present-but-
# wrong token is what this looks like, because an empty one is refused
# earlier with a different message entirely.
mkdir -p "$STUB_DIR/owner"
echo 401 > "$STUB_DIR/owner/repo.status"
echo example.com > "$T/base"
run_tool render
[ "$status" -ne 0 ]
[ ! -f "$T/out/ci-allow.conf" ]
[[ "$output" == *"rejected the token"* ]]
[[ "$output" == *"$T/token"* ]]
}
@test "a token refused permission is not confused with one refused outright" {
# 403 authenticates and cannot read. Telling someone to replace a token
# that is fine, when the answer is a scope they did not grant, costs them
# the one thing the message was supposed to save.
mkdir -p "$STUB_DIR/owner"
echo 403 > "$STUB_DIR/owner/repo.status"
echo example.com > "$T/base"
run_tool render
[ "$status" -ne 0 ]
[[ "$output" == *"refused the read"* ]]
[[ "$output" == *"scope"* ]]
[[ "$output" != *"rejected the token"* ]]
}
@test "surrounding whitespace in the token file never reaches the forge" {
# Command substitution strips trailing newlines and nothing else, so a
# trailing space or a carriage return survives into the Authorization
# header and the forge rejects it - while the file still looks correct to
# anyone who cats it.
printf 'tok123 \r\n' > "$T/token"
echo example.com > "$T/base"
run_tool render
[ "$status" -eq 0 ]
grep -q 'Authorization: token tok123$' "$STUB_DIR/last-headers"
}
@test "a renamed repository is named as such, and not followed" {
# Gitea 301s the old path after a rename or a transfer. Following it would
# mean reading the allowlist from whichever repository the forge points at
# now, which is the one thing this program must not do - the whole premise
# is that the list comes from a branch of a repository the operator named.
mkdir -p "$STUB_DIR/owner"
echo 301 > "$STUB_DIR/owner/repo.status"
echo example.com > "$T/base"
run_tool render
[ "$status" -ne 0 ]
[ ! -f "$T/out/ci-allow.conf" ]
[[ "$output" == *"renamed or moved"* ]]
[[ "$output" == *"$T/repos"* ]]
[[ "$output" != *"unexpected HTTP"* ]]
}
@test "an empty collection is refused rather than installed" {
mkdir -p "$STUB_DIR/owner"; : > "$STUB_DIR/owner/repo.body"
run_tool render
[ "$status" -ne 0 ]
[ ! -f "$T/out/ci-allow.conf" ]
[[ "$output" == *"empty allowlist"* ]]
}
@test "a malformed entry is refused, and refused loudly" {
mkdir -p "$STUB_DIR/owner"
printf 'example.com\nnot a domain\n' > "$STUB_DIR/owner/repo.body"
run_tool render
[ "$status" -ne 0 ]
[ ! -f "$T/out/ci-allow.conf" ]
[[ "$output" == *"malformed"* ]]
}
# An entry that smuggles a second directive past the renderer would turn a
# declaration file into resolver configuration.
@test "an entry carrying a newline or a directive cannot reach the output" {
mkdir -p "$STUB_DIR/owner"
printf 'example.com\nevil.com/1.2.3.4\naddr=/x/1.2.3.4\n' > "$STUB_DIR/owner/repo.body"
run_tool render
[ "$status" -ne 0 ]
[ ! -f "$T/out/ci-allow.conf" ]
}
@test "an existing allowlist survives a failed run" {
mkdir -p "$STUB_DIR/owner"; echo example.com > "$STUB_DIR/owner/repo.body"
run_tool render
[ "$status" -eq 0 ]
cp "$T/out/ci-allow.conf" "$T/before"
echo 500 > "$STUB_DIR/owner/repo.status"
run_tool render
[ "$status" -ne 0 ]
cmp "$T/before" "$T/out/ci-allow.conf"
}
@test "a config that fails dnsmasq --test is not installed" {
mkdir -p "$STUB_DIR/owner"; echo example.com > "$STUB_DIR/owner/repo.body"
STUB_DNSMASQ_FAIL=1 run_tool render
[ "$status" -ne 0 ]
[ ! -f "$T/out/ci-allow.conf" ]
}
@test "an unchanged allowlist is not reinstalled or reloaded" {
mkdir -p "$STUB_DIR/owner"; echo example.com > "$STUB_DIR/owner/repo.body"
run_tool render
before=$(stat -c %Y "$T/out/ci-allow.conf")
sleep 1
run_tool render
[ "$status" -eq 0 ]
[ "$(stat -c %Y "$T/out/ci-allow.conf")" -eq "$before" ]
}
# The config file names a token path and sits on a CI host. Sourcing it would
# make an unexpected line executable.
@test "the config is parsed, not sourced" {
echo 'upstream = $(touch '"$T"'/pwned)' >> "$T/config"
mkdir -p "$STUB_DIR/owner"; echo example.com > "$STUB_DIR/owner/repo.body"
run_tool render
[ ! -f "$T/pwned" ]
}
@test "an unknown config key is a hard error, not a silent typo" {
echo 'upstreem = 198.51.100.53' >> "$T/config"
run_tool render
[ "$status" -ne 0 ]
[[ "$output" == *"unknown key"* ]]
}
@test "a first install reports what it would install, not a diff error" {
# The allowlist does not exist yet on a first run, which is the ordinary
# case and not a fault. Handing diff a path that is not there prints its
# complaint instead of the answer, on the one run where the operator is
# least able to tell the difference.
mkdir -p "$STUB_DIR/owner"
printf 'example.com\ndeb.debian.org\n' > "$STUB_DIR/owner/repo.body"
[ ! -f "$T/out/ci-allow.conf" ]
run_tool check
[ "$status" -eq 1 ]
[[ "$output" == *"would install"* ]]
[[ "$output" == *"2 names"* ]]
[[ "$output" != *"No such file"* ]]
[ ! -f "$T/out/ci-allow.conf" ]
}
@test "check reports a pending change without installing it" {
mkdir -p "$STUB_DIR/owner"; echo example.com > "$STUB_DIR/owner/repo.body"
run_tool check
[ "$status" -eq 1 ]
[ ! -f "$T/out/ci-allow.conf" ]
}
# --- preflight and exit codes ------------------------------------------------
#
# The first version reported configuration problems one at a time, and the
# worst of them not at all: a missing token_file surfaced as bash's own "No
# such file or directory" with a line number, then "empty token" (it was
# absent, not empty), then "collection failed" - three messages, none naming
# the setting that pointed there.
@test "a missing token file names the setting, the path, and what belongs there" {
rm -f "$T/token"
run_tool check
[ "$status" -eq 2 ]
[[ "$output" == *"token_file points at $T/token"* ]]
[[ "$output" == *"does not exist"* ]]
# Which kind of token, not just that one is wanted. A forge hands out two
# and this tool asks for both; naming only "a token" is how the wrong one
# ends up in the file, where it authenticates against nothing and the only
# symptom is a 401 from an unrelated-looking command.
[[ "$output" == *"API token"* ]]
[[ "$output" == *"registration token"* ]]
}
@test "every configuration problem is reported in one run, not one per run" {
rm -f "$T/token" "$T/repos"
sed -i 's|^upstream = .*|upstream =|' "$T/config"
run_tool check
[ "$status" -eq 2 ]
[[ "$output" == *"token_file"* ]]
[[ "$output" == *"repos_file"* ]]
[[ "$output" == *"upstream"* ]]
[[ "$output" == *"3 problem(s)"* ]]
}
# The upstream setting is the one an operator is most likely to fill in wrongly,
# so its hint has to say what it is rather than only that it is missing.
@test "the upstream hint says which resolver is meant" {
sed -i 's|^upstream = .*|upstream =|' "$T/config"
run_tool check
[[ "$output" == *"forwards ALLOWED queries"* ]]
[[ "$output" == *"not the bridge gateway"* ]]
}
@test "an empty token file is reported as empty, not as missing" {
: > "$T/token"
run_tool check
[ "$status" -eq 2 ]
[[ "$output" == *"is empty"* ]]
}
# 1 means "there is a pending change" and is an ordinary answer; everything
# that is a fault has to be distinguishable from it, or a timer's alerting
# cannot tell "the allowlist moved" from "the forge is unreachable".
@test "a fault exits 2 and a pending change exits 1" {
mkdir -p "$STUB_DIR/owner"; echo example.com > "$STUB_DIR/owner/repo.body"
run_tool check
[ "$status" -eq 1 ]
echo 500 > "$STUB_DIR/owner/repo.status"
run_tool check
[ "$status" -eq 2 ]
}
# `show` reads only what is already rendered, so it has to keep working on the
# host whose configuration is the thing being diagnosed.
@test "show still works when the configuration is unusable" {
mkdir -p "$STUB_DIR/owner"; echo example.com > "$STUB_DIR/owner/repo.body"
run_tool render
[ "$status" -eq 0 ]
rm -f "$T/token"
run_tool show
[ "$status" -eq 0 ]
[[ "$output" == *"server=/example.com/"* ]]
}
# "no config at ..." was what a non-root user saw for a file sitting right
# there at a root-owned path - sending them to create something that already
# existed. The two cases need different messages because they need different
# actions.
@test "a config behind an unsearchable directory is not reported as missing" {
# /etc/dejima-ci is 0750 root-owned, so every unprivileged run cannot even
# stat the file inside it. Reporting that as "no config - run install.sh"
# sends someone to redo an install that already succeeded, when the only
# thing missing is sudo.
[ "$(id -u)" -ne 0 ] || skip "root searches any directory, so this cannot arise"
mkdir -p "$T/locked"
cp "$T/config" "$T/locked/config"
chmod 000 "$T/locked"
run "$BIN" check --config "$T/locked/config"
chmod 755 "$T/locked"
[ "$status" -eq 2 ]
[[ "$output" == *"not searchable"* ]]
[[ "$output" == *"sudo"* ]]
[[ "$output" != *"run install.sh to create it"* ]]
}
@test "a missing config says so, and says what creates one" {
# Not run_tool: it appends its own --config, which would win over this one.
run "$BIN" check --config "$T/does-not-exist"
[ "$status" -ne 0 ]
[[ "$output" == *"no config at"* ]]
[[ "$output" == *"install.sh"* ]]
}
@test "an unreadable config is a different message from a missing one" {
# Only a caller who cannot read the file ever sees this branch, which makes
# the assertion depend on who is running it. `[ -r ]` is access(2) with
# R_OK, and that always succeeds for a process holding CAP_DAC_OVERRIDE -
# so as root the file is readable at mode 000, the branch is unreachable,
# and this fails on the privilege of the caller rather than on anything the
# program does.
#
# Skipped rather than asserted-around, because there is nothing here to
# check when the caller can bypass the permission being tested. CI runs the
# suite as an ordinary user precisely so that this runs there.
[ "$(id -u)" -ne 0 ] || skip "root reads a mode-000 file, so this branch is unreachable"
chmod 000 "$T/config"
run_tool render
[ "$status" -ne 0 ]
[[ "$output" == *"is not readable by"* ]]
[[ "$output" == *"sudo"* ]]
[[ "$output" != *"no config at"* ]]
chmod 644 "$T/config"
}
# --- the base list is a config file, not a domain list -----------------------
@test "a comment in base-domains is a comment, not a malformed domain" {
# It was cat'd raw while the repos file one line below was comment-stripped,
# and install.sh strips comments when it reads this same file back. So a
# `#` line here reached valid_domain and killed the run with "refusing a
# malformed entry: #namesallowedregardless...", which names no file and
# sends an operator to /etc/dejima-ci/config - a file that legitimately has
# a comment at the top, put there by install.sh, and was never the problem.
mkdir -p "$STUB_DIR/owner"; echo example.com > "$STUB_DIR/owner/repo.body"
printf '# names allowed regardless of any declaration\nbase.example.com\n' > "$T/base"
run_tool render
[ "$status" -eq 0 ]
grep -q '^server=/base.example.com/' "$T/out/ci-allow.conf"
! grep -q '#names' "$T/out/ci-allow.conf"
}
@test "a malformed entry says which file it might have come from" {
# Two files can produce one, and they are edited by different people at
# different times. Naming neither leaves the operator grepping for a `#`.
mkdir -p "$STUB_DIR/owner"; printf 'not a domain!\n' > "$STUB_DIR/owner/repo.body"
run_tool render
[ "$status" -eq 2 ]
[[ "$output" == *"$T/base"* ]]
[[ "$output" == *"$T/repos"* ]]
}
@test "collection says what each repository contributed" {
# "My project declared domains and got none of them" had no diagnostic
# anywhere: a repository absent from repos_file is never fetched, so it
# produced no line, no error and no trace.
mkdir -p "$STUB_DIR/owner"; printf 'a.example.com\nb.example.com\n' > "$STUB_DIR/owner/repo.body"
run_tool render
[[ "$output" == *"owner/repo: 2 name(s) declared"* ]]
}
@test "a repository with no declaration says so rather than staying silent" {
mkdir -p "$STUB_DIR/owner"
echo 404 > "$STUB_DIR/owner/repo.status"
echo base.example.com > "$T/base"
run_tool render
[[ "$output" == *"owner/repo: no .dejima/allowed-domains"* ]]
}
# --- trusting a repository without re-running the installer ------------------
@test "repos list prints the trusted repositories" {
printf 'acme/one\nacme/two\n' > "$T/repos"
run_tool repos list
[ "$status" -eq 0 ]
[[ "$output" == *"acme/one"* ]]
[[ "$output" == *"acme/two"* ]]
}
@test "repos add trusts one without touching the others" {
# The whole point. Adding one used to mean re-running install.sh and
# retyping every entry already there, from memory.
printf 'acme/one\nacme/two\n' > "$T/repos"
run_tool repos add acme/three
[ "$status" -eq 0 ]
for r in acme/one acme/two acme/three; do
grep -qx "$r" "$T/repos" || { echo "lost $r"; false; }
done
}
@test "repos add is idempotent" {
printf 'acme/one\n' > "$T/repos"
run_tool repos add acme/one
[ "$(grep -c . "$T/repos")" -eq 1 ]
[[ "$output" == *"already trusted"* ]]
}
@test "repos remove takes one out and leaves the rest" {
printf 'acme/one\nacme/two\n' > "$T/repos"
run_tool repos remove acme/one
! grep -qx acme/one "$T/repos"
grep -qx acme/two "$T/repos"
}
@test "repos add refuses something that is not owner/repo" {
printf 'acme/one\n' > "$T/repos"
run_tool repos add "https://example.com/acme/two"
[ "$status" -eq 2 ]
[ "$(grep -c . "$T/repos")" -eq 1 ]
}
@test "repos says the list is not live until the allowlist is rebuilt" {
# Editing the file changes nothing on its own. An operator who adds a repo
# and walks away has done half a job.
printf 'acme/one\n' > "$T/repos"
run_tool repos add acme/two
[[ "$output" == *"render"* ]]
}
@test "repos works without a usable forge configuration" {
# It edits a list of names. Requiring a working token and resolver to do
# that would make it useless on the host whose configuration is the thing
# being diagnosed.
rm -f "$T/token"
printf 'acme/one\n' > "$T/repos"
run_tool repos list
[ "$status" -eq 0 ]
}
@test "repos honours --config placed after the operands" {
# `repos` used to consume the rest of the line where it was seen, so a
# following --config was swallowed and the edit landed in whatever the
# default config pointed at. Silently, on the file that decides whose
# branch may widen an allowlist.
printf 'acme/one\n' > "$T/repos"
run "$BIN" repos add acme/two --config "$T/config"
[ "$status" -eq 0 ]
grep -qx acme/two "$T/repos"
}
@test "a comment in a repository's declaration is a comment too" {
# The first fix stripped base-domains and nothing else, and the very next
# run on a real host died on a bare `#` from a repository's own
# declaration. A fetched body was cat'd raw, so the bug was the same one a
# layer over. Both arrive at one place now, and that is where stripping
# happens.
mkdir -p "$STUB_DIR/owner"
printf '# what this project needs\n#\n# and why\nreal.example.com\n' > "$STUB_DIR/owner/repo.body"
run_tool render
[ "$status" -eq 0 ]
grep -q '^server=/real.example.com/' "$T/out/ci-allow.conf"
[ "$(grep -c '^server=' "$T/out/ci-allow.conf")" -eq 1 ]
}
@test "a bare hash line does not become an entry" {
# Exactly what a real host produced: `refusing a malformed entry: #`. A
# separator line in a comment block, which is how this organisation's own
# generated files are written.
mkdir -p "$STUB_DIR/owner"
printf '#\nreal.example.com\n' > "$STUB_DIR/owner/repo.body"
run_tool render
[ "$status" -eq 0 ]
}
@test "the declared count is names, not lines" {
# A comment block counted as declared names, so a repository reporting
# "15 name(s) declared" might be declaring rather fewer.
mkdir -p "$STUB_DIR/owner"
printf '# three comment lines\n#\n# here\none.example.com\ntwo.example.com\n' > "$STUB_DIR/owner/repo.body"
run_tool render
[[ "$output" == *"owner/repo: 2 name(s) declared"* ]]
}
# --- revoking a name --------------------------------------------------------
#
# dnsmasq puts the addresses it answers with into the firewall's set, and
# nothing took them out again. After a re-render the name stopped resolving and
# every address it had ever resolved to stayed reachable until the host
# rebooted, so removing a domain from a declaration revoked nothing.
stub_ipset() {
cat > "$T/stub/ipset" <<'IPSETEOF'
#!/usr/bin/env bash
case "$1" in
list) printf '%s\n' "${STUB_IPSET_NAMES:-ci-allow}" ;;
create) ;;
flush) printf '%s\n' "$2" >> "$STUB_DIR/flushed" ;;
esac
exit 0
IPSETEOF
chmod +x "$T/stub/ipset"
}
@test "a revocation flushes the project sets, not just the bare name" {
# The config key is a prefix now: names live in a set per project plus a
# base set, and the bare name holds nothing. Flushing it alone would empty
# an unused set and report a revocation that had not happened.
stub_ipset
STUB_IPSET_NAMES=$'ci-allow\nci-allow-base\nci-allow-1'
export STUB_IPSET_NAMES
mkdir -p "$STUB_DIR/owner"
printf 'keep.example.com\ndrop.example.com\n' > "$STUB_DIR/owner/repo.body"
run_tool render
printf 'keep.example.com\n' > "$STUB_DIR/owner/repo.body"
run_tool render
[ "$status" -eq 0 ]
grep -qx ci-allow-1 "$STUB_DIR/flushed"
grep -qx ci-allow-base "$STUB_DIR/flushed"
}
@test "a set belonging to something else is not flushed" {
# The prefix is the boundary. Another tool's set on the same host is not
# this program's to empty.
stub_ipset
STUB_IPSET_NAMES=$'ci-allow-1\nsomeone-elses-set'
export STUB_IPSET_NAMES
mkdir -p "$STUB_DIR/owner"
printf 'keep.example.com\ndrop.example.com\n' > "$STUB_DIR/owner/repo.body"
run_tool render
printf 'keep.example.com\n' > "$STUB_DIR/owner/repo.body"
run_tool render
! grep -qx someone-elses-set "$STUB_DIR/flushed"
}
@test "removing a name flushes the set, or it stays reachable by address" {
stub_ipset
mkdir -p "$STUB_DIR/owner"
printf 'keep.example.com\ndrop.example.com\n' > "$STUB_DIR/owner/repo.body"
run_tool render
[ "$status" -eq 0 ]
printf 'keep.example.com\n' > "$STUB_DIR/owner/repo.body"
run_tool render
[ "$status" -eq 0 ]
grep -qx ci-allow "$STUB_DIR/flushed"
[[ "$output" == *"1 name(s) revoked"* ]]
}
@test "adding a name does not flush, since nothing was revoked" {
# A flush on every change would empty the set whenever a name was added,
# and a job mid-run would find a destination it had already resolved
# refused until it looked it up again.
stub_ipset
mkdir -p "$STUB_DIR/owner"
printf 'one.example.com\n' > "$STUB_DIR/owner/repo.body"
run_tool render
printf 'one.example.com\ntwo.example.com\n' > "$STUB_DIR/owner/repo.body"
run_tool render
[ "$status" -eq 0 ]
[ ! -f "$STUB_DIR/flushed" ]
}
@test "an unchanged render flushes nothing and reloads nothing" {
stub_ipset
mkdir -p "$STUB_DIR/owner"
printf 'one.example.com\n' > "$STUB_DIR/owner/repo.body"
run_tool render
run_tool render
[ "$status" -eq 0 ]
[ ! -f "$STUB_DIR/flushed" ]
}
@test "a missing set is reported rather than passed over" {
# The names go out of the resolver either way, so this half-succeeds. An
# operator who is not told will believe a revocation took effect.
stub_ipset
mkdir -p "$STUB_DIR/owner"
printf 'keep.example.com\ndrop.example.com\n' > "$STUB_DIR/owner/repo.body"
run_tool render
printf 'keep.example.com\n' > "$STUB_DIR/owner/repo.body"
STUB_IPSET_NAMES="something-else" run_tool render
[[ "$output" == *"stays reachable"* ]]
}
@test "the flush happens before the reload, not after" {
# The reload is a SIGHUP, which clears dnsmasq's cache. Flushing after it
# would leave cached answers that repopulate nothing, so names still on the
# list would be unreachable until their TTL ran out.
stub_ipset
cat > "$T/stub/reloader" <<'RELEOF'
#!/usr/bin/env bash
printf 'reload\n' >> "$STUB_DIR/order"
RELEOF
chmod +x "$T/stub/reloader"
cat > "$T/stub/ipset" <<'IPSETEOF'
#!/usr/bin/env bash
case "$1" in
list) printf 'ci-allow\n' ;;
flush) printf 'flush\n' >> "$STUB_DIR/order" ;;
esac
exit 0
IPSETEOF
chmod +x "$T/stub/ipset"
sed -i "s|^reload = .*|reload = $T/stub/reloader|" "$T/config"
mkdir -p "$STUB_DIR/owner"
printf 'keep.example.com\ndrop.example.com\n' > "$STUB_DIR/owner/repo.body"
run_tool render
: > "$STUB_DIR/order" # the first render installs and reloads too
printf 'keep.example.com\n' > "$STUB_DIR/owner/repo.body"
run_tool render
[ "$(head -1 "$STUB_DIR/order")" = flush ]
[ "$(tail -1 "$STUB_DIR/order")" = reload ]
}
# --- scoping a project to its own set ----------------------------------------
#
# One list and one set meant every project's declaration was reachable from
# every project's jobs. The set is the part that matters: it holds addresses
# with no memory of which name put them there, so scoping the resolver alone
# would leave a project able to connect by address to anything another project
# resolved.
@test "a declaration with no set of its own refuses the render" {
# There is no shared fallback. A set every project can reach is the thing
# this exists to end, and falling back to one would be arriving there by
# doing nothing rather than by choosing it.
: > "$T/pools"
mkdir -p "$STUB_DIR/owner"; echo one.example.com > "$STUB_DIR/owner/repo.body"
run_tool render
[ "$status" -eq 2 ]
[[ "$output" == *"owner/repo"* ]]
[[ "$output" == *"pools add"* ]]
}
@test "a refused render leaves the allowlist that was already there" {
# The cost of an unfinished migration is a loud error on the timer, not a
# host that has lost its allowlist.
mkdir -p "$STUB_DIR/owner"; echo one.example.com > "$STUB_DIR/owner/repo.body"
run_tool render
[ "$status" -eq 0 ]
before=$(cat "$T/out/ci-allow.conf")
: > "$T/pools"
run_tool render
[ "$status" -eq 2 ]
[ "$(cat "$T/out/ci-allow.conf")" = "$before" ]
}
@test "a repository that declares nothing needs no set" {
# It has no names to scope. Requiring an assignment would be friction with
# nothing behind it.
: > "$T/pools"
mkdir -p "$STUB_DIR/owner"; echo 404 > "$STUB_DIR/owner/repo.status"
echo base.example.com > "$T/base"
run_tool render
[ "$status" -eq 0 ]
}
@test "base names go in a set of their own, not the shared one" {
# Otherwise an assigned project matching base would be matching every
# unassigned project too, and the scoping would be for nothing.
mkdir -p "$STUB_DIR/owner"; echo one.example.com > "$STUB_DIR/owner/repo.body"
echo base.example.com > "$T/base"
run_tool render
grep -qx 'ipset=/base.example.com/ci-allow-base' "$T/out/ci-allow.conf"
}
@test "an assigned project's names go in its own set" {
printf 'owner/repo 1\n' > "$T/pools"
mkdir -p "$STUB_DIR/owner"; echo one.example.com > "$STUB_DIR/owner/repo.body"
run_tool render
[ "$status" -eq 0 ]
grep -qx 'ipset=/one.example.com/ci-allow-1' "$T/out/ci-allow.conf"
! grep -qx 'ipset=/one.example.com/ci-allow' "$T/out/ci-allow.conf"
}
@test "a name two projects declare lands in both their sets" {
# dnsmasq takes several sets per name, which is what lets one resolver
# serve every project without the answer widening anyone else's reach.
printf 'owner/repo 1\nother/repo 2\n' > "$T/pools"
printf 'owner/repo\nother/repo\n' > "$T/repos"
mkdir -p "$STUB_DIR/owner" "$STUB_DIR/other"
echo shared.example.com > "$STUB_DIR/owner/repo.body"
echo shared.example.com > "$STUB_DIR/other/repo.body"
run_tool render
grep -qx 'ipset=/shared.example.com/ci-allow-1,ci-allow-2' "$T/out/ci-allow.conf"
}
@test "a name one project declares does not reach the other's set" {
# The whole point.
printf 'owner/repo 1\nother/repo 2\n' > "$T/pools"
printf 'owner/repo\nother/repo\n' > "$T/repos"
mkdir -p "$STUB_DIR/owner" "$STUB_DIR/other"
echo mine.example.com > "$STUB_DIR/owner/repo.body"
echo theirs.example.com > "$STUB_DIR/other/repo.body"
run_tool render
grep -qx 'ipset=/mine.example.com/ci-allow-1' "$T/out/ci-allow.conf"
grep -qx 'ipset=/theirs.example.com/ci-allow-2' "$T/out/ci-allow.conf"
}
@test "every name still resolves, since the scoping is the set and not the view" {
# One dnsmasq has one view. The leak is names, which are public in the
# repositories they come from; what a job can reach is the set.
printf 'owner/repo 1\n' > "$T/pools"
mkdir -p "$STUB_DIR/owner"; echo one.example.com > "$STUB_DIR/owner/repo.body"
run_tool render
grep -q '^server=/one.example.com/' "$T/out/ci-allow.conf"
}
@test "the name count is names, not lines" {
# Each name emits a server= and an ipset= line now. Counting lines and
# subtracting the header reported double.
mkdir -p "$STUB_DIR/owner"; printf 'a.example.com\nb.example.com\n' > "$STUB_DIR/owner/repo.body"
run_tool render
[[ "$output" == *"2 names"* ]]
}
@test "pools add gives out an id and never reuses one" {
# An id names a set and a bridge that may still hold addresses and still
# carry rules. Handing a freed one to a different pool would give it
# whatever the last occupant left behind.
: > "$T/pools"
run_tool pools add acme/one
run_tool pools add acme/two
run_tool pools remove acme/one
run_tool pools add acme/three
[ "$(awk '$1 == "acme/three" { print $2 }' "$T/pools")" = 3 ]
}
@test "pools add is idempotent" {
: > "$T/pools"
run_tool pools add acme/one
run_tool pools add acme/one
[ "$(grep -c 'acme/one' "$T/pools")" -eq 1 ]
[[ "$output" == *"already has id"* ]]
}
@test "pools add says what has to happen before it means anything" {
# Writing the file scopes nothing on its own: the network has to exist, the
# allowlist has to be re-rendered, the firewall has to point the bridge at
# the set, and the pool's runners have to be on that network.
: > "$T/pools"
run_tool pools add acme/one
[[ "$output" == *"docker network create"* ]]
[[ "$output" == *"render"* ]]
[[ "$output" == *"dejima-ci-firewall"* ]]
[[ "$output" == *"--network"* ]]
}
@test "pools add says the registration is what decides, not the label" {
# A branch chooses its own runs-on, so a label is a request. What a runner
# may take is decided by what it is registered to, which a branch cannot
# change.
: > "$T/pools"
run_tool pools add acme/one
[[ "$output" == *"REGISTERED"* ]]
[[ "$output" == *"label"* ]]
}
@test "pools list explains what a scope is" {
run_tool pools list
[[ "$output" == *"REGISTERED"* ]]
[[ "$output" == *"bare owner"* ]]
}
# --- an owner-scoped pool holds the union it can actually run ----------------
@test "an owner-scoped pool gets every repository of that owner" {
# Safe, because a pool takes jobs from the repositories it is registered
# for: a hostile branch in one of them can already put a job on it, so its
# names being in that pool's set gives away nothing new.
printf 'acme 1
' > "$T/pools"
printf 'acme/one
acme/two
' > "$T/repos"
mkdir -p "$STUB_DIR/acme"
echo one.example.com > "$STUB_DIR/acme/one.body"
echo two.example.com > "$STUB_DIR/acme/two.body"
run_tool render
[ "$status" -eq 0 ]
grep -qx 'ipset=/one.example.com/ci-allow-1' "$T/out/ci-allow.conf"
grep -qx 'ipset=/two.example.com/ci-allow-1' "$T/out/ci-allow.conf"
}
@test "a repository-scoped pool does not get the owner's other repositories" {
# The reason repository scoping is the recommendation: it is how a pool
# gets a small set.
printf 'acme/one 1
acme 2
' > "$T/pools"
printf 'acme/one
acme/two
' > "$T/repos"
mkdir -p "$STUB_DIR/acme"
echo one.example.com > "$STUB_DIR/acme/one.body"
echo two.example.com > "$STUB_DIR/acme/two.body"
run_tool render
# acme/one runs on both pools, so both may reach its name.
grep -qx 'ipset=/one.example.com/ci-allow-1,ci-allow-2' "$T/out/ci-allow.conf"
# acme/two runs only on the owner pool, and the repository pool never sees it.
grep -qx 'ipset=/two.example.com/ci-allow-2' "$T/out/ci-allow.conf"
}
@test "a declaration no pool can run refuses the render" {
# Almost always a repository trusted to widen the allowlist and never given
# a runner, which is a job queueing rather than a security decision.
# Rendering its names into nothing would hide that.
printf 'other/repo 1
' > "$T/pools"
mkdir -p "$STUB_DIR/owner"; echo one.example.com > "$STUB_DIR/owner/repo.body"
run_tool render
[ "$status" -eq 2 ]
[[ "$output" == *"owner/repo"* ]]
[[ "$output" == *"pools add"* ]]
}
@test "an owner scope does not match a different owner" {
printf 'other 1
' > "$T/pools"
mkdir -p "$STUB_DIR/owner"; echo one.example.com > "$STUB_DIR/owner/repo.body"
run_tool render
[ "$status" -eq 2 ]
}