Skip to content

Installer: no secret on a command line (bootstrap env, GitHub/Cloudflare tokens) with a census (o3d-kb3dq) - #762

Merged
OneTwo3D merged 17 commits into
developmentfrom
o3d-installer-secrets-argv
Oct 16, 2026
Merged

OneTwo3D merged 17 commits into
developmentfrom
o3d-installer-secrets-argv

Conversation

@OneTwo3D

@OneTwo3D OneTwo3D commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

Installer: no secret on a command line (o3d-kb3dq), with a census; o3d-31qrv verified fixed by #750

Problem

ps shows every process's command line to every local account. During the production reinstall install.sh put these on command lines: the default administrator's password, the SMTP password and the WooCommerce key and secret (run_as_user_db env NAME=value ... node provision-instance.mjs, so they sat in the argv of runuser and env for the life of the step), and the GitHub deploy-key token (curl -H "Authorization: Bearer ...").

Design

Secrets travel in the environment (readable by root and the owner only) or on a config line read from standard input, never as an argument.

  • Bootstrap (scripts/install.sh). A function bootstrap_with_env exports the 17 values inside a subshell and runs "$@"; the call is bootstrap_with_env run_as_user_db node "${BOOTSTRAP_SCRIPT}" || bootstrap_rc=$?. run_as_user_db already hands the environment to the application account through db_run_as_user_inheriting_env (the way DATABASE_URL travels). It is a function so the consumer statement keeps the capture shape deploy-order.test.ts reads (status captured, placed, then propagated); the exports stay in the subshell and do not reach any other child.
  • GitHub deploy-key token (install.sh, github_api) and Cloudflare token (provision-ims-tenant.sh, cf_request). curl reads header = "Authorization: Bearer ..." from -K - (a here-string); backslash and double-quote characters are stripped from the token first (neither occurs in either token format).

Census (every shell script under scripts/ and scripts/lib/: 16 files, 14,665 logical lines; round 1 added the RENDERED output of the cron writers)

Rules: R1 a secret-named NAME=${...} as an argument to env/runuser/run_as_user*/sudo; R2 a curl -H Authorization: / -u / --user argument carrying a variable; R3 a *DATABASE_URL as an argument of pg_dump/psql/pg_restore/redis-cli; R4 --password <var>; R5 redis-cli -a <var>. Result:

Site Finding Disposition
install.sh bootstrap block R1 x4 secrets FIXED (environment)
install.sh github_api R2 FIXED (-K -)
provision-ims-tenant.sh cf_request R2 FIXED (-K -)
provision-ims-tenant.sh pct create --password "${PASSWORD}" R4 NOT CHANGED: pct has no stdin/env form; it runs on the Proxmox host from an operator tool (not the installer); listed in the test allowlist with this reason
scripts/backup.sh pg_dump "${DATABASE_URL}" R3 NOT CHANGED: unwired legacy helper (docs: "scripts/backup.sh is NOT covered by any of this"; nothing schedules it; a regression pins its paths). Wiring it needs its own rule first; listed in the allowlist so wiring it re-opens the entry

Already clean on inspection: PGPASSWORD is exported inside a subshell (pg_endpoint_psql), db_pg_dump_through_url uses a mode-600 service file, the fence helpers inherit DATABASE_URL, redis requirepass is written to the conf file. Not scanned (outside "scripts/.sh and scripts/lib/.sh"): .mjs/.ts scripts (a grep of their spawn/exec calls for password/secret/token/url names found nothing).

The test (tests/scripts/installer-secrets-argv.test.ts) fails on any NEW finding and on a stale allowlist entry.

Tests and mutations

  1. Runs the shipped bootstrap block (extracted from install.sh) with canary values and stand-in runuser/env/node that log every command line; asserts no canary on any command line and all 16 reach node in its environment.
  2. Mutation proof that the ps assertion CAN fail: the same rig with the shape trunk shipped (env NAME=value) puts all four secrets on a command line (asserted).
  3. Census as above; 4. each rule fires on its trunk shape and the fixed forms are not flagged; 5. both API token call sites read credentials from -K -.
Mutation applied to the real file Result
B1 bootstrap back to env NAME=value ... node RED (tests 1 and 3)
B2 SMTP_PASS no longer exported RED (test 1)
B3 github_api bearer header back on argv RED (tests 3 and 5)
B4 cf_request bearer header back on argv RED (tests 3 and 5)

Each restored by md5.

o3d-31qrv (re-run wipes WC credentials / webhook secret / TRUSTED_PROXY_IPS): already fixed by #750 (13c0291), nothing remains

Read against the merged code and tests: the WC store URL, key, secret and webhook secret are the template's own keys and are preserved by their prompts (defaults come from the old file; tests/scripts/env-rewrite-preservation.test.ts seeds WC_WEBHOOK_SECRET=whsec_old and asserts it is kept), TRUSTED_PROXY_IPS and REQUIRE_TRUSTED_PROXY_CONFIG are on the carry list and kept (TRUSTED_PROXY_IPS=10.0.0.1,10.0.0.2 asserted), a root-only mode-600 backup is written outside the app tree before the file is replaced, and the summary names what was kept, dropped and why. Nothing was changed for this bead here; the coordinator closes it.

Review round 1 (Codex on fe3aede): 1 HIGH

HIGH: the cron jobs put CRON_SECRET in curl's argv. install.sh built CRON_CURL_PREFIX with curl ... -H "Authorization: Bearer $CRON_SECRET" (inside escaped quotes in a generated string, which census rule R2 did not see because it read source text with \" / \$ escapes), wrote it into the application user's crontab, and the in-app scheduler (lib/crontab-sync.ts, Settings > Scheduler Save & Apply) emitted the same header argument. The shell expands the secret into curl's argv for as long as each job runs, readable through ps by every local account. My earlier "Two sites remain" claim in the PR body and in docs/installation.md was therefore false. Corrected below.

Fix (design). No file is needed, so none of the file-lifecycle questions (rotation, mode, missing file) arise:

CRON_SECRET=$(grep -m1 '^CRON_SECRET=' '<env>' | cut -d= -f2- | tr -d '"'); case "$CRON_SECRET" in ''|*\\*) echo "cron-auth: CRON_SECRET is missing or unusable (empty, or holds a backslash); <slug> was not run" ;; *) echo "header = \"Authorization: Bearer $CRON_SECRET\"" | curl -sf -o /dev/null -K - "$BASE_URL/<slug>" ;; esac >> '<log>' 2>&1

echo is a shell builtin, so the header goes into a pipe; curl reads it as a config line from stdin (-K -, supported by every curl the target Debian releases ship). The only processes with the secret in their command line: none (the sh -c that cron starts carries the unexpanded text, $(grep ...), not the value). Runtime mode is unchanged (a rotation needs only the .env edit and a restart, no re-sync). In the embedded-literal fallback the secret is the one CRON_SECRET="..." assignment line in the crontab spool (mode 600, root:crontab on Debian/Ubuntu: a property of the cron package that I state here and in the docs, not one I could verify on this box without writing a crontab); it is never on a job line. Fail closed: an empty/missing secret, or one holding a backslash (which would corrupt the config line), runs nothing and writes a cron-auth: line to the cron log (never the value). The installer (cron_job_command in install.sh) and the app renderer (renderCronJobCommand in lib/crontab-sync.ts) produce the byte-identical line. I did NOT use a protected header file as suggested: a file would need to be written by the installer, by the renderer inside the app, on every rotation (installer re-run, update.sh, settings), with a missing-file case; stdin has none of that and leaves nothing on disk.

Compatibility. The managed-line signature is now -K - "$BASE_URL/; the old -H "Authorization: Bearer $CRON_SECRET" "$BASE_URL/ is still recognised everywhere a managed line is recognised (TS isManagedRemnant, the installer/lock awk isRemnant), so an upgrade drops and replaces old lines. Until the next installer run or Scheduler Save & Apply, an existing install keeps its old lines (and the Scheduler page reports the block as not what the scheduler writes).

Tests (failing first on the old lines, then green).

  • Rendered text: the installer's line (the shipped cron_job_command, run) and the app renderer's line (buildOtiCrontabBlock, runtime and literal modes) are byte-identical; no job line in any of them holds the canary; in literal mode only the CRON_SECRET="..." assignment line does.
  • The rendered command is RUN under /bin/sh with a recording curl stand-in: no canary in curl's argv, the header arrives on stdin, for installer/runtime, app/runtime and app/literal. Fail closed (missing, empty, backslash): curl never runs, the log has the cron-auth: line, no canary anywhere.
  • The census now scans RENDERED output (the installer line and the app block in both modes), and rule R2 tolerates the \" / \$ escapes that hid this site in source text.
  • Mutations, each red and restored by md5: C1 installer line back to -H argv (4 tests red), C2 app renderer back to -H argv (3 red), C3 installer fail-closed guard removed, C4 app renderer guard removed, C5 awk drops the legacy signature (4 red in the reconcile suite); the legacy line is also run in the rig to prove the argv assertion can fail.
  • Existing tests updated for the new line: tests/crontab-sync.test.ts (2), tests/settings/crontab-reconcile-serialization.test.ts (the shell/TS signature equality now covers both signatures).

Other generated command lines with secrets (audited). The backup cron is one of the jobs above (same fix). No other generator in lib/, app/actions/ or scripts/ writes a curl/secret command: the app's pg_dump calls pass the password through the PGPASSWORD environment variable, not argv; deploy.sh's health check sends no credential; webhooks are registered over the API, not from a shell line. docs/backup-restore.md told operators to write curl -s -H "Authorization: Bearer $CRON_SECRET" by hand; corrected to the stdin form.

Corrected claim. Allowlisted sites that remain: pct create --password and the legacy scripts/backup.sh (as before); the cron lines are no longer an exception because they are fixed, not because the census learned to excuse them.

Review round 2 (Codex on e864168): 1 HIGH + 1 medium

HIGH: an upgrade left the old -H lines in place. The bootstrap treated an existing managed block as the application's and skipped the whole crontab write, and starting the service does not reconcile it, so a production reinstall finished with every old job still showing CRON_SECRET in ps.

Fix (scripts/install.sh, bootstrap_managed_crontab_block_locked, under the crontab lock). When the existing managed block holds any line with the legacy signature (the same -H "Authorization: Bearer $CRON_SECRET" "$BASE_URL/ the TS isManagedRemnant and the crontab-lock.sh awk recognise):

  1. migrate_legacy_cron_lines rewrites the legacy job lines INSIDE complete START..END blocks line by line: each keeps its schedule fields (and their spacing), slug, log path and secret source (runtime .env path, or the embedded-literal mode with no extraction prefix); only the command becomes the stdin form, produced by the same cron_job_command_into the installer uses for fresh blocks. Customised schedules (Scheduler Save & Apply) are therefore preserved; the header, BASE_URL, the CRON_SECRET="..." assignment line, unrelated user lines and everything outside the block are untouched.
  2. A legacy line of a shape it cannot rewrite is not guessed at: the function returns non-zero with the crontab untouched.
  3. The result is written through write_crontab_for (status taken), then the crontab is RE-READ and count_legacy_cron_lines must be 0.
  4. Any failure (unrewritable shape, rejected write, read-back unreadable or still holding old lines) sets CRON_LEGACY_STATE=failed, and the caller then dies with an operator-visible error naming the cause and the two ways out (fix and re-run, or Scheduler Save & Apply then re-run). A lock conflict or failed read that leaves the state unknown is also checked: the installer looks again, and refuses if old lines remain or the crontab cannot be read. A block this run wrote fresh is not re-checked. Previously every one of these only warned.
  5. Idempotent: a second run finds no legacy lines and changes nothing.

Tests (installer rig: the shipped functions run against a fake crontab file). Legacy block in => new block out for BOTH legacy forms (runtime-read and embedded-literal), schedules preserved including customised ones (*/7 * * * *, 30 4 * * 1), operator lines before and after (MAILTO=, a job) preserved in place, the CRON_SECRET="..." literal line kept, verified, second run byte-identical. Refusal paths: rejected write => state=failed; a write the client accepted but that did not take effect => the read-back catches it; an unrewritable line shape (--max-time 5 inserted) => refused with the crontab untouched; the real gate block is extracted from install.sh and run: failed state => dies with the message, a lock-conflict outcome that left old lines => dies, a clean crontab => continues, a freshly written block => not re-checked. Mutations (each red, restored by md5): D1 migration skipped when a block exists (the trunk behaviour; 3 tests red), D2 read-back verification removed, D3 refusal gate turned into a warning, D4 default schedule written instead of the preserved one. Guard updates: the crontab-write count in crontab-reconcile-serialization (13 -> 14, the new write), install-cron-secret (the recognising regexes are the only lines allowed to name the old form).

Superseded by round 3 below: update.sh and deploy.sh now run the same rewrite.

Medium: the argv census was too narrow. Rules R2-R9 (R1 unchanged) now cover, over source text AND the rendered cron/installer output:

  • curl -H/--header with ANY header whose value expands a secret-named variable or carries Bearer/Basic/Token <value> (so X-API-Key: $VENDOR_API_KEY is caught), -u/--user/--proxy-user (variable or literal user:pass), --oauth2-bearer; escaped (\", \$) generated forms included;
  • psql/pg_dump/pg_dumpall/pg_restore/redis-cli/mongo tools with a *_URL variable or a literal scheme://user:pass@ URL;
  • --password/--http-password/--ftp-password/--proxy-password/--passwd options;
  • mysql/mysqladmin/mysqldump/mariadb -p<password> (attached, quoted or variable) and sshpass -p;
  • openssl ... -pass pass: / -passin pass: / -k <pw>;
  • wget --header (secret or Bearer), --user/--password/--http-*;
  • docker ... -e/--env NAME_SECRET=value.
    Each rule has canary shapes in the mutation test (31 in all, every one asserted to fire: R1 1, R2 8 including the escaped generated form, R3 4, R4 3, R5 1, R6 5, R7 3, R8 3, R9 3), and 12 benign shapes (curl -K -, Content-Type headers, mysql --defaults-extra-file, sshpass -f, openssl rand, -pass env:, docker run -e NAME, --env-file, ...) must NOT fire. The census on the real tree finds nothing new beyond the two allowlisted sites (pct create --password, the unwired legacy backup.sh) and the two regex lines in install.sh that RECOGNISE the old cron form (allowlisted with that reason).

Considered and NOT included (not small install-time hardening)

  • o3d-kg9v (chown -R over ${APP_DIR} resolves a pathname): the four sites need the descriptor-based walk chown_state_tree uses, but that function is pinned by design to ${DATA_DIR} and nothing else (a run-time check refuses any other root), and the walk's helper is published through the privileged snapshot. Generalising it to the application root, including .git, touches the privileged-helper set and its lexical guards. Left.
  • o3d-rlon (update.sh/deploy.sh never run the symlinked-root gate): lifting require_real_service_root/enter_service_root into cutover-namespace.sh and calling it from three entrypoints before any root is created changes the fenced cutover flow and several byte-equality regressions. Left.
  • o3d-ej1w (fence marker in an application-writable directory): a design change to the recovery namespace (publish a root-owned copy and prefer it), not a local fix. Left.

Gate (head e5f1559[:8]; local, as ims; the heavy tiers ran under flock /var/tmp/ims-gate-heavy.lock)

Step Result
npm run validate (all 18 steps ran) 17 PASS; unit step 13024 tests, 12509 pass, 2 fail (the known local-only install-root-safe-writes r20 pair, ZFS /tmp, o3d-t2lvg), 1 TODO (o3d-qf6q)
RE-RUNS (marked) Round 0 and round 1 re-runs as listed above. Round 2: before the full validate, a targeted run of the related suites failed three guards the new code tripped (the crontab-write count 13 -> 14; the "command substitution in an errexit-suspended body" roster, fixed by moving the rewrite to variable-returning functions; install-cron-secret, whose regex now ignores the recogniser lines) and my own mutation test (anchor moved); all fixed, targeted suites green, then the full validate run from scratch. CI on e5f1559 then failed Gitleaks (rule curl-auth-user) on a CANARY SAMPLE of the shape curl -u user:pw in the census test; the sample is now built so no literal user:pw is at rest, the earlier commit's fingerprint is in .gitleaksignore with a reason, and the full validate was re-run from scratch on 3cf2a26
related suites run directly crontab-sync, crontab-reconcile-serialization, crontab-read-discrimination, backup-schedule-writer, ops/read-sync-scheduler-guard, ops/read-sync-liveness, security/install-cron-secret, deploy-order, privileged-helper-set, install-root-safe-writes
npm run check:all exit 0
npx tsc --noEmit last, clean tree exit 0
not run locally test:db, test:concurrency, drift (no schema change; CI runs them), local npm run build
install.sh itself NOT run on this box; the rewrite was run through the shipped functions against a fake crontab file, not a real crontab binary

Not done

The two allowlisted sites above; .mjs/.ts scripts not in scope; no behaviour change to the bootstrap other than how values reach it; o3d-kg9v / rlon / ej1w as explained.

Review round 3 (Codex on 3cf2a26): 2 HIGH

HIGH 1: update.sh and deploy.sh left the old -H bearer lines in place. A host upgraded through either script restored its managed crontab verbatim and kept every old job, so each cron run showed CRON_SECRET in ps.

Fix. The rewrite is now ONE implementation in scripts/lib/crontab-lock.sh (already sourced by all three scripts and already in the privileged-helper set): cron_job_command_into/cron_job_command, migrate_legacy_cron_lines, count_legacy_cron_lines, rewrite_legacy_cron_block_locked (rewrite, write, RE-READ, verify; sets CRON_LEGACY_STATE), migrate_legacy_cron_block_locked (the body run under with_crontab_lock), die_if_legacy_cron_lines_remain and ensure_cron_bearer_off_argv. install.sh keeps its bootstrap but calls the shared functions (its private copies are gone; a test pins that). update.sh and deploy.sh call ensure_cron_bearer_off_argv once, in a rewrite-legacy-cron step placed AFTER the cron restore (unfence_cron) and the stand-down of the fence flag, BEFORE DEPLOY_OK=true / the "complete" banner, and never in a dry run. If legacy lines remain after the attempt (rejected write, write that did not take effect, a line shape it cannot rewrite, a lock conflict that left old lines, an unreadable crontab) the run dies with the operator-visible message naming the cause and the two ways out. That happens after the point of no return, so the existing exit trap prints "THE UPDATE IS UP" and does NOT stop the service. A lock conflict or no managed block with nothing legacy continues; a crontab with no client/spool/daemon (require_crontab_command rc 2) is a no-op.

Guards kept green (read first, changed minimally): privileged-helper set (no new file), deploy-order (the surface scan reads the library, so the job template is an unquoted heredoc with escaped dollars and the signature is built without a bare $NAME), crontab-reconcile roster and write census (the two new *_locked bodies are rostered; the write count stays 14 because the installer's second write moved into the shared function), install-cron-secret and the argv census (read the library). No change to fence, unfence, backup or order of any existing step.

HIGH 2: & in a valid legacy log path became @LOG@. cron_job_command_into substituted with ${command//@LOG@/${logfile}}; with patsub_replacement (bash 5.2 default) the value is a REPLACEMENT string, so & expands to the matched text and a backslash is an escape. Reproduced first (precondition arm prints patsub_replacement on and acme@LOG@sons). Fix: the template is walked once and every value (log path, slug, env path) is APPENDED, never used as pattern or replacement and never rescanned (a path holding @LOG@ is safe). No reliance on shopt -u. Audit of every ${var/pat/repl} and ${var//pat/repl} in scripts/*.sh and scripts/lib/*.sh: that was the only variable replacement (the rest have constant, &-free replacements: ${x//-/_}, ${x//$'\r'/}, ${x//[\\\"]/}, ${raw//\'/\'\'} and so on); a test now fails on any new one (variable, command substitution or bare & in a replacement) and is shown to fire on the trunk shape.

Tests (new tests/scripts/cron-bearer-rewrite-upgrade-paths.test.ts, 19 tests; the existing installer tests re-pointed at the library). They run the SHIPPED library sourced as-is and the REAL call site lifted out of update.sh / deploy.sh, against a fake crontab file, and print their preconditions:

  • both scripts: legacy runtime and literal blocks rewritten; custom schedules (*/7 * * * *, 30 4 * * 1) kept; operator lines before/after and the header/BASE_URL/literal-secret lines untouched; rewritten text byte-identical to lib/crontab-sync.ts; second run byte-identical; a legacy-looking line outside the block untouched; dry run changes nothing; no crontab / no block / lock conflict with nothing legacy continue.
  • refusal (run ends, completion never reached, message names cause): rejected write, accepted-but-not-written (read-back catches it), unknown line shape (crontab untouched), lock conflict that leaves old lines, unreadable crontab.
  • &, \, \\, trailing \, %, space, ", $HOME/backtick, @LOG@/@SLUG@-looking values in log path, env path and slug: fresh rendering compared byte for byte with renderCronJobCommand (120 combinations plus the $/backtick ones via the environment), migration of 36 crontabs (9 valid legacy paths x 2 env paths x 2 modes).

Mutations, each red, restored from a copy and md5-verified (a failing-first run against the 3cf2a26 scripts is 18 red of 19):

Mutation Red
M1 call removed from update.sh tests 1,2,3,5,16,17
M2 call removed from deploy.sh tests 6,7,8,10
M3 pattern replacement back in cron_job_command_into tests 13,14,16,17,18 (fresh, migration and the audit)
M4 refusal (die) removed tests 3,8,11
M5 read-back verification blinded tests 3,8,11
M6 default schedule written instead of the preserved one tests 2,7,16

Docs: docs/installation.md (cron section) no longer says update/deploy skip the rewrite.

Gate on the final tree (15f8e98; nothing run here that touches a DB, no next build locally per the preamble, CI runs it): prisma generate 0, tsc 0, test:unit (with the CI CRON_SECRET and SETTINGS_ENCRYPTION_KEY, TMPDIR=/dev/shm) 0: 12530 pass, 0 fail, 1 TODO (o3d-qf6q), check:all 0, tsc again 0, git status clean. test:concurrency/test:db not run: the change is shell and tests only, no DB or TypeScript runtime path.

Not proven: no real crontab binary, cron daemon or flock contention was used (the crontab client is a file fake; with_crontab_lock is faked in the new rig, the real one is covered by the existing serialization suite); install.sh, update.sh and deploy.sh were not run on this box.

Review round 4 (Codex on 15f8e98): 1 HIGH

HIGH: an older installer wrote UNMARKED jobs with curl -H "Authorization: Bearer ${CRON_SECRET}". The rewrite only looked inside the OTI markers for the $BASE_URL signature, so update.sh/deploy.sh/a reinstall with an existing block restored or kept those lines and reported success with a legacy count of 0.

Fix (all in scripts/lib/crontab-lock.sh, so all three scripts get it):

  1. General detector cron_line_exposes_bearer: any non-comment crontab line, in or out of the managed block, that runs curl with a -H/--header argument mentioning Authorization/Bearer (${CRON_SECRET}, $CRON_SECRET or a literal). The stdin form does not match. count_legacy_cron_lines (the pre-check and the post-write read-back) now uses it.
  2. Rewrite of the known unmarked shape anywhere in the crontab, under the lock, to the stdin form, keeping schedule, .env path, URL and the > /dev/null 2>&1 redirect (cron_historical_command_into; values appended, never pattern-replaced). Accepted spellings: ${CRON_SECRET} and $CRON_SECRET, grep -m 1/-m1, optional tr -d and [ -n ] guard.
  3. Refusal: if ANY line still matches the detector after the rewrite (a literal secret, an extra flag, other quoting, --header, a managed-shape line outside the block) the crontab is left untouched and the run dies, naming the crontab line number(s) and never printing the line.
    install.sh now runs the shared refusal on every path (it used to skip it when it had just written a fresh block). The update.sh/deploy.sh call sites are unchanged.

Historical shapes (git log -- scripts/install.sh, every revision that wrote cron):

Revision(s) Shape Handling
47e4016/1adfde71, 0ff23a6, ba14fec, 3835651 <sched> curl -fsS http://localhost:<port>/api/cron/<slug> > /dev/null 2>&1 (no header, no secret) not a leak, left untouched (test asserts it survives and is not refused)
80bb370, d3ebd53 UNMARKED <sched> CRON_SECRET=$(grep -m 1 '^CRON_SECRET=' '<env>' | cut -d= -f2-) && curl -fsS -H "Authorization: Bearer ${CRON_SECRET}" http://localhost:<port>/api/cron/<slug> > /dev/null 2>&1 REWRITTEN (both ${CRON_SECRET} and $CRON_SECRET, inside or outside a block)
c831a8b to d9007b1 MARKED runtime-read and embedded-literal ... -H "Authorization: Bearer $CRON_SECRET" "$BASE_URL/<slug>" >> '<log>' 2>&1 REWRITTEN (rounds 2-3)
any other curl with an Authorization/Bearer header argument, e.g. a hand-written line with the literal secret REFUSED by line number

Tests (cron-bearer-rewrite-upgrade-paths.test.ts, installer-secrets-argv.test.ts): the historical text from those revisions in both scripts and in the installer bootstrap, both secret spellings, other port and .env path, jobs before/inside/after the block, unrelated/no-auth/comment lines kept, idempotent, the rewritten job RUN under sh (secret on no command line), five unrecognised shapes refused with the line number named, line content and secret absent from the message, crontab untouched. Failing-first: 11 red against 15f8e98. Mutations (restored from copies, md5 verified): M7 historical rewrite removed => 8 red; M8 detector blinded => 16 red; M9 line numbers dropped from the message => 2 red; M10 installer gate conditional again => 1 red.

Secret scan: the two literal canary lines in the refusal tests tripped Gitleaks curl-auth-header on the commit that introduced them; they are now built by concatenation and their two fingerprints are in .gitleaksignore (local gitleaks detect over the CI range: no leaks).

Guards adjusted: install-cron-secret ignores comment lines (they quote the historical shape); the unchecked-substitution census flagged a $( lookalike in the new regex, so the regex avoids it.

Gate on the merged tree (head ed9b2b9, origin/development 0a1bed1 merged): prisma generate 0, tsc 0, test:unit with the CI env 0 (12555 pass, 0 fail, only the o3d-qf6q TODO), check:all 0, tsc again 0, clean tree. Not proven: no real crontab binary or cron daemon; the run-under-sh check uses a curl stand-in.

Review round 5 (Codex on ed9b2b9): 1 HIGH, first rollout

HIGH: the first rollout runs the PREDECESSOR driver. The documented sudo bash /etc/ims-cutover-driver/driver/update.sh runs the standing root-owned copy of an older update.sh; the new rewrite step exists only in this release's scripts, so it never executes, and the old driver restores the old crontab and reports success. (The driver is refreshed only at the end of a run and only when IMS_DRIVER_SHA256 vouches for the release.)

What can and cannot be done. Nothing in this release's code can run inside the predecessor's run: the old update.sh only executes the helpers it already knows, and the release tree is owned by the application account, so a root run of a "trusted copy" from it is exactly what the driver design forbids. So the first-rollout path is the one the repo already has for exactly this: run the release's OWN scripts from a root-fetched directory, which the startup guard accepts (it refuses only trees another account can write) and which executes the new tail. No code change to the scripts.

Changes:

  • docs/installation.md, Updating: new section "First rollout of the release that takes the cron bearer off the command line". It says plainly that the documented command completes with the old lines in place on this release, tells the operator to fetch the release as root into a new directory and run its install.sh or update.sh (optionally with IMS_DRIVER_SHA256 to refresh the driver too), gives a check that prints ONLY crontab line numbers (never the lines, which may carry the secret) and nothing when clean, and says no special step is needed once the standing driver is this release or later.
  • Test (in cron-bearer-rewrite-upgrade-paths.test.ts, both scripts): simulates the predecessor tail (this script with the rewrite step removed) against a legacy crontab (three managed jobs plus a literal-secret job): it completes and the crontab is byte-identical; the check is lifted FROM THE DOCS and run as written (stand-in sudo/crontab): it prints exactly the four line numbers and not the secret; the remedy (the release's own tail) refuses the unrecognised literal line, and on a clean legacy crontab rewrites everything and the documented check then prints nothing. A second test pins the section's content (both ways out, the digest option, numbers-only).
  • Mutations (restored from a copy, md5 verified): M11 the check prints the lines (cut removed) => 2 red; M12 the check pattern can never match => 2 red; M13 the section removed => red.

Not fixed, stated plainly: an operator who ignores the section and updates through the standing driver still gets a "successful" update with the old lines; nothing in the predecessor can stop that. An automatic fix would have to run in the application after update.sh unfences the crontab (a startup hook runs while it is still fenced); filed as a follow-up (P2, proj:ims) rather than folded in.

Gate on the merged tree (head 41ca6cf, origin/development merged): prisma generate 0, tsc 0 (the first tsc of the run was SIGKILLed, exit 137, under box memory pressure; the final tsc after the last edit passed), test:unit with the CI env 0, check:all 0, clean tree.

🤖 Generated with Claude Code

https://claude.ai/code/session_01M2fZEeZ5fYPBhB3TEKv9vb

OneTwo3D and others added 2 commits October 9, 2026 18:57
…udflare tokens) with a census

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2fZEeZ5fYPBhB3TEKv9vb
… captured where the deploy-order guard reads it

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2fZEeZ5fYPBhB3TEKv9vb
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-09T20:03:45.063775Z fe3aede PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fe3aede70f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// R1: a secret handed to a command as an `env NAME=value` / runuser argument (the value is on argv).
['R1 env-assignment argument', new RegExp(`(?:^|[\\s(;&|\`])(?:env|run_as_user|run_as_user_db|as_app_user|as_app_user_db|runuser|sudo)\\s[^#]*\\b[A-Z0-9_]*${SECRET_WORD}[A-Z0-9_]*=["']?\\$`)],
// R2: a bearer token / credential in a curl header or -u argument.
['R2 curl credential argument', /curl\b[^#]*(?:-H\s+["']?Authorization:[^"']*\$|\s-u\s+["']?[^\s"']*\$|--user\s)/],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Include generated curl commands in the census

When the installer emits its managed crontab, scripts/install.sh:10881 contains escaped quotes around Authorization: Bearer $CRON_SECRET, so this regex does not match the source even though the shell expands the secret into curl's -H argument on every cron invocation. Consequently, the new census passes while a recurring bearer credential remains visible through process listings; recognize generated command strings in the scan and then remove this argv transport or explicitly account for it.

Useful? React with 👍 / 👎.

OneTwo3D and others added 15 commits October 9, 2026 22:26
…p scheduler), fail closed; census scans rendered output

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2fZEeZ5fYPBhB3TEKv9vb
… with the stdin form

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2fZEeZ5fYPBhB3TEKv9vb
… block (verified, refuse otherwise); wider argv census rules

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2fZEeZ5fYPBhB3TEKv9vb
… the earlier commit's sample

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2fZEeZ5fYPBhB3TEKv9vb
….sh too; render job lines without pattern replacement

Codex round 3 on #762:
- HIGH: the legacy `curl -H "Authorization: Bearer $CRON_SECRET"` rewrite ran in install.sh only. The rewrite
  (cron_job_command_into, migrate_legacy_cron_lines, count_legacy_cron_lines, the locked rewrite+verify and the
  refusal) now lives once in scripts/lib/crontab-lock.sh; install.sh, update.sh and deploy.sh call it. update.sh and
  deploy.sh run it after the cron restore and before they report completion (never in a dry run) and end the run with
  an operator-visible error if legacy lines remain.
- HIGH: an `&` in a valid legacy log path became `@LOG@` (patsub_replacement). The job line is now built by walking the
  template once and appending each value, so no value is ever a pattern or a replacement. Audit of every
  ${var/pat/repl} in scripts/*.sh and scripts/lib/*.sh: that one site was the only variable replacement; a test pins it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2fZEeZ5fYPBhB3TEKv9vb
…thout bare $NAME

The shared rewrite is a `*_locked` body (errexit suspended), so the crontab-reconcile roster and write census now
include it; the deploy-order surface scan reads the library, so the job template is an unquoted heredoc with escaped
dollars; install-cron-secret reads the library too.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2fZEeZ5fYPBhB3TEKv9vb
…tector

Codex round 4 on #762: an older installer (d3ebd53..80bb370) wrote UNMARKED jobs with
`curl -H "Authorization: Bearer ${CRON_SECRET}"` and a literal localhost URL. The rewrite now (1) rewrites that shape
anywhere in the crontab to the stdin form, keeping schedule, .env path, URL and redirect, and (2) refuses completion,
naming crontab line numbers and never the line, when ANY non-comment line still runs curl with an Authorization/Bearer
header argument (literal secrets included), in all three scripts. install.sh now checks on every path.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2fZEeZ5fYPBhB3TEKv9vb
…itution lookalike; install-cron-secret ignores comments

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2fZEeZ5fYPBhB3TEKv9vb
…itleaks fingerprints for the previous commit's two samples

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2fZEeZ5fYPBhB3TEKv9vb
…essor driver cannot run the new rewrite), with a numbers-only check; simulate it

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2fZEeZ5fYPBhB3TEKv9vb
@OneTwo3D
OneTwo3D merged commit b6a64de into development Oct 16, 2026
26 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant