Files
waggle-os/docs/plans/VERIFIER-VERDICT-INSTALLER-2026-07-10.md
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

8.6 KiB

Adversarial Verifier Verdict — Installer Arc (Steal #5)

Date: 2026-07-10 Verifier: adversarial Opus (read-only on code; non-destructive probes only) Scope: 6 commits on feat/steal-5-installer over main b8c65c22 (94cc971e plan · 3715b349 Wave 1 · ce20af22 liveness fix · 364772ec Wave 2 CI+docs · 31295ad0 CI runner-context fix · 9606414d exec-bit restore). Contract: docs/plans/INSTALLER-ARC-2026-07-10.md (decisions D1-D10).

VERDICT: APPROVED (with residuals)

No CRITICAL or HIGH findings. The installer's security posture is genuinely strong on the dimensions that matter most for curl | bash software: no user-controlled value reaches any shell/eval sink, the whole script is wrapped in main() invoked on the last line (partial download cannot execute a destructive prefix), idempotency/marker timing is correct, and the CI smoke job is a real from-scratch Linux E2E that gates regressions (no always-green trap). Residuals below are 1 MEDIUM + 5 LOW, all non-blocking.


What I tried to break, and what held

1. Injection (hostile flag values / wizard answers) — HELD

Traced every user-controlled value (--dir --port --data-dir --branch --local-source + the five /dev/tty wizard answers) to every sink:

  • git clone --depth 1 --branch "$BRANCH" "$REPO_URL" "$INSTALL_DIR", mkdir -p "$INSTALL_DIR", cd "$INSTALL_DIR", the tar pipelines, bash …/waggle-server.sh start --port "$PORT" --data-dir "$DATA_DIR" — all pass values as quoted, already-expanded arguments. Bash does not re-parse variable contents for command substitution or word-into-command splitting, so --data-dir '$(rm -rf ~)' is stored and used as a literal directory name; the $( ) never executes.
  • The install marker (write_marker, install.sh:267-288) is the one place answers cross into a file, and it does so exactly per D6: PORT/DATA_DIR/BRANCH are passed as environment variables into node -e and emitted via JSON.stringify; the marker path is argv[1]. Zero shell interpolation. This is belt-and-suspenders — no other sink evals user input either.
  • verify_runtime runs a constant node script; there is no eval anywhere in either file. Result: D6 satisfied and exceeded. No injection path found.

2. curl | bash semantics — HELD (design strength)

  • Partial-download execution: all logic lives in functions; the only top-level statements before main are colour setup, the trap, arg-parsing (a no-op when $@ is empty, as under curl|bash), and OS detection — all harmless. main is literally the last line (install.sh:367). A truncated download therefore either syntax-errors before running anything, or defines functions and hits EOF before mainnothing destructive runs. Correct pattern.
  • /dev/tty absence (CI/docker): ask/ask_yesno/run_wizard all guard [ ! -e /dev/tty ] (and --yes) → silently take defaults. Verified.
  • Ctrl-C mid-install: trap on_interrupt INT restores tty (stty sane) and aborts; a partial dir without a marker is picked up by the resume branch next run.

3. Idempotency / resume — HELD

3-way branch (install.sh:324-340) is correct; the marker is written after build+verify and before start (install.sh:357), so its presence means "fully built." Failure between clone and marker → next run resumes (skips clone, re-runs npm install+build:packages). Re-run with marker → exit 0 with upgrade hint. write_marker timestamp-backs-up any existing marker.

4. waggle-server.sh process management — mostly HELD (see MEDIUM/LOW residuals)

Health-first liveness is the right call. stop refuses to signal when /health responds but no pidfile exists (won't kill an unidentified process on the port). read_pid sanitizes to digits. Stale pidfile (dead PID, free port) → start proceeds correctly. tasklist/taskkill fallbacks are correct for the msys/Git-Bash native-PID false-negative case.

5. Plan-vs-impl drift — D1-D10 all implemented as specified

Verified each decision against code. Only wording drift: D7 says "pure-bash timeout shim," but the health poll actually depends on curl/wget (the script comment even states "no pure-bash HTTP"). See MEDIUM-1.

6. CI gating — HELD

installer-smoke.yml triggers on push/PR touching install.sh, scripts/waggle-server.sh, or the workflow; runs shellcheck as a failing gate, then a real from-scratch install → boot → assert /health 200 → status → stop → assert port freed. No || true / continue-on-error on any assertion (the || true / || echo 000 occurrences are all in diagnostics/polling, not masking a gate). CI run 29096830297 = success, on HEAD commit 9606414d (verified via gh). shellcheck therefore passes on both scripts (proven by the green run).

7. CLAUDE.md §7 security — HELD

No hardcoded secrets in any scope file (grepped). No eval/dynamic-require. Vault untouched. .gitattributes addition (*.sh text eol=lf) is clean and does not disturb existing binary pins.


Residuals (non-blocking)

# Sev File:line Issue Break scenario / note
M-1 MEDIUM scripts/waggle-server.sh:82-91,141-149 Health poll requires curl or wget; neither is checked by install.sh preflight. Contradicts D7's "pure-bash timeout shim." On a minimal box with neither tool (e.g. some Alpine images), start spawns a healthy sidecar but http_ok returns 2, so wait_for_health times out at 60s and reports "did not become healthy" + exits 1 — a false failure. Non-security; clear error + log tail, no corruption. curl/wget is present on essentially every VPS/homelab image and is proven on the CI ubuntu runner, so real-world impact is low. Fix options: /dev/tcp fallback in http_ok, or add a curl/wget check to install.sh preflight.
L-1 LOW install.sh:246-252 Predictable temp path /tmp/waggle-verify.$$ for stderr capture (CWE-377). On a shared multi-user box an attacker who wins a PID-guess race could pre-symlink the path and have the 2> truncate a file the user can write. Single-user localhost typical; low probability. Fix: mktemp.
L-2 LOW scripts/waggle-server.sh:214-253 PID-reuse in stop: if our server is down but the recorded integer PID was reused by an unrelated process not serving /health on our port, signal_pid TERM/KILL targets that process. Standard pidfile-manager risk, single-user localhost; mitigated by the health-first early-return and the "refuse to signal unknown PID" guard. Matches industry norm.
L-3 LOW install.sh:78-79; waggle-server.sh:58-59 --port is never validated as numeric. --port abc yields a broken-but-safe install (health never comes up → clear timeout). No injection. CLAUDE.md coding-style asks for boundary validation — worth a [[ "$PORT" =~ ^[0-9]+$ ]] guard.
L-4 LOW README.md:60 / getting-started.md:18 The documented one-liner points at raw.githubusercontent.com/marolinik/waggle-os/main/install.sh, but install.sh is not on main yet (only on feat/steal-5-installer). The docs describe the post-merge reality. Slug marolinik/waggle-os is correct and the repo is PUBLIC. Sequencing dependency: the merge must land install.sh on main for the published one-liner to resolve; the README/getting-started edits should not be published ahead of that merge. Honest, not a lie — but flag the ordering.
L-5 LOW install.sh:88-90 --help uses sed -n '2,40p' "$0"; under curl | bash, $0 is bash, so --help won't print the header. Cosmetic; nobody pipes --help. Works when run as ./install.sh --help.

Informational (out of scope / by design)

  • getting-started.md Options 2-3 reference github.com/marolinik/waggle (desktop releases + a git clone …/waggle.git) while origin is marolinik/waggle-os. These lines are pre-existing (not introduced by this arc) — noted for a future docs pass, not this verdict.
  • The resume branch (install.sh:330-337) runs npm install on pre-existing directory contents when a non-marked dir exists. This trusts the directory by design (it is the user's own machine); a pre-seeded malicious package.json postinstall would run, but that presupposes attacker write access to the user's home dir. Acceptable under the local trust model.

Bottom line: the security-critical properties (no injection, partial-download safety, correct idempotency, honest git-over-TLS acquisition, real gating CI) all hold. Ship it; address M-1 and L-1/L-3 as fast-follows, and ensure the merge lands install.sh on main before the README one-liner is relied upon (L-4).