@mnaimfaizy/agent-kit 0.0.0-stage → 1.0.0-beta.1
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/README.md +21 -2
- package/bin/agent-kit.mjs +13 -0
- package/package.json +29 -4
- package/src/callers.mjs +204 -0
- package/src/checks/claude-app.mjs +55 -0
- package/src/checks/kit-owned-trees.mjs +78 -0
- package/src/checks/labels.mjs +100 -0
- package/src/checks/pins.mjs +76 -0
- package/src/checks/result.mjs +74 -0
- package/src/checks/rulesets.mjs +346 -0
- package/src/checks/secrets-and-variables.mjs +252 -0
- package/src/cli.mjs +229 -0
- package/src/doctor.mjs +405 -0
- package/src/exec.mjs +64 -0
- package/src/github.mjs +88 -0
- package/src/init.mjs +627 -0
- package/src/kit-owned-paths.mjs +48 -0
- package/src/kit-tag.mjs +257 -0
- package/src/local-tree.mjs +240 -0
- package/src/local-writes.mjs +307 -0
- package/src/prompt.mjs +42 -0
- package/src/safe-paths.mjs +68 -0
- package/src/tags.mjs +55 -0
- package/src/templates.mjs +133 -0
- package/src/upgrade.mjs +288 -0
- package/src/workflow-call.mjs +44 -0
- package/src/yaml-subset.mjs +118 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
// The rulesets docs/repository-hardening.md sets up, which keep the Claude
|
|
2
|
+
// GitHub App token away from the default branch, release branches and
|
|
3
|
+
// release tags. Each gap is a warning: the flows still run without them.
|
|
4
|
+
//
|
|
5
|
+
// A rule only counts when no one the App can act as is on the ruleset's
|
|
6
|
+
// bypass list: rulesets stack, so an actor on one ruleset's bypass list is
|
|
7
|
+
// still held by any other ruleset with the same rule that does not list it.
|
|
8
|
+
|
|
9
|
+
import { stripComment } from "../yaml-subset.mjs";
|
|
10
|
+
import { result } from "./result.mjs";
|
|
11
|
+
|
|
12
|
+
const HARDENING = "https://github.com/mnaimfaizy/agent-kit/blob/main/docs/repository-hardening.md";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* fnmatch with FNM_PATHNAME, the matching GitHub uses for ruleset ref
|
|
16
|
+
* patterns: `*` and `?` stay within one path segment, a double star
|
|
17
|
+
* followed by a slash matches zero or more whole segments, `[...]` is a character class (`[!...]` or `[^...]`
|
|
18
|
+
* negates it), and `\` escapes the next character.
|
|
19
|
+
*/
|
|
20
|
+
export function fnmatchToRegExp(pattern) {
|
|
21
|
+
let re = "";
|
|
22
|
+
let i = 0;
|
|
23
|
+
const esc = (c) => c.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
24
|
+
while (i < pattern.length) {
|
|
25
|
+
const c = pattern[i];
|
|
26
|
+
if (c === "*" && pattern[i + 1] === "*" && pattern[i + 2] === "/") {
|
|
27
|
+
re += "(?:[^/]*/)*";
|
|
28
|
+
i += 3;
|
|
29
|
+
} else if (c === "*" && pattern[i + 1] === "*") {
|
|
30
|
+
re += "[^/]*";
|
|
31
|
+
i += 2;
|
|
32
|
+
} else if (c === "*") {
|
|
33
|
+
re += "[^/]*";
|
|
34
|
+
i += 1;
|
|
35
|
+
} else if (c === "?") {
|
|
36
|
+
re += "[^/]";
|
|
37
|
+
i += 1;
|
|
38
|
+
} else if (c === "\\" && i + 1 < pattern.length) {
|
|
39
|
+
re += esc(pattern[i + 1]);
|
|
40
|
+
i += 2;
|
|
41
|
+
} else if (c === "[") {
|
|
42
|
+
const close = pattern.indexOf("]", i + 2);
|
|
43
|
+
if (close === -1) {
|
|
44
|
+
re += "\\[";
|
|
45
|
+
i += 1;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
let body = pattern.slice(i + 1, close);
|
|
49
|
+
let negate = false;
|
|
50
|
+
if (body.startsWith("!") || body.startsWith("^")) {
|
|
51
|
+
negate = true;
|
|
52
|
+
body = body.slice(1);
|
|
53
|
+
}
|
|
54
|
+
const cls = body.replace(/[\\\]^]/g, "\\$&");
|
|
55
|
+
re += negate ? `(?!/)[^${cls}]` : `[${cls}]`;
|
|
56
|
+
i = close + 1;
|
|
57
|
+
} else {
|
|
58
|
+
re += esc(c);
|
|
59
|
+
i += 1;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return new RegExp(`^${re}$`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Whether a ruleset's `conditions.ref_name` covers `ref`. */
|
|
66
|
+
export function coversRef(ruleset, ref, { defaultBranch } = {}) {
|
|
67
|
+
// A ruleset with a pattern doctor cannot interpret is not counted at all.
|
|
68
|
+
if (unreadablePatterns(ruleset).length) return false;
|
|
69
|
+
const cond = ruleset.conditions?.ref_name ?? {};
|
|
70
|
+
const matches = (p) =>
|
|
71
|
+
p === "~ALL" ||
|
|
72
|
+
(p === "~DEFAULT_BRANCH" && defaultBranch && ref === `refs/heads/${defaultBranch}`) ||
|
|
73
|
+
fnmatchToRegExp(p).test(ref);
|
|
74
|
+
return (cond.include ?? []).some(matches) && !(cond.exclude ?? []).some(matches);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The ref patterns of a ruleset that do not compile (an invalid range such as `[z-a]`). */
|
|
78
|
+
export function unreadablePatterns(ruleset) {
|
|
79
|
+
const cond = ruleset.conditions?.ref_name ?? {};
|
|
80
|
+
return [...(cond.include ?? []), ...(cond.exclude ?? [])].filter((p) => {
|
|
81
|
+
if (typeof p !== "string") return true;
|
|
82
|
+
if (p === "~ALL" || p === "~DEFAULT_BRANCH") return false;
|
|
83
|
+
try {
|
|
84
|
+
fnmatchToRegExp(p);
|
|
85
|
+
return false;
|
|
86
|
+
} catch {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Note the rulesets of `target` that were not counted because a pattern could
|
|
94
|
+
* not be interpreted. Rulesets only add restrictions, so a check that holds
|
|
95
|
+
* without them stays a pass; one that does not is already a warning.
|
|
96
|
+
*/
|
|
97
|
+
function withPatternNotes(res, rulesets, target) {
|
|
98
|
+
const notes = rulesets
|
|
99
|
+
.filter((r) => r.target === target)
|
|
100
|
+
.flatMap((r) =>
|
|
101
|
+
unreadablePatterns(r).map(
|
|
102
|
+
(p) =>
|
|
103
|
+
`${label(r)}: pattern ${JSON.stringify(p)} could not be interpreted, so it was not counted.`,
|
|
104
|
+
),
|
|
105
|
+
);
|
|
106
|
+
if (notes.length === 0) return res;
|
|
107
|
+
return { ...res, details: [...notes, ...res.details] };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Active rulesets with their full rules and bypass lists. `bypass_actors` is
|
|
112
|
+
* only returned by the single-ruleset endpoint, and only to an admin, which
|
|
113
|
+
* the local checks require. Throws GhError on any API failure.
|
|
114
|
+
*/
|
|
115
|
+
export function loadRulesets(github, repo) {
|
|
116
|
+
const ids = github.getLines(`repos/${repo}/rulesets?includes_parents=true`, ".[].id");
|
|
117
|
+
return ids
|
|
118
|
+
.map((id) => github.getJson(`repos/${repo}/rulesets/${Number(id)}`))
|
|
119
|
+
.filter((r) => r.enforcement === "active");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Base repository roles as ruleset bypass actors (docs/repository-hardening.md).
|
|
123
|
+
const ADMIN_ROLE = 5;
|
|
124
|
+
const MAINTAIN_ROLE = 2;
|
|
125
|
+
const ROLE_NAMES = { 1: "Read", 2: "Maintain", 3: "Triage", 4: "Write", 5: "Admin" };
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Bypass actors that undo the ruleset's protection against the Claude App:
|
|
129
|
+
* any GitHub App (the Claude App is an `Integration`), any repository role
|
|
130
|
+
* below Maintain (the Write role, or a custom role), and, on a branch
|
|
131
|
+
* ruleset, deploy keys. Admin, Maintain, teams and organization admins are
|
|
132
|
+
* the intended bypass list; a DeployKey bypass on a tag ruleset is the
|
|
133
|
+
* documented personal-account pattern.
|
|
134
|
+
*/
|
|
135
|
+
export function riskyBypassActors(ruleset) {
|
|
136
|
+
// Only an admin sees bypass lists, and an organization ruleset's may be
|
|
137
|
+
// hidden even then: an unseen list is never assumed to be clean.
|
|
138
|
+
if (!Array.isArray(ruleset.bypass_actors)) return ["a bypass list not visible to this login"];
|
|
139
|
+
const risky = [];
|
|
140
|
+
for (const actor of ruleset.bypass_actors) {
|
|
141
|
+
const type = actor.actor_type;
|
|
142
|
+
if (type === "Integration") {
|
|
143
|
+
risky.push(`a GitHub App (Integration ${actor.actor_id})`);
|
|
144
|
+
} else if (type === "RepositoryRole" && ![ADMIN_ROLE, MAINTAIN_ROLE].includes(actor.actor_id)) {
|
|
145
|
+
const name = ROLE_NAMES[actor.actor_id];
|
|
146
|
+
risky.push(name ? `the ${name} role` : `custom repository role ${actor.actor_id}`);
|
|
147
|
+
} else if (type === "DeployKey" && ruleset.target !== "tag") {
|
|
148
|
+
risky.push("deploy keys");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return risky;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const hasRule = (ruleset, type) => (ruleset.rules ?? []).some((x) => x.type === type);
|
|
155
|
+
const label = (r) => `"${r.name ?? `ruleset ${r.id}`}"`;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* For one rule type: the covering rulesets that have it, and whether any of
|
|
159
|
+
* them has a clean bypass list.
|
|
160
|
+
*/
|
|
161
|
+
function ruleStatus(covering, type) {
|
|
162
|
+
const withRule = covering.filter((r) => hasRule(r, type));
|
|
163
|
+
const clean = withRule.filter((r) => riskyBypassActors(r).length === 0);
|
|
164
|
+
const bypassed = withRule
|
|
165
|
+
.filter((r) => !clean.includes(r))
|
|
166
|
+
.map((r) => `${label(r)} lets ${riskyBypassActors(r).join(", ")} bypass its ${type} rule.`);
|
|
167
|
+
return { present: withRule.length > 0, held: clean.length > 0, bypassed };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const BYPASS_ADVICE =
|
|
171
|
+
"Keep the bypass list to Admin and Maintain: never a GitHub App, the Write role, or a team the App acts through.";
|
|
172
|
+
|
|
173
|
+
function updateRule(rulesets, { defaultBranch }) {
|
|
174
|
+
const ID = "ruleset-update";
|
|
175
|
+
const TITLE = "Default branch has an update rule";
|
|
176
|
+
const recipe = `Recipe: ${HARDENING}#1-default-branch-an-update-rule`;
|
|
177
|
+
const ref = `refs/heads/${defaultBranch}`;
|
|
178
|
+
const covering = rulesets.filter(
|
|
179
|
+
(r) => r.target === "branch" && coversRef(r, ref, { defaultBranch }),
|
|
180
|
+
);
|
|
181
|
+
const s = ruleStatus(covering, "update");
|
|
182
|
+
if (s.held) return result(ID, TITLE, "pass", `${defaultBranch} has an active update rule.`);
|
|
183
|
+
if (s.present) {
|
|
184
|
+
return result(ID, TITLE, "warn", `The update rule on ${defaultBranch} can be bypassed.`, {
|
|
185
|
+
details: [...s.bypassed, BYPASS_ADVICE, recipe],
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
return result(ID, TITLE, "warn", `No active update rule on ${defaultBranch}.`, {
|
|
189
|
+
details: [
|
|
190
|
+
"A required pull request with zero approvals does not stop the Claude App merging its own pull request; an update rule does.",
|
|
191
|
+
recipe,
|
|
192
|
+
],
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Release-branch names a creation rule should cover; any one is enough.
|
|
197
|
+
const RELEASE_BRANCH_REFS = [
|
|
198
|
+
"refs/heads/release/1.0.0",
|
|
199
|
+
"refs/heads/releases/1.0.0",
|
|
200
|
+
"refs/heads/release-1.0.0",
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
function creationRule(rulesets) {
|
|
204
|
+
const ID = "ruleset-creation";
|
|
205
|
+
const TITLE = "Release branches have a creation rule";
|
|
206
|
+
const recipe = `Recipe: ${HARDENING}#2-release-branches-a-creation-rule`;
|
|
207
|
+
const branchRulesets = rulesets.filter((r) => r.target === "branch");
|
|
208
|
+
const covering = branchRulesets.filter((r) =>
|
|
209
|
+
RELEASE_BRANCH_REFS.some((ref) => coversRef(r, ref)),
|
|
210
|
+
);
|
|
211
|
+
const s = ruleStatus(covering, "creation");
|
|
212
|
+
if (s.held) {
|
|
213
|
+
return result(ID, TITLE, "pass", "An active creation rule covers release branches.");
|
|
214
|
+
}
|
|
215
|
+
if (s.present) {
|
|
216
|
+
return result(ID, TITLE, "warn", "Every release-branch creation rule can be bypassed.", {
|
|
217
|
+
details: [...s.bypassed, BYPASS_ADVICE, recipe],
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
const elsewhere = ruleStatus(branchRulesets, "creation").present;
|
|
221
|
+
return result(
|
|
222
|
+
ID,
|
|
223
|
+
TITLE,
|
|
224
|
+
"warn",
|
|
225
|
+
elsewhere
|
|
226
|
+
? "A branch creation rule exists, but none covers a release/* branch."
|
|
227
|
+
: "No active branch ruleset has a creation rule.",
|
|
228
|
+
{
|
|
229
|
+
details: [
|
|
230
|
+
"If you cut releases from branches, stop anyone outside the bypass list creating one.",
|
|
231
|
+
`Checked against ${RELEASE_BRANCH_REFS.join(", ")}.`,
|
|
232
|
+
recipe,
|
|
233
|
+
],
|
|
234
|
+
},
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const TAG_RULES = ["creation", "update", "deletion"];
|
|
239
|
+
|
|
240
|
+
// A stable and a pre-release tag: the v* ruleset must cover both.
|
|
241
|
+
const TAG_REFS = ["refs/tags/v1.0.0", "refs/tags/v1.0.0-alpha.1"];
|
|
242
|
+
|
|
243
|
+
function tagRuleset(rulesets) {
|
|
244
|
+
const ID = "ruleset-tags";
|
|
245
|
+
const TITLE = "Release tags have a v* tag ruleset";
|
|
246
|
+
const recipe = `Recipe: ${HARDENING}#3-release-tags-a-v-tag-ruleset`;
|
|
247
|
+
const problems = [];
|
|
248
|
+
const details = new Set();
|
|
249
|
+
for (const ref of TAG_REFS) {
|
|
250
|
+
const covering = rulesets.filter((r) => r.target === "tag" && coversRef(r, ref));
|
|
251
|
+
if (covering.length === 0) {
|
|
252
|
+
problems.push(`no active tag ruleset covers ${ref.slice("refs/tags/".length)}`);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const statuses = TAG_RULES.map((type) => [type, ruleStatus(covering, type)]);
|
|
256
|
+
const missing = statuses.filter(([, st]) => !st.present).map(([t]) => t);
|
|
257
|
+
const bypassed = statuses.filter(([, st]) => st.present && !st.held);
|
|
258
|
+
const tag = ref.slice("refs/tags/".length);
|
|
259
|
+
if (missing.length) problems.push(`${tag} lacks: ${missing.join(", ")}`);
|
|
260
|
+
if (bypassed.length) {
|
|
261
|
+
problems.push(`${tag} can be bypassed: ${bypassed.map(([t]) => t).join(", ")}`);
|
|
262
|
+
for (const [, st] of bypassed) for (const d of st.bypassed) details.add(d);
|
|
263
|
+
details.add(BYPASS_ADVICE);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (problems.length) {
|
|
267
|
+
const unique = [...new Set(problems)];
|
|
268
|
+
return result(ID, TITLE, "warn", `The v* tag ruleset: ${unique.join("; ")}.`, {
|
|
269
|
+
details: [...details, recipe],
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
return result(
|
|
273
|
+
ID,
|
|
274
|
+
TITLE,
|
|
275
|
+
"pass",
|
|
276
|
+
"An active tag ruleset blocks creating, moving and deleting v* tags, pre-releases included.",
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function checkUpdateRule(rulesets, opts) {
|
|
281
|
+
return withPatternNotes(updateRule(rulesets, opts), rulesets, "branch");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function checkCreationRule(rulesets) {
|
|
285
|
+
return withPatternNotes(creationRule(rulesets), rulesets, "branch");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function checkTagRuleset(rulesets) {
|
|
289
|
+
return withPatternNotes(tagRuleset(rulesets), rulesets, "tag");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Workflows that push tags with GITHUB_TOKEN: a `git push` of tags, with no
|
|
294
|
+
* SSH deploy key in the same file.
|
|
295
|
+
*/
|
|
296
|
+
export function workflowsPushingTagsWithToken(files) {
|
|
297
|
+
return files
|
|
298
|
+
.filter(({ text }) => {
|
|
299
|
+
const body = String(text)
|
|
300
|
+
.replace(/\r\n?/g, "\n")
|
|
301
|
+
.split("\n")
|
|
302
|
+
.filter((l) => !l.trimStart().startsWith("#"))
|
|
303
|
+
.map(stripComment)
|
|
304
|
+
.join("\n");
|
|
305
|
+
const pushes = body
|
|
306
|
+
.split("\n")
|
|
307
|
+
.some(
|
|
308
|
+
(l) =>
|
|
309
|
+
/\bgit\b.*\bpush\b/.test(l) &&
|
|
310
|
+
/--tags|--follow-tags|refs\/tags\/|\bTAG\b|\btag\b/i.test(l),
|
|
311
|
+
);
|
|
312
|
+
const tagged = /\bgit\b[^\n]*\btag\b/.test(body) && /\bgit\b[^\n]*\bpush\b/.test(body);
|
|
313
|
+
const ssh = /GIT_SSH_COMMAND|git@github\.com:|ssh:\/\//.test(body);
|
|
314
|
+
return (pushes || tagged) && !ssh;
|
|
315
|
+
})
|
|
316
|
+
.map((f) => f.path);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function checkPersonalTagPush({ ownerType, files }) {
|
|
320
|
+
const ID = "release-tag-push";
|
|
321
|
+
const TITLE = "Release tags can be pushed under the tag ruleset";
|
|
322
|
+
if (ownerType !== "User") {
|
|
323
|
+
return result(
|
|
324
|
+
ID,
|
|
325
|
+
TITLE,
|
|
326
|
+
"pass",
|
|
327
|
+
"Owned by an organization: the GitHub Actions app can be a ruleset bypass actor.",
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
const offenders = workflowsPushingTagsWithToken(files);
|
|
331
|
+
if (offenders.length === 0)
|
|
332
|
+
return result(ID, TITLE, "pass", "No workflow pushes tags with GITHUB_TOKEN.");
|
|
333
|
+
return result(
|
|
334
|
+
ID,
|
|
335
|
+
TITLE,
|
|
336
|
+
"warn",
|
|
337
|
+
"A personal-account repository whose workflow pushes tags with GITHUB_TOKEN.",
|
|
338
|
+
{
|
|
339
|
+
details: [
|
|
340
|
+
...offenders,
|
|
341
|
+
"On a personal account the GitHub Actions app cannot bypass a tag ruleset, so these pushes are refused once v* tags are protected.",
|
|
342
|
+
`Fix: ${HARDENING}#personal-account-repositories-pushing-release-tags`,
|
|
343
|
+
],
|
|
344
|
+
},
|
|
345
|
+
);
|
|
346
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// Secrets and repository variables, worked out from the Consumer's Callers.
|
|
2
|
+
// A secret is required when the reusable workflow at the pinned tag declares
|
|
3
|
+
// the secret it is passed to as `required: true`; otherwise it is optional.
|
|
4
|
+
// A variable is optional when every read of it falls back with `||`.
|
|
5
|
+
|
|
6
|
+
import { secretNames } from "../callers.mjs";
|
|
7
|
+
import { parseWorkflowCallSecrets } from "../workflow-call.mjs";
|
|
8
|
+
import { byHand, command, NAME_PATTERN, result } from "./result.mjs";
|
|
9
|
+
|
|
10
|
+
/** GitHub provides `secrets.GITHUB_TOKEN`; no `GITHUB_*` secret can be set by hand. */
|
|
11
|
+
const isProvided = (name) => /^github_/i.test(name);
|
|
12
|
+
|
|
13
|
+
/** Caller parts doctor could not read fully: reported, never assumed fine. */
|
|
14
|
+
function unreadable(callers) {
|
|
15
|
+
const out = [];
|
|
16
|
+
for (const caller of callers) {
|
|
17
|
+
if (caller.parseIncomplete) out.push(`${caller.path}: ${caller.parseIncomplete}`);
|
|
18
|
+
for (const job of caller.jobs) {
|
|
19
|
+
if (job.secretsUnsupported) {
|
|
20
|
+
out.push(
|
|
21
|
+
`${caller.path}:${job.name}: ${job.secretsUnsupported}; its secrets were not checked.`,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The variables every Caller reads. Needs no tag. */
|
|
30
|
+
export function deriveVariables(callers) {
|
|
31
|
+
const variables = new Map();
|
|
32
|
+
for (const caller of callers) {
|
|
33
|
+
for (const v of caller.variables) {
|
|
34
|
+
const prev = variables.get(v.name);
|
|
35
|
+
variables.set(v.name, {
|
|
36
|
+
name: v.name,
|
|
37
|
+
optional: prev ? prev.optional && v.optional : v.optional,
|
|
38
|
+
where: [...(prev?.where ?? []), caller.path],
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return [...variables.values()];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {{ path: string, jobs: object[], variables: { name: string, optional: boolean }[] }[]} callers
|
|
47
|
+
* @param {(workflowFile: string) => string | null} readReusable reads .github/workflows/<file> at the pinned tag
|
|
48
|
+
*/
|
|
49
|
+
export function deriveRequirements(callers, readReusable) {
|
|
50
|
+
const secrets = new Map();
|
|
51
|
+
const problems = [];
|
|
52
|
+
const specs = new Map();
|
|
53
|
+
const specFor = (file) => {
|
|
54
|
+
if (!specs.has(file)) {
|
|
55
|
+
const text = readReusable(file);
|
|
56
|
+
specs.set(file, text === null ? null : parseWorkflowCallSecrets(text));
|
|
57
|
+
}
|
|
58
|
+
return specs.get(file);
|
|
59
|
+
};
|
|
60
|
+
const addSecret = (names, required, where) => {
|
|
61
|
+
// A GITHUB_* secret is always set, so an expression that falls back to one is too.
|
|
62
|
+
if (names.some(isProvided)) return;
|
|
63
|
+
const key = names.join(" || ");
|
|
64
|
+
const prev = secrets.get(key);
|
|
65
|
+
secrets.set(key, {
|
|
66
|
+
names,
|
|
67
|
+
required: Boolean(prev?.required || required),
|
|
68
|
+
where: [...(prev?.where ?? []), where],
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
for (const caller of callers) {
|
|
73
|
+
for (const job of caller.jobs) {
|
|
74
|
+
const where = `${caller.path}:${job.name}`;
|
|
75
|
+
if (job.secretsUnsupported) continue;
|
|
76
|
+
const spec = specFor(job.uses.file);
|
|
77
|
+
if (spec === null) {
|
|
78
|
+
problems.push(`${where}: ${job.uses.file} is not a reusable workflow at the pinned tag`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (job.secrets === "inherit") {
|
|
82
|
+
for (const { name, required } of spec.values()) {
|
|
83
|
+
addSecret([name.toUpperCase()], required, where);
|
|
84
|
+
}
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const passed = new Set(Object.keys(job.secrets).map((k) => k.toLowerCase()));
|
|
88
|
+
for (const [input, value] of Object.entries(job.secrets)) {
|
|
89
|
+
const names = secretNames(value);
|
|
90
|
+
if (names.length === 0) continue;
|
|
91
|
+
const declared = spec.get(input.toLowerCase());
|
|
92
|
+
if (!declared) {
|
|
93
|
+
problems.push(
|
|
94
|
+
`${where}: passes secret ${JSON.stringify(input)}, which ${job.uses.file} does not declare`,
|
|
95
|
+
);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
addSecret(names, declared.required, `${where} (${input})`);
|
|
99
|
+
}
|
|
100
|
+
for (const { name, required } of spec.values()) {
|
|
101
|
+
if (required && !passed.has(name.toLowerCase())) {
|
|
102
|
+
problems.push(
|
|
103
|
+
`${where}: does not pass required secret ${JSON.stringify(name)} to ${job.uses.file}`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
secrets: [...secrets.values()],
|
|
111
|
+
variables: deriveVariables(callers),
|
|
112
|
+
problems,
|
|
113
|
+
unsupported: unreadable(callers),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function listNames(github, repo, kind, ownerType) {
|
|
118
|
+
const key = kind === "secret" ? "secrets" : "variables";
|
|
119
|
+
const names = new Set(github.getLines(`repos/${repo}/actions/${key}`, `.${key}[].name`));
|
|
120
|
+
let orgNote = null;
|
|
121
|
+
if (ownerType === "Organization") {
|
|
122
|
+
try {
|
|
123
|
+
for (const n of github.getLines(
|
|
124
|
+
`repos/${repo}/actions/organization-${key}`,
|
|
125
|
+
`.${key}[].name`,
|
|
126
|
+
)) {
|
|
127
|
+
names.add(n);
|
|
128
|
+
}
|
|
129
|
+
} catch {
|
|
130
|
+
orgNote = `Organization ${key} could not be read; one reported missing may be set at the organization.`;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { names: new Set([...names].map((n) => n.toUpperCase())), orgNote };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** `gh secret set` / `gh variable set` for a valid name, or a by-hand note. */
|
|
137
|
+
function setCommand(kind, name, repo, extra = []) {
|
|
138
|
+
if (!NAME_PATTERN.test(name)) return { note: byHand(`${kind} named`, name) };
|
|
139
|
+
return { cmd: command("gh", kind, "set", name, "--repo", repo, ...extra) };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function splitFixes(items) {
|
|
143
|
+
return {
|
|
144
|
+
fix: items.filter((i) => i.cmd).map((i) => i.cmd),
|
|
145
|
+
notes: items.filter((i) => i.note).map((i) => i.note),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function checkSecrets({ github, repo, ownerType, requirements }) {
|
|
150
|
+
const ID = "secrets";
|
|
151
|
+
const TITLE = "Secrets the Callers pass are set";
|
|
152
|
+
const { problems, unsupported = [] } = requirements;
|
|
153
|
+
let listed;
|
|
154
|
+
try {
|
|
155
|
+
listed = listNames(github, repo, "secret", ownerType);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
// Problems found in the Caller files need no API call: they still fail.
|
|
158
|
+
if (problems.length) {
|
|
159
|
+
return result(ID, TITLE, "fail", `${problems.length} Caller problem(s) with secrets.`, {
|
|
160
|
+
details: [
|
|
161
|
+
...problems,
|
|
162
|
+
...unsupported,
|
|
163
|
+
`Could not list repository secrets, so unset secrets were not checked: ${err.message}`,
|
|
164
|
+
],
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return result(ID, TITLE, "skip", `Could not list repository secrets: ${err.message}`, {
|
|
168
|
+
details: unsupported,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
const isSet = (x) => x.names.some((n) => listed.names.has(n.toUpperCase()));
|
|
172
|
+
const missingRequired = requirements.secrets.filter((x) => x.required && !isSet(x));
|
|
173
|
+
const missingOptional = requirements.secrets.filter((x) => !x.required && !isSet(x));
|
|
174
|
+
const describe = (x) => `${x.names.join(" or ")} (${x.where.join(", ")})`;
|
|
175
|
+
// No --body: gh prompts for the value, so it never lands in shell history.
|
|
176
|
+
const { fix, notes } = splitFixes(
|
|
177
|
+
[...missingRequired, ...missingOptional].map((x) => setCommand("secret", x.names[0], repo)),
|
|
178
|
+
);
|
|
179
|
+
const details = [
|
|
180
|
+
...problems,
|
|
181
|
+
...missingRequired.map((x) => `required, not set: ${describe(x)}`),
|
|
182
|
+
...missingOptional.map((x) => `optional, not set: ${describe(x)}`),
|
|
183
|
+
...unsupported,
|
|
184
|
+
...notes,
|
|
185
|
+
];
|
|
186
|
+
if (listed.orgNote && (missingRequired.length || missingOptional.length)) {
|
|
187
|
+
details.push(listed.orgNote);
|
|
188
|
+
}
|
|
189
|
+
if (missingRequired.length || problems.length) {
|
|
190
|
+
const summary = missingRequired.length
|
|
191
|
+
? `${missingRequired.length} required secret(s) missing.`
|
|
192
|
+
: `${problems.length} Caller problem(s) with secrets.`;
|
|
193
|
+
return result(ID, TITLE, "fail", summary, { details, fix });
|
|
194
|
+
}
|
|
195
|
+
if (missingOptional.length) {
|
|
196
|
+
return result(
|
|
197
|
+
ID,
|
|
198
|
+
TITLE,
|
|
199
|
+
"warn",
|
|
200
|
+
`${missingOptional.length} optional secret(s) referenced but not set.`,
|
|
201
|
+
{ details, fix },
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
if (unsupported.length) {
|
|
205
|
+
return result(
|
|
206
|
+
ID,
|
|
207
|
+
TITLE,
|
|
208
|
+
"skip",
|
|
209
|
+
`${unsupported.length} Caller part(s) doctor could not read; their secrets were not checked.`,
|
|
210
|
+
{ details: unsupported },
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
return result(
|
|
214
|
+
ID,
|
|
215
|
+
TITLE,
|
|
216
|
+
"pass",
|
|
217
|
+
`All ${requirements.secrets.length} secret(s) the Callers pass are set.`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function checkVariables({ github, repo, ownerType, requirements }) {
|
|
222
|
+
const ID = "variables";
|
|
223
|
+
const TITLE = "Repository variables the Callers read are set";
|
|
224
|
+
let listed;
|
|
225
|
+
try {
|
|
226
|
+
listed = listNames(github, repo, "variable", ownerType);
|
|
227
|
+
} catch (err) {
|
|
228
|
+
return result(ID, TITLE, "skip", `Could not list repository variables: ${err.message}`);
|
|
229
|
+
}
|
|
230
|
+
const required = requirements.variables.filter((v) => !v.optional && !isProvided(v.name));
|
|
231
|
+
const missing = required.filter((v) => !listed.names.has(v.name.toUpperCase()));
|
|
232
|
+
if (missing.length === 0) {
|
|
233
|
+
return result(ID, TITLE, "pass", `All ${required.length} required variable(s) are set.`);
|
|
234
|
+
}
|
|
235
|
+
const killSwitch = (name) => /_ENABLED$/.test(name);
|
|
236
|
+
const details = missing.map(
|
|
237
|
+
(v) =>
|
|
238
|
+
`not set: ${v.name} (${v.where.join(", ")})` +
|
|
239
|
+
(killSwitch(v.name) ? " — the Kill switch: the flow never runs until it is set to true" : ""),
|
|
240
|
+
);
|
|
241
|
+
if (listed.orgNote) details.push(listed.orgNote);
|
|
242
|
+
// The Kill switch takes true; for anything else, gh prompts for the value.
|
|
243
|
+
const { fix, notes } = splitFixes(
|
|
244
|
+
missing.map((v) =>
|
|
245
|
+
setCommand("variable", v.name, repo, killSwitch(v.name) ? ["--body", "true"] : []),
|
|
246
|
+
),
|
|
247
|
+
);
|
|
248
|
+
return result(ID, TITLE, "fail", `${missing.length} required variable(s) missing.`, {
|
|
249
|
+
details: [...details, ...notes],
|
|
250
|
+
fix,
|
|
251
|
+
});
|
|
252
|
+
}
|