# AI penetration test, sixth run (2026-11-06)

A short targeted step 4 of `docs/ai-pentest.md` on commit `d451174`, the fixes for the fifth run
(`reports/AI pentest, fifth run (2026-11-06).md`), as the launch gate asks for a change to a deploy script
(`docs/mainnet.md` §1, gate 1). `d451174` changed `scripts/deploy-all.sh` and `scripts/rehearse-mainnet.sh` (the
pinned addresses instead of keys on command lines, a throwaway router key in the rehearsal),
`contracts/script/DeployAll.s.sol` (a check of the attestor and promo keys before anything is sent), `ops/yescrow-ctl`
(`secret set` keeps the attestor's address with its key; `lockdown` hides each account's processes in `/proc`) and the
launch procedure. The contracts in `contracts/src/` are unchanged since `1e08fee`. One fresh attack agent on area F,
aimed at those changes. On `d451174` the attack agent re-ran the fifth run's proofs, its verifier's test and the
build pins (12 of 12 pass), the R5-F-1 regression (no key on any command line) and the ops tool's tests (no failure);
the whole suite, which no longer fits this server's memory in one run, runs in parts with this run's fixes
(`docs/audit.md` §6e). A fresh verifier checked each finding. Proofs are under `contracts/test/pentest/sixth/` and `scripts/pentest/sixth/`; the verifiers' are
`contracts/test/pentest/verify/V6_F-1_TryCatch.s.sol` and `scripts/pentest/sixth/verify/`.

# Part 1. Summary

## Result

No Critical, High or Medium finding, and **no finding needs a contract change**. Both fifth-run fixes hold and are
complete for what they claim: no mainnet key reaches a command line in the launch scripts or the documented commands
(the regression logs every command the scripts run: none holds any of the five keys), `DeployAll`'s key check runs
before the first send in the deployment and in the rehearsal, `secret set` keeps the attestor's address with its key,
and `/proc` on the server hides other accounts' processes since the admin ran `lockdown` during this round.

Two Low findings and one Info survived verification, all in the procedure around the contracts:

- **Low: R6-F-1.** A key line edited by hand with a stray character (a CR from a Windows paste, a non-ASCII space, a
  tab inside quotes) makes forge print the whole key in its error and its trace: the deployer key
  (`DeployAll.s.sol:74`), the relayer key (`DeployRouter.s.sol:43`, `docs/mainnet.md` §3 step 1), and, as the
  verifier showed, the attestor and promo keys inside `DeployAll`'s new key check, whose try/catch hides the error but
  not the trace of a failing run. Only on the admin's own screen, and none of the documented ways of writing a key line
  produces such a value. The verifier also showed that the suggested try/catch fix doesn't work; a check of each key's
  format in the shell, before forge runs, does.
- **Low: R6-F-2.** `lockdown`'s `/proc` step appends its line to `/etc/fstab` without checking that the file ends with
  a newline; if it didn't, the last entry would be dropped silently at the next boot (on this server a swap file;
  elsewhere possibly the root file system). Not the case here: checked after the admin ran it, the file ended with a
  newline and every entry still generates its unit. The verifier added that the step takes a commented-out copy of its
  own line for done (U-3) and would then report the setting kept when it isn't.
- **Info: R6-F-3.** The testnet path of `deploy-all.sh` accepts the mainnet env file: with a Sepolia RPC in the admin's
  shell, it derives the mainnet deployer and promo addresses with `cast` (keys on command lines, now hidden from other
  accounts) and asks forge to deploy on Arbitrum Sepolia with the mainnet deployer. Nothing on Arbitrum One is
  affected. The verifier found the same in other testnet scripts (`deploy-escrow.sh`, `fund-testnet-wallets.sh`), that
  §3 step 1 leaves `ENV_FILE=.env.mainnet` exported in the admin's shell, and that `DeployAll`, unlike `DeployRouter`,
  doesn't refuse the mainnet deployer on other chains.

## All findings

"Attacker" is the severity the attack agent claimed; "Verifier" is the fresh agent's verdict; "Final" is the
verifier's severity. The rule (the user's): a finding whose proof runs and shows the claimed impact can't be dropped
by argument alone.

| ID | Finding | Attacker | Verifier | Final | Where the fix goes |
|---|---|---|---|---|---|
| R6-F-1 | forge prints a whole private key in its error and trace when a key value holds a character it escapes | Low | Confirmed, wider: the attestor, promo and relayer keys too, through the trace of a failing run; the suggested try/catch doesn't stop it; only the admin's own screen | **Low** | The shell: check that each set `*_PRIVATE_KEY` is `0x` and 64 hex digits before forge runs, without printing it (`deploy-all.sh`, and the router command of §3 step 1) |
| R6-F-2 | `lockdown` appends to `/etc/fstab` without a newline guard: the last entry is lost at the next boot | Low | Confirmed: the boot drops the line without a message; a root entry last would leave root read-only; not the case on this server; U-3 is broader | **Low** | `hide_processes`: newline guard, re-read before appending, a line-aware check of live `/proc` entries, the generated units compared before and after |
| R6-F-3 | The testnet path of `deploy-all.sh` accepts the mainnet env file | Info | Confirmed, wider: other testnet scripts, `ENV_FILE` exported by §3 step 1, `DeployAll` doesn't refuse the mainnet deployer off mainnet | **Info** | `DeployAll`: refuse `MAINNET_DEPLOYER` on other chains; the testnet scripts refuse an env file made for Arbitrum One; §3 step 1 in a subshell |

Six ideas stayed unconfirmed (U-1 to U-6 in the area report): the boot-time half of the `/proc` step, a warning before
`secret rotate` replaces a pinned key, a commented-out `/proc` line taken for done (which the R6-F-2 verifier
reproduced in a likelier form), what `checkKeys` can't see (the running containers' keys; the live proof of §4 covers
the attestor's), RPC URLs with API keys on command lines, and the window before `chmod 600` in §3 step 0.

## Limits

- **Targeted and short.** One attack agent, on area F only; the rest was covered by the regression run of every
  earlier proof. The service changes since v0.33 were not attacked in this run either.
- **Not independent of the author.** Launched from the session that wrote the fixes, at the user's request; each agent
  had a fresh context and the procedure's prompt word for word; the attack agent used Fable 5.1, the verifiers Opus
  5.5, all at maximum reasoning effort. No model from another vendor was run.
- **Some points are read or reasoned, not run:** the vault's contents (no agent can read them), the launch commands
  as the admin runs them, the server's next boot with the new `/proc` line (the boot-time unit generator, run on the
  real `/etc/fstab`, produces every unit).
- **No findings is not proof of no bugs** (the area report's "Checked and found sound" lists what was tried).
- **Not fixed yet.** The fixes follow the user's decisions; fixes to `deploy-all.sh` and `DeployAll.s.sol` are
  themselves changes the gate asks a targeted round for.

---

# Part 2. Area report, with each verification

## Area F: deployment

### AI penetration test, sixth run, Area F: deployment (commit `d451174`, the fifth run's fixes)

A short targeted round on what `CHANGES-since-ab7980d.diff` changed: the launch scripts' use of the pins, `DeployAll.checkKeys`, `yescrow-ctl secret set` and the new `lockdown` step `hide_processes()`, the rehearsal's throwaway key, and the live-proof step of `docs/mainnet.md` §4. Everything ran on a local copy, a local anvil or a fork of Arbitrum One (reads only); every key in the proofs is a throwaway one. The contracts in `contracts/src/` are unchanged since `1e08fee`.

Proofs: `contracts/test/pentest/sixth/F6_KeyChecks.t.sol` (4 tests, one on a fork), `contracts/test/pentest/sixth/F6_KeyEcho.s.sol` (a stand-in script the shell proof runs), `scripts/pentest/sixth/F6_forge_output_no_keys.sh`, `scripts/pentest/sixth/F6_hide_processes.py`. The fifth run's proofs and verifier test were re-run as regressions (12 of 12 pass; `F5_fixed_no_keys_on_command_lines.sh` passes: 0 keys in 14 logged commands and 69 watched command lines).

**Result.** The two fifth-run fixes hold and are complete for what they claim: no mainnet key reaches a command line in the launch scripts or the documented commands, `checkKeys` sits after `check()` and before the first send and is reached by the rehearsal, forge's traces redact what it reads, `secret set` keeps the attestor's address with its key, and `/proc` on this server is now mounted `hidepid=invisible,gid=982` (the lockdown ran during this round: from the agent account only its own 17 processes are visible and `/proc/1/cmdline` does not exist). Three findings, none above Low, all in the procedure around the contracts; none needs a contract change.

#### Findings

#### R6-F-1. forge prints a whole private key in its error when an unparsable key value carries an escaped character

- **Severity:** Low. A key on the admin's screen and in whatever output the admin shares (a pasted failing run is the natural thing to show Claude, since "Claude prepares the commit and checks the results"). The deployer key is the one read this way; before launch its leak has R5-F-1's impact (someone can use the deployer's nonces and occupy the promised addresses, holding the launch for a new deployer, new pins and an audit round). No party's money. Narrow precondition (below).
- **Attacker and preconditions:** anyone who reads the admin's terminal output or a transcript it is pasted into. The value of `DEPLOYER_PRIVATE_KEY` (or `ROUTER_DEPLOYER_PRIVATE_KEY`) in the deploy clone's `.env.mainnet` must fail to parse *and* contain a character Rust's `Debug` escapes: a CR (a CRLF line ending from an editor or a Windows paste), a tab, a non-ASCII byte. `yescrow-ctl secret set` strips CR and tab (checked), `secret gen` writes clean hex, and §3 step 0's `echo "DEPLOYER_PRIVATE_KEY=$(cast wallet decrypt-keystore …)"` is clean, so it takes a hand edit of `.env.mainnet` or of the vault file (`sudoedit`), or a paste of the decrypted key by hand.
- **Steps** (`F6_forge_output_no_keys.sh`, part 1, throwaway key): `forge script` of a stand-in with the shape of `DeployAll.s.sol:74` (`vm.envUint("DEPLOYER_PRIVATE_KEY")`, no try/catch) and of `DeployRouter.s.sol:212` (`vm.envOr("ROUTER_DEPLOYER_PRIVATE_KEY", 0)`; `envOr` falls back only when the variable is missing, not when it doesn't parse). With `0x<key>Z`, a quoted key or two keys, forge's message reads `failed parsing $F6_BAD as type uint256` (the value replaced by its name). With `0x<key>\r`, `0x<key>\t…` or `0x<key>` + U+00A0, three lines of the message carry the full key: `failed parsing "0x<64 hex>\r" as type uint256: parser error: …`. Reason: foundry redacts by replacing the value's plain spelling in the message with `$NAME`; the message holds the `Debug` form (`\r`, `\t`, `\u{a0}` escaped), so the replacement finds nothing. The same read inside `checkKeys`'s try/catch (the attestor and promo keys) prints nothing: the catch swallows the message and the script reports "missing or not the key of …".
- **Impact:** the mainnet deployer key (launch and every rehearsal) on the admin's screen and in any shared output, for one malformed line in the env file. The attestor and promo keys are not affected. The traces at default verbosity and `-vvvv` are clean (`<env var value>`, `<pk>`).
- **Command:** `bash scripts/pentest/sixth/F6_forge_output_no_keys.sh` from the repository root (on the VPS inside the memory scope: `FOUNDRY_DYNAMIC_TEST_LINKING=false systemd-run --user --scope -p MemoryMax=3300M -p MemorySwapMax=3G --quiet -- bash scripts/pentest/sixth/F6_forge_output_no_keys.sh`). Passed: `PASS: R6-F-1 reproduced …`.
- **Root cause:** `contracts/script/DeployAll.s.sol:74` (`vm.envUint` outside the try/catch that `keyAddress` has); `contracts/script/DeployRouter.s.sol:212–213`; foundry's `map_env_err` (forge 1.8.5) redacting the plain value only.
- **Suggested fix:** read every key through a `keyAddress`-style try/catch: in `DeployAll.run`, `address deployer = keyAddress("DEPLOYER_PRIVATE_KEY"); require(deployer != address(0), "DEPLOYER_PRIVATE_KEY is missing or malformed")`, with `vm.envUint` only inside the try (the value never reaches a message); the same in `DeployRouter`. In `deploy-all.sh` and `rehearse-mainnet.sh`, refuse an env file whose `*_PRIVATE_KEY=` lines hold a CR, a tab or a non-ASCII byte (`grep -nP '^[A-Z_]*_PRIVATE_KEY=.*[^\x20-\x7e]'`) before sourcing it. Optionally report upstream that `map_env_err` should compare against the `Debug` form.

#### R6-F-2. `hide_processes()` appends its `/proc` line to `/etc/fstab` without a newline guard: an fstab whose last line has none loses that entry at the next boot

- **Severity:** Low. Not a lockout: the root entry is read, `/proc` is read; the entry the comment glues onto is dropped silently at the next boot, which is the reboot `lockdown` asks for right after. If that entry is the swap file (the usual last line; this VPS added an 8 GB swap file by hand on 2026-11-03 against its OOMs), the server comes back without it; if it is `/boot/efi`, without that mount. Precondition unverifiable from the copy: whether the server's `/etc/fstab` ends with a newline (a hand `echo … >> /etc/fstab` adds one; some editors and pastes don't).
- **Attacker and preconditions:** none; root's own code. `/etc/fstab` without a trailing newline when `hide_processes()` first runs (it runs on every `lockdown` until the fstab line exists).
- **Steps** (`F6_hide_processes.py`, scenario 6, a fake fstab in a temporary directory): `hide_processes()` writes `'# yescrow-ctl lockdown: …\nproc /proc proc … 0 0\n'` with `open('/etc/fstab', 'a')` (`ops/yescrow-ctl:712–715`); the result's line 3 becomes `LABEL=UEFI /boot/efi vfat umask=0077 0 1# yescrow-ctl lockdown: each account sees only its own processes`. `systemd-fstab-generator` (what the boot uses; run as a plain user on that file via `SYSTEMD_FSTAB`) generates `-.mount` only: libmount reports `parse error at line 3 -- ignored` and `boot-efi.mount` is gone. With a swap line last, `swapfile.swap` is gone the same way, while the control file with a newline generates it. (`findmnt --verify` segfaults on such a file in util-linux 2.41.3, so it can't be the check.)
- **Impact:** one fstab entry silently lost at the lockdown reboot; on this server plausibly the swap file the stacks rely on (`docs/runbook.md` §2 "Server").
- **Command:** `python3 -I scripts/pentest/sixth/F6_hide_processes.py` (0 failures; scenario 6 is the finding).
- **Root cause:** `ops/yescrow-ctl:712–715` (`hide_processes`, the append without checking that the existing content ends with `\n`).
- **Suggested fix:** `prefix = '' if fstab.endswith('\n') or not fstab else '\n'` before the comment; verify the result before offering the reboot (`systemd-fstab-generator` into a temp dir, refuse on "parse error"); write atomically as `set_in_file` does.

#### R6-F-3. The testnet path of `deploy-all.sh` accepts the mainnet env file: a slip puts the mainnet deployer and promo keys on `cast` command lines and asks forge to deploy on Sepolia with the mainnet deployer

- **Severity:** Info. The brief asks whether the testnet paths that still derive with `cast` can run with mainnet keys: they can, nothing in the script refuses it, but only by the admin's own mistake, and `hidepid` (now live) hides the arguments from every other account. On-chain nothing is lost: Sepolia nonces are not Arbitrum One nonces, so the promised addresses don't move, and the deployer has no Sepolia ETH.
- **Attacker and preconditions:** the admin, in the deploy clone of §3 with `.env.mainnet` present, typing `ENV_FILE=.env.mainnet scripts/deploy-all.sh arbitrum-sepolia` (the example file itself carries `ENV_FILE=.env.mainnet` on line 5) in a shell with `ARBITRUM_SEPOLIA_RPC_URL` exported (the vault file has none, so without it the script stops at "no RPC for arbitrum-sepolia" before any key is touched, part 3a).
- **Steps** (`F6_forge_output_no_keys.sh`, part 3, stub `cast`/`forge` logging every argument, anvil as chain 421614): 3a without a Sepolia RPC: stops, `cast` ran 0 times. 3b with one in the shell: 5 commands, 2 `cast wallet address --private-key` with the env file's deployer and promo keys (lines 89 and 104), then `forge script script/DeployAll.s.sol:DeployAll` on Sepolia with the mainnet file's `DEPLOYER_PRIVATE_KEY` (the stub refused). `YESCROW_ATTESTOR` in the file kept the attestor key off argv (line 96).
- **Impact:** keys on argv for the life of two `cast` processes (hidden since the lockdown) and a testnet deployment attempt signed by the mainnet deployer, using its Sepolia nonces only.
- **Command:** `bash scripts/pentest/sixth/F6_forge_output_no_keys.sh` (part 3 prints the fact; the script's PASS is R6-F-1).
- **Root cause:** `scripts/deploy-all.sh:53–59, 89, 104` (no check that a testnet run uses a testnet env file).
- **Suggested fix:** for a testnet network refuse an `ENV_FILE` other than `.env` unless the caller sets an explicit `TESTNET_ENV_FILE=1`, or refuse an env file that sets `ARBITRUM_ONE_RPC_URL` or `CONFIRM_MAINNET`; drop the `ENV_FILE=.env.mainnet` line from `.env.mainnet.example` (it does nothing there and invites the slip).

#### Unconfirmed

- **U-1. The boot-time half of `hide_processes` and the logind restart.** `systemctl restart systemd-logind` with live sessions, `systemd-remount-fs` applying the fstab's `/proc` options at boot and libmount resolving `gid=proc` by name were reasoned, not run (root only). The live mount shows `gid=982,hidepid=invisible`, so the name resolution worked for the remount; whether the fstab line survives a reboot is seen at the lockdown reboot.
- **U-2. `secret rotate mainnet ATTESTOR_PRIVATE_KEY` after launch** has no warning that the address is pinned in immutable contracts: the live attestor would sign with a key no verifier accepts (fail closed; the old key stays in `retired/` and §4 says how to put it back). A guard would save an outage.
- **U-3. `hide_processes` takes a commented-out line for done** (scenario 7): `PROC_OPTIONS in fstab` is a substring test, so an fstab where the options sit only in a comment, with `/proc` hidden by hand, returns at once and never writes the live line; a reboot would unhide `/proc`. Needs a hand-made state.
- **U-4. What `checkKeys` can't see.** It proves the vault copy's keys are the pins'; not that the attestor container runs with that value (the live proof of §4 covers it), nor that the service's promo key signs vouchers the escrows accept (nothing before opening sends a voucher; a `cast call` of a service-signed voucher against the escrow would, cheaply).
- **U-5. RPC URLs with keys on `--rpc-url`/`--fork-url`** (carried from the fifth run): now hidden by `hidepid`; the documented URLs are public.
- **U-6. The `.env.mainnet` creation window** in §3 step 0 (`sudo cat … > .env.mainnet && chmod 600`: mode 0644 under the default umask until the chmod): moot here, `~admin` is mode 0750 so `teo` can't traverse it; `umask 077` first on a server where it isn't.

#### Checked and found sound

- **R5-F-1's fix, both halves.** `F5_fixed_no_keys_on_command_lines.sh` passes on this copy (14 commands, 69 command lines, 5 keys, 0 on a command line), and a grep of `scripts/*.sh`, `Makefile`, `docker-compose.yml` and the commands of `docs/mainnet.md` §1–§5 and `docs/secrets.md` §6 finds no key as an argument on the mainnet path: `cast wallet decrypt-keystore` and `cast wallet import --interactive` prompt, the router key is an environment assignment, `sudo` lines carry names only (sudo logs its command lines to the journal; `teo` is in `users` only, not `adm`). The server half is live: `/proc` is `hidepid=invisible,gid=982`, the agent sees 17 processes, all its own, and `systemd-run --user` still works under it.
- **forge's traces redact the keys it reads** (part 1): at default verbosity and `-vvvv`, `VM::envUint("…")` returns `<env var value>`, `VM::addr(<pk>)`, `VM::startBroadcast(<pk>)`; 0 of 19 lines carry the key. Only the parse-error path leaks, and only for escaped characters (R6-F-1).
- **`pin()` of `deploy-all.sh`** (part 2): for `MAINNET_DEPLOYER`, `MAINNET_PROMO_SIGNER`, `YESCROW_ATTESTOR`, `MAINNET_FEE_RECIPIENT` and `MAINNET_JUDGE` exactly one line matches and equals the Solidity declaration. A second match in the same shape (a doc comment) gives a two-line value that `cast balance` refuses (`error: invalid value`); a declaration split over two lines gives no match, which `set -e` with `pipefail` turns into an exit: both fail closed. A wrong promo pin is refused by `DeployAll` ("PROMO_SIGNER is not the one in Networks.sol"); a wrong deployer pin only misdirects the balance and nonce checks, since `DeployAll` derives the deployer from the key.
- **`keyAddress` and the environment** (`F6_KeyChecks.t.sol` tests 1–2): the canonical `0x`+64 hex, a decimal, trailing whitespace and an `0X` prefix give the key's own address; quotes left in, a CR, a leading space and a bare key give `address(0)`; a bare key of digits only parses as a decimal (another address, refused); 0, n and above make `vm.addr` revert outside the try, still before any send and with no key value in the message. The check can accept only the key it read or refuse, never a third address.
- **`checkKeys` against the sends** (tests 3–4, fork of Arbitrum One at the latest block): both keys wrong, one missing, one right: each refusal names the key; `run()` with the env of §3 stops in `check()`/`checkKeys()` before `vm.startBroadcast`, the deployer's nonce stays 0 and the promised addresses stay empty; the rehearsal's fork has chainid 42161, so `n.mainnet` is true there and it reaches the same line. No throwaway key can satisfy a pin, so a passing mainnet run needs the vault's keys, by design; `RESUME=1` and `--resume` don't re-run a check that already passed.
- **The rehearsal's throwaway router key:** `cast wallet new --json` has the `.data[0].address` and `.data[0].private_key` the script reads; the key is passed as an environment assignment and through a bash here-string (a pipe in bash 5.3, no temp file), funded on the fork only, and `DeployRouter` refuses the mainnet deployer (`DeployRouter.s.sol:214`).
- **`secret set` with its companion** (`ops/test-yescrow-ctl.py`, 0 failures, plus an inline check): validates the key before retiring anything, strips a CR, CRLF or tab from a pasted value, writes a plain `KEY=0x…` line, and sets `YESCROW_ATTESTOR` whenever that line exists. A direct `secret set mainnet YESCROW_ATTESTOR` is an admin setting; a value that isn't the pin is refused by `DeployAll` (`:170`) and a key that isn't the pin's by `checkKeys`.
- **`hide_processes()`** (`F6_hide_processes.py` scenarios 1–5, 7–8): the command order (getent, groupadd, daemon-reload, restart logind, try-restart polkit, remount), the two drop-ins with `SupplementaryGroups=proc`, the remount options, the undo on "no" (remount `hidepid=off`, fstab untouched; the group and drop-ins stay, harmless and root-only), a dropped connection at the prompt (an internal error, `/proc` stays hidden until reboot, fstab untouched, a re-run completes), an existing `/proc` line refused before any command, an existing group skipped, and a second run doing nothing. The agent account can influence none of it: the inputs are `/proc/mounts`, `/etc/fstab`, the group database and the admin's answers; the tool itself reaches the server only through `install`'s per-file diff and confirmation.
- **The live-proof step of `docs/mainnet.md` §4:** `POST /v1/proofs` proves for the service's configured verifier (`service/src/api/app.ts:1628–1633`), and `proveFor` asks both attestors when that verifier has `yescrow()` (`service/src/prover.ts:79–81`); `DualRdapVerifier.verify` decodes two halves and verifies each with its own immutable `RdapVerifier` (`src/DualRdapVerifier.sol:36–39`); the record's `.verifier` is the deployed dual verifier the gate checked; `.encoded` is the response field and the documented `cast call` signature matches `RdapFacts`. A yescrow half signed by any key but the pin reverts `WrongAttestor`, so the step can't pass while the attestor runs with another key, including the leaked testnet key behind a wrong URL.
- **`Networks.sol` against Arbitrum One today** (`F5_ArbitrumOneToday.t.sol` 5/5, `BuildPins.t.sol` 1/1, re-run on the latest block): deployer nonce 0, promised addresses empty, attestor pin fresh, judge Safe 2 of 3 with the owner pin, creation-code pins equal to this build. `CheckDeployment` is unchanged by `d451174` and still covers every immutable (fifth run).
- **via-IR:** no contract changed; the new try/catch is in a script, compiled by the legacy pipeline (`compilation_restrictions` name only the three `src/` files), and `BuildPins` ties the via-IR artifacts to the pins.

### Verifications for area F

Each by a fresh agent (Claude Opus 5.5, maximum reasoning effort) that tried to refute the finding and ran its proof
again, under the user's rule.

#### R6-F-1: forge prints a key in its error and trace — Verdict: CONFIRMED, Low (wider than reported)

**Verdict:** Confirmed, at Low. The finding is real but wider than the report says, several of its details are wrong, and the Solidity half of its suggested fix does not work.

**Severity: Low (unchanged).** The proof passes. I also reproduced the leak through the real `scripts/deploy-all.sh` in rehearsal mode, so it can't be dropped. It stays Low rather than higher because none of the documented ways of writing a key line can produce a malformed one:
- §3 step 0 appends the clean hex that `cast wallet decrypt-keystore` prints (docs/mainnet.md:127).
- `secret gen` writes clean hex (ops/yescrow-ctl:363–365).
- `secret set` strips leading and trailing whitespace, including CR, tab and the non-ASCII space U+00A0 (ops/yescrow-ctl:585), and fully checks the attestor key's format (:589).

So a leak needs a hand edit (`sudoedit` on the vault, or an editor on the deploy clone's `.env.mainnet`). Even then the key only reaches the admin's own SSH terminal. Sharing that output with Claude is in no procedure, and docs/secrets.md:85 forbids pasting keys; the key is plainly visible in the message. This is narrower than R5-F-1 (Low: every local account, every rehearsal). It stays Low rather than Info because the keys exposed are wider than the report says: the deployer, the relayer, the attestor and the promo key. A leaked attestor or deployer key before launch means new pins and another audit round; a leaked promo key costs fees for up to 92 days. Also, a pasted deployer key would never be flagged: claude-guard only runs on tool calls, not on what the user types (ops/managed-settings.json), and the guard and the nightly scan only know keys still in the vault (ops/yescrow-ctl:154–162, 203–220). After `secret rm mainnet DEPLOYER_PRIVATE_KEY` (docs/secrets.md:105), the deployer key is no longer there.

**Proof result**

The given command, `FOUNDRY_DYNAMIC_TEST_LINKING=false systemd-run --user --scope -p MemoryMax=3300M -p MemorySwapMax=3G --quiet -- bash scripts/pentest/sixth/F6_forge_output_no_keys.sh`, printed `PASS: R6-F-1 reproduced …`:
- **Leaks:** for both `unparsable()` and `unparsableOr()`, a value with a CR, a tab followed by text, or a U+00A0 put the whole key in 3 lines.
- **No leak:** a stray `Z`, a quoted key and two keys printed `failed parsing $F6_BAD`.
- **Successful reads:** the traces were clean at default verbosity and at `-vvvv`.

My extra runs used throwaway keys and a plain local anvil with chain id 42161 (nothing left the machine):

| Run | Key value | Lines with the whole key |
|---|---|---|
| Real `deploy-all.sh arbitrum-one`, rehearsal mode | CR on the `DEPLOYER_PRIVATE_KEY` line | 3 |
| Same | U+00A0 at the end | 3 |
| Same | quoted value with a tab inside | 3 |
| Same | trailing unquoted tab (bash drops it, the run went on) | 0 |
| Real `DeployRouter.s.sol` | `ROUTER_DEPLOYER_PRIVATE_KEY` + CR | 3 |
| Same | the `DEPLOYER_PRIVATE_KEY` fallback + CR | 3 |
| Real `DeployAll.checkKeys` (through the agent's `F6Harness`) | `ATTESTOR_PRIVATE_KEY` + CR | 1, a trace line `VM::envUint("ATTESTOR_PRIVATE_KEY") … ← [Revert] vm.envUint: failed parsing "0x<64hex>\r"`, then the refusal |
| Value passed straight to forge | trailing tab or space | 0 (both parse as the key) |
| Same | leading U+00A0, or CR then a space | 3 |
| `cast wallet address --private-key` | malformed key | 0: it prints only "Failed to decode private key" |

**Corrections to the finding**
- **Attestor and promo keys:** the report says they "are not affected" because the try/catch in `checkKeys` "prints nothing". That is wrong. The catch in `keyAddress` (contracts/script/DeployAll.s.sol:217–223) does swallow the error, but `checkKeys` (:205–214) then refuses, the script fails, and forge 1.8.5 prints a failing script's traces at default verbosity (no `verbosity` setting in foundry.toml). Those traces include the cheatcode call that failed inside the try, with the key. I proved this for the attestor key with the real code. The promo key goes through the same `keyAddress`; with throwaway keys the run can't get past the attestor check to reach it, so I showed it with an identical stand-in.
- **Wrong line numbers:** `DeployRouter.s.sol:212–213` is really :43–44, and `:214` is :45 (the file has 54 lines). The same wrong `:212` is in the proof script (scripts/pentest/sixth/F6_forge_output_no_keys.sh:36) and in the stand-in's comment (F6_KeyEcho.s.sol:25).
- **DeployRouter doesn't read the deployer key at launch:** in the documented launch, `ROUTER_DEPLOYER_PRIVATE_KEY` is the relayer key (docs/mainnet.md:135); in the rehearsal it is a throwaway (scripts/rehearse-mainnet.sh:34). The deployer fallback at :44 is never reached by a documented command. The keys exposed are:
  - the deployer, read at DeployAll.s.sol:74 (this citation is correct);
  - the relayer, at DeployRouter.s.sol:43, in §3 step 1;
  - the attestor and promo keys, at DeployAll.s.sol:218.
- **Tab:** a trailing tab parses as the key and does not leak. Only a tab followed by more text leaks, and through `set -a; . file` (deploy-all.sh:43, docs/mainnet.md:132) only inside quotes. Unquoted, bash runs the rest as a command and `set -e` stops the script. `line()` in yescrow-ctl (ops/yescrow-ctl:99–104) does write such quotes for any value that isn't plain.
- **"Three lines of the message":** two are trace lines (the cheatcode's revert and the function's revert) and the third is `Error: script failed:`. "Traces … are clean" is true only for reads that succeed.
- **What `secret set` strips:** more than CR and tab. Python's `.strip()` (ops/yescrow-ctl:585) removes all leading and trailing Unicode whitespace, U+00A0 included. Characters inside the value stay, and only `ATTESTOR_PRIVATE_KEY` gets a format check (:589).
- **Where the deployer line comes from:** after the keystore import, the vault holds no `DEPLOYER_PRIVATE_KEY` (docs/secrets.md:105). For the deployer, the only path is a hand edit of the clone's `.env.mainnet`, not `sudoedit` on the vault.
- **Where the output goes:** only the admin's terminal. "Checks the results" (docs/mainnet.md:10) refers to the deployments and broadcast records copied over at the end of §3, not to pasted terminal output.
- I didn't check `map_env_err` against foundry's source. The output fits the explanation: the plain spelling is replaced by `$F6_BAD`, and the escaped spelling is not.

**Fix assessment**
- **The Solidity half doesn't work.** Wrapping the read in a `keyAddress`-style try/catch followed by a `require` still leaks one line at default, `-vv` and `-vvv` (`V6F1TryCatch.fixedShape`). Any cheatcode that fails while holding the value puts it in the trace of a failing run, and the `require` then makes the run fail. Today's `checkKeys` leaks the same way.
- **Don't use `vm.parseUint` either.** Reading the value as a string and parsing it with `vm.parseUint` prints the whole key in the trace even when the key is clean; `parseUint`'s argument is not redacted (`V6F1StringRead`).
- **What works:**
  - **(a) A shell check after sourcing, before any forge call, that never prints the value.** For example: `[[ ${!v} =~ ^0x[0-9a-fA-F]{64}$ ]]` for each of `DEPLOYER_PRIVATE_KEY`, `ATTESTOR_PRIVATE_KEY`, `PROMO_SIGNER_PRIVATE_KEY` and `RELAYER_PRIVATE_KEY` that is set. It belongs in deploy-all.sh after line 43, which also covers the rehearsal and the R6-F-3 testnet slip. §3 step 1 runs DeployRouter by hand outside the script, so it needs the same check or should become a script.
  - **(b) Or in Solidity:** read with `vm.envOr(name, string(""))`, whose return is redacted in the trace. Check `0x` plus 64 hex digits and convert to a number in plain Solidity, with no cheatcode, then call `vm.addr`. I didn't test (b) end to end.
- **The report's grep prints the key.** `grep -nP '^[A-Z_]*_PRIVATE_KEY=.*[^\x20-\x7e]'` as written shows the matching line, which is the key, on the screen. It needs `-q` or `-c`. A positive pattern on the sourced variables is also safer than a negative pattern on file lines, which misses `export` and indented lines.
- **Other places checked:**
  - `cast` doesn't echo a malformed key.
  - `vm.addr` and `vm.startBroadcast` are redacted.
  - Deploy.s.sol:46 and DeployTestUSDT.s.sol:12 have the same unwrapped read but are testnet-only.
  - Reporting it upstream is reasonable; mention the trace path as well as the error message.

Files I added: my stand-in, <scratchpad>/pentest6/F/contracts/test/pentest/sixth/V6_F1_TryCatch.s.sol (contracts `V6F1TryCatch` and `V6F1StringRead`), and my scripts in <scratchpad>/pentest6/F1-verify/ (`show.sh`, `e2e.sh`, `trycatch.sh`, `realcheck.sh`, `strread.sh`, `forms.sh`). I left them in place.

#### R6-F-2: lockdown's /etc/fstab append — Verdict: CONFIRMED, Low

**Verdict:** confirmed, at Low (the severity the report claims).

**Severity:** Low. The proof passes and shows the claimed effect. No attacker is involved, no money is at risk and no secret leaks: it is root's own tool losing one fstab entry, silently, at the reboot `lockdown` asks for right after (`ops/yescrow-ctl:677–678`). The precondition is narrow. Ubuntu cloud images, the installer and cloud-init all end fstab with a newline, and so do `echo … | tee -a`, nano 8.7.1 (unless run with `-L/--nonewlines`) and vim (its default `fixeol`). Even `… 0 0 ` with a trailing space survives. It takes `printf`, `echo -n`, a script or an editor set up not to add the newline. On this server the precondition was false, and `:693` now returns at once, so the step can't append again. I don't raise it for the worst variant (root entry last, below), because the admin is at the console for the reboot and it can be repaired by hand. I don't lower it to Info, because the docs tell the admin to re-run `lockdown` (`docs/secrets.md:56`, `docs/mainnet.md:92`), the tool is meant for any server, and nothing is logged when the entry is lost.

**Proof result:** `python3 -I scripts/pentest/sixth/F6_hide_processes.py` from the copy's root gives `0 failure(s)`, exit 0 (Python 3.14.4, systemd 259.5, util-linux 2.41.3).
- Scenario 6 writes `…umask=0077 0 1# yescrow-ctl lockdown: …`.
- The generator then produces only `-.mount`; `boot-efi.mount` is lost.
- With a swap line last, it produces `-.mount boot-efi.mount`, so `swapfile.swap` is lost.
- The control file with a newline does generate `swapfile.swap`.

My own runs (in `<scratchpad>/verify-R6F2/`, through `harness.py` and the generator):
- This server's layout with `/swapfile2` last loses only `swapfile2.swap`.
- A root line last loses `-.mount`.
- Last lines with 4 or 5 fields are dropped too.
- A trailing space or tab, a comment as the last line, and CRLF endings are all harmless.

**Corrections to the finding:**
- **"libmount reports `parse error at line 3 -- ignored`" (R6-F-2 Steps) is wrong for the generator.** `systemd-fstab-generator` says nothing about the dropped line, even at `SYSTEMD_LOG_LEVEL=debug`. It logs a "Found entry" line for each surviving entry and nothing for the lost one. That message is findmnt's, printed just before it segfaults (exit 139, with `--verify` and with a plain `--tab-file`). The proof's own output shows no message either (the script's line 185 prints none). So the boot leaves no trace in the journal, which strengthens "silently".
- **"Not a lockout: the root entry is read" holds only when root isn't the last line.** If the root entry is last with no newline, the generator writes no `-.mount`. `systemd-remount-fs` then finds no root entry; its man page says it "executes no operation" in that case. This server boots with `ro` on its kernel command line (`/proc/cmdline`). So root would most likely stay read-only after the boot, and Docker and the stacks would not come up until someone repairs it by hand. I reasoned this, I did not boot it. sshd and sudo work from `/run`, so it is still not a lockout.
- **"This VPS added an 8 GB swap file by hand" is wrong.** `docs/runbook.md:217–218` lists zram (from zram-generator, not fstab) plus `/swapfile` and `/swapfile2`, 4 GB each. With that layout, only `/swapfile2` would be lost; zram and `/swapfile` would stay up.
- **"Precondition unverifiable" is now settled.** The real fstab ended with a newline, nothing was lost, and `ops/yescrow-ctl:693` keeps the step from running again.
- **The cited lines are right:** `ops/yescrow-ctl:692` (read), `:712–715` (the append), `:693`, `:695` and `:712` (the substring tests), `:716` (the "kept" message).

**Fix assessment:**
- **The newline guard works.** I patched a copy (`<scratchpad>/verify-R6F2/fix1/ops/yescrow-ctl`) with the report's `prefix` line. Scenario 6 then flips: `boot-efi.mount` and `swapfile.swap` are generated again. My variants (`/swapfile2` last, root only) also keep all their units. The check should read `/etc/fstab` again just before the append: the read at `:692` happens before the remount and before the admin opens a second login.
- **"Refuse on parse error" from the generator would never trigger.** The generator is silent about dropped lines. A check that works is to generate units from the old and the new file and require the same set; the `/proc` line adds no unit, since `/proc` is an API file system. `findmnt --verify` crashes on the bad file, so it can't be the check.
- **"Write atomically as `set_in_file` does" needs care.** `set_in_file` creates its temp file with mode `0o600` (`ops/yescrow-ctl:128`). Copied as it is, it would make `/etc/fstab` readable by root only; it needs `0o644` and `root:root`. An append with the guard is enough.
- **The fix doesn't cover U-3, and U-3 is broader than the report says.** Scenario 7 passes. I also reproduced a more likely variant. The admin comments out the tool's own line (to undo it) and reboots, so `/proc` is no longer hidden. On the next `lockdown`, the tool remounts `/proc`, writes no fstab line (`:712` finds the substring in the comment), and prints "kept: /etc/fstab mounts /proc that way at every boot" (`:716`). That message is false, and `/proc` is unhidden at the next boot. A live `proc /proc proc defaults 0 0` line plus the options in a comment also gets past the die guard at `:695`, with the same false "kept". I'd move U-3 into the findings at Low or Info: `hidepid` is defence in depth now that `d451174` keeps keys off command lines.
- **A complete fix uses one line-aware check at `:693`, `:695` and `:712`.**
  - Skip comment lines; take `normpath(fields[1]) == '/proc'`, and require the options split on `,` to include `hidepid=invisible` and `gid=proc`.
  - Stop with an error if any live `/proc` entry lacks them, and append only when there is no live `/proc` entry.
  - This also covers `proc /proc/ proc …`, which the regex at `:695` misses. The tool would then append a second `/proc` line; I didn't establish which of the two mount(8) applies at boot.
- **No other lockout path in `hide_processes`.** Logins and sudo don't need to see other accounts' processes, systemd-logind and polkit get the `proc` group through their drop-ins, and the "no" answer and a dropped connection are handled (scenarios 3 and 4).

#### R6-F-3: the testnet path and the mainnet env file — Verdict: CONFIRMED, Info

**Verdict:** confirmed at Info.

**Severity: Info.** I agree with the attack agent. Under the rubric no party's money, deadline or fee is at stake, and the mistake takes two of the admin's own deviations from the documented procedure. The admin has to run the testnet network in the mainnet deploy clone, and a Sepolia RPC has to be in a shell that §3 says to start clean (`env -i HOME PATH bash`, docs/mainnet.md:118). No step tells the admin to run any Sepolia script there; testnet deploys run as `teo` with `.env`. With `hidepid=invisible` live, only root and the admin's own processes could read the two `cast` command lines. That protection holds only if the fstab line survives a reboot, which this run left unconfirmed (U-1, U-3, R6-F-2). It does not raise the severity, because the admin's mistake comes first. On-chain, a Sepolia deployment by the mainnet deployer cannot touch Arbitrum One:
- Nonces are kept per chain.
- Forge's signatures bind chain id 421614, so nothing replays on Arbitrum One.
- `deploy-all.sh:87` checks the chain id before any key is used (`:89`).
- DeployAll's mainnet guards read Arbitrum One's state (`DeployAll.s.sol:168`).
- §3 copies only the 42161 records (docs/mainnet.md:170).

**Proof result**
- Part 3 run on its own and verbatim: `bash <scratchpad>/pentest6/F/scripts/pentest/sixth/verify/F6_part3_verbatim.sh`. It is lines 1–21 and 70–101 of the original; part 1 was not run, so forge never ran.
  - (a) With no Sepolia RPC it stops with `no RPC for arbitrum-sepolia in .env.mainnet`, and `cast` ran 0 times.
  - (b) With the Sepolia RPC in the shell it runs 5 commands: `chain-id`, 2 × `wallet address --private-key`, `forge script script/DeployAll.s.sol:DeployAll --rpc-url … --broadcast --slow`, then `cast nonce`. The attestor key stayed off the command line. Exit 0. **Reproduced.**
- My extended copy: `<scratchpad>/pentest6/F/scripts/pentest/sixth/verify/R6F3_part3.sh`. Its forge stub also records forge's environment.
  - Case b, matched by key: 1 deployer key and 1 promo key on a command line, 0 attestor keys. Forge's environment held the mainnet file's `DEPLOYER_PRIVATE_KEY`, with `YESCROW_ATTESTOR` set to the pin `0x2164…82b8` and `JUDGE` to the Safe. This confirms the "mainnet deployer" claim, which the original stub could not show.
  - Case c: the shell as it is after §3 step 1 (`set -a; . ./.env.mainnet`), with `ENV_FILE` not typed. Same result.
  - Case d (control: `arbitrum-one` on the same file): 0 keys on a command line.
- Read-only chain check of the mainnet deployer `0xA8C2…D14D` on 2026-11-06: balance 0 and nonce 0 on both 421614 and 42161. The promised escrow addresses have no code on Sepolia.

**Corrections to the finding**
- **The precondition is wider than stated.** Typing `ENV_FILE` is not needed. docs/mainnet.md:132 (§3 step 1, `set -a; . ./.env.mainnet; set +a`) exports the file's `ENV_FILE=.env.mainnet` (.env.mainnet.example:5) into the admin's shell. After step 1, a plain `scripts/deploy-all.sh arbitrum-sepolia` reads the mainnet file (case c). A Sepolia RPC is still needed.
- **"The vault file has none" was not checked.** The vault is root-only, so only the template (.env.mainnet.example) is known to lack `ARBITRUM_SEPOLIA_RPC_URL`. If the vault was ever seeded from a testnet `.env` (.env.example:6 sets it), the wrong network name alone would be enough.
- **".env.mainnet.example:5 does nothing there" is wrong.** docker-compose.yml:73 fills in `${ENV_FILE:-.env}` for the containers' env file, and the template's own command at .env.mainnet.example:3 relies on it. `yescrow-ctl` now sets `ENV_FILE` itself (ops/yescrow-ctl:250). The line also feeds the shell export in the point above.
- **The root cause stops at `deploy-all.sh`, but other testnet scripts that accept `ENV_FILE` do the same:**
  - `scripts/deploy-escrow.sh:30` puts `ATTESTOR_PRIVATE_KEY` on the command line before the RPC check at `:32`, and it ignores `YESCROW_ATTESTOR`. Reproduced with no Sepolia RPC: 1 attestor key on a command line, then exit at `:32` (`<scratchpad>/pentest6/F/scripts/pentest/sixth/verify/R6F3_other_scripts.sh`). It also puts the promo key on the command line at `:38`.
  - `scripts/fund-testnet-wallets.sh:11,15,21,23` puts the relayer key and the deployer key on the command line. `:15` and `:23` are `cast send … --private-key "$DEPLOYER_PRIVATE_KEY"` on Sepolia. This needs the Sepolia RPC (`:9`); I read it from the code and did not run it with an RPC.
  - `scripts/deploy.sh:8–9,16` reads the env file the same way.
- **A second root cause is missing.** `DeployAll.s.sol:121–150` does not refuse `Networks.MAINNET_DEPLOYER` on chains other than mainnet, while `DeployRouter.s.sol:45` refuses it on every chain. That gap is why the real forge would deploy on Sepolia.
- **"The deployer has no Sepolia ETH" is true today but protects nothing.** Anyone can fund that address on Sepolia from a faucet. The slip would then deploy at Sepolia nonces 0–4, which are the mainnet-promised addresses (`0xe13E…aBa`, `0x3A73…fbB`), with mainnet's judge, fee recipient, attestor pin and promo signer. This can confuse readers of explorers or the clone's `deployments/arbitrum-sepolia.json`, but does nothing on Arbitrum One.
- **The deployer half needs the §3 session's file.** After the keystore import, the vault no longer holds `DEPLOYER_PRIVATE_KEY` (docs/secrets.md:105), so outside the session that starts at docs/mainnet.md:127 the script stops at `deploy-all.sh:88`, before `:89`.
- **Label error:** `F6_forge_output_no_keys.sh:13–14` calls the testnet-path result "R6-F-1, Info"; it is R6-F-3.
- **Aside, not this finding:** the report cites `DeployRouter.s.sol:212–213` and `:214` (R6-F-1 and the "checked and found sound" list), but the file has 54 lines. The read is at `:43–44` and the refusal at `:45`.

**Fix assessment**
- "Refuse an `ENV_FILE` other than `.env` on a testnet unless `TESTNET_ENV_FILE=1`" works for cases b and c if it runs before `deploy-all.sh:43`. It is incomplete: it covers `deploy-all.sh` only, not `deploy-escrow.sh`, `deploy.sh` or `fund-testnet-wallets.sh`, and it misses a mainnet file copied as `.env`.
- The alternative "refuse an env file that sets `ARBITRUM_ONE_RPC_URL` or `CONFIRM_MAINNET`" is flawed both ways:
  - The testnet `.env` is documented to carry `ARBITRUM_ONE_RPC_URL` for the mainnet fork tests (.env.example:9–10; `contracts/test/fork/*`), so this would block real testnet deploys.
  - `CONFIRM_MAINNET` is taken only from the caller (`deploy-all.sh:31,49`) and is never in the file, so checking for it catches nothing.
  - `DEPLOYMENT=arbitrum-one` (.env.mainnet.example:6, versus `arbitrum-sepolia` at .env.example:83) or `NEXT_PUBLIC_CHAIN_ID=42161` would work as markers.
- Dropping line 5 from the template narrows case c. It does not change the live vault file, which step 0 copies; that needs an edit there too. Fix the template's stale line-3 command at the same time. A cleaner option is to run §3 step 1 in a subshell, `( set -a; . ./.env.mainnet; set +a; cd contracts; … )`, so neither the keys nor `ENV_FILE` stay in the admin's shell.
- A complete fix needs two more changes:
  - **In DeployAll's `check()`:** `if (!n.mainnet) require(deployer != Networks.MAINNET_DEPLOYER, …)`, mirroring `DeployRouter.s.sol:45`, and the same in `Deploy.s.sol`. This stops the Sepolia deployment inside forge, whichever script calls it.
  - **In the testnet scripts:** stop deriving addresses with `cast wallet address --private-key`. cast 1.8.5 has no environment variable for `--private-key` (`cast wallet address --help`), so derive them inside forge (`vm.addr` of `vm.envOr` keys), as mainnet already does. That removes the command-line half for any env file and meets docs/secrets.md:89. The env-file guard then becomes a second layer of defence.
