@intentius/chant 0.124.30 → 0.124.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +38 -4
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +50 -0
- package/dist/cli/registry.d.ts +18 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +12 -0
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-executor.js +22 -3
- package/dist/op/redact.d.ts +34 -0
- package/dist/op/redact.d.ts.map +1 -0
- package/dist/op/redact.js +94 -0
- package/dist/op/step-console.d.ts +2 -0
- package/dist/op/step-console.d.ts.map +1 -1
- package/dist/op/step-console.js +5 -2
- package/dist/pr-forge.d.ts +17 -0
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/pr-forge.js +12 -6
- package/dist/pr-report-url.d.ts +8 -0
- package/dist/pr-report-url.d.ts.map +1 -1
- package/dist/pr-report-url.js +47 -1
- package/dist/wave-runtime.mjs +159 -159
- package/dist/workspace/ci-run-index.d.ts +96 -0
- package/dist/workspace/ci-run-index.d.ts.map +1 -0
- package/dist/workspace/ci-run-index.js +162 -0
- package/dist/workspace/ci-run-index.schema.json +50 -0
- package/dist/workspace/ci-run.d.ts +84 -7
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +241 -14
- package/dist/workspace/ci-runner-key.d.ts +120 -0
- package/dist/workspace/ci-runner-key.d.ts.map +1 -0
- package/dist/workspace/ci-runner-key.js +217 -0
- package/dist/workspace/ci-secrets.d.ts +65 -0
- package/dist/workspace/ci-secrets.d.ts.map +1 -0
- package/dist/workspace/ci-secrets.js +211 -0
- package/dist/workspace/ci-serve.d.ts +13 -0
- package/dist/workspace/ci-serve.d.ts.map +1 -1
- package/dist/workspace/ci-serve.js +69 -13
- package/dist/workspace/reason-codes.d.ts +6 -2
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +7 -2
- package/dist/workspace/trust/trust-write.d.ts +109 -0
- package/dist/workspace/trust/trust-write.d.ts.map +1 -0
- package/dist/workspace/trust/trust-write.js +320 -0
- package/dist/workspace/trust-write.schema.json +265 -0
- package/package.json +1 -1
- package/src/cli/main.test.ts +1 -0
- package/src/cli/main.ts +38 -4
- package/src/cli/parse-args.ts +36 -0
- package/src/cli/registry.ts +18 -0
- package/src/op/local-executor.ts +31 -3
- package/src/op/redact.ts +93 -0
- package/src/op/step-console.ts +7 -2
- package/src/pr-forge.test.ts +29 -0
- package/src/pr-forge.ts +30 -6
- package/src/pr-report-url.ts +53 -1
- package/src/workspace/ci-run-index.schema.json +50 -0
- package/src/workspace/ci-run-index.test.ts +353 -0
- package/src/workspace/ci-run-index.ts +192 -0
- package/src/workspace/ci-run.test.ts +337 -37
- package/src/workspace/ci-run.ts +277 -15
- package/src/workspace/ci-runner-key.test.ts +166 -0
- package/src/workspace/ci-runner-key.ts +250 -0
- package/src/workspace/ci-secrets.test.ts +106 -0
- package/src/workspace/ci-secrets.ts +213 -0
- package/src/workspace/ci-serve.test.ts +136 -37
- package/src/workspace/ci-serve.ts +66 -13
- package/src/workspace/reason-codes.test.ts +2 -0
- package/src/workspace/reason-codes.ts +7 -2
- package/src/workspace/trust/trust-write.test.ts +179 -0
- package/src/workspace/trust/trust-write.ts +377 -0
- package/src/workspace/trust-write.schema.json +265 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runner key on a `chant ci serve` or `chant ci run` host (#4099, epic
|
|
3
|
+
* #4101).
|
|
4
|
+
*
|
|
5
|
+
* The host keeps its runner key in the serve's state directory, by default
|
|
6
|
+
* `<git common dir>/chant/ci-serve/runner-key.pem`: an Ed25519 private key in
|
|
7
|
+
* PEM, mode 0600, made by `--setup-key` when there is none. Its public half
|
|
8
|
+
* is the `ssh-ed25519` line `.chant/trust.json` lists under `runners`, and
|
|
9
|
+
* `--setup-key --trust` proposes that entry through `chant workspace trust
|
|
10
|
+
* runner add` (#3510) on a branch of its own, pushed for review. It never
|
|
11
|
+
* commits to the target branch: changing the trust config is a protected
|
|
12
|
+
* write, and the change is reviewed and merged like any other.
|
|
13
|
+
*
|
|
14
|
+
* `ci serve` refuses to start when its key is not listed, with the runner
|
|
15
|
+
* role, in `.chant/trust.json` at the target branch's head, since every run
|
|
16
|
+
* it signed would then be ignored by `ci tick --source runs`. `--unsigned`
|
|
17
|
+
* starts it without a key.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { execFileSync } from "node:child_process";
|
|
21
|
+
import { generateKeyPairSync } from "node:crypto";
|
|
22
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { hostname, tmpdir } from "node:os";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { ciTagSigner } from "./ci-green";
|
|
26
|
+
import { CiRunError } from "./ci-run";
|
|
27
|
+
import { loadRunnerKey } from "./trust/dsse";
|
|
28
|
+
import { trustWrite } from "./trust/trust-write";
|
|
29
|
+
|
|
30
|
+
/** The runner key's file in the state directory. */
|
|
31
|
+
export const RUNNER_KEY_FILE = "runner-key.pem";
|
|
32
|
+
|
|
33
|
+
/** The principal `--setup-key --trust` lists the key for when `--principal` names none. */
|
|
34
|
+
export function defaultRunnerPrincipal(): string {
|
|
35
|
+
return `chant-runner:${hostname().split(".")[0]!.toLowerCase().replace(/[^a-z0-9-]/g, "-") || "host"}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface HostRunnerKey {
|
|
39
|
+
path: string;
|
|
40
|
+
pem: string;
|
|
41
|
+
/** `ssh-ed25519 <base64>`. */
|
|
42
|
+
publicKey: string;
|
|
43
|
+
/** Whether this call made it. */
|
|
44
|
+
created: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const message = (err: unknown): string => (err instanceof Error ? err.message : String(err));
|
|
48
|
+
|
|
49
|
+
function git(cwd: string, args: string[]): string {
|
|
50
|
+
return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function tryGit(cwd: string, args: string[]): string | undefined {
|
|
54
|
+
try {
|
|
55
|
+
return git(cwd, args);
|
|
56
|
+
} catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Read the key at `path`, refusing one others can read or one that is not an Ed25519 private key in PEM. */
|
|
62
|
+
function readHostKey(path: string): HostRunnerKey {
|
|
63
|
+
const mode = statSync(path).mode & 0o777;
|
|
64
|
+
if (mode & 0o077) throw new CiRunError(`${path} can be read by other users (mode ${mode.toString(8)}); chmod 600 it, since it signs for this runner`);
|
|
65
|
+
const pem = readFileSync(path, "utf-8");
|
|
66
|
+
try {
|
|
67
|
+
return { path, pem, publicKey: loadRunnerKey(pem).publicKey, created: false };
|
|
68
|
+
} catch (err) {
|
|
69
|
+
throw new CiRunError(`${path} is not an Ed25519 private key in PEM: ${message(err)}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The host's runner key in `stateDir`, or undefined when there is none. */
|
|
74
|
+
export function hostRunnerKey(stateDir: string): HostRunnerKey | undefined {
|
|
75
|
+
const path = join(stateDir, RUNNER_KEY_FILE);
|
|
76
|
+
return existsSync(path) ? readHostKey(path) : undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The host's runner key in `stateDir`, made when there is none: Ed25519, PEM, mode 0600 in a directory of mode 0700. */
|
|
80
|
+
export function ensureHostRunnerKey(stateDir: string): HostRunnerKey {
|
|
81
|
+
const found = hostRunnerKey(stateDir);
|
|
82
|
+
if (found) return found;
|
|
83
|
+
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
84
|
+
const path = join(stateDir, RUNNER_KEY_FILE);
|
|
85
|
+
const { privateKey } = generateKeyPairSync("ed25519");
|
|
86
|
+
const pem = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
|
87
|
+
// wx: never replace a key another process made in the meantime.
|
|
88
|
+
writeFileSync(path, pem, { mode: 0o600, flag: "wx" });
|
|
89
|
+
chmodSync(path, 0o600);
|
|
90
|
+
return { path, pem, publicKey: loadRunnerKey(pem).publicKey, created: true };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The target branch's head, as the readers of runners take it: the remote's
|
|
95
|
+
* branch after a fetch (best effort), else the local branch. Undefined when
|
|
96
|
+
* neither exists.
|
|
97
|
+
*/
|
|
98
|
+
export function targetHead(top: string, branch: string, remote = "origin"): { commit: string; ref: string } | undefined {
|
|
99
|
+
if (tryGit(top, ["remote", "get-url", remote]) !== undefined) tryGit(top, ["fetch", "-q", remote, `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`]);
|
|
100
|
+
for (const ref of [`refs/remotes/${remote}/${branch}`, `refs/heads/${branch}`]) {
|
|
101
|
+
const commit = tryGit(top, ["rev-parse", "--verify", "-q", `${ref}^{commit}`]);
|
|
102
|
+
if (commit) return { commit, ref };
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The exact command that lists `publicKey` for `principal`. */
|
|
108
|
+
export function trustCommand(principal: string, publicKey: string): string {
|
|
109
|
+
return `chant workspace trust runner add ${principal} --key "${publicKey}"`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Whether `.chant/trust.json` at `commit` lists the key as a runner holding the runner role: its principal, or why not. */
|
|
113
|
+
export function runnerKeyListed(top: string, commit: string, pem: string): { ok: true; principal: string } | { ok: false; reason: string } {
|
|
114
|
+
try {
|
|
115
|
+
return { ok: true, principal: ciTagSigner(top, commit, pem, "the runs it signs").principal };
|
|
116
|
+
} catch (err) {
|
|
117
|
+
return { ok: false, reason: message(err) };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface RunnerTrustProposal {
|
|
122
|
+
/** The branch holding the commit, or null when the key was already listed at the target's head. */
|
|
123
|
+
branch: string | null;
|
|
124
|
+
commit: string | null;
|
|
125
|
+
/** Whether the branch was pushed to the remote. */
|
|
126
|
+
pushed: boolean;
|
|
127
|
+
/** The trust config's digest on the branch. */
|
|
128
|
+
digest: string | null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Propose the runner entry for review (#4099): in a temporary worktree at
|
|
133
|
+
* the target's head, run `trust runner add` (#3510), commit the change on
|
|
134
|
+
* `chant/trust-runner/<principal>`, and push that branch to `remote` when
|
|
135
|
+
* the clone has it. The target branch is never written. The commit is made
|
|
136
|
+
* with the host's git identity and signing settings; when the policy at base
|
|
137
|
+
* has signers, the change needs a signature by a policy writer before it
|
|
138
|
+
* merges.
|
|
139
|
+
*/
|
|
140
|
+
export function proposeRunnerTrust(req: { top: string; branch: string; principal: string; publicKey: string; by?: string; remote?: string }): RunnerTrustProposal {
|
|
141
|
+
const remote = req.remote ?? "origin";
|
|
142
|
+
const head = targetHead(req.top, req.branch, remote);
|
|
143
|
+
if (!head) throw new CiRunError(`there is no branch ${req.branch} here or on ${remote}, so there is no trust config to add the runner to`);
|
|
144
|
+
const branch = `chant/trust-runner/${req.principal.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "")}`;
|
|
145
|
+
if (tryGit(req.top, ["rev-parse", "--verify", "-q", `refs/heads/${branch}`])) {
|
|
146
|
+
throw new CiRunError(`the branch ${branch} exists already; review or delete it, then run --setup-key --trust again`);
|
|
147
|
+
}
|
|
148
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-trust-runner-"));
|
|
149
|
+
try {
|
|
150
|
+
git(req.top, ["worktree", "add", "-q", "-b", branch, dir, head.commit]);
|
|
151
|
+
const doc = trustWrite({ cwd: dir, action: "runner add", name: req.principal, key: req.publicKey, ...(req.by ? { by: req.by } : {}) });
|
|
152
|
+
if ("error" in doc) throw new CiRunError(`trust runner add refused (${doc.error.code}): ${doc.error.message}`);
|
|
153
|
+
if (!doc.changed) {
|
|
154
|
+
git(req.top, ["worktree", "remove", "--force", dir]);
|
|
155
|
+
git(req.top, ["branch", "-q", "-D", branch]);
|
|
156
|
+
return { branch: null, commit: null, pushed: false, digest: doc.digest };
|
|
157
|
+
}
|
|
158
|
+
git(dir, ["add", "--", ".chant/trust.json"]);
|
|
159
|
+
git(dir, ["commit", "-q", "-m", `trust the ${req.principal} runner key\n\nchant ci --setup-key --trust: list this host's runner key in .chant/trust.json (#4099).`]);
|
|
160
|
+
const commit = git(dir, ["rev-parse", "HEAD"]);
|
|
161
|
+
let pushed = false;
|
|
162
|
+
if (tryGit(req.top, ["remote", "get-url", remote]) !== undefined) {
|
|
163
|
+
try {
|
|
164
|
+
git(dir, ["push", "-q", remote, `refs/heads/${branch}:refs/heads/${branch}`]);
|
|
165
|
+
pushed = true;
|
|
166
|
+
} catch (err) {
|
|
167
|
+
throw new CiRunError(`committed ${branch} here, and pushing it to ${remote} failed: ${message(err).split("\n")[0]}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { branch, commit, pushed, digest: doc.digest };
|
|
171
|
+
} finally {
|
|
172
|
+
tryGit(req.top, ["worktree", "remove", "--force", dir]);
|
|
173
|
+
rmSync(dir, { recursive: true, force: true });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface RunnerKeySetup {
|
|
178
|
+
key: HostRunnerKey;
|
|
179
|
+
/** The target's head the trust config was read at, or null when there is no such branch. */
|
|
180
|
+
head: string | null;
|
|
181
|
+
/** The principal the key is listed for at the head, or null. */
|
|
182
|
+
listedAs: string | null;
|
|
183
|
+
principal: string;
|
|
184
|
+
command: string;
|
|
185
|
+
proposal?: RunnerTrustProposal;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** `--setup-key [--trust]`: make or read the key, see whether the target's head lists it, and with `trust` propose the entry. */
|
|
189
|
+
export function setupRunnerKey(req: { top: string; stateDir: string; branch: string; principal?: string; trust?: boolean; by?: string }): RunnerKeySetup {
|
|
190
|
+
const key = ensureHostRunnerKey(req.stateDir);
|
|
191
|
+
const head = targetHead(req.top, req.branch);
|
|
192
|
+
const listed = head ? runnerKeyListed(req.top, head.commit, key.pem) : undefined;
|
|
193
|
+
const principal = req.principal ?? defaultRunnerPrincipal();
|
|
194
|
+
const out: RunnerKeySetup = { key, head: head?.commit ?? null, listedAs: listed?.ok ? listed.principal : null, principal, command: trustCommand(principal, key.publicKey) };
|
|
195
|
+
if (req.trust && !out.listedAs) out.proposal = proposeRunnerTrust({ top: req.top, branch: req.branch, principal, publicKey: key.publicKey, ...(req.by ? { by: req.by } : {}) });
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** What `--setup-key` prints on stderr; the public key alone goes to stdout. */
|
|
200
|
+
export function formatRunnerKeySetup(s: RunnerKeySetup, branch: string): string {
|
|
201
|
+
const lines = [`runner key ${s.key.path} (${s.key.created ? "made now, mode 0600" : "already here"})`];
|
|
202
|
+
if (s.listedAs) {
|
|
203
|
+
lines.push(`.chant/trust.json at ${branch} ${s.head!.slice(0, 12)} lists it for ${s.listedAs}, with the runner role`);
|
|
204
|
+
} else if (s.proposal?.branch) {
|
|
205
|
+
lines.push(
|
|
206
|
+
`committed the runner entry for ${s.principal} on ${s.proposal.branch} (${s.proposal.commit!.slice(0, 12)})${s.proposal.pushed ? " and pushed it" : ""}; open a pull request into ${branch}, and the key counts once it merges`,
|
|
207
|
+
);
|
|
208
|
+
lines.push("changing .chant/trust.json is a protected write: when the trust policy has signers, an admin signs the commit before it merges");
|
|
209
|
+
} else if (s.proposal) {
|
|
210
|
+
lines.push(`the runner entry for ${s.principal} is already at ${branch}'s head`);
|
|
211
|
+
} else {
|
|
212
|
+
lines.push(`.chant/trust.json at ${branch}${s.head ? ` ${s.head.slice(0, 12)}` : ""} does not list it yet. In a checkout of ${branch}, run`, ` ${s.command}`, "commit the change through review, or run this again with --trust to push it on a branch");
|
|
213
|
+
}
|
|
214
|
+
return lines.join("\n");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The key `ci serve` signs with, checked before its first poll (#4099): the
|
|
219
|
+
* `--key` file, else `CHANT_RUNNER_KEY`, else the host's key in the state
|
|
220
|
+
* directory. Throws, with the command that lists it, when there is none or
|
|
221
|
+
* `.chant/trust.json` at the target's head does not list it with the runner
|
|
222
|
+
* role. With `unsigned` there is no key and nothing is checked.
|
|
223
|
+
*/
|
|
224
|
+
export function ciServeRunnerKey(req: { top: string; stateDir: string; branch: string; keyFile?: string; envKey?: string; unsigned?: boolean }): { pem?: string; fromFlag: boolean; source: string } {
|
|
225
|
+
if (req.unsigned) return { fromFlag: false, source: "none (--unsigned)" };
|
|
226
|
+
const host = req.keyFile === undefined && !req.envKey ? hostRunnerKey(req.stateDir) : undefined;
|
|
227
|
+
const pem = req.keyFile !== undefined ? readFileSync(req.keyFile, "utf-8") : req.envKey || host?.pem;
|
|
228
|
+
const source = req.keyFile !== undefined ? req.keyFile : req.envKey ? "CHANT_RUNNER_KEY" : (host?.path ?? join(req.stateDir, RUNNER_KEY_FILE));
|
|
229
|
+
if (pem === undefined) {
|
|
230
|
+
throw new CiRunError(`there is no runner key: no --key, no CHANT_RUNNER_KEY and no ${source}. Make one with chant ci serve --setup-key, list it with --trust, or start with --unsigned`);
|
|
231
|
+
}
|
|
232
|
+
let publicKey: string;
|
|
233
|
+
try {
|
|
234
|
+
publicKey = loadRunnerKey(pem).publicKey;
|
|
235
|
+
} catch (err) {
|
|
236
|
+
throw new CiRunError(`the runner key from ${source} is not an Ed25519 private key in PEM: ${message(err)}`);
|
|
237
|
+
}
|
|
238
|
+
const head = targetHead(req.top, req.branch);
|
|
239
|
+
if (!head) throw new CiRunError(`there is no branch ${req.branch} here or on origin, so the runner key can't be checked against its .chant/trust.json; fetch it, or start with --unsigned`);
|
|
240
|
+
const listed = runnerKeyListed(req.top, head.commit, pem);
|
|
241
|
+
if (!listed.ok) {
|
|
242
|
+
throw new CiRunError(
|
|
243
|
+
`the runner key from ${source} is not trusted at ${branchAt(req.branch, head.commit)}: ${listed.reason}\n` +
|
|
244
|
+
` List it on a branch for review with chant ci serve --setup-key --trust, or run ${trustCommand(defaultRunnerPrincipal(), publicKey)} in a checkout of ${req.branch} and merge the change. Start with --unsigned to serve without signing.`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
return { pem, fromFlag: req.keyFile !== undefined, source };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const branchAt = (branch: string, commit: string) => `${branch} ${commit.slice(0, 12)}`;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4096: the chant runner's secrets file, its allowlist, and the redactor
|
|
3
|
+
* built from it. Each file is written to its own scratch directory.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { chmodSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
9
|
+
import { cleanScratch, scratchDir } from "./__fixtures__/contract-repo";
|
|
10
|
+
import { ciRedactor, ciSecretsFor, CiSecretsError, parseCiSecrets, parseDotenv, readCiSecrets } from "./ci-secrets";
|
|
11
|
+
import { ciSecretsOf } from "./ci-run";
|
|
12
|
+
import { redactorOf, redactText } from "../op/redact";
|
|
13
|
+
import { parseArgs } from "../cli/parse-args";
|
|
14
|
+
|
|
15
|
+
afterAll(cleanScratch);
|
|
16
|
+
|
|
17
|
+
function fileWithMode(text: string, mode: number): string {
|
|
18
|
+
const file = join(scratchDir("chant-ci-secrets-"), "secrets.env");
|
|
19
|
+
writeFileSync(file, text);
|
|
20
|
+
chmodSync(file, mode);
|
|
21
|
+
return file;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("the secrets file (#4096)", () => {
|
|
25
|
+
test.skipIf(process.platform === "win32")("a file its group or other users can read or change is refused, as ssh refuses a loose key", () => {
|
|
26
|
+
for (const mode of [0o644, 0o640, 0o604, 0o620, 0o660]) {
|
|
27
|
+
const file = fileWithMode("A_TOKEN=abcdef\n", mode);
|
|
28
|
+
expect(() => readCiSecrets(file)).toThrow(CiSecretsError);
|
|
29
|
+
expect(() => readCiSecrets(file)).toThrow(new RegExp(`has mode 0${mode.toString(8)}.*chmod 600`));
|
|
30
|
+
}
|
|
31
|
+
for (const mode of [0o600, 0o400]) expect(readCiSecrets(fileWithMode("A_TOKEN=abcdef\n", mode)).values).toEqual({ A_TOKEN: "abcdef" });
|
|
32
|
+
// CHANT_CI_SECRETS names the file when --secrets does not, and the same check applies.
|
|
33
|
+
const loose = fileWithMode("A_TOKEN=abcdef\n", 0o644);
|
|
34
|
+
expect(() => ciSecretsOf(undefined, { CHANT_CI_SECRETS: loose })).toThrow(/chmod 600/);
|
|
35
|
+
expect(ciSecretsOf(undefined, { CHANT_CI_SECRETS: fileWithMode("A_TOKEN=abcdef\n", 0o600) })?.values).toEqual({ A_TOKEN: "abcdef" });
|
|
36
|
+
expect(ciSecretsOf(undefined, {})).toBeUndefined();
|
|
37
|
+
expect(() => readCiSecrets(join(scratchDir("chant-ci-secrets-"), "missing.env"))).toThrow(/can't be read: ENOENT/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("dotenv: comments, export, quotes and escapes, and errors that name the line and not the value", () => {
|
|
41
|
+
const text = [
|
|
42
|
+
"# cloud",
|
|
43
|
+
"export AWS_ACCESS_KEY_ID=AKIAEXAMPLE",
|
|
44
|
+
"AWS_SECRET_ACCESS_KEY = 'single # kept'",
|
|
45
|
+
'PEM="line one\\nline two"',
|
|
46
|
+
"PLAIN=value # a comment",
|
|
47
|
+
"",
|
|
48
|
+
'MULTI="a',
|
|
49
|
+
'b"',
|
|
50
|
+
].join("\n");
|
|
51
|
+
expect(parseDotenv(text, "s.env")).toEqual({
|
|
52
|
+
AWS_ACCESS_KEY_ID: "AKIAEXAMPLE",
|
|
53
|
+
AWS_SECRET_ACCESS_KEY: "single # kept",
|
|
54
|
+
PEM: "line one\nline two",
|
|
55
|
+
PLAIN: "value",
|
|
56
|
+
MULTI: "a\nb",
|
|
57
|
+
});
|
|
58
|
+
expect(() => parseDotenv("A=1\nnot a line hunter2\n", "s.env")).toThrow(/^s\.env:2: expected NAME=value$/);
|
|
59
|
+
expect(() => parseDotenv('A="unclosed hunter2', "s.env")).toThrow(/s\.env:1: the value of A has no closing "/);
|
|
60
|
+
expect(() => parseDotenv("A=1\nA=2\n", "s.env")).toThrow(/A is set twice/);
|
|
61
|
+
expect(() => parseCiSecrets("CHANT_RUNNER_KEY=x\n", "s.env")).toThrow(/runner key is never passed to a job/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("JSON: a flat map, or secrets with an allowlist for each job", () => {
|
|
65
|
+
expect(parseCiSecrets('{ "A_TOKEN": "abcd" }', "s.json")).toEqual({ values: { A_TOKEN: "abcd" }, jobs: {} });
|
|
66
|
+
const s = { file: "s.json", ...parseCiSecrets(JSON.stringify({ secrets: { A: "aaaa", B: "bbbb" }, jobs: { plan: ["A"] } }), "s.json") };
|
|
67
|
+
expect(ciSecretsFor(s, "plan")).toEqual({ A: "aaaa" });
|
|
68
|
+
expect(ciSecretsFor(s, "apply")).toEqual({ A: "aaaa", B: "bbbb" });
|
|
69
|
+
expect(ciSecretsFor(undefined, "plan")).toEqual({});
|
|
70
|
+
expect(ciSecretsFor({ ...s, jobs: { plan: [] } }, "plan")).toEqual({});
|
|
71
|
+
expect(() => parseCiSecrets(JSON.stringify({ secrets: { A: "aaaa" }, jobs: { plan: ["C"] } }), "s.json")).toThrow(/jobs\.plan names C, which the file does not set/);
|
|
72
|
+
expect(() => parseCiSecrets(JSON.stringify({ secrets: { A: "aaaa" }, jobs: { deploy: [] } }), "s.json")).toThrow(/the jobs are plan and apply/);
|
|
73
|
+
expect(() => parseCiSecrets('{ "A": 1 }', "s.json")).toThrow(/must be a string/);
|
|
74
|
+
// A parse error does not quote the text, which may hold a secret.
|
|
75
|
+
expect(() => parseCiSecrets('{ "A": "hunter2', "s.json")).toThrow(/^s\.json starts with \{, and is not valid JSON$/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("ci run and ci serve take --secrets", () => {
|
|
79
|
+
expect(parseArgs(["ci", "run", "plan", "--secrets", "/etc/chant/ci.env"])).toMatchObject({ ciSecrets: "/etc/chant/ci.env" });
|
|
80
|
+
expect(parseArgs(["ci", "serve", "--secrets", "s.json"])).toMatchObject({ ciSecrets: "s.json" });
|
|
81
|
+
expect(() => parseArgs(["ci", "run", "plan", "--secrets"])).toThrow(/--secrets needs/);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("redaction (#4096)", () => {
|
|
86
|
+
test("every secret, and each job variable whose name looks secret, is ***; a value under 4 characters is kept", () => {
|
|
87
|
+
const secrets = { file: "s", values: { DB: "hunter22", PIN: "abc" }, jobs: {} };
|
|
88
|
+
const redact = ciRedactor({ GITHUB_TOKEN: "ghp_abcdef", AWS_SECRET_ACCESS_KEY: "aws-secret", MY_PASSWORD: "pw-1234", HOME: "/home/runner", GITHUB_REPOSITORY: "acme/estate" }, secrets)!;
|
|
89
|
+
expect(redact("hunter22 ghp_abcdef aws-secret pw-1234 abc /home/runner acme/estate")).toBe("*** *** *** *** abc /home/runner acme/estate");
|
|
90
|
+
expect(ciRedactor({ HOME: "/h" })).toBeUndefined();
|
|
91
|
+
expect(redactorOf(["abc", ""])).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("a JSON document or line stays JSON, a value that spans lines is redacted line by line, and the longest value wins", () => {
|
|
95
|
+
const redact = redactorOf(["true", 'qu"ote', "-----BEGIN KEY-----\nAAAABBBB\n-----END KEY-----", "abcd", "abcdefgh"])!;
|
|
96
|
+
const line = `${JSON.stringify({ ok: true, msg: "it is true", q: 'say qu"ote' })}\n`;
|
|
97
|
+
const out = redactText(line, redact);
|
|
98
|
+
expect(JSON.parse(out)).toEqual({ ok: true, msg: "it is ***", q: "say ***" });
|
|
99
|
+
expect(out.endsWith("\n")).toBe(true);
|
|
100
|
+
expect(redactText("[x] cmd | AAAABBBB", redact)).toBe("[x] cmd | ***");
|
|
101
|
+
expect(redact("abcdefgh abcd")).toBe("*** ***");
|
|
102
|
+
expect(redactText("nothing here", redact)).toBe("nothing here");
|
|
103
|
+
// JSON Lines: each line on its own.
|
|
104
|
+
expect(redactText('{"a":"abcd"}\nplain abcd\n', redact)).toBe('{"a":"***"}\nplain ***\n');
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The chant runner's secrets file (#4096, epic #4101): the values a job gets
|
|
3
|
+
* from the host, such as cloud credentials and the endpoint, which a forge
|
|
4
|
+
* job would get from the forge's secrets.
|
|
5
|
+
*
|
|
6
|
+
* `chant ci run` and `chant ci serve` read it from `--secrets <file>`, or
|
|
7
|
+
* the file `CHANT_CI_SECRETS` names. It is a dotenv file of `NAME=value`
|
|
8
|
+
* lines, or a JSON object:
|
|
9
|
+
*
|
|
10
|
+
* - a map of name to value, `{ "AWS_SECRET_ACCESS_KEY": "..." }`;
|
|
11
|
+
* - or `{ "secrets": { ... }, "jobs": { "plan": [...], "apply": [...] } }`,
|
|
12
|
+
* where `jobs` lists, for a job, the names it gets. A job `jobs` doesn't
|
|
13
|
+
* name gets every secret.
|
|
14
|
+
*
|
|
15
|
+
* The allowlist lives in this file on the host, not in the repository, so a
|
|
16
|
+
* pull request can't widen what its plan gets.
|
|
17
|
+
*
|
|
18
|
+
* The file must belong to the user reading it and give group and others no
|
|
19
|
+
* permission at all, as ssh asks of a private key (`chmod 600`). A file
|
|
20
|
+
* that does is refused before any job runs.
|
|
21
|
+
*
|
|
22
|
+
* Every value in the file, and every value of the job's environment whose
|
|
23
|
+
* name looks secret ({@link SECRET_NAME}), is redacted from what the run
|
|
24
|
+
* writes about itself (../op/redact.ts).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { readFileSync, statSync } from "node:fs";
|
|
28
|
+
import { redactorOf, type Redact } from "../op/redact";
|
|
29
|
+
import { RUNNER_KEY_ENV } from "./ci-green";
|
|
30
|
+
import type { CiJobName } from "./ci-run";
|
|
31
|
+
|
|
32
|
+
/** The variable that names the secrets file when `--secrets` is not given. */
|
|
33
|
+
export const CI_SECRETS_ENV = "CHANT_CI_SECRETS";
|
|
34
|
+
|
|
35
|
+
/** A variable whose name matches this is taken as secret, and its value redacted. */
|
|
36
|
+
export const SECRET_NAME = /TOKEN|SECRET|KEY|PASSWORD/i;
|
|
37
|
+
|
|
38
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
39
|
+
|
|
40
|
+
const JOBS: readonly CiJobName[] = ["plan", "apply"];
|
|
41
|
+
|
|
42
|
+
/** The secrets file, read. */
|
|
43
|
+
export interface CiSecrets {
|
|
44
|
+
/** Where it was read from. */
|
|
45
|
+
file: string;
|
|
46
|
+
/** Every secret, by name. */
|
|
47
|
+
values: Record<string, string>;
|
|
48
|
+
/** For a job the file names, the secrets it gets. A job missing here gets every one. */
|
|
49
|
+
jobs: Partial<Record<CiJobName, string[]>>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class CiSecretsError extends Error {
|
|
53
|
+
constructor(message: string) {
|
|
54
|
+
super(message);
|
|
55
|
+
this.name = "CiSecretsError";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Refuse a secrets file anyone but its owner can read or write, or that
|
|
61
|
+
* belongs to another user, as ssh refuses a loose key. Not checked on
|
|
62
|
+
* Windows, whose files carry no such mode.
|
|
63
|
+
*/
|
|
64
|
+
export function checkCiSecretsMode(file: string): void {
|
|
65
|
+
let st: ReturnType<typeof statSync>;
|
|
66
|
+
try {
|
|
67
|
+
st = statSync(file);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
throw new CiSecretsError(`the secrets file ${file} can't be read: ${(err as NodeJS.ErrnoException).code ?? String(err)}`);
|
|
70
|
+
}
|
|
71
|
+
if (!st.isFile()) throw new CiSecretsError(`the secrets file ${file} is not a file`);
|
|
72
|
+
if (process.platform === "win32") return;
|
|
73
|
+
const loose = Number(st.mode) & 0o077;
|
|
74
|
+
if (loose !== 0) {
|
|
75
|
+
const mode = (Number(st.mode) & 0o777).toString(8).padStart(4, "0");
|
|
76
|
+
throw new CiSecretsError(
|
|
77
|
+
`the secrets file ${file} has mode ${mode}, so its group or other users can ${loose & 0o044 ? "read" : "change"} it; a secrets file must be readable only by its owner, as ssh asks of a key: chmod 600 ${file}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
81
|
+
if (uid !== undefined && Number(st.uid) !== uid) {
|
|
82
|
+
throw new CiSecretsError(`the secrets file ${file} belongs to uid ${st.uid}, not to this user (uid ${uid}); a secrets file must be its reader's own`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A dotenv text's `NAME=value` pairs. Errors name the line, never a value. */
|
|
87
|
+
export function parseDotenv(text: string, file: string): Record<string, string> {
|
|
88
|
+
const out: Record<string, string> = {};
|
|
89
|
+
let i = 0;
|
|
90
|
+
let line = 1;
|
|
91
|
+
const fail = (why: string): never => {
|
|
92
|
+
throw new CiSecretsError(`${file}:${line}: ${why}`);
|
|
93
|
+
};
|
|
94
|
+
while (i < text.length) {
|
|
95
|
+
// One line: blank, a comment, or NAME=value.
|
|
96
|
+
const eol = text.indexOf("\n", i);
|
|
97
|
+
const rest = text.slice(i, eol === -1 ? text.length : eol);
|
|
98
|
+
const trimmed = rest.trim();
|
|
99
|
+
if (trimmed === "" || trimmed.startsWith("#")) {
|
|
100
|
+
i = eol === -1 ? text.length : eol + 1;
|
|
101
|
+
line++;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*/.exec(rest);
|
|
105
|
+
if (!m) fail("expected NAME=value");
|
|
106
|
+
const name = m![1]!;
|
|
107
|
+
let j = i + m![0].length;
|
|
108
|
+
const startLine = line;
|
|
109
|
+
let value = "";
|
|
110
|
+
const q = text[j];
|
|
111
|
+
if (q === '"' || q === "'") {
|
|
112
|
+
j++;
|
|
113
|
+
for (;;) {
|
|
114
|
+
if (j >= text.length) {
|
|
115
|
+
line = startLine;
|
|
116
|
+
fail(`the value of ${name} has no closing ${q}`);
|
|
117
|
+
}
|
|
118
|
+
const c = text[j]!;
|
|
119
|
+
if (c === q) break;
|
|
120
|
+
if (c === "\n") line++;
|
|
121
|
+
if (q === '"' && c === "\\" && j + 1 < text.length) {
|
|
122
|
+
const n = text[j + 1]!;
|
|
123
|
+
value += n === "n" ? "\n" : n === "r" ? "\r" : n === "t" ? "\t" : n;
|
|
124
|
+
j += 2;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
value += c;
|
|
128
|
+
j++;
|
|
129
|
+
}
|
|
130
|
+
j++;
|
|
131
|
+
const end = text.indexOf("\n", j);
|
|
132
|
+
const after = text.slice(j, end === -1 ? text.length : end).trim();
|
|
133
|
+
if (after !== "" && !after.startsWith("#")) fail(`unexpected text after the quoted value of ${name}`);
|
|
134
|
+
i = end === -1 ? text.length : end + 1;
|
|
135
|
+
} else {
|
|
136
|
+
const end = text.indexOf("\n", j);
|
|
137
|
+
value = text.slice(j, end === -1 ? text.length : end).replace(/\s+#.*$/, "").trim();
|
|
138
|
+
i = end === -1 ? text.length : end + 1;
|
|
139
|
+
}
|
|
140
|
+
if (Object.prototype.hasOwnProperty.call(out, name)) fail(`${name} is set twice`);
|
|
141
|
+
out[name] = value;
|
|
142
|
+
line++;
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function checkName(name: string, file: string): void {
|
|
148
|
+
if (!ENV_NAME.test(name)) throw new CiSecretsError(`${file} names a secret ${JSON.stringify(name)}, which is not a variable name`);
|
|
149
|
+
if (name === RUNNER_KEY_ENV) throw new CiSecretsError(`${file} sets ${RUNNER_KEY_ENV}, and the runner key is never passed to a job; give it to ci run or ci serve with --key or ${RUNNER_KEY_ENV}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The secrets in a file's text: JSON when it starts with `{`, else dotenv. */
|
|
153
|
+
export function parseCiSecrets(text: string, file: string): Omit<CiSecrets, "file"> {
|
|
154
|
+
if (!text.trimStart().startsWith("{")) {
|
|
155
|
+
const values = parseDotenv(text, file);
|
|
156
|
+
for (const name of Object.keys(values)) checkName(name, file);
|
|
157
|
+
return { values, jobs: {} };
|
|
158
|
+
}
|
|
159
|
+
let raw: unknown;
|
|
160
|
+
try {
|
|
161
|
+
raw = JSON.parse(text);
|
|
162
|
+
} catch {
|
|
163
|
+
// The parser's message quotes the text near the error, which may be a secret.
|
|
164
|
+
throw new CiSecretsError(`${file} starts with {, and is not valid JSON`);
|
|
165
|
+
}
|
|
166
|
+
const doc = raw as Record<string, unknown>;
|
|
167
|
+
const structured = doc.secrets !== null && typeof doc.secrets === "object" && !Array.isArray(doc.secrets);
|
|
168
|
+
const map = (structured ? doc.secrets : doc) as Record<string, unknown>;
|
|
169
|
+
const values: Record<string, string> = {};
|
|
170
|
+
for (const [name, value] of Object.entries(map)) {
|
|
171
|
+
checkName(name, file);
|
|
172
|
+
if (typeof value !== "string") throw new CiSecretsError(`${file}: the secret ${name} must be a string`);
|
|
173
|
+
values[name] = value;
|
|
174
|
+
}
|
|
175
|
+
const jobs: CiSecrets["jobs"] = {};
|
|
176
|
+
if (structured) {
|
|
177
|
+
for (const key of Object.keys(doc)) if (key !== "secrets" && key !== "jobs") throw new CiSecretsError(`${file} has ${JSON.stringify(key)} beside secrets and jobs`);
|
|
178
|
+
const given = doc.jobs ?? {};
|
|
179
|
+
if (given === null || typeof given !== "object" || Array.isArray(given)) throw new CiSecretsError(`${file}: jobs must map plan and apply to lists of secret names`);
|
|
180
|
+
for (const [job, names] of Object.entries(given as Record<string, unknown>)) {
|
|
181
|
+
if (!(JOBS as readonly string[]).includes(job)) throw new CiSecretsError(`${file}: jobs names ${JSON.stringify(job)}, and the jobs are plan and apply`);
|
|
182
|
+
if (!Array.isArray(names) || names.some((n) => typeof n !== "string")) throw new CiSecretsError(`${file}: jobs.${job} must be a list of secret names`);
|
|
183
|
+
for (const n of names as string[]) if (!Object.prototype.hasOwnProperty.call(values, n)) throw new CiSecretsError(`${file}: jobs.${job} names ${n}, which the file does not set`);
|
|
184
|
+
jobs[job as CiJobName] = [...(names as string[])];
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { values, jobs };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Read the secrets file, refusing one others can read. */
|
|
191
|
+
export function readCiSecrets(file: string): CiSecrets {
|
|
192
|
+
checkCiSecretsMode(file);
|
|
193
|
+
return { file, ...parseCiSecrets(readFileSync(file, "utf-8"), file) };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The secrets `job` gets: those `jobs` lists for it, else every one. */
|
|
197
|
+
export function ciSecretsFor(secrets: CiSecrets | undefined, job: CiJobName): Record<string, string> {
|
|
198
|
+
if (!secrets) return {};
|
|
199
|
+
const names = secrets.jobs[job] ?? Object.keys(secrets.values);
|
|
200
|
+
return Object.fromEntries(names.map((n) => [n, secrets.values[n]!]));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The redactor for a job: every value in the secrets file, whether the job
|
|
205
|
+
* gets it or not, and the value of each variable in the job's environment
|
|
206
|
+
* whose name looks secret, such as `GITHUB_TOKEN` or a `--pass-env
|
|
207
|
+
* AWS_SECRET_ACCESS_KEY`. Undefined when there is nothing to redact.
|
|
208
|
+
*/
|
|
209
|
+
export function ciRedactor(jobEnv: Record<string, string>, secrets?: CiSecrets): Redact | undefined {
|
|
210
|
+
const values = Object.values(secrets?.values ?? {});
|
|
211
|
+
for (const [name, value] of Object.entries(jobEnv)) if (SECRET_NAME.test(name)) values.push(value);
|
|
212
|
+
return redactorOf(values);
|
|
213
|
+
}
|