Repository navigation
Installer: no secret on a command line (bootstrap env, GitHub/Cloudflare tokens) with a census (o3d-kb3dq) - #762
Conversation
…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
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 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)/], |
There was a problem hiding this comment.
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 👍 / 👎.
…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
…usal 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
Installer: no secret on a command line (o3d-kb3dq), with a census; o3d-31qrv verified fixed by #750
Problem
psshows every process's command line to every local account. During the production reinstallinstall.shput 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 ofrunuserandenvfor 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.
scripts/install.sh). A functionbootstrap_with_envexports the 17 values inside a subshell and runs"$@"; the call isbootstrap_with_env run_as_user_db node "${BOOTSTRAP_SCRIPT}" || bootstrap_rc=$?.run_as_user_dbalready hands the environment to the application account throughdb_run_as_user_inheriting_env(the wayDATABASE_URLtravels). It is a function so the consumer statement keeps the capture shapedeploy-order.test.tsreads (status captured, placed, then propagated); the exports stay in the subshell and do not reach any other child.install.sh,github_api) and Cloudflare token (provision-ims-tenant.sh,cf_request). curl readsheader = "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/andscripts/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 toenv/runuser/run_as_user*/sudo; R2 a curl-H Authorization:/-u/--userargument carrying a variable; R3 a*DATABASE_URLas an argument ofpg_dump/psql/pg_restore/redis-cli; R4--password <var>; R5redis-cli -a <var>. Result:install.shbootstrap blockinstall.shgithub_api-K -)provision-ims-tenant.shcf_request-K -)provision-ims-tenant.shpct create --password "${PASSWORD}"pcthas no stdin/env form; it runs on the Proxmox host from an operator tool (not the installer); listed in the test allowlist with this reasonscripts/backup.shpg_dump "${DATABASE_URL}"Already clean on inspection:
PGPASSWORDis exported inside a subshell (pg_endpoint_psql),db_pg_dump_through_urluses a mode-600 service file, the fence helpers inheritDATABASE_URL,redisrequirepassis written to the conf file. Not scanned (outside "scripts/.sh and scripts/lib/.sh"):.mjs/.tsscripts (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
install.sh) with canary values and stand-inrunuser/env/nodethat log every command line; asserts no canary on any command line and all 16 reach node in its environment.psassertion CAN fail: the same rig with the shape trunk shipped (env NAME=value) puts all four secrets on a command line (asserted).-K -.env NAME=value ... nodegithub_apibearer header back on argvcf_requestbearer header back on argvEach 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.tsseedsWC_WEBHOOK_SECRET=whsec_oldand asserts it is kept),TRUSTED_PROXY_IPSandREQUIRE_TRUSTED_PROXY_CONFIGare on the carry list and kept (TRUSTED_PROXY_IPS=10.0.0.1,10.0.0.2asserted), 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_SECRETin curl's argv.install.shbuiltCRON_CURL_PREFIXwithcurl ... -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 throughpsby every local account. My earlier "Two sites remain" claim in the PR body and indocs/installation.mdwas therefore false. Corrected below.Fix (design). No file is needed, so none of the file-lifecycle questions (rotation, mode, missing file) arise:
echois 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 (thesh -cthat cron starts carries the unexpanded text,$(grep ...), not the value). Runtime mode is unchanged (a rotation needs only the.envedit and a restart, no re-sync). In the embedded-literal fallback the secret is the oneCRON_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 acron-auth:line to the cron log (never the value). The installer (cron_job_commandininstall.sh) and the app renderer (renderCronJobCommandinlib/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 (TSisManagedRemnant, the installer/lock awkisRemnant), 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).
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 theCRON_SECRET="..."assignment line does./bin/shwith a recordingcurlstand-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 thecron-auth:line, no canary anywhere.\"/\$escapes that hid this site in source text.-Hargv (4 tests red), C2 app renderer back to-Hargv (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.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
backupcron is one of the jobs above (same fix). No other generator inlib/,app/actions/orscripts/writes acurl/secret command: the app'spg_dumpcalls pass the password through thePGPASSWORDenvironment 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.mdtold operators to writecurl -s -H "Authorization: Bearer $CRON_SECRET"by hand; corrected to the stdin form.Corrected claim. Allowlisted sites that remain:
pct create --passwordand the legacyscripts/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
-Hlines 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 showingCRON_SECRETinps.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 TSisManagedRemnantand thecrontab-lock.shawk recognise):migrate_legacy_cron_linesrewrites 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.envpath, or the embedded-literal mode with no extraction prefix); only the command becomes the stdin form, produced by the samecron_job_command_intothe installer uses for fresh blocks. Customised schedules (Scheduler Save & Apply) are therefore preserved; the header,BASE_URL, theCRON_SECRET="..."assignment line, unrelated user lines and everything outside the block are untouched.write_crontab_for(status taken), then the crontab is RE-READ andcount_legacy_cron_linesmust be 0.CRON_LEGACY_STATE=failed, and the caller thendies 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.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, theCRON_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 5inserted) => refused with the crontab untouched; the real gate block is extracted frominstall.shand 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 incrontab-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.shanddeploy.shnow 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:
-H/--headerwith ANY header whose value expands a secret-named variable or carriesBearer/Basic/Token <value>(soX-API-Key: $VENDOR_API_KEYis caught),-u/--user/--proxy-user(variable or literaluser:pass),--oauth2-bearer; escaped (\",\$) generated forms included;*_URLvariable or a literalscheme://user:pass@URL;--password/--http-password/--ftp-password/--proxy-password/--passwdoptions;-p<password>(attached, quoted or variable) andsshpass -p;openssl ... -pass pass:/-passin pass:/-k <pw>;--header(secret or Bearer),--user/--password/--http-*;docker ... -e/--envNAME_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-Typeheaders,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 legacybackup.sh) and the two regex lines ininstall.shthat RECOGNISE the old cron form (allowlisted with that reason).Considered and NOT included (not small install-time hardening)
chown -Rover${APP_DIR}resolves a pathname): the four sites need the descriptor-based walkchown_state_treeuses, 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.require_real_service_root/enter_service_rootintocutover-namespace.shand calling it from three entrypoints before any root is created changes the fenced cutover flow and several byte-equality regressions. Left.Gate (head e5f1559[:8]; local, as
ims; the heavy tiers ran underflock /var/tmp/ims-gate-heavy.lock)npm run validate(all 18 steps ran)install-root-safe-writesr20 pair, ZFS /tmp, o3d-t2lvg), 1 TODO (o3d-qf6q)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 3cf2a26npm run check:allnpx tsc --noEmitlast, clean treetest:db,test:concurrency, drift (no schema change; CI runs them), localnpm run buildinstall.shitselfcrontabbinaryNot done
The two allowlisted sites above;
.mjs/.tsscripts 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.shanddeploy.shleft the old-Hbearer lines in place. A host upgraded through either script restored its managed crontab verbatim and kept every old job, so each cron run showedCRON_SECRETinps.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; setsCRON_LEGACY_STATE),migrate_legacy_cron_block_locked(the body run underwith_crontab_lock),die_if_legacy_cron_lines_remainandensure_cron_bearer_off_argv.install.shkeeps its bootstrap but calls the shared functions (its private copies are gone; a test pins that).update.shanddeploy.shcallensure_cron_bearer_off_argvonce, in arewrite-legacy-cronstep placed AFTER the cron restore (unfence_cron) and the stand-down of the fence flag, BEFOREDEPLOY_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 rundies 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_commandrc 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*_lockedbodies 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_intosubstituted with${command//@LOG@/${logfile}}; withpatsub_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 printspatsub_replacement onandacme@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 onshopt -u. Audit of every${var/pat/repl}and${var//pat/repl}inscripts/*.shandscripts/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 ofupdate.sh/deploy.sh, against a fake crontab file, and print their preconditions:*/7 * * * *,30 4 * * 1) kept; operator lines before/after and the header/BASE_URL/literal-secret lines untouched; rewritten text byte-identical tolib/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.&,\,\\, trailing\,%, space,",$HOME/backtick,@LOG@/@SLUG@-looking values in log path, env path and slug: fresh rendering compared byte for byte withrenderCronJobCommand(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):
update.shdeploy.shcron_job_command_intodie) removedDocs:
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 buildlocally per the preamble, CI runs it): prisma generate 0, tsc 0,test:unit(with the CICRON_SECRETandSETTINGS_ENCRYPTION_KEY,TMPDIR=/dev/shm) 0: 12530 pass, 0 fail, 1 TODO (o3d-qf6q),check:all0, tsc again 0,git statusclean.test:concurrency/test:dbnot run: the change is shell and tests only, no DB or TypeScript runtime path.Not proven: no real
crontabbinary,crondaemon orflockcontention was used (the crontab client is a file fake;with_crontab_lockis faked in the new rig, the real one is covered by the existing serialization suite);install.sh,update.shanddeploy.shwere 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_URLsignature, soupdate.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):cron_line_exposes_bearer: any non-comment crontab line, in or out of the managed block, that runscurlwith a-H/--headerargument mentioningAuthorization/Bearer(${CRON_SECRET},$CRON_SECRETor a literal). The stdin form does not match.count_legacy_cron_lines(the pre-check and the post-write read-back) now uses it..envpath, URL and the> /dev/null 2>&1redirect (cron_historical_command_into; values appended, never pattern-replaced). Accepted spellings:${CRON_SECRET}and$CRON_SECRET,grep -m 1/-m1, optionaltr -dand[ -n ]guard.--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.shnow runs the shared refusal on every path (it used to skip it when it had just written a fresh block). Theupdate.sh/deploy.shcall sites are unchanged.Historical shapes (
git log -- scripts/install.sh, every revision that wrote cron):<sched> curl -fsS http://localhost:<port>/api/cron/<slug> > /dev/null 2>&1(no header, no secret)<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${CRON_SECRET}and$CRON_SECRET, inside or outside a block)... -H "Authorization: Bearer $CRON_SECRET" "$BASE_URL/<slug>" >> '<log>' 2>&1Tests (
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.envpath, jobs before/inside/after the block, unrelated/no-auth/comment lines kept, idempotent, the rewritten job RUN undersh(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-headeron the commit that introduced them; they are now built by concatenation and their two fingerprints are in.gitleaksignore(localgitleaks detectover 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:unitwith the CI env 0 (12555 pass, 0 fail, only theo3d-qf6qTODO),check:all0, tsc again 0, clean tree. Not proven: no real crontab binary or cron daemon; the run-under-shcheck 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.shruns the standing root-owned copy of an olderupdate.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 whenIMS_DRIVER_SHA256vouches 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.shonly 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 itsinstall.shorupdate.sh(optionally withIMS_DRIVER_SHA256to 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.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-insudo/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).cutremoved) => 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.shunfences 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:unitwith the CI env 0,check:all0, clean tree.🤖 Generated with Claude Code
https://claude.ai/code/session_01M2fZEeZ5fYPBhB3TEKv9vb