Give each runner pool the allowlist it could actually run
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

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.
This commit is contained in:
2026-09-01 13:34:15 -07:00
parent 8cb4fb0447
commit e4e0816f6f
7 changed files with 398 additions and 276 deletions
+114 -71
View File
@@ -26,7 +26,7 @@ usage() {
cat >&2 <<EOF cat >&2 <<EOF
Usage: $PROG [render|check|show] [--config PATH] Usage: $PROG [render|check|show] [--config PATH]
$PROG repos [list|add|remove] [owner/repo...] $PROG repos [list|add|remove] [owner/repo...]
$PROG projects [list|assign|unassign] [owner/repo] $PROG pools [list|add|remove] [owner/repo | owner]
render fetch, validate and install the allowlist, reloading the resolver render fetch, validate and install the allowlist, reloading the resolver
only if it changed (the default; what the timer runs) only if it changed (the default; what the timer runs)
@@ -38,9 +38,9 @@ Usage: $PROG [render|check|show] [--config PATH]
repos list, add to, or remove from the repositories trusted to widen this repos list, add to, or remove from the repositories trusted to widen this
allowlist. Adding one used to mean re-running install.sh and allowlist. Adding one used to mean re-running install.sh and
retyping every entry already there retyping every entry already there
projects give a repository a network and firewall set of its own, so its pools declare the runner pools this host has, by what each is registered
names are reachable from its jobs and not from every project's. to. Each gets a network and a firewall set holding the union of what
Unassigned repositories share one set, as they always have that pool could be made to run, and nothing else
Reads $CONFIG unless --config or \$DEJIMA_CI_CONFIG says otherwise. Reads $CONFIG unless --config or \$DEJIMA_CI_CONFIG says otherwise.
EOF EOF
@@ -75,7 +75,7 @@ declare -A CFG=(
[forge_api]="" [forge_api]=""
[token_file]="/etc/dejima-ci/forge-token" [token_file]="/etc/dejima-ci/forge-token"
[repos_file]="/etc/dejima-ci/repos" [repos_file]="/etc/dejima-ci/repos"
[projects_file]="/etc/dejima-ci/projects" [pools_file]="/etc/dejima-ci/pools"
[base_file]="/etc/dejima-ci/base-domains" [base_file]="/etc/dejima-ci/base-domains"
[upstream]="" [upstream]=""
[out]="/etc/dnsmasq.d/ci-allow.conf" [out]="/etc/dnsmasq.d/ci-allow.conf"
@@ -325,36 +325,58 @@ collect() {
# the failure out for free. # the failure out for free.
declared=$(fetch_repo "$repo" "$token") || return 1 declared=$(fetch_repo "$repo" "$token") || return 1
[ -n "$declared" ] || continue [ -n "$declared" ] || continue
if [ -z "$(project_id "$repo")" ]; then
# No pool can run it, so its names have nowhere to go. Almost always a
# repository added to the trusted list and never given a runner, which
# is a job that would queue forever rather than a security decision -
# but rendering its names into nothing would hide that.
sets=$(sets_for_repo "$repo")
if [ -z "$sets" ]; then
printf '%s\n' "$repo" >> "$UNSCOPED" printf '%s\n' "$repo" >> "$UNSCOPED"
continue continue
fi fi
printf '%s\n' "$declared" | sed "s|^|$(project_set "$repo")\t|" while IFS= read -r one_set; do
[ -n "$one_set" ] || continue
printf '%s\n' "$declared" | sed "s|^|$one_set\t|"
done <<< "$sets"
done < "${CFG[repos_file]}" done < "${CFG[repos_file]}"
} }
# --- which set a project's names go in --------------------------------------- # --- which runner pools a repository's names have to reach ------------------
# #
# A repository that declares names needs a set of its own. There is no shared # THE SET BELONGS TO A POOL, not to a project, and it holds the union of every
# fallback, because a shared set is a set every project can reach, which is the # declaration that pool could be made to run. That is the only union that is
# thing this exists to end - and it would be arrived at by doing nothing rather # safe: a pool takes jobs from the repositories it is registered for, so a
# than by choosing it. # hostile branch in one of them can already put a job on it. Adding that
# repository's names to that pool's set gives away nothing the branch did not
# already have, and leaving out every other repository's is the whole point.
# #
# So an unassigned declaration refuses the whole render. Nothing is installed on # Labels cannot do this. A branch chooses its own runs-on, so a label is a
# a failure, so the previous allowlist stays in force: the cost of arriving here # request rather than a boundary. What a runner may take is decided by what it
# is a loud error on the timer, not a host that has lost its allowlist. # is registered to, which a branch cannot change.
# #
# A repository declaring nothing needs no assignment, having no names to scope. # So the scoping an operator gets is the scoping they registered:
# It needs one the day it declares something, and the refusal is what tells #
# whoever added that declaration. # a pool registered to one repository its declaration, and nothing else
project_id() { # project_id owner/repo -> id, or empty # a pool registered to an owner the union across that owner's repos
[ -r "${CFG[projects_file]}" ] || return 0 #
sed 's/#.*//' "${CFG[projects_file]}" \ # Repository scoping is how a pool gets a minimal set. It is the recommendation
| awk -v r="$1" '$1 == r { print $2; exit }' # rather than the mechanism, and 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.
pool_ids_for_repo() { # pool_ids_for_repo owner/repo -> ids, one per line
local repo=$1 owner=${1%%/*}
[ -r "${CFG[pools_file]}" ] || return 0
sed 's/#.*//' "${CFG[pools_file]}" \
| awk -v r="$repo" -v o="$owner" 'NF >= 2 && ($1 == r || $1 == o) { print $2 }'
} }
project_set() { # project_set owner/repo -> the ipset its names belong in sets_for_repo() { # sets_for_repo owner/repo -> set names, one per line
printf '%s-%s\n' "${CFG[ipset]}" "$(project_id "$1")" local id
while IFS= read -r id; do
[ -n "$id" ] || continue
printf '%s-%s\n' "${CFG[ipset]}" "$id"
done < <(pool_ids_for_repo "$1")
} }
# Base names are reachable from every project, so they get a set of their own # Base names are reachable from every project, so they get a set of their own
@@ -374,21 +396,22 @@ render() {
raw=$(collect) || die "collection failed; leaving the existing allowlist in place" raw=$(collect) || die "collection failed; leaving the existing allowlist in place"
if [ -s "$UNSCOPED" ]; then if [ -s "$UNSCOPED" ]; then
local list; list=$(sed 's/^/ /' "$UNSCOPED"); rm -f "$UNSCOPED" local list; list=$(sort -u "$UNSCOPED" | sed 's/^/ /'); rm -f "$UNSCOPED"
die "these repositories declare names but have no set of their own: die "these repositories declare names, and no runner pool can run them:
$list $list
Their names are not rendered, and nothing was installed: the allowlist already Nothing was installed; the allowlist already in place is untouched.
in place is untouched. A repository without a set of its own would have to share
one with every other project, which is what this stopped doing.
For each, assign it and then do what the assignment prints: A pool is registered either to one repository or to an owner, and its set holds
what it could be made to run. Declare the pools you have:
sudo $PROG projects assign owner/repo sudo $PROG pools add owner/repo # a pool registered to that repository
sudo $PROG pools add owner # a pool registered to that owner
A repository that should declare nothing at all belongs out of If a repository above has no runner at all, its jobs are queueing rather than
${CFG[repos_file]} instead: running, and the fix is a pool rather than an allowlist. If it should not be
trusted to widen anything:
sudo $PROG repos remove owner/repo" sudo $PROG repos remove owner/repo"
fi fi
@@ -459,6 +482,16 @@ valid_repo_entry() {
[[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$ ]] [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$ ]]
} }
# owner/repo, or a bare owner for a pool registered to an organisation or user.
valid_scope() {
[[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*(/[A-Za-z0-9][A-Za-z0-9._-]*)?$ ]]
}
# The bridge names are the configured one with its trailing digits replaced, so
# there is one source of truth for the prefix and the existing bridge keeps
# whatever it was called.
bridge_prefix() { local b="${CFG[bridge]:-dejima-ci0}"; printf '%s\n' "${b%%[0-9]*}"; }
repos_read() { # the entries, comments and blanks removed repos_read() { # the entries, comments and blanks removed
[ -r "${CFG[repos_file]}" ] || return 0 [ -r "${CFG[repos_file]}" ] || return 0
sed 's/#.*//' "${CFG[repos_file]}" | tr -d '[:blank:]' | grep -v '^$' || true sed 's/#.*//' "${CFG[repos_file]}" | tr -d '[:blank:]' | grep -v '^$' || true
@@ -514,68 +547,78 @@ cmd_repos() { # cmd_repos [list|add|remove] [owner/repo...]
say " sudo $PROG render" say " sudo $PROG render"
} }
cmd_projects() { # cmd_projects [list|assign|unassign] [owner/repo] cmd_pools() { # cmd_pools [list|add|remove] [scope]
local action=${1:-list}; shift || true local action=${1:-list}; shift || true
local f="${CFG[projects_file]}" local f="${CFG[pools_file]}"
case "$action" in case "$action" in
list) list)
if [ -s "$f" ]; then if [ -s "$f" ]; then
printf '%-40s %s\n' REPOSITORY SET printf '%-32s %-14s %s\n' SCOPE BRIDGE SET
sed 's/#.*//' "$f" | awk -v s="${CFG[ipset]}" \ sed 's/#.*//' "$f" | awk -v s="${CFG[ipset]}" -v b="$(bridge_prefix)" \
'NF >= 2 { printf "%-40s %s-%s\n", $1, s, $2 }' 'NF >= 2 { printf "%-32s %-14s %s-%s\n", $1, b $2, s, $2 }'
else else
say "no project is scoped yet." say "no pools declared."
fi fi
say "" say ""
say "A repository that declares names needs one of these. Without it the" say "A scope is what a pool is REGISTERED to, which is the only thing a"
say "render refuses, rather than putting its names somewhere every other" say "branch cannot change. owner/repo is one repository; a bare owner is"
say "project can reach. One that declares nothing needs no entry." say "every repository of theirs, which is what an organisation or user"
say "scoped runner can run."
return 0 ;; return 0 ;;
assign|unassign) ;; add|remove) ;;
*) die "unknown projects action: $action (list, assign, unassign)" ;; *) die "unknown pools action: $action (list, add, remove)" ;;
esac esac
[ "$#" -ge 1 ] || die "$action needs an owner/repo" [ "$#" -ge 1 ] || die "$action needs a scope: owner/repo, or a bare owner"
local repo="$1" local scope="$1"
valid_repo_entry "$repo" || die "not an owner/repo: $repo" valid_scope "$scope" || die "not a scope: $scope
A scope is owner/repo for a pool registered to one repository, or a bare owner
for one registered to an organisation or a user."
local current; current=$([ -r "$f" ] && sed 's/#.*//' "$f" | grep -v '^[[:space:]]*$' || true) local current; current=$([ -r "$f" ] && sed 's/#.*//' "$f" | grep -v '^[[:space:]]*$' || true)
if [ "$action" = unassign ]; then if [ "$action" = remove ]; then
printf '%s\n' "$current" | awk -v r="$repo" '$1 != r' | grep -v '^$' > "$f.new" || true printf '%s\n' "$current" | awk -v r="$scope" '$1 != r' | grep -v '^$' > "$f.new" || true
chmod 0640 "$f.new"; mv "$f.new" "$f" chmod 0640 "$f.new"; mv "$f.new" "$f"
say "$repo now shares ${CFG[ipset]} again." say "$scope removed. Its names go nowhere until another pool covers them."
say "Re-run: sudo $PROG render, then sudo dejima-ci-firewall" say "Re-run: sudo $PROG render, then sudo dejima-ci-firewall"
return 0 return 0
fi fi
local existing; existing=$(printf '%s\n' "$current" | awk -v r="$repo" '$1 == r { print $2; exit }') local existing; existing=$(printf '%s\n' "$current" | awk -v r="$scope" '$1 == r { print $2; exit }')
[ -z "$existing" ] || { say "$repo already has id $existing (${CFG[ipset]}-$existing)"; return 0; } [ -z "$existing" ] || { say "$scope already has id $existing (${CFG[ipset]}-$existing)"; return 0; }
# Never reused, even when an id is freed. An id names a set and a bridge # Never reused, even when one is freed. An id names a set and a bridge that
# that may still hold addresses and still carry rules, and handing a # may still hold addresses and still carry rules, and handing a freed one to
# different project the same one would give it whatever the last occupant # a different pool would give it whatever the last occupant left behind.
# left behind.
local next; next=$(printf '%s\n' "$current" | awk 'NF >= 2 { print $2 }' | sort -n | tail -1) local next; next=$(printf '%s\n' "$current" | awk 'NF >= 2 { print $2 }' | sort -n | tail -1)
next=$(( ${next:-0} + 1 )) next=$(( ${next:-0} + 1 ))
printf '%s\n' "$current" | grep -v '^$' > "$f.new" || true printf '%s\n' "$current" | grep -v '^$' > "$f.new" || true
printf '%s %s\n' "$repo" "$next" >> "$f.new" printf '%s %s\n' "$scope" "$next" >> "$f.new"
chmod 0640 "$f.new"; mv "$f.new" "$f" chmod 0640 "$f.new"; mv "$f.new" "$f"
say "$repo -> ${CFG[ipset]}-$next, on bridge dejima-ci$next" say "$scope -> ${CFG[ipset]}-$next, on bridge $(bridge_prefix)$next"
say "" say ""
say "Nothing is scoped until all three of these have run:" say "That set will hold the union of every declaration this pool could be"
say "made to run, which for a bare owner is all of theirs. Register the pool"
say "to one repository if you want a smaller set."
say ""
say "Nothing takes effect until all of these have run:"
say " 1. create the network, once:" say " 1. create the network, once:"
say " docker network create --driver bridge \\" say " docker network create --driver bridge \\"
say " -o com.docker.network.bridge.name=dejima-ci$next dejima-ci-$next" say " -o com.docker.network.bridge.name=$(bridge_prefix)$next \\"
say " 2. sudo $PROG render # puts its names in its own set" say " dejima-ci-$next"
say " 3. sudo dejima-ci-firewall # points that bridge at that set" say " 2. sudo $PROG render"
say " 3. sudo dejima-ci-firewall"
say " 4. point the pool's runners at that network:"
say " sudo dejima-ci-runners --role confined --network dejima-ci-$next"
say "" say ""
say "And a runner pool registered to $repo alone, on that network, or its" say "The pool has to be REGISTERED at this scope for any of it to mean"
say "jobs will keep landing on the shared one:" say "anything. A label is a request a branch makes; the registration is what"
say " sudo dejima-ci-runners --role confined --network dejima-ci-$next" say "decides which jobs a runner may take."
} }
# --- commands --------------------------------------------------------------- # --- commands ---------------------------------------------------------------
@@ -584,7 +627,7 @@ args=()
repos_args=() repos_args=()
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
render|check|show|repos|projects) cmd="$1" ;; render|check|show|repos|pools) cmd="$1" ;;
--config) --config)
[ $# -ge 2 ] && [ -n "${2:-}" ] || die "--config needs a path" [ $# -ge 2 ] && [ -n "${2:-}" ] || die "--config needs a path"
CONFIG="$2"; shift ;; CONFIG="$2"; shift ;;
@@ -597,7 +640,7 @@ done
# the point `repos` is seen would have been simpler and wrong: it swallowed a # the point `repos` is seen would have been simpler and wrong: it swallowed a
# following --config, so `repos add x --config /path` edited the file named by # following --config, so `repos add x --config /path` edited the file named by
# the default config rather than the one asked for, silently. # the default config rather than the one asked for, silently.
if [ "$cmd" = repos ] || [ "$cmd" = projects ]; then if [ "$cmd" = repos ] || [ "$cmd" = pools ]; then
repos_args=("${args[@]}") repos_args=("${args[@]}")
else else
[ ${#args[@]} -eq 0 ] || usage [ ${#args[@]} -eq 0 ] || usage
@@ -610,16 +653,16 @@ read_config "$CONFIG"
# an already-rendered file and the other edits a list of names. Requiring a # an already-rendered file and the other edits a list of names. Requiring a
# working configuration to look at either would make them useless on the host # working configuration to look at either would make them useless on the host
# whose configuration is the thing being diagnosed. # whose configuration is the thing being diagnosed.
case "$cmd" in show|repos|projects) ;; *) preflight ;; esac case "$cmd" in show|repos|pools) ;; *) preflight ;; esac
case "$cmd" in case "$cmd" in
repos) repos)
[ -n "${CFG[repos_file]}" ] || die "no repos_file in $CONFIG" [ -n "${CFG[repos_file]}" ] || die "no repos_file in $CONFIG"
cmd_repos "${repos_args[@]:-}" cmd_repos "${repos_args[@]:-}"
;; ;;
projects) pools)
[ -n "${CFG[projects_file]}" ] || die "no projects_file in $CONFIG" [ -n "${CFG[pools_file]}" ] || die "no pools_file in $CONFIG"
cmd_projects "${repos_args[@]:-}" cmd_pools "${repos_args[@]:-}"
;; ;;
show) show)
[ -f "${CFG[out]}" ] || die "nothing installed at ${CFG[out]}" [ -f "${CFG[out]}" ] || die "nothing installed at ${CFG[out]}"
+26 -19
View File
@@ -122,27 +122,34 @@ ipset create "$SET" hash:net -exist
# connect to the address that name resolved to, and declarations are public. # connect to the address that name resolved to, and declarations are public.
# That is why scoping the resolver alone would have bought nothing. # That is why scoping the resolver alone would have bought nothing.
# #
# A project assigned an id in the projects file gets a set and a bridge of its # Each runner pool gets a set and a bridge of its own, holding the union of
# own; its jobs match that set and the base set, and nothing else. A project # every declaration that pool could be made to run. Its jobs match that set and
# with no id keeps using the shared set, which is what every project did before # the base set, and nothing else.
# this existed - so this file changes no behaviour until an id is assigned. POOLS=${POOLS:-$(cfg_get pools_file)}
PROJECTS=${PROJECTS:-$(cfg_get projects_file)} : "${POOLS:=/etc/dejima-ci/pools}"
: "${PROJECTS:=/etc/dejima-ci/projects}"
ipset create "${SET}-base" hash:net -exist ipset create "${SET}-base" hash:net -exist
# The bridge name is derived, not configured, because it has to agree with what
# created the network. 15 characters is the kernel's limit on an interface name,
# which is why an id is a number rather than the scope it stands for.
BRIDGE_PREFIX="${BRIDGES[0]%%[0-9]*}"
: "${BRIDGE_PREFIX:=dejima-ci}"
declare -A BRIDGE_SET=() declare -A BRIDGE_SET=()
if [ -r "$PROJECTS" ]; then if [ -r "$POOLS" ]; then
while read -r repo id extra || [ -n "$repo" ]; do while read -r scope id extra || [ -n "$scope" ]; do
repo="${repo%%#*}" scope="${scope%%#*}"
[ -n "$repo" ] && [ -n "${id:-}" ] || continue [ -n "$scope" ] && [ -n "${id:-}" ] || continue
# The bridge name is derived, not configured, because it has to agree
# with what created the network. 15 characters is the kernel's limit on
# an interface name, which is why the id is a number and not the
# repository's own name.
ipset create "${SET}-${id}" hash:net -exist ipset create "${SET}-${id}" hash:net -exist
BRIDGE_SET["${BRIDGE_BASE:-dejima-ci}${id}"]="${SET}-${id}" BRIDGE_SET["${BRIDGE_PREFIX}${id}"]="${SET}-${id}"
done < "$PROJECTS" # Each pool's bridge takes rules whether or not it was named in the
# config's bridge list, which otherwise only knows about the first one.
case " ${BRIDGES[*]} " in
*" ${BRIDGE_PREFIX}${id} "*) ;;
*) BRIDGES+=("${BRIDGE_PREFIX}${id}") ;;
esac
done < "$POOLS"
fi fi
# --- job egress: DOCKER-USER is the chain Docker jumps to first from FORWARD, # --- job egress: DOCKER-USER is the chain Docker jumps to first from FORWARD,
@@ -186,10 +193,10 @@ for b in "${BRIDGES[@]}"; do
# Two sets per bridge: the one this bridge's project declared, and the base # Two sets per bridge: the one this bridge's project declared, and the base
# every project shares. A bridge with no entry in the projects file falls # every project shares. A bridge with no entry in the projects file falls
# back to the shared set, which is the pre-existing behaviour. # back to the shared set, which is the pre-existing behaviour.
# Base only where a bridge has no project. There is no shared set of # Base only where a bridge belongs to no pool. There is no shared set of
# project names any more, so a bridge nothing claims reaches the names every # project names any more, so a bridge nothing claims reaches the names every
# project shares and nothing else. A job there fails rather than reaching # pool shares and nothing else. A job there fails rather than reaching too
# too far, which is the direction to be wrong in. # far, which is the direction to be wrong in.
for set_for_bridge in ${BRIDGE_SET[$b]:+"${BRIDGE_SET[$b]}"} "${SET}-base"; do for set_for_bridge in ${BRIDGE_SET[$b]:+"${BRIDGE_SET[$b]}"} "${SET}-base"; do
iptables -A DEJIMA-CI-FWD -i "$b" -p tcp -m set --match-set "$set_for_bridge" dst \ iptables -A DEJIMA-CI-FWD -i "$b" -p tcp -m set --match-set "$set_for_bridge" dst \
-m multiport --dports 80,443 -j ACCEPT -m multiport --dports 80,443 -j ACCEPT
+21 -13
View File
@@ -174,11 +174,12 @@ there is one.
what it cannot do is connect to one outside its own project's set. One dnsmasq what it cannot do is connect to one outside its own project's set. One dnsmasq
has one view, and a per-project view would mean a resolver per project. The has one view, and a per-project view would mean a resolver per project. The
leak is names, which are public in the repositories that declared them. leak is names, which are public in the repositories that declared them.
- **A project whose runners are on the wrong network.** The sets are only as - **Everything a runner pool could be asked to run.** A pool's set is the union
good as which network a job lands on, and a runner takes whatever jobs it is of the declarations of every repository it is registered for, so an
offered. A pool registered to an organisation rather than a repository puts organisation pool holds that whole organisation's names. That union is what
those jobs on a network that is not that project's, which is why the upgrade the pool can already be made to run: a branch in any of those repositories can
note treats moving the pools as part of the change rather than as a follow-up. put a job on it. Registering a pool to one repository is how the union gets
small, and is the recommendation.
- **The forge and the resolver.** Both are reachable from this VM on the ports - **The forge and the resolver.** Both are reachable from this VM on the ports
listed, by anything on it. "No LAN access" is not literally true. listed, by anything on it. "No LAN access" is not literally true.
- **The forge credential the runner registers with.** It is on the box, and - **The forge credential the runner registers with.** It is on the box, and
@@ -237,15 +238,22 @@ declarations are public in the repositories they come from.
So the set is what got scoped. dnsmasq will place a name's addresses in several So the set is what got scoped. dnsmasq will place a name's addresses in several
sets at once (`ipset=/name/set-a,set-b`), which means one resolver can fill each sets at once (`ipset=/name/set-a,set-b`), which means one resolver can fill each
declaring project's set and no others, and the shared resolver stops mattering. pool's set and no others, and the shared resolver stops mattering. The cost is
The cost is that every job can still resolve every name, which is a names leak that every job can still resolve every name, which is a names leak rather than a
rather than a reach one. reach one.
The three pieces have to move together: a set per project, a bridge per project ### Scoping by label, rather than by what a pool is registered to
because the incoming network is the only thing the firewall can tell about a
job, and a runner pool registered to that repository so a pool only receives A label looks like the natural handle: give each pool its own, and let workflows
jobs it has the network for. A pool left on the shared network undoes the other name it. It is not a boundary. A branch writes its own `runs-on`, so a label is
two however carefully they are set up. a request the job makes rather than a property the operator sets, and a hostile
branch can name any label whose pool is willing to serve its repository.
What a runner may take is decided by its registration, which a branch cannot
change: a repository scoped runner only ever takes that repository's jobs. So
the set is built from the registration, and the union it holds is exactly what
that pool could be asked to run. Labels are left alone, and pools may share
them.
Recorded so they are not re-proposed without new information. Recorded so they are not re-proposed without new information.
+102 -32
View File
@@ -53,13 +53,13 @@ EOF
# A correctly configured host: a repository that declares names has a set of # 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 # its own. Without one the render refuses, which is its own test rather than
# the state every other test starts from. # the state every other test starts from.
echo "owner/repo 1" > "$T/projects" echo "owner/repo 1" > "$T/pools"
cat > "$T/config" <<EOF cat > "$T/config" <<EOF
forge_api = https://gitea.example.com/api/v1 forge_api = https://gitea.example.com/api/v1
token_file = $T/token token_file = $T/token
repos_file = $T/repos repos_file = $T/repos
projects_file = $T/projects pools_file = $T/pools
base_file = $T/base base_file = $T/base
upstream = 198.51.100.53 upstream = 198.51.100.53
out = $T/out/ci-allow.conf out = $T/out/ci-allow.conf
@@ -683,12 +683,12 @@ IPSETEOF
# There is no shared fallback. A set every project can reach is the thing # 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 # this exists to end, and falling back to one would be arriving there by
# doing nothing rather than by choosing it. # doing nothing rather than by choosing it.
: > "$T/projects" : > "$T/pools"
mkdir -p "$STUB_DIR/owner"; echo one.example.com > "$STUB_DIR/owner/repo.body" mkdir -p "$STUB_DIR/owner"; echo one.example.com > "$STUB_DIR/owner/repo.body"
run_tool render run_tool render
[ "$status" -eq 2 ] [ "$status" -eq 2 ]
[[ "$output" == *"owner/repo"* ]] [[ "$output" == *"owner/repo"* ]]
[[ "$output" == *"projects assign"* ]] [[ "$output" == *"pools add"* ]]
} }
@test "a refused render leaves the allowlist that was already there" { @test "a refused render leaves the allowlist that was already there" {
@@ -698,7 +698,7 @@ IPSETEOF
run_tool render run_tool render
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
before=$(cat "$T/out/ci-allow.conf") before=$(cat "$T/out/ci-allow.conf")
: > "$T/projects" : > "$T/pools"
run_tool render run_tool render
[ "$status" -eq 2 ] [ "$status" -eq 2 ]
[ "$(cat "$T/out/ci-allow.conf")" = "$before" ] [ "$(cat "$T/out/ci-allow.conf")" = "$before" ]
@@ -707,7 +707,7 @@ IPSETEOF
@test "a repository that declares nothing needs no set" { @test "a repository that declares nothing needs no set" {
# It has no names to scope. Requiring an assignment would be friction with # It has no names to scope. Requiring an assignment would be friction with
# nothing behind it. # nothing behind it.
: > "$T/projects" : > "$T/pools"
mkdir -p "$STUB_DIR/owner"; echo 404 > "$STUB_DIR/owner/repo.status" mkdir -p "$STUB_DIR/owner"; echo 404 > "$STUB_DIR/owner/repo.status"
echo base.example.com > "$T/base" echo base.example.com > "$T/base"
run_tool render run_tool render
@@ -724,7 +724,7 @@ IPSETEOF
} }
@test "an assigned project's names go in its own set" { @test "an assigned project's names go in its own set" {
printf 'owner/repo 1\n' > "$T/projects" printf 'owner/repo 1\n' > "$T/pools"
mkdir -p "$STUB_DIR/owner"; echo one.example.com > "$STUB_DIR/owner/repo.body" mkdir -p "$STUB_DIR/owner"; echo one.example.com > "$STUB_DIR/owner/repo.body"
run_tool render run_tool render
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
@@ -735,7 +735,7 @@ IPSETEOF
@test "a name two projects declare lands in both their sets" { @test "a name two projects declare lands in both their sets" {
# dnsmasq takes several sets per name, which is what lets one resolver # dnsmasq takes several sets per name, which is what lets one resolver
# serve every project without the answer widening anyone else's reach. # serve every project without the answer widening anyone else's reach.
printf 'owner/repo 1\nother/repo 2\n' > "$T/projects" printf 'owner/repo 1\nother/repo 2\n' > "$T/pools"
printf 'owner/repo\nother/repo\n' > "$T/repos" printf 'owner/repo\nother/repo\n' > "$T/repos"
mkdir -p "$STUB_DIR/owner" "$STUB_DIR/other" mkdir -p "$STUB_DIR/owner" "$STUB_DIR/other"
echo shared.example.com > "$STUB_DIR/owner/repo.body" echo shared.example.com > "$STUB_DIR/owner/repo.body"
@@ -746,7 +746,7 @@ IPSETEOF
@test "a name one project declares does not reach the other's set" { @test "a name one project declares does not reach the other's set" {
# The whole point. # The whole point.
printf 'owner/repo 1\nother/repo 2\n' > "$T/projects" printf 'owner/repo 1\nother/repo 2\n' > "$T/pools"
printf 'owner/repo\nother/repo\n' > "$T/repos" printf 'owner/repo\nother/repo\n' > "$T/repos"
mkdir -p "$STUB_DIR/owner" "$STUB_DIR/other" mkdir -p "$STUB_DIR/owner" "$STUB_DIR/other"
echo mine.example.com > "$STUB_DIR/owner/repo.body" echo mine.example.com > "$STUB_DIR/owner/repo.body"
@@ -759,7 +759,7 @@ IPSETEOF
@test "every name still resolves, since the scoping is the set and not the view" { @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 # 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. # repositories they come from; what a job can reach is the set.
printf 'owner/repo 1\n' > "$T/projects" printf 'owner/repo 1\n' > "$T/pools"
mkdir -p "$STUB_DIR/owner"; echo one.example.com > "$STUB_DIR/owner/repo.body" mkdir -p "$STUB_DIR/owner"; echo one.example.com > "$STUB_DIR/owner/repo.body"
run_tool render run_tool render
grep -q '^server=/one.example.com/' "$T/out/ci-allow.conf" grep -q '^server=/one.example.com/' "$T/out/ci-allow.conf"
@@ -773,40 +773,110 @@ IPSETEOF
[[ "$output" == *"2 names"* ]] [[ "$output" == *"2 names"* ]]
} }
@test "projects assign gives out an id and never reuses one" { @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 # An id names a set and a bridge that may still hold addresses and still
# carry rules. Handing a freed one to a different project would give it # carry rules. Handing a freed one to a different pool would give it
# whatever the last occupant left behind. # whatever the last occupant left behind.
: > "$T/projects" # the fixture assigns owner/repo id 1 : > "$T/pools"
run_tool projects assign acme/one run_tool pools add acme/one
run_tool projects assign acme/two run_tool pools add acme/two
run_tool projects unassign acme/one run_tool pools remove acme/one
run_tool projects assign acme/three run_tool pools add acme/three
[ "$(awk '$1 == "acme/three" { print $2 }' "$T/projects")" = 3 ] [ "$(awk '$1 == "acme/three" { print $2 }' "$T/pools")" = 3 ]
} }
@test "projects assign is idempotent" { @test "pools add is idempotent" {
run_tool projects assign acme/one : > "$T/pools"
run_tool projects assign acme/one run_tool pools add acme/one
[ "$(grep -c 'acme/one' "$T/projects")" -eq 1 ] run_tool pools add acme/one
[ "$(grep -c 'acme/one' "$T/pools")" -eq 1 ]
[[ "$output" == *"already has id"* ]] [[ "$output" == *"already has id"* ]]
} }
@test "projects assign says the three things that have to happen next" { @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 # Writing the file scopes nothing on its own: the network has to exist, the
# allowlist has to be re-rendered, and the firewall has to point the bridge # allowlist has to be re-rendered, the firewall has to point the bridge at
# at the set. # the set, and the pool's runners have to be on that network.
run_tool projects assign acme/one : > "$T/pools"
run_tool pools add acme/one
[[ "$output" == *"docker network create"* ]] [[ "$output" == *"docker network create"* ]]
[[ "$output" == *"render"* ]] [[ "$output" == *"render"* ]]
[[ "$output" == *"dejima-ci-firewall"* ]] [[ "$output" == *"dejima-ci-firewall"* ]]
[[ "$output" == *"--network"* ]] [[ "$output" == *"--network"* ]]
} }
@test "projects list says what an entry is for, and what its absence costs" { @test "pools add says the registration is what decides, not the label" {
# The absence of an entry is the interesting state, and a list that only # A branch chooses its own runs-on, so a label is a request. What a runner
# shows entries never mentions it. # may take is decided by what it is registered to, which a branch cannot
run_tool projects list # change.
[[ "$output" == *"render refuses"* ]] : > "$T/pools"
[[ "$output" == *"declares nothing needs no entry"* ]] 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 ]
} }
+1 -1
View File
@@ -318,7 +318,7 @@ privileged() {
} }
@test "the flag dejima-ci-allowlist tells operators to run exists" { @test "the flag dejima-ci-allowlist tells operators to run exists" {
# `projects assign` prints a dejima-ci-runners command. A note telling # `pools add` prints a dejima-ci-runners command. A note telling
# somebody to run a flag that was never added is the shape of mistake this # somebody to run a flag that was never added is the shape of mistake this
# repository keeps making, so the two are held together here. # repository keeps making, so the two are held together here.
flag=$(grep -oE '\-\-network' "$REPO/bin/dejima-ci-allowlist" | head -1) flag=$(grep -oE '\-\-network' "$REPO/bin/dejima-ci-allowlist" | head -1)
+134
View File
@@ -0,0 +1,134 @@
---
version: 3
title: Each runner pool gets the allowlist it could actually run
layers: guest
tiers: confined
snapshot: required
---
# v3: each runner pool gets the allowlist it could actually run
Confined tier only. **This one has a migration**: the render refuses until the
pools on this host are declared. Do the whole sequence in one sitting.
## Why
Every job 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 the part that matters. It holds addresses with no memory of which
name put them there, so scoping the resolver alone would have changed almost
nothing: a job that could not resolve another project's name could still connect
to the address that name resolved to, and declarations are public.
## What replaces it
**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 that pool; 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.
The scoping you get is the scoping you registered:
| A pool registered to | Its set holds |
| --- | --- |
| one repository | that repository's declaration |
| an owner, so an organisation or user pool | the union across that owner's repositories |
**Registering a pool to one repository is how it gets a small set.** It is the
recommendation, not 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.
**Labels are not part of this.** A branch chooses its own `runs-on`, so a label
is a request rather than a boundary. What a runner may take is decided by what
it is registered to, which a branch cannot change. Pools can share labels.
## 1. Install
```sh
cd ~/dejima-ci-hardener
git pull
sudo ./install.sh
```
The timer's next render will refuse, naming repositories no declared pool can
run. That is expected here and it is safe: nothing is installed on a failure,
so the allowlist already in force stays in force while you work through the
rest.
**Re-snapshot before this tier's next scheduled rollback.** These are guest
changes on a runner rebuilt from a snapshot, so one taken before this restores a
host without them, and the render starts refusing again with nothing to explain
it.
## 2. Declare the pools you already have
One line per pool, named by what it is registered to. Check the forge's
**Actions, Runners** pages if you are unsure which is which: a runner appears
under the repository or the organisation it was registered to.
```sh
sudo dejima-ci-allowlist pools add dejima # an org-scoped pool
sudo dejima-ci-allowlist pools add branderson # a user-scoped pool
sudo dejima-ci-allowlist pools add acme/widget # one registered to a repo
```
Each prints an id, a bridge and a set, and the commands that make it real.
## 3. Give each pool its network
Once per pool, using the bridge name the previous step printed:
```sh
docker network create --driver bridge \
-o com.docker.network.bridge.name=dejima-ci1 dejima-ci-1
```
## 4. Render and apply
```sh
sudo dejima-ci-allowlist render # should stop refusing
sudo dejima-ci-firewall
```
If it still refuses it names what is missing, and nothing has been installed in
the meantime.
## 5. Point each pool's runners at its own network
**This is the step that decides whether any of it took effect.** A pool still
on the shared network reaches the base names only.
```sh
sudo dejima-ci-runners --role confined --network dejima-ci-1 \
--dir ~/runners/acme-widget
```
Labels can stay as they are. The registration is what decides which jobs a
runner may take, and a pool registered to one repository cannot take another's
however a branch writes its `runs-on`.
## Checking it worked
```sh
sudo dejima-ci-allowlist pools list
sudo iptables -S DEJIMA-CI-FWD | grep dejima-ci1
```
The rule for that bridge should name `ci-allow-1` and `ci-allow-base`, and
nothing else.
## What this does not do
**Every job can still resolve every allowlisted name.** One dnsmasq has one
view, and a per-pool view would mean a resolver per pool. What a job can reach
is the set its bridge matches, so resolving a name it may not connect to gets it
an address the firewall drops. The leak is names, which are public in the
repositories that declared them.
It does not scope the privileged tier, which has no allowlist and no job network
to scope: its jobs are in the host's network namespace by design.
-140
View File
@@ -1,140 +0,0 @@
---
version: 3
title: A project can be given a firewall set of its own
layers: guest
tiers: confined
snapshot: required
---
# v3: every project gets a firewall set of its own
Confined tier only. **This one has a migration**: after it, a repository that
declares names needs a set of its own, and the render refuses until each of them
has one. Do the whole sequence in one sitting.
## Why
Every job on this host 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 the part that matters. It holds addresses with no memory of which
name put them there, so giving each project its own resolver would have changed
almost nothing: a project that could not resolve another's name could still
connect to the address that name resolved to, and declarations are public in the
repositories they come from.
## What is new
`dejima-ci-allowlist` writes an `ipset=` line per name, listing the sets of the
projects that declared it. dnsmasq accepts several per name, so one resolver
fills each declaring project's set and no others.
`dejima-ci-firewall` matches each bridge against its own project's set and a
base set every project shares.
There is no shared fallback. A repository that declares names and has no set is
refused rather than put somewhere every other project can reach: a set everyone
shares is what this exists to end, and falling back to one would mean arriving
there by doing nothing rather than by choosing it.
A repository that declares nothing needs no assignment, having no names to
scope. It needs one the day it declares something, and the refusal is what tells
whoever added that declaration.
## 1. Before you install: what will need a set
```sh
sudo dejima-ci-allowlist render
```
Read the collection lines. Every repository reporting names declared is one that
needs a set; the ones reporting no declaration do not. Write that list down.
## 2. Install
```sh
cd ~/dejima-ci-hardener
git pull
sudo ./install.sh
```
The timer's next render will refuse, naming the repositories that need a set.
That is expected here, and it is safe: nothing is installed on a failure, so the
allowlist already in force stays in force while you work through the rest.
**Re-snapshot before this tier's next scheduled rollback.** These are guest
changes on a runner rebuilt from a snapshot, so one taken before this restores a
host without them: the tools go back, the projects file goes back, and the
render starts refusing again with nothing to explain it.
## 3. Assign each declaring repository
One at a time:
```sh
sudo dejima-ci-allowlist projects assign owner/repo
```
It prints the id and the commands that make it real, including the network. Do
those before moving on: an assignment with no network is a bridge the firewall
has no rules for.
## 4. Render and apply
Once every declaring repository has a set:
```sh
sudo dejima-ci-allowlist render # should stop refusing
sudo dejima-ci-firewall
```
If it still refuses, it names what is missing. Nothing has been installed in the
meantime.
## 5. Move each project's runners onto its own network
**This is the step that decides whether any of it took effect.** A runner takes
whatever jobs it is offered, so a pool on the shared network puts that project's
jobs back among everyone else's names however carefully the sets were assigned.
Register a pool to that repository alone, from the forge's
**Settings, Actions, Runners** page on the repository rather than the
organisation, and point it at the new network:
```sh
sudo dejima-ci-runners --role confined --network dejima-ci-1 \
--dir ~/runners/owner-repo
```
Then take that repository out of any organisation-wide pool's reach, or its jobs
will keep landing wherever they did before.
Until a project's pool has moved, its jobs run on the shared network and reach
the base names only, because the shared bridge now matches the base set and
nothing else. That is a job that fails rather than a job that reaches too far,
which is the direction to be wrong in, but it is why steps 4 and 5 belong in one
sitting.
## Checking it worked
```sh
sudo dejima-ci-allowlist projects list
sudo ipset list ci-allow-1 | head
sudo iptables -S DEJIMA-CI-FWD | grep dejima-ci1
```
The rule for that bridge should name `ci-allow-1` and `ci-allow-base`, and
nothing else. If it names `ci-allow`, the bridge has no entry in the projects
file or the firewall has not been re-run.
## What this does not do
**Every job can still resolve every allowlisted name.** One dnsmasq has one
view, and giving each project its own would mean a resolver instance per
project. What a job can reach is the set its bridge matches, so resolving a name
it may not connect to gets it an address the firewall drops. The leak is names,
which are already public in the repositories that declared them.
It does not scope the privileged tier, which has no allowlist and no job network
to scope: its jobs are in the host's network namespace by design.