Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.

self-deploy-xtrace.test.tsBlame148 lines · 1 contributor
7de856fccantynz-alt1/**
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});