Skip to content

Commit b9c1c75

Browse files
itcmsgrclaude
andcommitted
feat(v1.100 PR-P2-4): exec-trace CI gate (G3-EXEC-TRACE) — process-spawning purity
Pre-PR-23 assurance blocker #4 of 4 remaining. Adds process-spawn-level truth check to every dry-run CI path. Wraps the dry-run binary under `strace -f -e trace=execve`, captures every process spawn, and fails the gate if any forbidden mutation binary was invoked — regardless of whether the call was statically visible in the source (grep-based) or dynamically constructed (only visible at runtime). ## Layered defense — what this adds on top of the existing gates | Layer | Catches | |---|---| | `G3-UN-NO-MUTATION` structural grep | Source code with forbidden patterns | | `G3-U3` / filesystem snapshot | Writes to `/var/lib/nftban/` or `/etc/nftban/` | | `G3-KS-SNAPSHOT` (PR-P2-3) | Kernel nft table / service state changes | | **`G3-EXEC-TRACE` (this PR)** | **ANY forbidden binary spawning, even if it made no syscalls we could observe (e.g. rm was executed but the file was already absent)** | The classes complement each other. A regression that somehow avoided filesystem AND kernel-state changes but still forked a forbidden binary now fails at the execve syscall boundary. ## Implementation - NEW: `scripts/ci-exec-trace-assert.sh <command args...>` — wraps its argv under strace, propagates exit code unchanged, fails if any FORBIDDEN pattern matches in the trace. Contract: * read-only on the system (only syscall it observes is execve) * exit 0 if wrapped command succeeded AND no forbidden spawns * exit != 0 if EITHER the wrapped command failed OR forbidden spawns detected * graceful degrade: if strace unavailable, wrapped command runs with a CI warning (never silently weakens) - Added `strace` to the dependency install step of all 3 canonization workflows (apt-get for ubuntu-24.04, dnf for almalinux-9 container). - Wired into 3 gates: * `ci-install-canonization.yml / G3-IN-REFUSE-DRY-RUN` — wraps the refuse-dry-run invocation; refusal must exit before spawning anything forbidden * `ci-update-canonization.yml / G3-U3` — wraps the update dry-run; purity assertion layered on top of filesystem + KS snapshots * `ci-uninstall-canonization.yml / G3-UN-PLAN-RENDERS` — wraps the uninstall dry-run; uninstall is the most scope-sensitive mode, so exec-trace purity here is the strongest PR-23 precondition ## Forbidden patterns (strace execve regex) - nft with mutation verbs: `add|create|delete|flush` (list/save are read-only and allowed) - systemctl lifecycle verbs anywhere in argv: `start|stop|restart| reload|enable|disable|mask|unmask` (is-active/is-enabled/status/show are read-only and allowed) - External firewall binaries — any invocation: `ufw`, `firewall-cmd`, `iptables-restore`, `ip6tables-restore` - CSF with destructive flags: `-e`, `-x`, `--enable`, `--disable` - Package-manager mutation: `apt-get remove|purge`, `dnf remove|erase`, `rpm -e`, `dpkg --remove|--purge` - User/group deletion: `userdel`, `groupdel` Each pattern targets a specific execve shape. A match = gate failure. The dry-run paths must be observational at the process-spawning level, not just the Go-function-call level. ## Non-goals (scope-lock per authorization 2026-04-20) - NO shell refactor - NO command-wrapper redesign - NO broad syscall observability platform - NO inference beyond "spawned or not spawned" - NO new protection surfaces beyond exec-trace on dry-run ## Also: tracking update Marks blocker #3 (kernel/service snapshot CI gate, PR #487) as LANDED. Remaining pre-PR-23 blockers: 3 (this PR = P2-4, plus P2-5 auto-elevate shim removal gate + P2-6 payload integrity). Refs: internal/installer/uninstall/contract.md §"Pre-PR-23 blockers" Authorization: locked Phase 2 sequencing (2026-04-20) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c111602 commit b9c1c75

5 files changed

Lines changed: 146 additions & 10 deletions

File tree

.github/workflows/ci-install-canonization.yml

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,13 @@ jobs:
6161
if: matrix.label == 'ubuntu-24.04'
6262
run: |
6363
sudo apt-get update -qq
64-
sudo apt-get install -y nftables
64+
sudo apt-get install -y nftables strace
6565
6666
- name: Install system dependencies (RPM)
6767
if: matrix.label == 'almalinux-9'
6868
run: |
6969
dnf -y install epel-release
70-
dnf -y install nftables git tar gzip procps-ng findutils sudo golang
70+
dnf -y install nftables git tar gzip procps-ng findutils sudo golang strace
7171
7272
- name: Set up Go (DEB only)
7373
if: matrix.label == 'ubuntu-24.04'
@@ -111,8 +111,14 @@ jobs:
111111
# so we can hard-assert they are byte-identical afterward.
112112
before_ks=$(bash scripts/ci-snapshot-kernel-service.sh)
113113
114+
# PR-P2-4 (G3-EXEC-TRACE): wrap the refuse-dry-run under strace
115+
# so we can assert the binary did NOT spawn any forbidden
116+
# mutation process (nft add/flush/delete, systemctl lifecycle
117+
# verbs, external firewall binaries, package-manager removal,
118+
# user/group deletion) before it exits with its refusal.
114119
set +e
115-
out=$(sudo ./bin/nftban-installer --mode=install --dry-run \
120+
out=$(sudo bash scripts/ci-exec-trace-assert.sh \
121+
./bin/nftban-installer --mode=install --dry-run \
116122
--state-dir=/var/lib/nftban/state 2>&1)
117123
rc=$?
118124
set -e

.github/workflows/ci-uninstall-canonization.yml

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,13 @@ jobs:
6060
if: matrix.label == 'ubuntu-24.04'
6161
run: |
6262
sudo apt-get update -qq
63-
sudo apt-get install -y nftables jq
63+
sudo apt-get install -y nftables jq strace
6464
6565
- name: Install system dependencies (RPM)
6666
if: matrix.label == 'almalinux-9'
6767
run: |
6868
dnf -y install epel-release
69-
dnf -y install nftables jq git tar gzip procps-ng findutils sudo golang
69+
dnf -y install nftables jq git tar gzip procps-ng findutils sudo golang strace
7070
7171
- name: Set up Go (DEB only)
7272
if: matrix.label == 'ubuntu-24.04'
@@ -214,8 +214,14 @@ jobs:
214214
# mutates kernel tables or service states without touching
215215
# tracked files — exactly the class of drift this gate closes.
216216
before_ks=$(bash scripts/ci-snapshot-kernel-service.sh)
217+
# PR-P2-4 (G3-EXEC-TRACE): wrap the dry-run under strace.
218+
# Uninstall is the most scope-sensitive mode — verifying that
219+
# no mutation process ever spawns during dry-run is the
220+
# strongest proof that PR-23's future mutation code will be
221+
# the ONLY mutation surface.
217222
set +e
218-
sudo ./bin/nftban-installer --mode=uninstall --dry-run \
223+
sudo bash scripts/ci-exec-trace-assert.sh \
224+
./bin/nftban-installer --mode=uninstall --dry-run \
219225
--state-dir=/var/lib/nftban/state 2>&1 | tee /tmp/uninstall-dryrun.out
220226
rc=${PIPESTATUS[0]}
221227
set -e

.github/workflows/ci-update-canonization.yml

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,14 @@ jobs:
6363
if: matrix.label == 'ubuntu-24.04'
6464
run: |
6565
sudo apt-get update -qq
66-
sudo apt-get install -y nftables jq systemd util-linux coreutils
66+
sudo apt-get install -y nftables jq systemd util-linux coreutils strace
6767
6868
- name: Install system dependencies (RPM)
6969
if: matrix.label == 'almalinux-9'
7070
run: |
7171
dnf -y install epel-release
7272
# coreutils-single is preinstalled on almalinux:9 minimal — omit coreutils.
73-
dnf -y install nftables jq systemd util-linux git tar gzip which procps-ng findutils sudo golang
73+
dnf -y install nftables jq systemd util-linux git tar gzip which procps-ng findutils sudo golang strace
7474
7575
- name: Set up Go (DEB only)
7676
if: matrix.label == 'ubuntu-24.04'
@@ -171,8 +171,13 @@ jobs:
171171
# around the update dry-run.
172172
before_ks=$(bash scripts/ci-snapshot-kernel-service.sh)
173173
174+
# PR-P2-4 (G3-EXEC-TRACE): wrap the dry-run under strace so we
175+
# can hard-assert the process did NOT spawn any forbidden
176+
# mutator. Falsifiability at the syscall layer, not just the
177+
# Go-function-call layer.
174178
set +e
175-
sudo ./bin/nftban-installer --mode=upgrade --dry-run \
179+
sudo bash scripts/ci-exec-trace-assert.sh \
180+
./bin/nftban-installer --mode=upgrade --dry-run \
176181
--source --source-dir="$PWD" \
177182
--state-dir=/var/lib/nftban/state 2>&1 | tee /tmp/dryrun.out
178183
rc=${PIPESTATUS[0]}

internal/installer/uninstall/contract.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,7 @@ discipline.
282282
|---|---|---|---|
283283
| 1 | Prior-authority record hardening | PR #484 / `3b834033` | Added `recorded_at`, `installer_version`, explicit `active_at_install=false` handling to `prior.go`; 5-state classification |
284284
| 2 | External-firewall detection unification | PR #486 / `49d98fc1` | `internal/installer/extfw` canonical detector; Option A CSF config-file signal shared across install/update/uninstall; multi-active → `Ambiguous` (no silent collapse); cross-caller consistency test locked as regression guard |
285+
| 3 | Kernel/service snapshot CI gate | PR #487 / (tracked post-merge) | `G3-KS-SNAPSHOT` added to all 3 canonization workflows; `scripts/ci-snapshot-kernel-service.sh` helper; hard-asserts kernel nft tables + firewall-adjacent service states byte-identical after every dry-run path |
285286

286287
### Behavioral / semantic blockers (code contract changes)
287288

@@ -293,7 +294,6 @@ discipline.
293294

294295
| # | PR | Scope | Blocking because |
295296
|---|---|---|---|
296-
| 3 | Kernel/service snapshot CI gate | Before/after `nft list tables` + `systemctl is-active` diff around every dry-run path; hard-assert equal | Filesystem snapshot alone cannot prove process/kernel purity |
297297
| 4 | Exec-trace CI gate | `strace -f -e trace=execve` (or equivalent) around dry-run paths; assert no forbidden mutators spawned | Strictest purity guarantee; catches dynamically-constructed commands that source grep cannot see |
298298
| 5 | Auto-elevate shim removal gate | CI rule: PR-23-class changes blocked while the shim block in `flags.go` still exists when any mutation code lands in `internal/installer/uninstall/` | Prevents scaffold-era UX semantics leaking into mutation-era behavior |
299299

scripts/ci-exec-trace-assert.sh

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
#!/usr/bin/env bash
2+
# =============================================================================
3+
# NFTBan v1.100 PR-P2-4 — CI exec-trace assertion helper
4+
# =============================================================================
5+
# SPDX-License-Identifier: MPL-2.0
6+
# meta:name="ci-exec-trace-assert"
7+
# meta:type="script"
8+
# meta:version="1.100.0"
9+
# meta:owner="Antonios Voulvoulis <contact@nftban.com>"
10+
# meta:created_date="2026-04-20"
11+
# meta:description="Wrap a dry-run command under strace and fail if any forbidden mutation process was spawned"
12+
# meta:inventory.files="scripts/ci-exec-trace-assert.sh"
13+
# meta:inventory.binaries="/usr/bin/strace"
14+
# meta:inventory.env_vars=""
15+
# meta:inventory.config_files=""
16+
# meta:inventory.systemd_units=""
17+
# meta:inventory.network=""
18+
# meta:inventory.privileges="root"
19+
# =============================================================================
20+
#
21+
# Usage: ci-exec-trace-assert.sh <command args...>
22+
#
23+
# Runs the given command under `strace -f -e trace=execve`, captures
24+
# every process spawn, then asserts no forbidden mutation binary was
25+
# invoked. Passes through the wrapped command's exit code on success;
26+
# exits non-zero if any forbidden execve is detected OR if the wrapped
27+
# command itself failed.
28+
#
29+
# Contract (PR-P2-4, frozen 2026-04-20):
30+
# - Only fires on execve syscalls — does not depend on Go-level mocks
31+
# - Independent of source-grep patterns (catches dynamically-
32+
# constructed commands that grep cannot see)
33+
# - Minimal surface — focused grep pass on the trace file
34+
# - Degrades gracefully: if strace is unavailable, the command runs
35+
# unwrapped with a CI warning (never silently weakens)
36+
#
37+
# Falsifiability: every regex in FORBIDDEN below matches a specific
38+
# execve shape the test expects to NEVER see during a dry-run. Any
39+
# match = gate failure. The dry-run paths must be observational at
40+
# the process-spawning level, not just the Go-function-call level.
41+
#
42+
# =============================================================================
43+
set -Eeuo pipefail
44+
45+
if [[ $# -lt 1 ]]; then
46+
echo "usage: $0 <command args...>" >&2
47+
exit 2
48+
fi
49+
50+
# Graceful degrade: if strace isn't present (should not happen in CI
51+
# after the explicit install step, but possible on developer laptops),
52+
# run the command unwrapped. Emit a GitHub-Actions warning so CI logs
53+
# flag the missing coverage.
54+
if ! command -v strace >/dev/null 2>&1; then
55+
echo "::warning::strace not available — exec-trace assertion skipped for this invocation"
56+
exec "$@"
57+
fi
58+
59+
TRACE=$(mktemp /tmp/ci-exec-trace.XXXXXX)
60+
trap 'rm -f "$TRACE"' EXIT
61+
62+
# Wrap the caller's command. -f follows forks (so Go subprocess spawns
63+
# are caught); -e trace=execve restricts to the one syscall that spawns
64+
# a new binary. Output goes to $TRACE, the actual command runs against
65+
# the real terminal so its output is still visible in the CI log.
66+
set +e
67+
strace -f -e trace=execve -o "$TRACE" -- "$@"
68+
rc=$?
69+
set -e
70+
71+
# FORBIDDEN patterns — each is an extended regex; a match = gate
72+
# failure. Every pattern targets a specific mutation-flavored invocation
73+
# shape. Read-only calls (nft list, systemctl is-active, iptables-save)
74+
# do NOT match and are allowed.
75+
FORBIDDEN=(
76+
# nft with mutation verbs. Read-only "nft list ..." does not match.
77+
'execve\("[^"]*", \["nft", "(add|create|delete|flush)"'
78+
# systemctl lifecycle verbs anywhere in argv. Read-only
79+
# "is-active"/"is-enabled"/"status"/"show" do not match.
80+
'execve\("[^"]*", \["systemctl"[^]]*"(start|stop|restart|reload|enable|disable|mask|unmask)"'
81+
# External firewall binaries — any invocation is mutation intent
82+
# (these tools have no read-only subcommands our dry-run could
83+
# legitimately need). Match on argv[0].
84+
'execve\("[^"]*", \["ufw"'
85+
'execve\("[^"]*", \["firewall-cmd"'
86+
'execve\("[^"]*", \["iptables-restore"'
87+
'execve\("[^"]*", \["ip6tables-restore"'
88+
# CSF invocations with destructive flags.
89+
'execve\("[^"]*", \["csf"[^]]*"(-e|-x|--enable|--disable)"'
90+
# Package-manager mutation verbs.
91+
'execve\("[^"]*", \["apt-get"[^]]*"(remove|purge)"'
92+
'execve\("[^"]*", \["dnf"[^]]*"(remove|erase)"'
93+
'execve\("[^"]*", \["rpm"[^]]*"-e"'
94+
'execve\("[^"]*", \["dpkg"[^]]*"(--remove|--purge)"'
95+
# User/group deletion.
96+
'execve\("[^"]*", \["userdel"'
97+
'execve\("[^"]*", \["groupdel"'
98+
)
99+
100+
fail=0
101+
for pat in "${FORBIDDEN[@]}"; do
102+
if grep -nE "$pat" "$TRACE" >/dev/null 2>&1; then
103+
echo "::error::G3-EXEC-TRACE FAIL: forbidden mutator spawned during dry-run"
104+
echo " pattern: $pat"
105+
echo " matched execve calls (up to 5):"
106+
grep -nE "$pat" "$TRACE" | head -5 | sed 's/^/ /'
107+
fail=1
108+
fi
109+
done
110+
111+
if (( fail > 0 )); then
112+
echo "::error::G3-EXEC-TRACE: dry-run spawned one or more forbidden mutation binaries (see patterns above)"
113+
exit 1
114+
fi
115+
116+
# Propagate the wrapped command's exit code unchanged. The trace
117+
# assertion is purely additive — it neither rescues a failing command
118+
# nor masks a passing one.
119+
exit $rc

0 commit comments

Comments
 (0)