diff --git a/README.md b/README.md index 01066ad..564e639 100644 --- a/README.md +++ b/README.md @@ -86,8 +86,6 @@ detects the stack (**Node, Python, Deno, Ruby/Rails, Rust, Go, Java, PHP, .NET, Dart** - anything else runs via the generic base), scaffolds the config, and on confirm builds + runs it. -

a config-less node project: bare sluice finds no config, detects the stack (node/npm), scaffolds sluice.config.sh and previews the knobs it chose (ports, run command, an empty allowlist pointing at sluice learn), asks [Y/n], then builds and runs it sandboxed - ending on the egress receipt with registry.npmjs.org reached in green and a first-run nudge to sluice learn and sluice lock

- When a run exits, sluice prints an **egress receipt**: the hosts it reached and any it tried but the firewall blocked. `sluice learn` then turns the blocked list into your allowlist with a **per-host review** - allow / skip / collapse to a `.domain` wildcard - @@ -100,9 +98,8 @@ The firewall is not just a claim - an allowlisted host gets through, an exfil PO raw-IP bypass are blocked, and the run's audit log is **tamper-evident** (`egress --verify` flips from green to red if a record is altered). -When a host you actually need is blocked, `sluice learn` allows it from the receipt, live: - -

a real run is blocked by the firewall: curl fails and the egress receipt shows pypi.org in red as 'blocked, not allowlisted'; sluice learn then reviews the host with an allow/skip/domain/quit prompt, 'a' allows it and the running box is reloaded live with no rebuild; the rerun returns HTTP 200 and the receipt flips to green 'reached pypi.org, all egress was allowlisted'

+When a host you actually need is blocked, `sluice learn` allows it from the receipt, live - +see [examples/](examples/README.md) for the full walkthrough. ```bash sluice # build (if needed) + run SLUICE_RUN_CMD in the sandbox diff --git a/assets/demos/hard-cap-demo.config.sh b/assets/demos/hard-cap-demo.config.sh index 2a72fd3..4146006 100644 --- a/assets/demos/hard-cap-demo.config.sh +++ b/assets/demos/hard-cap-demo.config.sh @@ -7,4 +7,4 @@ SLUICE_DESC="hard-cap: preventive per-boot egress byte ceiling" SLUICE_ALLOW_DOMAINS="httpbin.org" SLUICE_EGRESS_HARD_CAP_BYTES=1258291 -SLUICE_RUN_CMD='printf ">> httpbin.org is allowlisted - the box really can reach it:\n"; curl -sS --max-time 8 -o /dev/null -w " warm GET ok (http %{http_code})\n" https://httpbin.org/get; printf ">> now launder 4 MiB OUT to that same allowed host (the honest caveat):\n"; head -c 4194304 /dev/urandom | curl -sS --max-time 8 -o /dev/null -w " uploaded %{size_upload} of 4194304 bytes before the wire went dead\n" -T - https://httpbin.org/anything; printf " curl exit=%s (non-zero: killed mid-flight by the cap)\n" "$?"; sleep 1' +SLUICE_RUN_CMD='sh demo.sh' diff --git a/assets/demos/hard-cap-demo.tape b/assets/demos/hard-cap-demo.tape index e308d31..b952af7 100644 --- a/assets/demos/hard-cap-demo.tape +++ b/assets/demos/hard-cap-demo.tape @@ -15,7 +15,8 @@ # # Reproduce (from the repo root; .demos/ is gitignored recording scratch): # d=.demos/hard-cap; rm -rf "$d"; mkdir -p "$d" .demos/render -# cp assets/demos/hard-cap-demo.config.sh "$d/sluice.config.sh" +# cp assets/demos/hard-cap-demo.config.sh "$d/sluice.config.sh" +# cp assets/demos/hard-cap-demo.workload.sh "$d/demo.sh" # SLUICE_RUN_CMD='sh demo.sh' # printf 'export PATH="$PWD/bin:$PATH"\nexport SLUICE_NO_BANNER=1 SLUICE_NO_UPDATE_CHECK=1 DOCKER_CLI_HINTS=false\nexport PS1="$ "\ncd .demos/hard-cap\n' > .demos/hard-cap-setup.sh # ( cd "$d" && git init -q ) # own .git => no git-common-dir warning # ( cd "$d" && PATH="$PWD/../../bin:$PATH" SLUICE_NO_BANNER=1 SLUICE_YES=1 sluice build ) # build off-camera @@ -63,12 +64,15 @@ Enter Sleep 200ms Show -# beat 2: run it. warm GET proves httpbin.org is reachable; the 4 MiB launder-out dies mid-upload - -# curl exits non-zero with a truncated body. The at-exit receipt bounds httpbin.org's bytes. +# beat 2: run it. The warm GET proves httpbin.org is genuinely reachable; the launder-out then runs +# until the wire dies; and the RE-RUN of that same warm GET fails, which is what makes it conclusive +# (curl's exit 28 is a timeout, and a slow upstream would produce that too - only a box-wide egress +# death distinguishes the cap). The workload derives every verdict; nothing is narrated. +# Timing: warm GET ~1s + upload to --max-time 15 + re-check to --max-time 8 => ~26s of run. Type "sluice" Sleep 600ms Enter -Sleep 10500ms +Sleep 30000ms Hide Type "clear" diff --git a/assets/demos/hard-cap-demo.workload.sh b/assets/demos/hard-cap-demo.workload.sh new file mode 100644 index 0000000..330852f --- /dev/null +++ b/assets/demos/hard-cap-demo.workload.sh @@ -0,0 +1,62 @@ +#!/bin/sh +# Hard-cap demo workload. Copied into the fixture as demo.sh; the config runs it via +# SLUICE_RUN_CMD='sh demo.sh' so `cat sluice.config.sh` shows POSTURE (the allowlist + the cap), +# not 500 characters of curl. +# +# Every verdict below is DERIVED from the observed result. The previous version printed +# "non-zero: killed mid-flight by the cap" as a literal string next to an interpolated "$?" - so a +# 503 from the upstream, a server-truncated upload and curl exiting 0 all still rendered as the cap +# working. A demo that cannot fail proves nothing; this one exits non-zero and says WHY. +set -u + +CAP=1258291 # keep in sync with SLUICE_EGRESS_HARD_CAP_BYTES in sluice.config.sh +WANT=4194304 # 4 MiB - comfortably past the cap + +printf '>> httpbin.org is allowlisted - prove the box really can reach it:\n' +# NB: capture without `|| echo 000` - curl's -w already emits 000 on failure, and appending another +# renders "000000". Normalise an empty capture instead. +code="$(curl -sS --max-time 8 -o /dev/null -w '%{http_code}' https://httpbin.org/get 2>/dev/null)" +code="${code:-000}" +case "$code" in + 2*) printf ' warm GET ok (http %s)\n' "$code" ;; + *) printf ' ABORT: warm GET returned http %s - upstream is unhealthy, not a cap result.\n' "$code" + printf ' (this run proves nothing about the cap; re-record when the host is up)\n' + exit 1 ;; +esac + +printf '>> now launder data OUT to that same allowed host (the honest caveat):\n' +# One upload cannot exhaust the cap: httpbin truncates a chunked PUT server-side well below it. So +# push repeatedly - every rejected body still crosses the wire and debits the in-box quota - until +# the cap severs the flow. The loop is bounded so a healthy upstream cannot hang the recording. +total=0; fired=0; i=0 +while [ "$i" -lt 12 ]; do + i=$((i + 1)) + sent="$(head -c "$WANT" /dev/urandom \ + | curl -sS --max-time 15 -o /dev/null -w '%{size_upload}' -T - https://httpbin.org/anything 2>/dev/null)" + rc=$? + total=$((total + ${sent:-0})) + printf ' attempt %-2s %8s bytes on the wire (running total %s)\n' "$i" "${sent:-0}" "$total" + # the cap severs the connection: curl dies non-zero having sent less than it was asked to. + if [ "$rc" -ne 0 ] && [ "${sent:-0}" -lt "$WANT" ]; then fired=1; break; fi +done + +if [ "$fired" -eq 1 ]; then + printf ' => wire went dead after %s bytes (cap %s, curl exit=%s)\n' "$total" "$CAP" "$rc" + # curl exit 28 is a TIMEOUT - which a merely slow upstream also produces. Distinguish them: the cap + # is per-boot and severs ALL proxied egress, so the warm GET that worked seconds ago must now fail + # too. A slow upstream would still serve it. This is what makes the run conclusive. + printf '>> re-run the warm GET that succeeded above - the cap severs ALL egress, not one flow:\n' + code2="$(curl -sS --max-time 8 -o /dev/null -w '%{http_code}' https://httpbin.org/get)" + code2="${code2:-000}" + case "$code2" in + 2*) printf ' INCONCLUSIVE: http %s - egress still works, so the stall was upstream, not the cap.\n' "$code2" + exit 1 ;; + *) printf ' http %s - egress is dead box-wide. CONFIRMED: the cap fired.\n' "$code2" + exit 0 ;; + esac +fi + +printf ' => NOT capped: %s bytes crossed the wire over %s attempts without the cap severing.\n' \ + "$total" "$i" +printf ' (this run does NOT demonstrate the cap - do not ship it)\n' +exit 1 diff --git a/assets/demos/laundering-cover.config.sh b/assets/demos/laundering-cover.config.sh new file mode 100644 index 0000000..f90b124 --- /dev/null +++ b/assets/demos/laundering-cover.config.sh @@ -0,0 +1,9 @@ +# Laundering-cover demo: a REAL agent posture, not a contrived one. The claude preset must allow +# api.anthropic.com for the agent to work at all - and that host is POST-capable, so an attacker +# inside the box can write data OUT through it. sluice splices, never decrypts, so the request body +# is not inspected. Allowlisting cannot close this; the gate names it instead. +SLUICE_DESC="Claude Code (Anthropic)" +SLUICE_ALLOW_DOMAINS="api.anthropic.com" +SLUICE_MASK=".env*" +SLUICE_ENV="ANTHROPIC_API_KEY" +SLUICE_RUN_CMD="true" diff --git a/assets/demos/laundering-cover.tape b/assets/demos/laundering-cover.tape new file mode 100644 index 0000000..26d90fa --- /dev/null +++ b/assets/demos/laundering-cover.tape @@ -0,0 +1,82 @@ +# LAUNDERING-COVER demo: the limit sluice cannot close by allowlisting, and the knob that turns it +# into a refusal. The fixture is a REAL agent posture - the claude preset allows api.anthropic.com +# because the agent needs it. That host is POST-capable, so data can be written OUT through it; we +# splice, never decrypt, so the request body is never inspected (THREAT_MODEL "allowed-host +# laundering"). Beat 2 runs and the box WORKS, with a yellow note naming the risk. Beat 3 sets +# SLUICE_STRICT_LAUNDERING=1 and the same command REFUSES, exit 1. +# +# Why the exit code is the whole point: `resolve_engine` runs ~725 lines BEFORE warn_laundering, and +# `die` also exits 1 - so a tape that renders `|| echo "refused (exit $?)"` on a host with no engine +# prints its success line for the WRONG reason and proves nothing. This tape shows the refusal text +# itself (which only warn_laundering emits) and requires a working engine, verified by beat 2 having +# just built and run the same box. Verified 2026-07-21 on docker-desktop: strict exit=1, plain exit=0. +# +# Honest limits: SLUICE_LAUNDERING_OK=1 silences the note (it does not add a control); the matcher is +# a curated host LIST (src/10-egress-helpers.sh laundering_host) - an allowed host it does not know +# is not flagged; and strict mode refuses the RUN, it does not inspect traffic. The guarantee is +# "you are told, and can hard-fail", not "laundering is prevented". +# +# Reproduce (from the repo root; .demos/ is gitignored recording scratch): +# d=.demos/laundering; rm -rf "$d"; mkdir -p "$d" .demos/render +# cp assets/demos/laundering-cover.config.sh "$d/sluice.config.sh" +# ( cd "$d" && git init -q ) # own .git => no git-common-dir warning +# printf 'export PATH="$PWD/bin:$PATH"\nexport SLUICE_NO_BANNER=1 SLUICE_NO_UPDATE_CHECK=1 DOCKER_CLI_HINTS=false\nexport PS1="$ "\ncd .demos/laundering\n' > .demos/laundering-setup.sh +# ( cd "$d" && PATH="$PWD/../../bin:$PATH" SLUICE_NO_BANNER=1 SLUICE_YES=1 sluice build ) # build off-camera +# vhs assets/demos/laundering-cover.tape # writes .demos/render/laundering-cover.gif +# gifsicle -O3 --colors 128 .demos/render/laundering-cover.gif -o assets/laundering-cover.gif + +Output ".demos/render/laundering-cover.gif" +Set Shell "bash" +Set FontSize 18 +Set Width 1400 +Set Height 620 +Set Padding 28 +Set Theme "Dracula" +Set TypingSpeed 0ms + +Hide +Type "source .demos/laundering-setup.sh" +Enter +Sleep 1000ms +Type "SLUICE_YES=1 sluice build >/dev/null 2>&1" +Enter +Sleep 8000ms +Type "clear" +Enter +Sleep 1000ms +Show + +# beat 1: the fixture - a real agent posture. ONE allowed host, and it is the one the agent needs. +Sleep 500ms +Type "cat sluice.config.sh" +Sleep 600ms +Enter +Sleep 4000ms + +# clear between beats (off camera) so each starts fresh - no scroll accumulation (bloats the gif). +Hide +Type "clear" +Enter +Sleep 200ms +Show + +# beat 2: it RUNS (exit 0). The yellow note names the risk: an allowlisted host an attacker can also +# write to. Allowlisting cannot close this - we splice, not decrypt, so the body is never inspected. +Type "sluice run true; echo exit=$?" +Sleep 600ms +Enter +# the box boots on camera here - exit=0 only lands at ~5.5s, so dwell long enough to READ it. +Sleep 9000ms + +Hide +Type "clear" +Enter +Sleep 200ms +Show + +# beat 3: same command, strict mode. Now it REFUSES - red, exit 1, before the box ever boots. The +# refusal TEXT is the proof: only warn_laundering emits it, so this cannot pass for a die() elsewhere. +Type "SLUICE_STRICT_LAUNDERING=1 sluice run true; echo exit=$?" +Sleep 600ms +Enter +Sleep 6000ms diff --git a/assets/fleet-audit.gif b/assets/fleet-audit.gif deleted file mode 100644 index 78b47ae..0000000 Binary files a/assets/fleet-audit.gif and /dev/null differ diff --git a/assets/hard-cap-demo.gif b/assets/hard-cap-demo.gif index 03eb8c9..6f34e67 100644 Binary files a/assets/hard-cap-demo.gif and b/assets/hard-cap-demo.gif differ diff --git a/assets/learn-demo.gif b/assets/learn-demo.gif deleted file mode 100644 index c1dfb85..0000000 Binary files a/assets/learn-demo.gif and /dev/null differ diff --git a/assets/lock-demo.gif b/assets/lock-demo.gif deleted file mode 100644 index f0df27b..0000000 Binary files a/assets/lock-demo.gif and /dev/null differ diff --git a/assets/onboard-demo.gif b/assets/onboard-demo.gif deleted file mode 100644 index 0c39ac6..0000000 Binary files a/assets/onboard-demo.gif and /dev/null differ diff --git a/assets/overlay-demo.gif b/assets/overlay-demo.gif deleted file mode 100644 index 970f187..0000000 Binary files a/assets/overlay-demo.gif and /dev/null differ diff --git a/assets/policy-refusal.gif b/assets/policy-refusal.gif deleted file mode 100644 index 5df91bd..0000000 Binary files a/assets/policy-refusal.gif and /dev/null differ diff --git a/docs/hardening.md b/docs/hardening.md index 6f59bf8..2740f08 100644 --- a/docs/hardening.md +++ b/docs/hardening.md @@ -65,8 +65,6 @@ After a session sluice prints the changeset counts as a nudge. Cost: the git com mounted (an rw mount would bypass the protection), so a git worktree can't resolve refs in the box; seeding a large repo costs startup time. -

with SLUICE_WORKSPACE=overlay the box edits a throwaway copy: on the host notes.txt still reads 'original' and created.txt does not exist; sluice diff shows the box's changes (notes.txt modified, created.txt added); sluice apply prompts [y/N] and on 'y' writes them back - applied 1 added, 1 modified, 0 deleted

- ## `SLUICE_MASK`: in-repo secret masking Protects against: the agent reading secrets that live inside the repo - the project mount is @@ -114,7 +112,7 @@ Protects against: a runaway agent or build exhausting the host The default posture is default-drop with an allowlist; these knobs gate or bound what an allowed host can still carry ([residual risk](../THREAT_MODEL.md#residual-risk-one-line)). -

with SLUICE_EGRESS_HARD_CAP_BYTES set just above the 1 MiB floor, a warm GET proves httpbin.org is genuinely allowlisted, then a 4 MiB upload to that same allowed host is killed mid-flight by the in-box xt_quota - curl aborts non-zero after ~2 MB and the egress receipt shows the bytes bounded near the cap, not the full 4 MiB: even a host you permit can't be used to launder unlimited volume out

+

with SLUICE_EGRESS_HARD_CAP_BYTES=1258291 a warm GET returns http 200, proving httpbin.org is genuinely allowlisted; an upload to that same allowed host then puts 1556480 bytes on the wire before it dies (curl exit 28); re-running the warm GET that had just succeeded now times out with http 000, showing egress is dead box-wide rather than one flow being slow - which is what confirms the cap fired; the receipt closes at 1 reached, 0 blocked, 1.4 MB

- `SLUICE_EGRESS_MAX_BYTES` - a **detective** budget on bytes sent to reached hosts. Over the cap, `sluice egress` exits non-zero (a CI gate) and `sluice learn` warns. Bounds laundering volume. diff --git a/docs/operations.md b/docs/operations.md index 05f9736..52243ce 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -59,8 +59,6 @@ sourced, `--all` is chain-integrity only: per-box egress knobs (host budgets, by it, and `-b ` is rejected for `--all` (it spans every box by definition). An unreadable log reports `verified:false, reason:"unreadable"` - never a silent pass. -

with the container engine down, sluice egress --verify --all walks every box's append-only hash chain host-side and reports all 3 boxes intact; --verify --all --json emits the sluice.fleet-verify/v1 schema per box; --export --all concatenates every box's JSONL each record tagged with its box; then one dropped log line flips one box to TAMPERED with a non-zero exit - never a silent pass

- ## Machine-readable contracts Every JSON surface carries a `schema` id so a consumer (a dashboard, a CI gate, the future control plane) diff --git a/docs/policy.md b/docs/policy.md index 0e45713..e6d684c 100644 --- a/docs/policy.md +++ b/docs/policy.md @@ -5,7 +5,18 @@ it can **add** and **deny** allowlist hosts and **refuse to run** when local con Inert unless a policy is configured. Signing is planned (v2.1); today integrity rests on the source (see [the managed-mode boundary](#managed-mode-the-honest-boundary)). -

an org policy denies a host and sets deny-ip for the cloud-metadata IP 169.254.169.254/32; a developer's local config crosses both lines - it allowlists the denied host and opens a direct-IP supernet 169.254.169.0/24 that overlaps the denied /32 - so sluice refuses to run before any box is built, naming the overlapping deny-ip and exiting non-zero; swapping to a compliant config then runs clean under the same policy

+A local config that opens a direct-IP **supernet** containing a denied address is refused +**pre-build** - no box is ever started - and the violated directive is named: + +``` +$ sluice run true # local config sets SLUICE_ALLOW_IPS="169.254.169.0/24:80" +[sluice] policy denies SLUICE_ALLOW_IPS '169.254.169.0/24:80' (overlaps deny-ip 169.254.169.254/32) +$ echo $? +1 +``` + +The overlap check is what closes the bypass: `169.254.169.0/24` contains the denied +cloud-metadata `/32`, so a base-address-only comparison would have let it through. ## Sources and precedence diff --git a/docs/supply-chain.md b/docs/supply-chain.md index 6399137..f4417e0 100644 --- a/docs/supply-chain.md +++ b/docs/supply-chain.md @@ -23,8 +23,6 @@ sluice update # rebuild --no-cache, then refresh the lock All three report forms take `--json`. -

sluice lock --check reports the inventory in sync; after a dependency is added and the box rebuilt, lock --check catches the drift (classified: + apk tree, exit 1); a CycloneDX SBOM then carries the new package with its purl and SHA-1 integrity hash; finally lock --scan --fail-on high runs that SBOM through a host grype and gates the build on the lodash CVEs (non-zero exit)

- ### Reproducibility, in tiers (be honest) Wolfi apk is a rolling repo, so the same config builds different versions on different days. sluice diff --git a/examples/README.md b/examples/README.md index 84d6e19..4dba62a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -18,9 +18,7 @@ everyday task it stands in for. Bread-and-butter first; skim down for the securi The [overlay](overlay.config.sh) preset is the human gate in motion - the box edits a throwaway copy, you review with `sluice diff`, and only `sluice apply` (with a `[y/N]` -confirm) touches your real files: - -

with SLUICE_WORKSPACE=overlay the box edits a throwaway copy: on the host notes.txt still reads 'original' and created.txt does not exist; sluice diff shows the box's changes (notes.txt modified, created.txt added); sluice apply prompts [y/N] and on 'y' writes them back - applied 1 added, 1 modified, 0 deleted

+confirm) touches your real files. ## Your own stack @@ -28,8 +26,6 @@ No preset needed: `sluice init` detects 11 stacks (list in the [main README](../ anything else runs via the generic base) and scaffolds the config, then `sluice learn` (below) fills the egress allowlist from what the app actually tried to reach. -

bare sluice in a config-less node project finds no config, detects the stack (node/npm), scaffolds sluice.config.sh and previews the knobs it chose, asks [Y/n], then builds and runs it sandboxed - ending on the egress receipt with registry.npmjs.org reached in green and a first-run nudge to sluice learn / sluice lock

- ## Stronger isolation (Linux) Any preset above - or your own repo - runs under an own-kernel micro-VM with @@ -59,8 +55,6 @@ $ sluice learn [sluice] reloaded the running box (squid reconfigure) - live now, no rebuild. ``` -

a real run is blocked by the firewall: curl fails and the egress receipt shows the host in red as 'blocked, not allowlisted'; sluice learn then reviews it with an allow/skip/domain/quit prompt, 'a' allows it and the running box is reloaded live with no rebuild; the rerun returns HTTP 200 and the receipt flips to green

- Picks are written to the config **and** hot-loaded into the running box - no rebuild. `[d]omain` allows the whole `.parent` wildcard, and when 2+ blocked hosts share a parent, learn offers the collapse up front. Flags: diff --git a/test/verify-security-hardcap.bats b/test/verify-security-hardcap.bats index 8547e3a..4596481 100644 --- a/test/verify-security-hardcap.bats +++ b/test/verify-security-hardcap.bats @@ -1,14 +1,22 @@ #!/usr/bin/env bats # SLUICE_EGRESS_HARD_CAP_BYTES: a PREVENTIVE in-box byte ceiling on proxied egress, enforced with an # xt_quota rule on squid's uid followed by a DROP - so exhaustion severs even established flows. This -# suite asserts the rule SHAPE (never the byte value - `iptables -S` prints the counting-down remaining -# quota) and the ordering (the pair precedes the ESTABLISHED accept), plus the host-side <1MiB refusal. +# suite asserts the rule SHAPE, the ordering (the pair precedes the ESTABLISHED accept), the host-side +# <1MiB refusal, and - in the final test - that the cap ACTUALLY FIRES against live traffic. +# +# Do NOT assert on the byte value printed by `iptables -S`: it is the CONFIGURED quota, not the +# remaining one. Verified on Docker Desktop's LinuxKit kernel - after 123K of real proxied egress the +# printed value was still the full 1258291. Enforcement is observable through the RULE COUNTERS +# (`iptables -L -v`) instead: bytes on the quota ACCEPT, packets on the following DROP once exhausted. # xt_quota is absent on some kernels; the box then fails closed (refuses to boot), so the enforcement # tests skip on such a runner rather than false-fail. Needs Docker (engine lane). load test_helper/common setup_file() { - make_box hardcap hc 'SLUICE_EGRESS_HARD_CAP_BYTES="2097152"' 'SLUICE_RUN_CMD="bash"' + # cap at the 1 MiB floor + one allowlisted host: the exhaustion test drives real bytes through + # squid, and the floor keeps the traffic it must generate to the minimum. + make_box hardcap hc 'SLUICE_EGRESS_HARD_CAP_BYTES="1048576"' 'SLUICE_ALLOW_DOMAINS="example.com"' \ + 'SLUICE_RUN_CMD="bash"' "${SLUICE_ENGINE:-docker}" logs sluice-sectest-hardcap > "$WORK/hclog" 2>&1 || true } teardown_file() { destroy_box hardcap hc; } @@ -70,3 +78,48 @@ _skip_if_no_xtquota() { run bash -c "cd '$WORK/hc' && '$SLUICE' egress" assert_success } + +# _uid999_rule : the live counters for squid's quota ACCEPT ("accept") or the DROP that follows +# it ("drop"), as " ". Reads `iptables -L -v` (counters), not `-S` (configured value). +_uid999_rule() { + local want="$1" line + line="$("$ENG" exec --user root sluice-sectest-hardcap iptables -L OUTPUT -v -n 2>/dev/null \ + | grep -E "owner UID match 999" | grep -vE "dpt:3128")" + case "$want" in + accept) printf '%s\n' "$line" | grep -F "quota:" | awk '{print $1, $2}' ;; + drop) printf '%s\n' "$line" | grep -v -F "quota:" | awk '{print $1, $2}' ;; + esac +} + +# The behavioural end of the suite: every test above proves the rule is INSTALLED; this one proves it +# BITES. Without it the only evidence the cap ever stopped a byte was a demo GIF whose "killed +# mid-flight by the cap" line was a hardcoded string that printed even when curl exited 0. +# +# Not gated on the upstream's cooperation: example.com 405s a PUT, but the rejected body still +# crosses the wire through squid and debits the quota, so the DROP fires regardless of status code. +@test "hardcap: the cap FIRES - egress past the ceiling is dropped, not merely rule-shaped" { + _skip_if_no_xtquota + "$ENG" ps --filter "name=sluice-sectest-hardcap" --filter status=running --format '{{.Names}}' \ + | grep -qx sluice-sectest-hardcap || skip "box not up" + + local before_drop; before_drop="$(_uid999_rule drop | awk '{print $1}')" + [ -n "$before_drop" ] || skip "no uid-999 DROP rule found (cap not installed on this runner)" + + # drive > the 1 MiB cap through the proxy; each PUT is refused at ~170 KB, so several are needed. + "$ENG" exec sluice-sectest-hardcap sh -c ' + i=0; while [ $i -lt 12 ]; do + head -c 1048576 /dev/urandom \ + | curl -sS --max-time 15 -o /dev/null -T - https://example.com/ >/dev/null 2>&1 + i=$((i+1)) + done' >/dev/null 2>&1 || true + + local after_drop after_bytes + after_drop="$(_uid999_rule drop | awk '{print $1}')" + after_bytes="$(_uid999_rule accept | awk '{print $2}')" + + # the ACCEPT must have carried real traffic (guards against a vacuous pass when egress never left) + [ "$after_bytes" != "0" ] || skip "no proxied egress recorded - runner has no outbound network" + + # and past the ceiling, packets must land on the DROP + [ "$after_drop" -gt "$before_drop" ] +}