Public: anyone can read this; the crew can change it.

scripts/drills/run.ts refuses to send the admin token to a plain-http forge that is not loopback, and strips control characters from what it prints #2203

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/2202-scripts-drills-run-ts-refuses-to-send-th → main 1 commit fork

  • Merged. scout/2202-scripts-drills-run-ts-refuses-to-send-th was merged into main as ade2924.

Review

Sign in to review this change.
3 files changed +94 −13
README.md +1 −1

Diff not shown — a line 4,279 characters wide (limit 2,000). +1 −1

View file
scripts/drills/run.ts +47 −11
@@ -27,8 +27,15 @@ export interface DrillRun {
2727 const SEEN_MAX = 8_000;
2828 const DEFAULT_CEILING_MS = 3_600_000;
2929
30 /** C0 and C1 control characters out (tab stays): the forge's words never drive the terminal. */
31 export const clean = (s: string): string => s.replace(/[\x00-\x08\x0a-\x1f\x7f-\x9f]/g, "");
32
3033 /** Every string in a JSON value, or the text's own lines when it is not JSON. */
3134 function linesOf(text: string, contentType: string): string[] {
35 return rawLinesOf(text, contentType).map(clean).filter((l) => l.trim() !== "");
36 }
37
38 function rawLinesOf(text: string, contentType: string): string[] {
3239 if (contentType.includes("json")) {
3340 try {
3441 const out: string[] = [];
@@ -159,21 +166,50 @@ export async function runDrill(run: DrillRun): Promise<number> {
159166 return 0;
160167 }
161168
169 export interface DrillArgs {
170 domain: string;
171 forge: string;
172 token: string;
173 }
174
175 const USAGE = "usage: FORGE_TOKEN=… bun scripts/drills/run.ts <domain> --forge <url> [--insecure]";
176 const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
177
178 /** The options, or the one plain line to print before exiting 2. */
179 export function checkArgs(
180 argv: string[],
181 env: Record<string, string | undefined>,
182 isTTY: boolean,
183 ): { ok: true; args: DrillArgs } | { ok: false; error: string } {
184 const at = argv.indexOf("--forge");
185 const forge = at >= 0 ? argv[at + 1] : undefined;
186 const insecure = argv.includes("--insecure");
187 const domain = argv.find((a, i) => !a.startsWith("--") && i !== at + 1);
188 if (!domain) return { ok: false, error: `a domain is required; ${USAGE}` };
189 if (!forge) return { ok: false, error: `--forge <url> is required; ${USAGE}` };
190 let url: URL;
191 try { url = new URL(forge); } catch { return { ok: false, error: `--forge must be an http(s) URL; ${USAGE}` }; }
192 if (url.protocol !== "http:" && url.protocol !== "https:") {
193 return { ok: false, error: `--forge must be an http(s) URL; ${USAGE}` };
194 }
195 const token = env.FORGE_TOKEN ?? "";
196 if (token === "") return { ok: false, error: `FORGE_TOKEN is empty; ${USAGE}` };
197 if (!isTTY) return { ok: false, error: "stdin is not a terminal; run the drill from an interactive shell" };
198 if (url.protocol === "http:" && !LOOPBACK.has(url.hostname) && !insecure) {
199 return { ok: false, error: `refusing to send the admin token over plain http to ${url.hostname}; use https, or pass --insecure` };
200 }
201 return { ok: true, args: { domain, forge: forge.replace(/\/+$/, ""), token } };
202 }
203
162204 if (import.meta.main) {
163 const args = process.argv.slice(2);
164 const at = args.indexOf("--forge");
165 const forge = at >= 0 ? args[at + 1] : undefined;
166 const domain = args.find((a, i) => !a.startsWith("--") && i !== at + 1);
167 const token = process.env.FORGE_TOKEN ?? "";
168 if (!domain || !forge || !/^https?:\/\//.test(forge) || token === "") {
169 console.error("usage: FORGE_TOKEN=… bun scripts/drills/run.ts <domain> --forge <url>");
205 const checked = checkArgs(process.argv.slice(2), process.env, process.stdin.isTTY === true);
206 if (!checked.ok) {
207 console.error(checked.error);
170208 process.exit(2);
171209 }
172210 const code = await runDrill({
173 domain,
174 forge: forge.replace(/\/+$/, ""),
175 token,
176 say: (l) => console.log(l),
211 ...checked.args,
212 say: (l) => console.log(clean(l)),
177213 enter: async () => {
178214 for await (const _ of console) break;
179215 },
test/drill-run.test.ts +46 −1
@@ -1,5 +1,5 @@
11 import { afterEach, expect, test } from "bun:test";
2 import { runDrill } from "../scripts/drills/run.ts";
2 import { checkArgs, runDrill } from "../scripts/drills/run.ts";
33
44 let server: ReturnType<typeof Bun.serve> | undefined;
55 afterEach(() => server?.stop(true));
@@ -116,3 +116,48 @@ test("a sign-in redirect from / is up, and a changing counter is one line", asyn
116116 expect(state.posts).toHaveLength(1);
117117 expect(h.lines.filter((l) => l.startsWith("health: up "))).toHaveLength(1);
118118 });
119
120 test("checkArgs: each missing piece and the cleartext refusal is an error; the good case is options", () => {
121 const env = { FORGE_TOKEN: "t" };
122 const bad = (argv: string[], e: Record<string, string | undefined>, tty: boolean) => {
123 const r = checkArgs(argv, e, tty);
124 expect(r.ok).toBe(false);
125 return r.ok ? "" : r.error;
126 };
127 expect(bad(["--forge", "https://f.example"], env, true)).toContain("domain");
128 expect(bad(["runner"], env, true)).toContain("--forge");
129 expect(bad(["runner", "--forge", "https://f.example"], { FORGE_TOKEN: "" }, true)).toContain("FORGE_TOKEN");
130 expect(bad(["runner", "--forge", "https://f.example"], env, false)).toContain("not a terminal");
131 expect(bad(["runner", "--forge", "http://forge.example"], env, true)).toContain("plain http");
132 const ok = (argv: string[]) => checkArgs(argv, env, true);
133 expect(ok(["runner", "--forge", "http://forge.example", "--insecure"]).ok).toBe(true);
134 for (const host of ["localhost:3000", "127.0.0.1:1", "[::1]:1"]) expect(ok(["runner", "--forge", `http://${host}/`]).ok).toBe(true);
135 expect(ok(["runner", "--forge", "https://f.example/"])).toEqual({
136 ok: true,
137 args: { domain: "runner", forge: "https://f.example", token: "t" },
138 });
139 });
140
141 test("control characters the forge sends are removed before printing or posting; tab stays", async () => {
142 const state: Stub = { down: false, posts: [], refuse: false };
143 server = Bun.serve({
144 port: 0,
145 hostname: "127.0.0.1",
146 async fetch(req) {
147 const path = new URL(req.url).pathname;
148 if (path === "/api/v1/health")
149 return state.down
150 ? Response.json({ ok: false, note: "bad\x1b[31mRED\x07\tx\u009b" }, { status: 503 })
151 : Response.json({ ok: true });
152 if (path === "/") return new Response("<p>fine</p>", { headers: { "content-type": "text/html" } });
153 state.posts.push({ auth: null, body: await req.json() });
154 return Response.json({ green: true, why: "ok" }, { status: 201 });
155 },
156 });
157 const h = harness(state);
158 expect(await h.run("runner", `http://127.0.0.1:${server.port}`)).toBe(0);
159 const said = h.lines.find((l) => l.startsWith("health: "))!;
160 expect(said).toBe("health: bad[31mRED\tx");
161 expect(said).not.toContain("\x1b");
162 expect(JSON.stringify(state.posts[0]!.body)).not.toContain("\\u001b");
163 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs