106 lines
8.6 KiB
Markdown
106 lines
8.6 KiB
Markdown
# 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 `main` — **nothing 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).
|