Blame · Line-by-line history
self-deploy-xtrace.test.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 7de856f | 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 | ||
| 29 | import { describe, it, expect } from "bun:test"; | |
| 30 | import { readFileSync } from "node:fs"; | |
| 31 | import { join } from "node:path"; | |
| 32 | ||
| 33 | const 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. */ | |
| 43 | function 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 | ||
| 53 | describe("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 | ||
| 61 | describe("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 | ||
| 128 | describe("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 | ||
| 141 | describe("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 | }); |