Skip to content
198 linesCodeBlameRaw
1/**
2 * Brokered secrets, as the runner Worker fills them in. A workflow job is
3 * given a stand-in (`g1tsec_…`) in place of a brokered secret's value
4 * (services/actions broker.rs), and every HTTP and HTTPS request its
5 * sandbox makes passes through `egress` (guard.ts). There, a request to one
6 * of the secret's hosts has the stand-in replaced by the value; a request
7 * anywhere else goes as it is, stand-in and all, which is useless to
8 * whoever receives it, and the job's log says so once.
9 *
10 * Where a stand-in is filled in: every request header, as it is, and
11 * inside `Authorization: Basic` (decoded, swapped, encoded again), and the
12 * URL's query string. Never the path or the body. An answer that echoes the
13 * value back has it swapped for the stand-in again in its headers, and in
14 * its body when that is small, plain text.
15 *
16 * Pure, so it can be tested on its own, but for `brokerFor`, which asks
17 * the actions service and keeps the answer a minute per job.
18 */
19
20import type { BrokeredSecret, ServiceBinding } from "@g1t/contracts";
21
22import { allows, normalizeHost } from "./egress.ts";
23
24export type { BrokeredSecret };
25
26/**
27 * A stand-in, wherever it is: `STAND_IN_PREFIX` (the actions contracts)
28 * and 48 hex characters.
29 */
30const STAND_IN = /g1tsec_[0-9a-f]{48}/g;
31
32/** The job a sandbox runs, for the outbound handler: who to ask for stand-ins. */
33export type BrokeredJob = { id: string; token: string };
34
35/** Base64 to text, or null when it is not base64 of UTF-8. */
36function fromBase64(text: string): string | null {
37 try {
38 const bytes = Uint8Array.from(atob(text.trim()), (c) => c.charCodeAt(0));
39 return new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes);
40 } catch {
41 return null;
42 }
43}
44
45function toBase64(text: string): string {
46 let binary = "";
47 for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte);
48 return btoa(binary);
49}
50
51/** A header's value as a stand-in may sit in it: Basic credentials decoded. */
52function readable(value: string): string {
53 const basic = /^basic\s+(.+)$/i.exec(value.trim());
54 return (basic && fromBase64(basic[1])) || value;
55}
56
57/** The stand-ins in a request: in its URL, its headers, or its Basic credentials. */
58export function standInsIn(url: string, headers: Headers): string[] {
59 const found = new Set<string>();
60 const look = (text: string) => {
61 for (const match of text.matchAll(STAND_IN)) found.add(match[0]);
62 };
63 look(url);
64 headers.forEach((value) => look(readable(value)));
65 return [...found];
66}
67
68/** What filling in a request came to. */
69export type Filled = {
70 url: string;
71 headers: Headers;
72 /** The secrets filled in, whose values are kept out of the answer. */
73 filled: BrokeredSecret[];
74 /** The secrets whose stand-ins went to a host they are not for, left as they were. */
75 misdirected: BrokeredSecret[];
76};
77
78function swap(text: string, secrets: readonly BrokeredSecret[], encode: (value: string) => string = (v) => v): string {
79 let out = text;
80 for (const secret of secrets) out = out.split(secret.standIn).join(encode(secret.value));
81 return out;
82}
83
84/**
85 * Fills in the stand-ins of `secrets` that a request to `url` carries, for
86 * those whose hosts include its host. A stand-in the job was not given is
87 * left alone; one for another host is left as it is and reported.
88 */
89export function fill(url: string, headers: Headers, secrets: readonly BrokeredSecret[]): Filled {
90 const present = new Set(standInsIn(url, headers));
91 const carried = secrets.filter((secret) => present.has(secret.standIn));
92 const host = normalizeHost(new URL(url).host);
93 const filled = carried.filter((secret) => allows(secret.hosts, host));
94 const misdirected = carried.filter((secret) => !allows(secret.hosts, host));
95 if (filled.length === 0) return { url, headers, filled, misdirected };
96
97 const out = new Headers();
98 headers.forEach((value, name) => {
99 const basic = /^(basic\s+)(.+)$/i.exec(value.trim());
100 const decoded = basic ? fromBase64(basic[2]) : null;
101 if (basic && decoded != null && filled.some((secret) => decoded.includes(secret.standIn))) {
102 out.append(name, `${basic[1]}${toBase64(swap(decoded, filled))}`);
103 } else {
104 out.append(name, swap(value, filled));
105 }
106 });
107 // Only the query string: a value in a path is rare, and is left as the
108 // job wrote it.
109 const parsed = new URL(url);
110 if (parsed.search) parsed.search = swap(parsed.search, filled, encodeURIComponent);
111 return { url: parsed.toString(), headers: out, filled, misdirected };
112}
113
114/** The largest answer whose body is searched for an echoed value. */
115export const MAX_SCRUBBED_BYTES = 256 * 1024;
116
117/** Whether an answer's body is small, plain text the Worker may rewrite. */
118export function scrubbable(headers: Headers): boolean {
119 const type = (headers.get("content-type") ?? "").toLowerCase();
120 const textual = /^(text\/|application\/(json|xml|x-www-form-urlencoded|[a-z0-9.+-]+\+(json|xml)))/.test(type);
121 const encoding = (headers.get("content-encoding") ?? "identity").toLowerCase();
122 const length = Number(headers.get("content-length") ?? NaN);
123 return textual && encoding === "identity" && Number.isFinite(length) && length <= MAX_SCRUBBED_BYTES;
124}
125
126/** Text with each filled-in value swapped back for its stand-in. */
127export function scrub(text: string, filled: readonly BrokeredSecret[]): string {
128 let out = text;
129 for (const secret of filled) {
130 if (secret.value) out = out.split(secret.value).join(secret.standIn);
131 }
132 return out;
133}
134
135/** An answer's headers, with any filled-in value swapped back for its stand-in. */
136export function scrubHeaders(headers: Headers, filled: readonly BrokeredSecret[]): Headers {
137 const out = new Headers();
138 headers.forEach((value, name) => out.append(name, scrub(value, filled)));
139 return out;
140}
141
142/**
143 * The line a job's log gets when a brokered secret's stand-in went to a
144 * host it is not for.
145 */
146export function misdirectedNote(name: string, host: string): string {
147 return `##[warning]${name} was sent to ${normalizeHost(host)}, which it is not for, so g1t did not fill it in.`;
148}
149
150/** The most misdirected secrets noted in one job's log. */
151const MAX_MISDIRECTED_NOTED = 25;
152
153/** Notes a misdirected secret once per secret and host. `seen` is what was noted. */
154export function newlyMisdirected(seen: readonly string[], name: string, host: string): { note: string; seen: string[] } | null {
155 const key = `${name} ${normalizeHost(host)}`;
156 if (seen.includes(key) || seen.length >= MAX_MISDIRECTED_NOTED) return null;
157 return { note: misdirectedNote(name, host), seen: [...seen, key] };
158}
159
160type Cached = { at: number; secrets: BrokeredSecret[] };
161/** Each job's stand-ins, in this isolate, for a minute: requests come in bursts. */
162const CACHE = new Map<string, Cached>();
163const CACHE_MS = 60_000;
164/** A stand-in not in the cache is asked about again, but not more often than this. */
165const RECHECK_MS = 5_000;
166const CACHE_JOBS = 500;
167
168type Outcome<T> = { ok: true; value: T } | { ok: false; error: { message: string } };
169
170/**
171 * A running job's stand-ins and what they stand for, from the actions
172 * service with the job's token. Kept a minute; asked again sooner when the
173 * request carries a stand-in the kept answer lacks. Empty when the service
174 * cannot say: the stand-ins then go as they are.
175 */
176export async function brokerFor(actions: ServiceBinding, job: BrokeredJob, wanted: readonly string[], now = Date.now()): Promise<BrokeredSecret[]> {
177 const kept = CACHE.get(job.id);
178 const fresh = kept && now - kept.at < CACHE_MS;
179 const complete = kept && wanted.every((standIn) => kept.secrets.some((secret) => secret.standIn === standIn));
180 if (kept && fresh && (complete || now - kept.at < RECHECK_MS)) return kept.secrets;
181 try {
182 const response = await actions.fetch("https://actions/rpc/brokered_secrets", {
183 method: "POST",
184 headers: { "content-type": "application/json" },
185 body: JSON.stringify({ job: job.id, token: job.token }),
186 });
187 if (!response.ok) throw new Error(`brokered_secrets failed with status ${response.status}`);
188 const answer = (await response.json()) as Outcome<BrokeredSecret[]>;
189 const secrets = answer.ok ? answer.value : [];
190 if (CACHE.size >= CACHE_JOBS) CACHE.delete(CACHE.keys().next().value as string);
191 CACHE.set(job.id, { at: now, secrets });
192 return secrets;
193 } catch (error) {
194 // Never the value: only that it could not be had.
195 console.log("brokered secrets not read", job.id, String(error));
196 return kept?.secrets ?? [];
197 }
198}