Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh

Commit

A secret a job uses only to call an API can be brokered, so its value never enters the job's sandbox. Under Settings, Secrets and variables, a secret's new Brokered choice (Only for these hosts) takes the hosts it is for, written as allowed domains are (registry.npmjs.org, *.example.com); the list shows a Brokered badge whose hint names them. A workflow job then gets a stand-in in its place, g1tsec_ and 48 random hex characters, made for that job and that secret, wherever the value would have gone: as the secret, under any key a called workflow passes it as, and inside a value built from it. As the job's requests pass the runner Worker, a stand-in on a request to one of the secret's hosts becomes the real value in any header (inside Authorization: Basic it is decoded, filled in and encoded again) and in the query string, never the path or the body; a filled-in request is not followed through redirects outside the sandbox, and an answer that echoes the value has it swapped back for the stand-in in its headers, and in its body when that is small, uncompressed text. A stand-in sent to any other host goes as it is and the job's log says once that NPM_TOKEN was sent to example.com, which it is not for, so g1t did not fill it in.

The actions service keeps each stand-in in a new table (migration 0019), its value sealed and bound to the stand-in, until the job's time limit and ten minutes are up; the runner Worker asks brokered_secrets for a running job's stand-ins with the job's own token and keeps the answer a minute per job. The API never passes that method on. A job whose network is open but whose project or workspace has a brokered secret still sends its HTTP and HTTPS through the Worker, which reaches every host for it and leaves other protocols alone, and it trusts the re-signing certificate as a restricted sandbox does. The stand-in is masked in the job's log, and the real value is masked as the log arrives, should an answer have carried it in. A job on a self-hosted runner gets the stand-in too, never the value, and a warning at the top of its log that brokered secrets are filled in only on g1t's machines for now. A brokered secret is read by workflows only, since a deployment's app needs the value itself; security updates' private registries go without it; its value is at least 8 characters, so it is not found inside other secrets by chance. The API and the MCP secret tool take and return brokered_hosts. The secrets guide has a Brokered secrets section (how to set one, where the value is filled in, where it works, and what it cannot cover: database passwords over raw TCP, signing keys, keys to files the job decrypts, tests that need the value, a value in a body), and the actions and guardrails guides describe it where they describe the network.

syntaqxcommitted Parent17a14e1Browse files
22 files+846−436 files not counted0/22 viewed
+14−3
16871687 }
16881688 Op::UpdateWorkflow => "Turn a workflow on or off without changing its file. Needs the Maintain role or higher.",
16891689 Op::ListActionsSecrets => {
1690− "The secrets of a repository (with the workspace's rows that reach it) or of a workspace: each row's key, the environments it applies to, and whether workflows (`secrets.NAME`), deployments, or both read it. Values are never returned. A repository's need the Admin role on it; a workspace's, a member."
1690+ "The secrets of a repository (with the workspace's rows that reach it) or of a workspace: each row's key, the environments it applies to, whether workflows (`secrets.NAME`), deployments, or both read it, and for a brokered secret the hosts g1t fills it in for (`brokered_hosts`). Values are never returned. A repository's need the Admin role on it; a workspace's, a member."
16911691 }
16921692 Op::SetActionsSecret => {
1693− "Add or change a secret's row. Without `id` or `environments`, the key's row for every environment, as GitHub's API addresses a secret. `available_to` is workflows and/or deployments (both, for a new row); `environments` limits it to some, such as production or preview, so a key can hold a value per environment. A variable's row can become a secret this way; a secret never becomes a variable. A repository's need the Admin role on it; a workspace's, an owner. Workspace tokens, G1T_TOKEN included, cannot change them."
1693+ "Add or change a secret's row. Without `id` or `environments`, the key's row for every environment, as GitHub's API addresses a secret. `available_to` is workflows and/or deployments (both, for a new row); `environments` limits it to some, such as production or preview, so a key can hold a value per environment. A variable's row can become a secret this way; a secret never becomes a variable. `brokered_hosts` brokers the secret: workflow jobs on g1t's machines get a stand-in in place of its value, and g1t puts the real value on the job's requests to those hosts (in headers, Basic auth included, and the query string) as they leave its sandbox, so the value never enters it. A brokered secret is read by workflows only and its value is at least 8 characters; an empty list stops brokering it. A repository's need the Admin role on it; a workspace's, an owner. Workspace tokens, G1T_TOKEN included, cannot change them."
16941694 }
16951695 Op::DeleteActionsSecret => "Remove a secret: one row by `id`, or every row of the key.",
16961696 Op::ListActionsVariables => {
28992899 "description": "A workspace's row: the projects it reaches, by slug. Empty is every one."
29002900 },
29012901 "note": { "type": "string", "description": "Where to rotate it, or who to ask." },
2902+ "brokered_hosts": {
2903+ "type": "array",
2904+ "items": { "type": "string" },
2905+ "description": "A secret's: the hosts g1t fills it in for, such as api.example.com or *.example.com. Jobs get a stand-in in place of its value. Empty stops brokering it; left out, unchanged."
2906+ },
29022907 })),
29032908 &["setting"],
29042909 ),
51805185 }
51815186 // Request bodies arrive in snake_case; the actions service
51825187 // takes `availableTo`.
5183− for (key, to) in [("available_to", "availableTo"), ("environments", "environments"), ("repositories", "projects"), ("projects", "projects")] {
5188+ for (key, to) in [
5189+ ("available_to", "availableTo"),
5190+ ("environments", "environments"),
5191+ ("repositories", "projects"),
5192+ ("projects", "projects"),
5193+ ("brokered_hosts", "brokeredHosts"),
5194+ ] {
51845195 if let Some(list) = strings(input, key) {
51855196 args[to] = json!(list);
51865197 }
+21−7
31803180 "environments": [],
31813181 "projects": [],
31823182 "note": null,
3183− "updated_by": "syntaqx"
3183+ "updated_by": "syntaqx",
3184+ "brokered_hosts": [
3185+ "registry.npmjs.org"
3186+ ]
31843187 },
31853188 {
31863189 "id": "set_01kpv9q4r7s0t3v6w9x2y5z8ac",
31983201 ],
31993202 "projects": [],
32003203 "note": "Rotate in the Stripe dashboard.",
3201− "updated_by": "syntaqx"
3204+ "updated_by": "syntaqx",
3205+ "brokered_hosts": []
32023206 }
32033207 ],
3204− "notes": "Values are never returned. A repository's list includes the workspace's rows that reach it, with `scope` set to `workspace`. Empty `environments` means every environment."
3208+ "notes": "Values are never returned. A repository's list includes the workspace's rows that reach it, with `scope` set to `workspace`. Empty `environments` means every environment; empty `brokered_hosts`, a secret that is not brokered."
32053209 },
32063210 "list_actions_secrets_for_workspace": {
32073211 "params": {
32213225 "environments": [],
32223226 "projects": [],
32233227 "note": null,
3224− "updated_by": "syntaqx"
3228+ "updated_by": "syntaqx",
3229+ "brokered_hosts": [
3230+ "registry.npmjs.org"
3231+ ]
32253232 }
32263233 ],
3227− "notes": "Values are never returned. A repository's list includes the workspace's rows that reach it, with `scope` set to `workspace`. Empty `environments` means every environment."
3234+ "notes": "Values are never returned. A repository's list includes the workspace's rows that reach it, with `scope` set to `workspace`. Empty `environments` means every environment; empty `brokered_hosts`, a secret that is not brokered."
32283235 },
32293236 "set_actions_secret": {
32303237 "params": {
32563263 ],
32573264 "projects": [],
32583265 "note": null,
3259− "updated_by": "syntaqx"
3266+ "updated_by": "syntaqx",
3267+ "brokered_hosts": []
32603268 },
32613269 "notes": "Keys are uppercased. See [secrets and variables](/guides/secrets-and-variables/)."
32623270 },
32693277 "value": "npm_…",
32703278 "projects": [
32713279 "hello"
3280+ ],
3281+ "brokered_hosts": [
3282+ "registry.npmjs.org"
32723283 ]
32733284 },
32743285 "response": {
32863297 "hello"
32873298 ],
32883299 "note": null,
3289− "updated_by": "syntaqx"
3300+ "updated_by": "syntaqx",
3301+ "brokered_hosts": [
3302+ "registry.npmjs.org"
3303+ ]
32903304 }
32913305 },
32923306 "delete_actions_secret": {
+21−0
619619 Agents never reach those hosts, and neither do runs of pull requests from
620620 forks. See [workflow-only domains](/guides/guardrails/#workflow-only-domains).
621621
622+### Brokered secrets
623+
624+A secret a job uses only to call an API can be brokered: the job gets a
625+stand-in (`g1tsec_…`) in place of `${{ secrets.KEY }}`, and g1t puts the
626+real value on the job's requests to the hosts the secret names as they
627+leave its sandbox, in headers (Basic auth included) and the query string.
628+The value never enters the job, so neither its steps nor a compromised
629+dependency can read it. A stand-in sent to any other host goes as it is,
630+and the log says so once. This works on g1t's machines, whether the
631+project's network is restricted or open; a job on a self-hosted runner
632+gets the stand-in too, never the value, and its log warns that it will
633+not work there yet. A secret the job must read itself (a database
634+password over raw TCP, a signing key, a key to files it decrypts) cannot
635+be brokered. See
636+[brokered secrets](/guides/secrets-and-variables/#brokered-secrets).
637+
622638 g1t does not run cryptocurrency miners: a step that names one (`xmrig`,
623639 a `stratum+tcp://` pool, `--donate-level`) is not run, and a job that
624640 looks like it is mining is stopped. See
14841500 A value of one character is not masked: it would hide that character
14851501 everywhere.
14861502
1503+A [brokered secret](#brokered-secrets)'s stand-in is masked the same way.
1504+Its real value never reaches the job, so the job cannot mask it; g1t
1505+masks it in the log as the log arrives, should an answer have carried it
1506+in.
1507+
14871508 ## Pull requests
14881509
14891510 A pull request's workflows run on each new head: when it is opened, when
+21−0
161161 `update_guardrails`, saying which domains were added or removed and what
162162 they were limited to.
163163
164+### Brokered secrets
165+
166+The same interception keeps a workflow's API keys out of its jobs. A
167+[brokered secret](/guides/secrets-and-variables/#brokered-secrets) names
168+the hosts it is for, and a job is given a stand-in in place of its value.
169+As a request to one of those hosts passes the runner Worker, the Worker
170+puts the real value in place of the stand-in, in the request's headers
171+(inside `Authorization: Basic` too) and its query string, and swaps it
172+back should the answer echo it. A request to any other host goes with the
173+stand-in, and the job's log notes it once. The Worker asks for a job's
174+stand-ins with that job's own token and keeps them only while the job
175+runs.
176+
177+Brokering does not widen the network: a secret's hosts must still be ones
178+the job may reach. A job whose project's network is open, but which may be
179+given a brokered secret, still sends its HTTP and HTTPS through the
180+Worker, which reaches every host for it, and trusts the certificate as a
181+restricted sandbox does; its other protocols go straight out, as before.
182+Self-hosted runners' traffic does not pass through g1t, so brokered
183+secrets stay stand-ins there.
184+
164185 ## Commands
165186
166187 The agent's harness checks every tool call the agent makes before it runs.
+89−0
2424 | **Type** | **Secret**: sealed when saved and never shown again, hidden in logs. For passwords, API keys and tokens. **Config**: readable by whoever may see the list. For values that are not sensitive. Config can be changed to a secret; a secret can never become config. |
2525 | **Key** | Letters, digits and underscores, not starting with a digit, upper-cased: `STRIPE_KEY`. Keys starting with `G1T_` or `GITHUB_` are g1t's own. |
2626 | **Value** | Up to 48 KB. |
27+| **Brokered** | A secret only: the hosts it is for. Jobs get a stand-in, and g1t fills in the value on their requests to those hosts. See [brokered secrets](#brokered-secrets). |
2728 | **Note** | Optional: where to rotate it, or who to ask. |
2829 | **Environments** | **All environments**, or only some: **Production**, **Preview**, or any name a workflow job uses in `environment:`, such as `staging`. |
2930 | **Available to** | **Workflows**, **Deployments**, or both (the default). |
9293 the environment's [protection rules](/guides/actions/#environments) let it
9394 through: required reviewers, a wait timer, and which branches may deploy.
9495
96+## Brokered secrets
97+
98+A secret a job uses only to call an API need not enter the job at all.
99+Broker it, and the job gets a stand-in in its place: a token such as
100+`g1tsec_3f9c…` (48 random characters after the prefix), made for that job
101+and that secret alone. As the job's requests leave its sandbox, g1t puts
102+the real value in place of the stand-in on requests to the hosts the
103+secret is for. Code in the job, a compromised dependency included, can
104+read only the stand-in, which works nowhere else and stops working when
105+the job ends.
106+
107+To broker a secret:
108+
109+1. Open **Settings → Secrets and variables**, and add or edit the secret.
110+2. Under **Brokered**, tick **Only for these hosts**.
111+3. List the hosts it is for, one per line, as for
112+ [allowed domains](/guides/guardrails/#network): `registry.npmjs.org`,
113+ or `*.example.com` for every subdomain of `example.com`.
114+4. Save.
115+
116+Workflows use it as they would any secret: `${{ secrets.NPM_TOKEN }}` in an
117+`Authorization` header, an `.npmrc`, or a tool's settings.
118+
119+```yaml
120+- run: npm publish
121+ env:
122+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
123+```
124+
125+### Where the value is filled in
126+
127+| In the request | |
128+| --- | --- |
129+| Any header | As it is: `Authorization: Bearer …`, `Authorization: token …`, `X-Api-Key: …` |
130+| `Authorization: Basic` | Decoded, filled in, and encoded again, so `user:$TOKEN` works |
131+| The query string | `?key=…`, encoded for the URL |
132+| The path and the body | Never: the stand-in goes as it is |
133+
134+A request to a host the secret is not for goes out with the stand-in,
135+which is useless to whoever receives it, and the job's log says so once:
136+`NPM_TOKEN was sent to example.com, which it is not for, so g1t did not
137+fill it in.` A host must also be one the job may reach (its project's
138+allowed domains, what builds need, or a
139+[workflow-only domain](/guides/guardrails/#workflow-only-domains)):
140+brokering a secret for a host does not open the network to it.
141+
142+If an answer echoes the value back, it is swapped for the stand-in again
143+in the answer's headers, and in its body when that is plain text (JSON,
144+XML, text) of at most 256 KB sent uncompressed. A request with a filled-in
145+value is not followed through redirects outside the sandbox: the job
146+follows them itself, and each is filled in only for the secret's hosts.
147+
148+### Where it works
149+
150+- **Workflow jobs on g1t's machines.** Brokering works whether the
151+ project's network is restricted or open: a job that may be given a
152+ brokered secret sends its HTTP and HTTPS through g1t either way, and
153+ trusts the certificate HTTPS is re-signed with, as a restricted one does.
154+ Other protocols are not affected.
155+- **Self-hosted runners: not yet.** Their traffic never passes through
156+ g1t, so a job there gets the stand-in too, never the value, and its log
157+ warns that requests using it will not be signed in. Keep a separate,
158+ unbrokered secret for jobs that must run there.
159+- **Deployments** never read a brokered secret: a running app needs the
160+ value itself. A brokered secret is available to workflows only.
161+- **Security updates' private registries** do not get a brokered secret.
162+
163+The value is masked in the job's log as any secret is, should it ever
164+appear there, and so is the stand-in.
165+
166+### What brokering cannot cover
167+
168+A secret the job itself must read cannot be brokered. Keep these as
169+ordinary secrets:
170+
171+- passwords for a database or service reached over raw TCP rather than
172+ HTTP, such as PostgreSQL or Redis;
173+- signing keys (code signing, GPG, SSH, a release's private key), which
174+ the job uses to sign rather than sends;
175+- keys to files the job decrypts;
176+- tests that need the real value inside the job;
177+- a value sent in a request's body or path, such as an OAuth client
178+ secret posted in a form.
179+
180+A brokered value is at least 8 characters, so it is not found inside
181+other secrets by chance.
182+
95183 ## G1T_TOKEN
96184
97185 Every workflow job gets `${{ secrets.G1T_TOKEN }}`: a token for that job
140228 | `environments` | `["production"]`, `["preview", "staging"]`; `[]` for all. |
141229 | `projects` | A workspace's row: project names; `[]` for every one. (`repositories` is read the same way.) |
142230 | `note` | Where to rotate it, or who to ask. |
231+| `brokered_hosts` | A secret's: `["registry.npmjs.org"]`, `["*.example.com"]` to [broker it](#brokered-secrets); `[]` stops brokering it. Listed rows return it too. |
143232
144233 ```sh
145234 curl -X PUT https://api.g1t.sh/repos/acme/web/actions/secrets/STRIPE_KEY \
+1−1
561561 | Action | What it does | Required | Scope |
562562 | --- | --- | --- | --- |
563563 | [`list_secrets`](/reference/api/secrets-and-variables/list-actions-secrets/) | Secrets' rows: key, environments, who reads them. Never values. | None | `secrets:read` |
564−| [`set_secret`](/reference/api/secrets-and-variables/set-actions-secret/) | Add or change a secret's row: `value`, and optionally `id`, `environments`, `available_to`, `projects`, `note`. | `setting` | `secrets:admin` |
564+| [`set_secret`](/reference/api/secrets-and-variables/set-actions-secret/) | Add or change a secret's row: `value`, and optionally `id`, `environments`, `available_to`, `projects`, `note`, and `brokered_hosts` to broker it (jobs get a stand-in; g1t fills in the value on requests to those hosts). | `setting` | `secrets:admin` |
565565 | [`delete_secret`](/reference/api/secrets-and-variables/delete-actions-secret/) | Remove one row (`id`) or every row of the key. | `setting` | `secrets:admin` |
566566 | [`list_variables`](/reference/api/secrets-and-variables/list-actions-variables/) | Config rows with their values. | None | `secrets:read` |
567567 | [`set_variable`](/reference/api/secrets-and-variables/set-actions-variable/) | Add or change a config row, as for secrets. | `setting` | `secrets:admin` |
+0−0

Binary or large file; its contents are not shown.

+68−11
1313
1414 import type { SecretsAction, SecretsData } from "../lib/secrets.server";
1515 import { ButtonLink, EmptyState, ErrorText, SubmitButton, TimeAgo } from "./ui";
16+import { Badge } from "./ui/badge";
1617 import { Card } from "./ui/card";
1718 import { CheckboxOption } from "./ui/checkbox";
1819 import { Hint } from "./ui/hint";
165166
166167 function Row({ row, inherited, manage }: { row: Setting; inherited: boolean; manage: boolean }) {
167168 const secret = row.kind === "secret";
169+ const hosts = row.brokeredHosts ?? [];
168170 return (
169171 <li className="grid grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_auto] items-center gap-x-4 gap-y-1 border-t border-line px-4 py-3 text-sm first:border-t-0 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_minmax(0,1fr)_6rem_6rem_auto]">
170172 <div className="min-w-0">
171− <p className="truncate font-mono text-[0.8125rem]">{row.name}</p>
173+ <div className="flex min-w-0 items-center gap-2">
174+ <p className="truncate font-mono text-[0.8125rem]">{row.name}</p>
175+ {secret && hosts.length > 0 && (
176+ <Hint label={`Filled in for ${hosts.join(", ")}`}>
177+ <Badge tone="accent">
178+ Brokered
179+ <span className="sr-only">: filled in for {hosts.join(", ")}</span>
180+ </Badge>
181+ </Hint>
182+ )}
183+ </div>
172184 {row.note && <p className="truncate text-xs text-faint">{row.note}</p>}
173185 {!secret && row.value != null && <p className="truncate font-mono text-xs text-muted">{row.value}</p>}
174186 </div>
244256 const [type, setType] = useState<"secret" | "config">(row?.kind === "variable" ? "config" : "secret");
245257 const [some, setSome] = useState(!!row && row.environments.length > 0);
246258 const [reach, setReach] = useState(row && row.projects.length > 0 ? "some" : "all");
259+ const [brokered, setBrokered] = useState((row?.brokeredHosts?.length ?? 0) > 0);
260+ // Only a secret is brokered, and only workflows read one.
261+ const brokering = type === "secret" && brokered;
247262 const custom = row?.environments.filter((env) => !KNOWN_ENVIRONMENTS.includes(env)) ?? [];
248263 const field =
249264 "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim";
324339 />
325340 </label>
326341
342+ {type === "secret" && (
343+ <fieldset>
344+ <legend className="mb-2 text-sm font-medium text-muted">Brokered</legend>
345+ <div className="space-y-2 text-sm">
346+ <CheckboxOption
347+ name="brokered"
348+ checked={brokered}
349+ onCheckedChange={(checked) => setBrokered(checked === true)}
350+ label="Only for these hosts"
351+ description="The job gets a stand-in; g1t puts the real value on requests to these hosts, so it never enters the sandbox."
352+ />
353+ {brokered && (
354+ <div className="ml-6 space-y-2">
355+ <textarea
356+ name="brokeredHosts"
357+ rows={2}
358+ required
359+ defaultValue={row?.brokeredHosts?.join("\n") ?? ""}
360+ placeholder={"api.example.com\n*.example.com"}
361+ autoComplete="off"
362+ spellCheck={false}
363+ aria-label="Hosts"
364+ className={`${field} font-mono`}
365+ />
366+ <p className="text-xs text-faint">
367+ One per line. Filled in in headers and the query string, for workflow jobs on g1t's machines.{" "}
368+ <a href="https://docs.g1t.sh/guides/secrets-and-variables/#brokered-secrets" className="text-fg hover:underline">
369+ What it covers
370+ </a>
371+ </p>
372+ </div>
373+ )}
374+ </div>
375+ </fieldset>
376+ )}
377+
327378 <label className="block">
328379 <span className="mb-1.5 block text-sm font-medium text-muted">Note (optional)</span>
329380 <input name="note" defaultValue={row?.note ?? ""} placeholder="Where to rotate it, or who to ask" className={field} />
370421 ["workflows", "Workflows", "secrets.KEY or vars.KEY in GitHub Actions workflows"],
371422 ["deployments", "Deployments", "The build's environment, and env.KEY in the running app"],
372423 ] as const
373− ).map(([value, title, text]) => (
374− <CheckboxOption
375− key={value}
376− name="availableTo"
377− value={value}
378− defaultChecked={row ? row.availableTo.includes(value) : true}
379− label={title}
380− description={text}
381− />
382− ))}
424+ ).map(([value, title, text]) => {
425+ // A deployment's app needs the value itself, so a brokered
426+ // secret is never one of its.
427+ const closed = brokering && value === "deployments";
428+ return (
429+ <CheckboxOption
430+ key={`${value}${closed ? "-closed" : ""}`}
431+ name="availableTo"
432+ value={value}
433+ defaultChecked={closed ? false : row ? row.availableTo.includes(value) : true}
434+ disabled={closed}
435+ label={title}
436+ description={closed ? "Not for a brokered secret: a deployment's app needs the value itself." : text}
437+ />
438+ );
439+ })}
383440 </div>
384441 </fieldset>
385442
+14−0
7171 if (availableTo.length === 0) return { error: "Choose who reads it: Workflows, Deployments, or both." };
7272 const linked =
7373 "workspace" in owner && form.get("reach") === "some" ? form.getAll("project").map(String) : [];
74+ // A secret is brokered when it names hosts; unticked, it stops being.
75+ const brokeredHosts =
76+ kind === "secret"
77+ ? form.get("brokered") === "on"
78+ ? String(form.get("brokeredHosts") ?? "")
79+ .split(/[\s,]+/)
80+ .map((host) => host.trim())
81+ .filter(Boolean)
82+ : []
83+ : undefined;
84+ if (brokeredHosts && form.get("brokered") === "on" && brokeredHosts.length === 0) {
85+ return { error: "Name the hosts a brokered secret is for, such as api.example.com." };
86+ }
7487 const note = String(form.get("note") ?? "");
7588 const key = String(form.get("key") ?? "").trim();
7689 const value = String(form.get("value") ?? "");
8699 environments,
87100 projects: "workspace" in owner ? linked : undefined,
88101 note,
102+ brokeredHosts,
89103 });
90104 if (!saved.ok) return { error: pasted.length > 0 ? `${name}: ${saved.error.message}` : saved.error.message };
91105 }
+31−0
595595 pub note: Option<String>,
596596 #[serde(default)]
597597 pub updated_by: Option<String>,
598+ /// A brokered secret's hosts: a job gets a stand-in for it, and g1t
599+ /// puts the real value on the job's requests to these hosts as they
600+ /// leave its sandbox. Empty for a secret that is not brokered, and for
601+ /// config.
602+ #[serde(default)]
603+ pub brokered_hosts: Vec<String>,
598604 }
599605
606+/// How a brokered secret's stand-in starts. The rest is 48 random hex
607+/// characters, so the whole is shaped like a token and tools take it.
608+pub const STAND_IN_PREFIX: &str = "g1tsec_";
609+
610+/// A stand-in a running job was given for a brokered secret, as the runner
611+/// Worker asks for them (`brokered_secrets`) to fill in the real value on
612+/// the job's requests to `hosts`.
613+#[derive(Clone, Debug, Serialize, Deserialize)]
614+#[serde(rename_all = "camelCase")]
615+pub struct BrokeredSecret {
616+ pub stand_in: String,
617+ /// The secret's key, for the job's log.
618+ pub name: String,
619+ pub value: String,
620+ pub hosts: Vec<String>,
621+}
622+
600623 // --- Methods ---------------------------------------------------------------
601624
602625 /// `workflows`. Returns `Outcome<Vec<Workflow>>`.
783806 pub projects: Option<Vec<String>>,
784807 #[serde(default)]
785808 pub note: Option<String>,
809+ /// A secret's brokered hosts (`Setting::brokered_hosts`); empty stops
810+ /// brokering it. Left out, unchanged.
811+ #[serde(default, rename = "brokeredHosts")]
812+ pub brokered_hosts: Option<Vec<String>>,
786813 }
787814
788815 /// `set_managed_workflow`: installs, or replaces, a managed workflow in a
9911018 /// `g1t-2core` or `g1t-4core`; absent, the standard one.
9921019 #[serde(default)]
9931020 pub instance: Option<String>,
1021+ /// Whether the job may be given brokered secrets, so the runner sends
1022+ /// its HTTP and HTTPS through the Worker even when its network is open.
1023+ #[serde(default)]
1024+ pub brokered: bool,
9941025 }
9951026
9961027 /// A size of machine g1t runs workflow jobs on, asked for by a label in
+17−0
340340 /** Where to rotate it, or who to ask. */
341341 note: string | null;
342342 updatedBy: string | null;
343+ /**
344+ * A brokered secret's hosts (`api.example.com`, `*.example.com`): jobs get
345+ * a stand-in in place of its value, and g1t puts the real value on their
346+ * requests to these hosts as they leave the sandbox. Empty when it is not
347+ * brokered. Absent from a service older than brokering.
348+ */
349+ brokeredHosts?: string[];
343350 };
344351
352+/**
353+ * A stand-in a running job was given for a brokered secret (`g1tsec_` and
354+ * 48 hex characters), as the actions service's `brokered_secrets` answers
355+ * the runner Worker, which fills in `value` on the job's requests to
356+ * `hosts`.
357+ */
358+export type BrokeredSecret = { standIn: string; name: string; value: string; hosts: string[] };
359+
345360 /** What saving a row sets beyond its value; left out is unchanged. */
346361 export type SettingOptions = {
347362 /** The row to change; left out, the key's row for every environment. */
351366 /** A workspace's row: project slugs; empty for every one. */
352367 projects?: string[];
353368 note?: string;
369+ /** A secret's brokered hosts; empty stops brokering it. */
370+ brokeredHosts?: string[];
354371 };
355372
356373 export type SettingsOwner = { repo: RepoPath } | { workspace: string };
+26−0
1+-- Brokered secrets. A secret row with hosts is brokered: a workflow job is
2+-- given a stand-in for it in place of its value, and the runner Worker puts
3+-- the value on the job's requests to those hosts as they leave its sandbox.
4+-- The hosts are a JSON array of domains (`api.example.com`,
5+-- `*.example.com`); null for a secret that is not brokered.
6+ALTER TABLE settings ADD COLUMN brokered_hosts TEXT;
7+
8+-- The stand-ins jobs were given: each a random token unique to one job and
9+-- one secret, its value sealed and bound to the stand-in, the hosts it is
10+-- filled in for, and when it stops working (the job's time limit and ten
11+-- minutes, in milliseconds since the epoch). Only a running job's own are
12+-- ever answered, with its token.
13+CREATE TABLE brokered_secrets (
14+ stand_in TEXT PRIMARY KEY,
15+ job_id TEXT NOT NULL,
16+ name TEXT NOT NULL,
17+ value TEXT NOT NULL,
18+ hosts TEXT NOT NULL,
19+ expires_at INTEGER NOT NULL
20+);
21+CREATE INDEX brokered_secrets_by_job ON brokered_secrets (job_id);
22+CREATE INDEX brokered_secrets_by_expiry ON brokered_secrets (expires_at);
23+
24+-- Whether a job was given any, so its log is masked for their values too
25+-- without asking on every report.
26+ALTER TABLE jobs ADD COLUMN brokered INTEGER NOT NULL DEFAULT 0;
+306−0
1+//! Brokered secrets: a secret a job uses only to call an API never enters
2+//! its sandbox. Its row names the hosts it is for (settings.rs); when a job
3+//! starts, `job_spec` gives the job a stand-in in its place, one per job and
4+//! secret, and keeps here what the stand-in stands for, sealed and bound to
5+//! it, until the job's time is up. The runner Worker sees every HTTP and
6+//! HTTPS request a job on g1t's machines makes (services/runner broker.ts),
7+//! asks `brokered_secrets` with the job's token for its stand-ins, and puts
8+//! the real value in place of a stand-in on requests to the secret's hosts
9+//! as they leave. Anywhere else the stand-in goes as it is, which is
10+//! useless to whoever receives it.
11+//!
12+//! A self-hosted runner's traffic never passes the Worker, so a job there
13+//! gets the stand-in too, and a line in its log saying why it will not
14+//! work: the real value is never sent to a machine g1t does not run.
15+
16+use g1t_contracts::actions::{BrokeredSecret, JobCallArgs, STAND_IN_PREFIX};
17+use g1t_contracts::time::rfc3339;
18+use g1t_contracts::Outcome;
19+use g1t_kit::now_ms;
20+use g1t_secrets::random_hex;
21+use serde::Deserialize;
22+use serde_json::{Map, Value};
23+use worker::Result;
24+use worker::wasm_bindgen::JsValue;
25+
26+use crate::plan::LOG_INSERT;
27+use crate::{Actions, Count, check};
28+
29+/// The most hosts one brokered secret names.
30+pub(crate) const MAX_HOSTS: usize = 20;
31+/// The shortest value a brokered secret may have: a shorter one would be
32+/// found, and swapped, inside unrelated secrets.
33+pub(crate) const MIN_VALUE_CHARS: usize = 8;
34+
35+/// Keeps a job's stand-in: what it stands for, sealed and bound to it.
36+pub(crate) const STAND_IN_INSERT: &str =
37+ "INSERT OR REPLACE INTO brokered_secrets (stand_in, job_id, name, value, hosts, expires_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)";
38+/// A job's stand-ins that still work at `?2`.
39+pub(crate) const STAND_INS_OF_JOB: &str =
40+ "SELECT stand_in, name, value, hosts FROM brokered_secrets WHERE job_id = ?1 AND expires_at > ?2";
41+/// Stand-ins whose jobs' time is up.
42+pub(crate) const STAND_INS_EXPIRED: &str = "DELETE FROM brokered_secrets WHERE expires_at <= ?1";
43+
44+/// A brokered secret as one reader resolved it: its key, real value and
45+/// the hosts it is for.
46+#[derive(Clone, Debug, PartialEq)]
47+pub(crate) struct Broker {
48+ pub name: String,
49+ pub value: String,
50+ pub hosts: Vec<String>,
51+}
52+
53+/// A stand-in made for one job.
54+#[derive(Clone, Debug, PartialEq)]
55+pub(crate) struct StandIn {
56+ pub stand_in: String,
57+ pub broker: Broker,
58+}
59+
60+/// A new stand-in: the prefix and 48 random hex characters.
61+pub(crate) fn stand_in() -> String {
62+ format!("{STAND_IN_PREFIX}{}", random_hex(24))
63+}
64+
65+/// Puts a stand-in in place of each brokered value wherever it appears in
66+/// `secrets`, as a whole value or inside one a called workflow built from
67+/// it (`${{ secrets.USER }}:${{ secrets.TOKEN }}`), and returns the
68+/// stand-ins made: one per brokered value found, whatever keys it is under.
69+/// Longer values go first, so one that holds another is replaced whole.
70+pub(crate) fn substitute(secrets: &mut Map<String, Value>, brokers: &[Broker], mut make: impl FnMut() -> String) -> Vec<StandIn> {
71+ let mut ordered: Vec<&Broker> = brokers.iter().filter(|b| b.value.chars().count() >= MIN_VALUE_CHARS).collect();
72+ ordered.sort_by_key(|b| std::cmp::Reverse(b.value.len()));
73+ let mut made: Vec<StandIn> = Vec::new();
74+ for broker in ordered {
75+ if made.iter().any(|m| m.broker.value == broker.value) {
76+ continue;
77+ }
78+ let mut stand_in: Option<String> = None;
79+ for value in secrets.values_mut() {
80+ let Value::String(text) = value else { continue };
81+ if text.contains(&broker.value) {
82+ let with = stand_in.get_or_insert_with(&mut make);
83+ *text = text.replace(&broker.value, with);
84+ }
85+ }
86+ if let Some(stand_in) = stand_in {
87+ made.push(StandIn { stand_in, broker: broker.clone() });
88+ }
89+ }
90+ made
91+}
92+
93+/// What a job on a self-hosted runner is told for each brokered secret it
94+/// was given.
95+pub(crate) fn self_hosted_note(name: &str) -> String {
96+ format!(
97+ "##[warning]{name} is a brokered secret: this job has a stand-in for it, which g1t fills in only on its own machines for now. On a self-hosted runner, requests that use it will not be signed in."
98+ )
99+}
100+
101+/// A line of a job's log, stamped as the runner stamps its own.
102+pub(crate) fn stamped(text: &str) -> String {
103+ let at = rfc3339(now_ms());
104+ text.lines().map(|line| format!("{at} {line}\n")).collect()
105+}
106+
107+#[derive(Deserialize)]
108+struct StandInRow {
109+ stand_in: String,
110+ name: String,
111+ value: String,
112+ hosts: String,
113+}
114+
115+impl Actions {
116+ /// Keeps a job's stand-ins until its time is up, and marks the job so
117+ /// its log is masked for their values. Stand-ins whose time is up go.
118+ pub(crate) async fn keep_stand_ins(&self, job_id: &str, timeout_minutes: u32, stand_ins: &[StandIn]) -> Result<()> {
119+ let Some(sealer) = &self.sealer else { return Ok(()) };
120+ let now = now_ms();
121+ let expires = now + (u64::from(timeout_minutes) * 60 + 600) * 1000;
122+ let mut statements = vec![self.db.prepare(STAND_INS_EXPIRED).bind(&[(now as f64).into()])?];
123+ for made in stand_ins {
124+ statements.push(self.db.prepare(STAND_IN_INSERT).bind(&[
125+ made.stand_in.as_str().into(),
126+ job_id.into(),
127+ made.broker.name.as_str().into(),
128+ sealer.seal(&made.broker.value, &made.stand_in).into(),
129+ serde_json::to_string(&made.broker.hosts)?.into(),
130+ (expires as f64).into(),
131+ ])?);
132+ }
133+ statements.push(self.db.prepare("UPDATE jobs SET brokered = 1 WHERE id = ?").bind(&[job_id.into()])?);
134+ self.db.batch(statements).await?;
135+ Ok(())
136+ }
137+
138+ /// A job's stand-ins that still work, opened.
139+ pub(crate) async fn stand_ins_of(&self, job_id: &str) -> Result<Vec<BrokeredSecret>> {
140+ let Some(sealer) = &self.sealer else { return Ok(Vec::new()) };
141+ let rows = self
142+ .db
143+ .prepare(STAND_INS_OF_JOB)
144+ .bind(&[job_id.into(), (now_ms() as f64).into()])?
145+ .all()
146+ .await?
147+ .results::<StandInRow>()?;
148+ Ok(rows
149+ .into_iter()
150+ .filter_map(|row| {
151+ Some(BrokeredSecret {
152+ value: sealer.open(&row.value, &row.stand_in)?,
153+ hosts: serde_json::from_str(&row.hosts).unwrap_or_default(),
154+ name: row.name,
155+ stand_in: row.stand_in,
156+ })
157+ })
158+ .collect())
159+ }
160+
161+ /// Whether a job in the repository could be given a brokered secret:
162+ /// its project or its workspace has one. Asked as a job starts, so the
163+ /// runner sends the job's HTTP through the Worker even when its network
164+ /// is open; a job that turns out to get none just goes the long way.
165+ pub(crate) async fn may_broker(&self, repo_id: &str, namespace: &str) -> Result<bool> {
166+ let Some(project) = self.project_of(repo_id).await? else { return Ok(false) };
167+ let found = self
168+ .db
169+ .prepare("SELECT COUNT(*) AS n FROM settings WHERE kind = 'secret' AND brokered_hosts IS NOT NULL AND owner IN (?, ?)")
170+ .bind(&[project.id.as_str().into(), namespace.to_lowercase().into()])?
171+ .first::<Count>(None)
172+ .await?;
173+ Ok(found.is_some_and(|count| count.n > 0))
174+ }
175+
176+ /// Adds g1t's own lines to a job's log, under `step` (0 for its setup).
177+ pub(crate) async fn note_log(&self, job_id: &str, step: u32, text: &str) -> Result<()> {
178+ self.db
179+ .prepare(LOG_INSERT)
180+ .bind(&[job_id.into(), step.into(), stamped(text).into(), JsValue::NULL])?
181+ .run()
182+ .await?;
183+ Ok(())
184+ }
185+
186+ /// `brokered_secrets`: a running job's stand-ins and what they stand
187+ /// for, for the runner Worker to fill in on the job's requests. Asked
188+ /// with the job's own token, which only the runner and the job hold;
189+ /// the API never passes this method on, so the job cannot ask it.
190+ pub async fn brokered_secrets(&self, a: JobCallArgs) -> Result<Outcome<Vec<BrokeredSecret>>> {
191+ let job = check!(self.job_for_token(&a).await?);
192+ Ok(Outcome::Ok(self.stand_ins_of(&job.id).await?))
193+ }
194+}
195+
196+#[cfg(test)]
197+mod tests {
198+ use rusqlite::{Connection, params};
199+ use serde_json::{Map, Value, json};
200+
201+ use super::{Broker, STAND_IN_INSERT, STAND_INS_EXPIRED, STAND_INS_OF_JOB, self_hosted_note, stand_in, substitute};
202+
203+ fn broker(name: &str, value: &str, hosts: &[&str]) -> Broker {
204+ Broker { name: name.into(), value: value.into(), hosts: hosts.iter().map(|h| (*h).to_owned()).collect() }
205+ }
206+
207+ fn counter() -> impl FnMut() -> String {
208+ let mut n = 0;
209+ move || {
210+ n += 1;
211+ format!("g1tsec_{n}")
212+ }
213+ }
214+
215+ #[test]
216+ fn stand_ins_are_long_random_tokens() {
217+ let one = stand_in();
218+ assert!(one.starts_with("g1tsec_"));
219+ assert_eq!(one.len(), "g1tsec_".len() + 48);
220+ assert!(one["g1tsec_".len()..].chars().all(|c| c.is_ascii_hexdigit()));
221+ assert_ne!(one, stand_in());
222+ }
223+
224+ #[test]
225+ fn a_brokered_value_never_reaches_the_job() {
226+ let mut secrets: Map<String, Value> = json!({
227+ "NPM_TOKEN": "npm_realvalue123",
228+ "ALSO_NPM": "npm_realvalue123",
229+ "BASIC": "me:npm_realvalue123",
230+ "PLAIN": "not-brokered-value",
231+ })
232+ .as_object()
233+ .cloned()
234+ .unwrap();
235+ let made = substitute(&mut secrets, &[broker("NPM_TOKEN", "npm_realvalue123", &["registry.npmjs.org"])], counter());
236+ assert_eq!(made.len(), 1);
237+ assert_eq!(made[0].stand_in, "g1tsec_1");
238+ assert_eq!(made[0].broker.hosts, ["registry.npmjs.org"]);
239+ assert_eq!(secrets["NPM_TOKEN"], "g1tsec_1");
240+ assert_eq!(secrets["ALSO_NPM"], "g1tsec_1");
241+ assert_eq!(secrets["BASIC"], "me:g1tsec_1");
242+ assert_eq!(secrets["PLAIN"], "not-brokered-value");
243+ assert!(!serde_json::to_string(&secrets).unwrap().contains("npm_realvalue123"));
244+ }
245+
246+ #[test]
247+ fn each_secret_gets_its_own_stand_in_and_longer_values_go_first() {
248+ let mut secrets: Map<String, Value> =
249+ json!({ "SHORT": "abcdefgh", "LONG": "abcdefgh-and-more" }).as_object().cloned().unwrap();
250+ let made = substitute(
251+ &mut secrets,
252+ &[broker("SHORT", "abcdefgh", &["a.example.com"]), broker("LONG", "abcdefgh-and-more", &["b.example.com"])],
253+ counter(),
254+ );
255+ assert_eq!(made.len(), 2);
256+ assert_eq!(made[0].broker.name, "LONG");
257+ assert_eq!(secrets["LONG"], "g1tsec_1");
258+ assert_eq!(secrets["SHORT"], "g1tsec_2");
259+ }
260+
261+ #[test]
262+ fn values_too_short_or_not_given_make_no_stand_in() {
263+ let mut secrets: Map<String, Value> = json!({ "PIN": "1234" }).as_object().cloned().unwrap();
264+ assert!(substitute(&mut secrets, &[broker("PIN", "1234", &["x.example.com"])], counter()).is_empty());
265+ assert!(substitute(&mut secrets, &[broker("GONE", "absent-value", &["x.example.com"])], counter()).is_empty());
266+ assert_eq!(secrets["PIN"], "1234");
267+ }
268+
269+ #[test]
270+ fn the_self_hosted_note_is_a_warning_naming_the_secret() {
271+ let note = self_hosted_note("NPM_TOKEN");
272+ assert!(note.starts_with("##[warning]NPM_TOKEN"));
273+ assert!(note.contains("only on its own machines"));
274+ }
275+
276+ fn database() -> Connection {
277+ let db = Connection::open_in_memory().unwrap();
278+ for name in ["0001_init.sql", "0019_brokered_secrets.sql"] {
279+ let path = format!("{}/migrations/{name}", env!("CARGO_MANIFEST_DIR"));
280+ db.execute_batch(&std::fs::read_to_string(path).unwrap()).unwrap();
281+ }
282+ db
283+ }
284+
285+ #[test]
286+ fn a_job_finds_only_its_own_stand_ins_while_they_work() {
287+ let db = database();
288+ let keep = |stand_in: &str, job: &str, expires: i64| {
289+ db.execute(STAND_IN_INSERT, params![stand_in, job, "NPM_TOKEN", "sealed", "[\"registry.npmjs.org\"]", expires]).unwrap()
290+ };
291+ keep("g1tsec_a", "job_1", 2_000);
292+ keep("g1tsec_b", "job_2", 2_000);
293+ keep("g1tsec_c", "job_1", 500);
294+ let of = |job: &str, now: i64| -> Vec<String> {
295+ let mut rows = db.prepare(STAND_INS_OF_JOB).unwrap();
296+ rows.query_map(params![job, now], |row| row.get::<_, String>(0)).unwrap().map(Result::unwrap).collect()
297+ };
298+ assert_eq!(of("job_1", 1_000), ["g1tsec_a"]);
299+ assert_eq!(of("job_2", 1_000), ["g1tsec_b"]);
300+ assert!(of("job_1", 3_000).is_empty());
301+ assert_eq!(db.execute(STAND_INS_EXPIRED, params![1_000]).unwrap(), 1);
302+ // Settings rows and jobs carry the new columns.
303+ db.execute("UPDATE settings SET brokered_hosts = '[]'", []).unwrap();
304+ db.execute("UPDATE jobs SET brokered = 1", []).unwrap();
305+ }
306+}
+6−1
99 //! is skipped or expanded into its matrix, queued, started in a sandbox
1010 //! when the workspace has room, and finished by the sandbox's report.
1111 //! - [`payload`] builds the webhook-shaped `github.event`.
12−//! - [`settings`] keeps secrets and variables.
12+//! - [`settings`] keeps secrets and variables, and [`broker`] the stand-ins
13+//! jobs are given for brokered secrets.
1314 //! - [`protection`] keeps a repository's workflow settings and its
1415 //! environments' protection rules, holds jobs at those rules until they
1516 //! pass, and lets pull requests' runs from outside wait for approval.
2627 //! `permissions:` give it, revoked when the job ends.
2728
2829 mod run_artifacts;
30+mod broker;
2931 mod cache;
3032 mod managed;
3133 mod mirrored;
271273 "job_action_archive" => reply(&service.job_action_archive(args(body)?).await?),
272274 "check_runs" => reply(&service.check_runs(args(body)?).await?),
273275 "job_report" => reply(&service.job_report(args(body)?).await?),
276+ // A running job's stand-ins for brokered secrets: the runner
277+ // Worker's alone, never passed on by the API.
278+ "brokered_secrets" => reply(&service.brokered_secrets(args(body)?).await?),
274279 // actions/cache, through the API with the job's token.
275280 "cache_lookup" => reply(&service.cache_lookup(args(body)?).await?),
276281 "cache_reserve" => reply(&service.cache_reserve(args(body)?).await?),
+57−4
1919 use serde_json::{Map, Value, json};
2020 use worker::Result;
2121
22+use crate::broker::{self, Broker};
2223 use crate::protection::Gate;
2324 use crate::sync::WorkflowRow;
2425 use crate::{Actions, Count, MAX_TIMEOUT_MINUTES, RUNNING_PER_WORKSPACE, SELF_HOSTED_MAX_TIMEOUT_MINUTES, SILENT_MS, SITE, check, fail, optional, repo_path};
3435 /// runner's number for the batch (null from runners that send none): a
3536 /// batch sent again, after an answer the runner did not hear, has the
3637 /// number of one already kept, and is not kept twice (migration 0016).
37−const LOG_INSERT: &str = "INSERT INTO logs (job_id, seq, step, text, chunk)
38+pub(crate) const LOG_INSERT: &str = "INSERT INTO logs (job_id, seq, step, text, chunk)
3839 SELECT ?1, COALESCE(MAX(seq), 0) + 1, ?2, ?3, ?4 FROM logs WHERE job_id = ?1
3940 ON CONFLICT (job_id, chunk) DO NOTHING";
4041 /// The most one step's job summary keeps, in bytes, and how many steps of a
231232 /// (migration 0009).
232233 #[serde(default)]
233234 pub cancel_requested_at: Option<String>,
235+ /// Whether it was given stand-ins for brokered secrets (migration
236+ /// 0019), so its log is masked for their values too.
237+ #[serde(default)]
238+ pub brokered: u32,
234239 }
235240
236241 /// How long a cancelled job has to run its `if: always()` and `cancelled()`
16241629 });
16251630 // Its environment and the machine it asked for, for the runner.
16261631 let details = run.as_ref().map(|run| start_details(run, &job)).unwrap_or_default();
1632+ let trusted = run.as_ref().is_some_and(|run| run.trusted != 0);
1633+ // Only a trusted run's jobs get secrets, brokered ones included.
1634+ let brokered = trusted && self.may_broker(&job.repo_id, &job.namespace).await?;
16271635 let started: Outcome<Value> = g1t_kit::call(
16281636 &self.runner,
16291637 "start_actions_job",
16341642 timeout_minutes: job.timeout_minutes,
16351643 workflow: run.as_ref().map(|run| run.path.clone()),
16361644 environment: details.environment,
1637− trusted: run.as_ref().is_some_and(|run| run.trusted != 0),
1645+ trusted,
16381646 instance: details.instance,
1647+ brokered,
16391648 },
16401649 )
16411650 .await
23332342 // A run that is not trusted (a pull request from outside the
23342343 // workspace) gets no secrets and an empty token.
23352344 let passed = job.call().filter(|c| c["role"] == "callee").and_then(|c| c.get("secrets").cloned());
2345+ // Brokered secrets among them (broker.rs), whatever keys they reach
2346+ // the job under.
2347+ let mut brokers: Vec<Broker> = Vec::new();
23362348 let mut secrets = match (trusted, passed) {
23372349 (false, _) => Map::new(),
2338− (true, None) => self.secrets_for(&run.repo_id, &run.repo, environment.as_deref(), true).await?,
2350+ (true, None) => {
2351+ let resolved = self.secrets_for(&run.repo_id, &run.repo, environment.as_deref(), true).await?;
2352+ brokers.extend(resolved.brokers());
2353+ resolved.values
2354+ }
23392355 // A called workflow's job: what its callers passed it (reach.rs),
23402356 // and its own environment's secrets over them.
23412357 (true, Some(plan)) => {
23422358 let base = self.secrets_for(&run.repo_id, &run.repo, None, true).await?;
2359+ brokers.extend(base.brokers());
2360+ let base = base.values;
23432361 let vars = self.variables_for(&run.repo_id, &run.repo, None, true).await?;
23442362 let github = run.info().context(&job.key, "", run.action.as_deref());
23452363 let mut passed = crate::reach::resolve_secrets(&plan, &base, &github, &vars);
23462364 if let Some(name) = environment.as_deref() {
23472365 let own = self.secrets_for(&run.repo_id, &run.repo, Some(name), true).await?;
2348− for (key, value) in own {
2366+ brokers.extend(own.brokers());
2367+ for (key, value) in own.values {
23492368 if base.get(&key) != Some(&value) {
23502369 passed.insert(key, value);
23512370 }
23542373 passed
23552374 }
23562375 };
2376+ // A brokered secret's value never reaches the sandbox: the job gets
2377+ // a stand-in, which the runner Worker fills in on its requests to
2378+ // the secret's hosts. A self-hosted runner's requests never pass
2379+ // the Worker, so there it stays a stand-in, and the log says so.
2380+ let stand_ins = broker::substitute(&mut secrets, &brokers, broker::stand_in);
2381+ if !stand_ins.is_empty() {
2382+ self.keep_stand_ins(&job.id, job.timeout_minutes, &stand_ins).await?;
2383+ if job.runner_id.is_some() {
2384+ let mut names: Vec<&str> = stand_ins.iter().map(|made| made.broker.name.as_str()).collect();
2385+ names.sort_unstable();
2386+ let notes: String = names.iter().map(|name| broker::self_hosted_note(name) + "\n").collect();
2387+ self.note_log(&job.id, 0, &notes).await?;
2388+ }
2389+ }
23572390 secrets.insert("G1T_TOKEN".into(), Value::String(token.clone()));
23582391 secrets.insert("GITHUB_TOKEN".into(), Value::String(token.clone()));
23592392 // Each secret as it is, a line at a time, base64 and JSON-escaped.
25352568 .run()
25362569 .await?;
25372570 }
2571+ // A line from g1t itself, not the job: the runner Worker's note
2572+ // that a brokered secret went to a host it is not for. Put
2573+ // under the step that is running.
2574+ "note" => {
2575+ let text = report["text"].as_str().unwrap_or_default().chars().take(1000).collect::<String>();
2576+ let steps: Vec<Value> = serde_json::from_str(&job.steps).unwrap_or_default();
2577+ let step = steps.iter().rev().find(|step| step["status"] == "in_progress").and_then(|step| step["number"].as_u64()).unwrap_or(0);
2578+ if !text.trim().is_empty() {
2579+ self.note_log(&job.id, step as u32, &text).await?;
2580+ }
2581+ }
25382582 "log" => {
25392583 let mut text = report["text"].as_str().unwrap_or_default().to_owned();
2584+ // A brokered secret's value never enters the sandbox, but an
2585+ // answer that echoed it whole could carry it in; it is
2586+ // masked here, where the value is known.
2587+ if job.brokered != 0 {
2588+ let values: Vec<String> = self.stand_ins_of(&job.id).await?.into_iter().map(|secret| secret.value).collect();
2589+ if !values.is_empty() {
2590+ text = g1t_actions::mask::apply(&text, &g1t_actions::mask::all_variants(values.iter().map(String::as_str)));
2591+ }
2592+ }
25402593 if text.len() > MAX_CHUNK_BYTES {
25412594 let mut cut = MAX_CHUNK_BYTES;
25422595 while !text.is_char_boundary(cut) {
+154−16
1414 //! are the project's. Rows from before projects move over the first time
1515 //! they are touched. Names are upper-cased, as GitHub treats
1616 //! them without regard to case. Agents never read any.
17+//!
18+//! A secret may be brokered (broker.rs): its row names the hosts it is for,
19+//! and workflow jobs get a stand-in in place of its value. Only workflows
20+//! read a brokered secret: a deployment's app needs the value itself.
21+
22+use std::collections::BTreeMap;
1723
1824 use g1t_contracts::access::Capability;
1925 use g1t_contracts::actions::{
2026 CONSUMERS, DeleteSettingArgs, ResolveSettingsArgs, ResolvedSettings, SetSettingArgs, Setting, SettingsArgs,
2127 SettingsOwner,
2228 };
29+use g1t_contracts::guardrails::normalize_domain;
2330 use g1t_contracts::projects::{ByRepoArgs, ProjectRef};
2431 use g1t_contracts::time::rfc3339;
2532 use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User, new_id};
2936 use worker::Result;
3037 use worker::wasm_bindgen::JsValue;
3138
39+use crate::broker::{Broker, MAX_HOSTS, MIN_VALUE_CHARS};
3240 use crate::{Actions, check, fail};
3341
3442 /// The largest value, as on GitHub.
4957 repositories: Option<String>,
5058 note: Option<String>,
5159 updated_by: Option<String>,
60+ /// A brokered secret's hosts, as a JSON array (migration 0019).
61+ #[serde(default)]
62+ brokered_hosts: Option<String>,
5263 }
5364
5465 /// A name GitHub would accept: letters, digits and `_`, not starting with
107118 Ok(out)
108119 }
109120
121+/// A brokered secret's hosts, tidied as allowed domains are: each once, at
122+/// most `MAX_HOSTS`.
123+fn valid_hosts(list: &[String]) -> Result<Vec<String>, String> {
124+ let mut out: Vec<String> = Vec::new();
125+ for host in list {
126+ if host.trim().is_empty() {
127+ continue;
128+ }
129+ let host = normalize_domain(host)?;
130+ if !out.contains(&host) {
131+ out.push(host);
132+ }
133+ }
134+ if out.len() > MAX_HOSTS {
135+ return Err(format!("A brokered secret is for at most {MAX_HOSTS} hosts."));
136+ }
137+ Ok(out)
138+}
139+
140+/// What readers of one kind get: each key's value, and the hosts of the
141+/// brokered secrets among them.
142+#[derive(Default)]
143+pub(crate) struct Resolved {
144+ pub values: Map<String, Value>,
145+ pub brokered: BTreeMap<String, Vec<String>>,
146+}
147+
148+impl Resolved {
149+ /// The brokered secrets among the values.
150+ pub(crate) fn brokers(&self) -> Vec<Broker> {
151+ self.brokered
152+ .iter()
153+ .filter_map(|(name, hosts)| {
154+ let value = self.values.get(name)?.as_str()?;
155+ Some(Broker { name: name.clone(), value: value.to_owned(), hosts: hosts.clone() })
156+ })
157+ .collect()
158+ }
159+
160+ /// The values, less the brokered secrets: for readers that would put
161+ /// them where no stand-in can work.
162+ fn unbrokered(mut self) -> Map<String, Value> {
163+ for name in self.brokered.keys() {
164+ self.values.remove(name);
165+ }
166+ self.values
167+ }
168+}
169+
110170 fn split(list: &str) -> Vec<String> {
111171 list.split(',').filter(|s| !s.is_empty()).map(str::to_owned).collect()
112172 }
122182 self.repositories.as_deref().and_then(|json| serde_json::from_str(json).ok()).unwrap_or_default()
123183 }
124184
185+ fn brokered_hosts(&self) -> Vec<String> {
186+ self.brokered_hosts.as_deref().and_then(|json| serde_json::from_str(json).ok()).unwrap_or_default()
187+ }
188+
125189 fn reaches(&self, project: &str) -> bool {
126190 let list = self.projects();
127191 list.is_empty() || list.iter().any(|p| p.eq_ignore_ascii_case(project))
135199
136200 fn describe(self) -> Setting {
137201 Setting {
202+ brokered_hosts: if self.kind == "secret" { self.brokered_hosts() } else { Vec::new() },
138203 available_to: split(&self.available_to),
139204 environments: self.environments(),
140205 projects: self.projects(),
299364 Ok(environments) => environments,
300365 Err(problem) => return Ok(fail(FailureCode::Invalid, problem)),
301366 };
367+ let brokered = match a.brokered_hosts.as_deref().map(valid_hosts).transpose() {
368+ Ok(hosts) => hosts,
369+ Err(problem) => return Ok(fail(FailureCode::Invalid, problem)),
370+ };
371+ if kind == "variable" && brokered.as_ref().is_some_and(|hosts| !hosts.is_empty()) {
372+ return Ok(fail(FailureCode::Invalid, "Only a secret can be brokered."));
373+ }
302374 let place = check!(self.place(&a.actor, &a.owner, true).await?);
303375 if a.projects.as_ref().is_some_and(|r| !r.is_empty()) && place.scope != "workspace" {
304376 return Ok(fail(FailureCode::Invalid, "Only a workspace's rows choose projects."));
345417 (None, Some(row)) => row.value.clone(),
346418 (None, None) => return Ok(fail(FailureCode::Invalid, "A new row needs a `value`.")),
347419 };
420+ let brokered: Vec<String> = match brokered {
421+ Some(hosts) => hosts,
422+ None => existing.filter(|_| kind == "secret").map(SettingRow::brokered_hosts).unwrap_or_default(),
423+ };
424+ // A new brokered secret is read by workflows alone, unless it says
425+ // otherwise, which is refused below.
426+ let default_readers = if brokered.is_empty() { CONSUMERS.join(",") } else { "workflows".to_owned() };
348427 let available_to = readers
349428 .map(|r| r.join(","))
350429 .or_else(|| existing.map(|row| row.available_to.clone()))
351− .unwrap_or_else(|| CONSUMERS.join(","));
430+ .unwrap_or(default_readers);
431+ if !brokered.is_empty() {
432+ if split(&available_to).iter().any(|r| r == "deployments") {
433+ return Ok(fail(
434+ FailureCode::Invalid,
435+ "A brokered secret is read by workflows only: a deployment's app needs the value itself. Leave Deployments out, or stop brokering it.",
436+ ));
437+ }
438+ // Too short a value would be found inside other secrets.
439+ let plain = match (&a.value, existing) {
440+ (Some(value), _) => Some(value.clone()),
441+ (None, Some(row)) if row.kind == "secret" => self.sealer.as_ref().and_then(|sealer| sealer.open(&row.value, &row.id)),
442+ (None, Some(row)) => Some(row.value.clone()),
443+ (None, None) => None,
444+ };
445+ if plain.is_some_and(|value| value.trim().chars().count() < MIN_VALUE_CHARS) {
446+ return Ok(fail(FailureCode::Invalid, format!("A brokered secret's value is at least {MIN_VALUE_CHARS} characters.")));
447+ }
448+ }
352449 let repositories: Option<String> = match &a.projects {
353450 Some(list) if list.is_empty() => None,
354451 Some(list) => Some(serde_json::to_string(list).unwrap_or_default()),
363460 let optional = |v: Option<&str>| v.map_or(JsValue::NULL, JsValue::from);
364461 self.db
365462 .prepare(
366− "INSERT INTO settings (id, scope, owner, kind, name, value, updated_at, available_to, environments, repositories, note, updated_by)
367− VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
463+ "INSERT INTO settings (id, scope, owner, kind, name, value, updated_at, available_to, environments, repositories, note, updated_by, brokered_hosts)
464+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
368465 ON CONFLICT (id) DO UPDATE SET kind = excluded.kind, value = excluded.value, updated_at = excluded.updated_at,
369466 available_to = excluded.available_to, environments = excluded.environments,
370− repositories = excluded.repositories, note = excluded.note, updated_by = excluded.updated_by",
467+ repositories = excluded.repositories, note = excluded.note, updated_by = excluded.updated_by,
468+ brokered_hosts = excluded.brokered_hosts",
371469 )
372470 .bind(&[
373471 id.as_str().into(),
382480 optional(repositories.as_deref()),
383481 optional(note.as_deref()),
384482 a.actor.username.as_str().into(),
483+ optional((!brokered.is_empty()).then(|| serde_json::to_string(&brokered).unwrap_or_default()).as_deref()),
385484 ])?
386485 .run()
387486 .await?;
433532 consumer: &str,
434533 environment: Option<&str>,
435534 trusted: bool,
436− ) -> Result<Map<String, Value>> {
535+ ) -> Result<Resolved> {
437536 if kind == "secret" && !trusted {
438− return Ok(Map::new());
537+ return Ok(Resolved::default());
439538 }
440539 let environment = environment.map(str::to_ascii_lowercase);
441− let mut out = Map::new();
540+ let mut out = Resolved::default();
442541 for owner in [namespace.to_lowercase(), project_id.to_owned()] {
443542 let rows: Vec<SettingRow> = self
444543 .rows(&owner)
459558 let Some(row) = chosen else {
460559 // Rows only for other environments: this reader gets
461560 // none, nor the workspace's.
462− out.remove(name);
561+ out.values.remove(name);
562+ out.brokered.remove(name);
463563 continue;
464564 };
465565 let value = if kind == "secret" {
470570 } else {
471571 row.value.clone()
472572 };
473− out.insert(name.to_owned(), Value::String(value));
573+ out.values.insert(name.to_owned(), Value::String(value));
574+ // The row that wins decides whether the key is brokered.
575+ match row.brokered_hosts() {
576+ hosts if kind == "secret" && !hosts.is_empty() => out.brokered.insert(name.to_owned(), hosts),
577+ _ => out.brokered.remove(name),
578+ };
474579 }
475580 }
476581 Ok(out)
483588 let Some(project) = self.project_of(repo_id).await? else {
484589 return Ok(Map::new());
485590 };
486− self.resolved(&project.id, &project.slug, namespace, "variable", "workflows", environment, trusted).await
591+ Ok(self.resolved(&project.id, &project.slug, namespace, "variable", "workflows", environment, trusted).await?.values)
487592 }
488593
489− /// The `secrets` context of a repository's runs, opened.
490− pub async fn secrets_for(&self, repo_id: &str, repo: &str, environment: Option<&str>, trusted: bool) -> Result<Map<String, Value>> {
594+ /// The `secrets` context of a repository's runs, opened, with which of
595+ /// them are brokered: `job_spec` gives the job stand-ins for those.
596+ pub(crate) async fn secrets_for(&self, repo_id: &str, repo: &str, environment: Option<&str>, trusted: bool) -> Result<Resolved> {
491597 let (namespace, _) = repo.split_once('/').unwrap_or((repo, ""));
492598 let Some(project) = self.project_of(repo_id).await? else {
493− return Ok(Map::new());
599+ return Ok(Resolved::default());
494600 };
495601 self.resolved(&project.id, &project.slug, namespace, "secret", "workflows", environment, trusted).await
496602 }
508614 _ => return Ok(ResolvedSettings::default()),
509615 };
510616 Ok(ResolvedSettings {
617+ // A brokered secret's value goes only to workflow jobs, as a
618+ // stand-in; a version update's registries go without it.
511619 secrets: self
512620 .resolved(&project_id, &slug, &a.repo.namespace, "secret", &a.consumer, environment, a.trusted)
513− .await?,
621+ .await?
622+ .unbrokered(),
514623 variables: self
515624 .resolved(&project_id, &slug, &a.repo.namespace, "variable", &a.consumer, environment, a.trusted)
516− .await?,
625+ .await?
626+ .values,
517627 })
518628 }
519629 }
520630
521631 #[cfg(test)]
522632 mod tests {
523− use super::{SettingRow, consumers, valid_environments, valid_name};
633+ use std::collections::BTreeMap;
634+
635+ use serde_json::json;
636+
637+ use super::{Resolved, SettingRow, consumers, valid_environments, valid_hosts, valid_name};
524638
525639 fn row(environments: &str) -> SettingRow {
526640 SettingRow {
535649 repositories: None,
536650 note: None,
537651 updated_by: None,
652+ brokered_hosts: None,
538653 }
539654 }
540655
565680 assert!(!row("").overlaps(&["preview".into()]));
566681 assert!(row("").overlaps(&[]));
567682 }
683+
684+ #[test]
685+ fn brokered_hosts_are_domains_each_once() {
686+ let hosts = valid_hosts(&["HTTPS://Registry.npmjs.org/".into(), "*.example.com".into(), "registry.npmjs.org".into(), " ".into()]).unwrap();
687+ assert_eq!(hosts, ["registry.npmjs.org", "*.example.com"]);
688+ assert!(valid_hosts(&["localhost".into()]).is_err());
689+ assert!(valid_hosts(&(0..21).map(|n| format!("h{n}.example.com")).collect::<Vec<_>>()).is_err());
690+ assert!(valid_hosts(&[]).unwrap().is_empty());
691+ }
692+
693+ #[test]
694+ fn readers_outside_workflows_never_get_a_brokered_value() {
695+ let resolved = Resolved {
696+ values: json!({ "NPM_TOKEN": "npm_realvalue123", "PLAIN": "value" }).as_object().cloned().unwrap(),
697+ brokered: BTreeMap::from([("NPM_TOKEN".to_owned(), vec!["registry.npmjs.org".to_owned()])]),
698+ };
699+ let brokers = resolved.brokers();
700+ assert_eq!(brokers.len(), 1);
701+ assert_eq!(brokers[0].value, "npm_realvalue123");
702+ let plain = resolved.unbrokered();
703+ assert!(!plain.contains_key("NPM_TOKEN"));
704+ assert_eq!(plain["PLAIN"], "value");
705+ }
568706 }
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…

Comments

Sign in to comment.