Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit7de856f

fix(deploy): stop self-deploy.sh tracing secrets into a world-readable log

fix(deploy): stop self-deploy.sh tracing secrets into a world-readable log

The script runs under `set -Eeuxo pipefail`. The -x is deliberate — the
header credits it with ending 17 hours of blind Hetzner deploy failures —
and its own comment notes the trace is captured into $LOG by the detached
re-exec's `>>"$LOG" 2>&1`.

With xtrace active, bash traces every assignment a sourced file performs.
So `set -a; source /etc/gluecron.env` would write the whole env file to
stderr, and on the live box that file holds GOOGLE_OAUTH_CLIENT_SECRET
while the log sits at mode 0644 — readable by any local user. The four
curl calls passing `Bearer $DEPLOY_EVENT_TOKEN` on the command line traced
the token the same way, two redirecting straight into $LOG.

Verified read-only on the box that NOTHING actually leaked: the log holds
zero xtrace lines and zero matches for any credential pattern. The hazard
was live in the code; the exposure had not happened.

Also verified, and worth stating plainly: this script is NOT currently
reachable. post-receive.ts dispatches it only when
process.env.SELF_HOST_REPO matches, and while SELF_HOST_REPO is set in the
HOST env file it is absent from the container the app runs in, so the hook
never fires. No systemd unit invokes it. The live deploy path is
gluecron-update.timer -> auto-update.sh, which is what CLAUDE.md's
"post-receive fires self-deploy.sh" claim gets wrong; the log's last write
was 2026-07-13.

Kept rather than deleted: admin-diagnose and the runbook still present it
as the self-host path, so it may be re-armed — and a dormant script is the
one most likely to be switched on without a fresh review, which is exactly
why closing the holes was worth doing rather than shrugging at dead code.
Reachability is now recorded in the header so nobody re-reads it as live.

xtrace is suppressed narrowly around the env source and the token curls,
NOT removed: deleting -x would "fix" the leak by discarding the diagnostic
the script exists for. The log is created 0600 before first write.

The test tracks the xtrace flag through the file rather than peeking at a
fixed window — one `set +x` legitimately covers both branches of the
finished-notify, and a window scan flagged that as a miss. Demonstrated
the underlying hazard directly: sourcing a fixture env file with xtrace on
puts 3 of 3 secrets in the trace, with the shipped pattern 0. Removing the
guard fails 3 tests.

Suite: 3524 pass, 0 fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ccantynz-alt committed on July 29, 2026Parent: dc32319
2 files changed+19107de856fd6e4cf3a5cd96891723d4377865c68b6b
2 changed files+191−0
Modifiedscripts/self-deploy.sh+43−0View fileUnifiedSplit
2525# - systemd unit `gluecron` is Type=notify and ExecStart=$EXEC_START
2626#
2727# TODO(ops): configure /etc/logrotate.d/gluecron-self-deploy for the log.
28#
29# REACHABILITY (verified on the box 2026-07-29): this script is NOT currently
30# running. post-receive.ts only dispatches it when process.env.SELF_HOST_REPO
31# matches, and while SELF_HOST_REPO is set in the HOST env file it is absent
32# from the container the app actually runs in, so the hook never fires. No
33# systemd unit invokes it either. The live deploy path is
34# gluecron-update.timer -> scripts/auto-update.sh, which is what CLAUDE.md's
35# "post-receive fires self-deploy.sh" claim gets wrong. The log's last write
36# was 2026-07-13.
37#
38# It is kept rather than deleted because admin-diagnose and the runbook still
39# present it as the self-host path and it may be re-armed. That is exactly
40# why the xtrace holes below were worth closing: a dormant script is the one
41# most likely to be switched on without a fresh review.
2842# =============================================================================
2943
3044# `set -E` so traps propagate into subshells; `-x` traces every command
3751WORKING_DIR="${GLUECRON_WORKING_DIR:-/opt/gluecron}"
3852LOG="${GLUECRON_SELF_DEPLOY_LOG:-/var/log/gluecron-self-deploy.log}"
3953ENV_FILE="${GLUECRON_ENV_FILE:-/etc/gluecron.env}"
54
55# The log receives this script's stderr, which under `set -x` is a trace of
56# every command. Create it 0600 before anything writes: on the live box it
57# was found at 0644, i.e. readable by any local user. Best-effort — a
58# pre-existing log keeps its mode, so tightening that is an operator step.
59if [ ! -e "$LOG" ]; then
60 ( umask 077; : >"$LOG" ) 2>/dev/null || true
61fi
62chmod 600 "$LOG" 2>/dev/null || true
4063BUN="${GLUECRON_BUN:-/root/.bun/bin/bun}"
4164HEALTHZ_URL="${GLUECRON_HEALTHZ_URL:-http://localhost:3010/healthz}"
4265PORT="${GLUECRON_PORT:-3010}"
5376 fi
5477 local DUR_FIELD=""
5578 if [ -n "$DUR" ]; then DUR_FIELD=",\"duration_ms\":$DUR"; fi
79 # xtrace OFF: the bearer token is on the command line, so tracing this
80 # would print DEPLOY_EVENT_TOKEN into $LOG on every single step.
81 set +x
5682 curl --silent --show-error --max-time 5 \
5783 -X POST "$APP_BASE_URL/api/events/deploy/step" \
5884 -H "authorization: Bearer $DEPLOY_EVENT_TOKEN" \
5985 -H "content-type: application/json" \
6086 --data "{\"run_id\":\"$RUN_ID\",\"sha\":\"$NEW_SHA\",\"step_name\":\"$NAME\",\"status\":\"$STATUS\"$DUR_FIELD}" \
6187 >/dev/null 2>&1 || true
88 set -x
6289}
6390
6491# ── re-exec into the background unless already detached ──────────────────
86113
87114# ── 1. Source env ──────────────────────────────────────────────────────────
88115if [ -f "$ENV_FILE" ]; then
116 # xtrace OFF across the source. With `set -x` active, bash traces every
117 # assignment the sourced file performs, so the entire env file — today
118 # that includes GOOGLE_OAUTH_CLIENT_SECRET — would be echoed to stderr.
119 # The header above is explicit that stderr is captured into $LOG by the
120 # detached re-exec, and that log is world-readable, so tracing here writes
121 # live credentials to disk for any local user to read.
122 set +x
89123 set -a
90124 # shellcheck disable=SC1090
91125 source "$ENV_FILE"
92126 set +a
127 set -x
93128 log " v sourced $ENV_FILE"
94129else
95130 log " ! $ENV_FILE not found — relying on inherited env"
113148
114149# ── 3.5 Notify deploy started (now that we have NEW_SHA + RUN_ID) ─────────
115150if [ -n "${DEPLOY_EVENT_TOKEN:-}" ] && [ -n "${APP_BASE_URL:-}" ]; then
151 # xtrace OFF: bearer token on the command line, and this one redirects
152 # straight into $LOG.
153 set +x
116154 curl --silent --show-error --max-time 10 \
117155 -X POST "$APP_BASE_URL/api/events/deploy/started" \
118156 -H "authorization: Bearer $DEPLOY_EVENT_TOKEN" \
119157 -H "content-type: application/json" \
120158 --data "{\"sha\":\"$NEW_SHA\",\"run_id\":\"$RUN_ID\",\"source\":\"self-deploy\"}" \
121159 >>"$LOG" 2>&1 || log " ! deploy/started notify failed (non-fatal)"
160 set -x
122161fi
123162
124163START_EPOCH=$(date +%s)
319358# ── 11. Notify deploy finished ────────────────────────────────────────────
320359DUR_MS=$(( ( $(date +%s) - START_EPOCH ) * 1000 ))
321360if [ -n "${DEPLOY_EVENT_TOKEN:-}" ] && [ -n "${APP_BASE_URL:-}" ]; then
361 # xtrace OFF across both branches: bearer token on the command line, both
362 # redirecting into $LOG.
363 set +x
322364 if [ "$DEPLOY_FAILED" = "0" ]; then
323365 curl --silent --show-error --max-time 10 \
324366 -X POST "$APP_BASE_URL/api/events/deploy/finished" \
340382 --data "{\"run_id\":\"$RUN_ID\",\"sha\":\"$NEW_SHA\",\"status\":\"failed\",\"duration_ms\":$DUR_MS,\"error\":$ERR_JSON}" \
341383 >>"$LOG" 2>&1 || true
342384 fi
385 set -x
343386fi
344387
345388if [ "$DEPLOY_FAILED" = "0" ]; then
Addedsrc/__tests__/self-deploy-xtrace.test.ts+148−0View fileUnifiedSplit
1/**
2 * Regression guard: self-deploy.sh must not trace secrets into its log.
3 *
4 * The script runs under `set -Eeuxo pipefail`. The `-x` is deliberate — the
5 * header explains it was added after 17 hours of blind Hetzner deploy
6 * failures — and its own comment states the trace is captured into $LOG by
7 * the detached re-exec's `>>"$LOG" 2>&1`.
8 *
9 * With xtrace active, bash traces every assignment performed by a sourced
10 * file. So `set -a; source /etc/gluecron.env` wrote the whole env file to
11 * stderr, and on the live box that file holds GOOGLE_OAUTH_CLIENT_SECRET
12 * while the log sits at mode 0644 — world-readable. The four curl calls that
13 * pass `Bearer $DEPLOY_EVENT_TOKEN` on the command line traced the token the
14 * same way, two of them redirecting straight into $LOG.
15 *
16 * Verified read-only on the box that NO secrets had actually leaked: the log
17 * contained zero xtrace lines and zero matches for any credential pattern.
18 * The hazard was live in the code, the exposure had not occurred.
19 *
20 * Also verified: the script is NOT currently reachable. post-receive.ts only
21 * dispatches when process.env.SELF_HOST_REPO matches, and that variable is
22 * absent from the container the app runs in; no systemd unit invokes it
23 * either. It is kept rather than deleted because admin-diagnose and the
24 * runbook still present it as the self-host path — and a dormant script is
25 * the one most likely to be switched back on without a fresh review, which
26 * is precisely why the holes were worth closing.
27 */
28
29import { describe, it, expect } from "bun:test";
30import { readFileSync } from "node:fs";
31import { join } from "node:path";
32
33const src = (() => {
34 const raw = readFileSync(
35 join(import.meta.dir, "..", "..", "scripts", "self-deploy.sh"),
36 "utf8"
37 );
38 expect(raw.length).toBeGreaterThan(0);
39 return raw;
40})();
41
42/** Slice by anchor, never by character count. */
43function slice(startAnchor: string, endAnchor: string): string {
44 const start = src.indexOf(startAnchor);
45 expect(start).toBeGreaterThan(-1);
46 const end = src.indexOf(endAnchor, start + startAnchor.length);
47 expect(end).toBeGreaterThan(start);
48 const body = src.slice(start, end);
49 expect(body.length).toBeGreaterThan(0);
50 return body;
51}
52
53describe("self-deploy.sh keeps xtrace on for debuggability", () => {
54 it("still traces by default — the fix must not blind the deploy", () => {
55 // Removing -x entirely would "fix" the leak by discarding the diagnostic
56 // the script exists to provide. The fix is scoped suppression, not that.
57 expect(src).toContain("set -Eeuxo pipefail");
58 });
59});
60
61describe("secrets are not traced", () => {
62 it("disables xtrace across the env-file source", () => {
63 const body = slice('if [ -f "$ENV_FILE" ]; then', "log \" v sourced");
64 const offAt = body.indexOf("set +x");
65 const sourceAt = body.indexOf('source "$ENV_FILE"');
66 expect(offAt).toBeGreaterThan(-1);
67 expect(sourceAt).toBeGreaterThan(-1);
68 expect(offAt).toBeLessThan(sourceAt);
69 });
70
71 it("re-enables xtrace after the source, so later steps stay traced", () => {
72 const body = slice('if [ -f "$ENV_FILE" ]; then', "log \" v sourced");
73 const sourceAt = body.indexOf('source "$ENV_FILE"');
74 const onAt = body.lastIndexOf("set -x");
75 expect(onAt).toBeGreaterThan(sourceAt);
76 });
77
78 it("has xtrace OFF at every bearer-token curl", () => {
79 // Track the flag rather than peeking at a fixed window: one `set +x` can
80 // legitimately cover several call sites (the finished-notify guards both
81 // its success and failure branches), and a window scan wrongly flags
82 // that. Walk the file and assert the state at each token line.
83 const lines = src.split("\n");
84 let tracing = false;
85 let tokenLines = 0;
86
87 for (const line of lines) {
88 const t = line.trim();
89 if (t === "set -Eeuxo pipefail" || t === "set -x") tracing = true;
90 else if (t === "set +x") tracing = false;
91
92 if (line.includes("Bearer $DEPLOY_EVENT_TOKEN")) {
93 tokenLines++;
94 expect(tracing).toBe(false);
95 }
96 }
97
98 // Guards against the loop passing vacuously if the calls are renamed.
99 expect(tokenLines).toBeGreaterThanOrEqual(4);
100 });
101
102 it("has xtrace OFF at the env-file source", () => {
103 const lines = src.split("\n");
104 let tracing = false;
105 let sourceSeen = 0;
106
107 for (const line of lines) {
108 const t = line.trim();
109 if (t === "set -Eeuxo pipefail" || t === "set -x") tracing = true;
110 else if (t === "set +x") tracing = false;
111
112 if (line.includes('source "$ENV_FILE"')) {
113 sourceSeen++;
114 expect(tracing).toBe(false);
115 }
116 }
117 expect(sourceSeen).toBe(1);
118 });
119
120 it("balances every suppression with a re-enable", () => {
121 const off = (src.match(/^\s*set \+x\s*$/gm) || []).length;
122 const on = (src.match(/^\s*set -x\s*$/gm) || []).length;
123 expect(off).toBeGreaterThan(0);
124 expect(on).toBe(off);
125 });
126});
127
128describe("the trace destination is not world-readable", () => {
129 it("creates the log 0600 before anything writes to it", () => {
130 expect(src).toContain("umask 077");
131 expect(src).toContain('chmod 600 "$LOG"');
132 // Must happen before the first log() call, or the first write creates
133 // the file with the default mode.
134 const chmodAt = src.indexOf('chmod 600 "$LOG"');
135 const firstLogAt = src.indexOf('log "==> gluecron self-deploy starting');
136 expect(chmodAt).toBeGreaterThan(-1);
137 expect(firstLogAt).toBeGreaterThan(chmodAt);
138 });
139});
140
141describe("reachability is documented", () => {
142 it("records that the hook does not currently fire this script", () => {
143 // A dormant deploy script that reads as live is how it gets re-armed
144 // without review.
145 expect(src).toContain("SELF_HOST_REPO");
146 expect(src).toContain("auto-update.sh");
147 });
148});
0149