@intentius/chant-lexicon-github 0.1.23 → 0.2.0
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/integrity.json +34 -4
- package/dist/manifest.json +1 -1
- package/dist/rules/{gha020.ts → gha013.ts} +4 -4
- package/dist/rules/gha029.ts +68 -0
- package/dist/rules/gha030.ts +41 -0
- package/dist/rules/gha031.ts +77 -0
- package/dist/rules/gha032.ts +39 -0
- package/dist/rules/gha033.ts +47 -0
- package/dist/rules/gha034.ts +41 -0
- package/dist/rules/gha035.ts +66 -0
- package/dist/rules/gha036.ts +42 -0
- package/dist/rules/gha037.ts +46 -0
- package/dist/rules/gha038.ts +41 -0
- package/dist/rules/gha039.ts +41 -0
- package/dist/rules/gha040.ts +43 -0
- package/dist/rules/gha041.ts +38 -0
- package/dist/rules/gha042.ts +40 -0
- package/dist/rules/gha043.ts +41 -0
- package/dist/rules/gha044.ts +41 -0
- package/dist/rules/gha045.ts +38 -0
- package/dist/rules/gha046.ts +56 -0
- package/dist/rules/gha047.ts +51 -0
- package/dist/rules/gha048.ts +41 -0
- package/dist/rules/gha049.ts +42 -0
- package/dist/rules/gha050.ts +44 -0
- package/dist/rules/gha051.ts +45 -0
- package/dist/rules/gha052.ts +42 -0
- package/dist/rules/gha053.ts +41 -0
- package/dist/rules/gha054.ts +41 -0
- package/dist/rules/gha055.ts +50 -0
- package/dist/rules/gha056.ts +37 -0
- package/dist/rules/gha057.ts +40 -0
- package/dist/rules/gha058.ts +48 -0
- package/dist/rules/yaml-helpers.ts +335 -0
- package/package.json +1 -1
- package/src/codegen/docs.ts +188 -0
- package/src/composites/dependabot.test.ts +48 -0
- package/src/composites/dependabot.ts +91 -0
- package/src/composites/index.ts +2 -0
- package/src/index.ts +2 -0
- package/src/lint/post-synth/{gha020.test.ts → gha013.test.ts} +7 -7
- package/src/lint/post-synth/{gha020.ts → gha013.ts} +4 -4
- package/src/lint/post-synth/gha029.test.ts +108 -0
- package/src/lint/post-synth/gha029.ts +68 -0
- package/src/lint/post-synth/gha030.test.ts +87 -0
- package/src/lint/post-synth/gha030.ts +41 -0
- package/src/lint/post-synth/gha031.test.ts +75 -0
- package/src/lint/post-synth/gha031.ts +77 -0
- package/src/lint/post-synth/gha032.test.ts +65 -0
- package/src/lint/post-synth/gha032.ts +39 -0
- package/src/lint/post-synth/gha033.test.ts +68 -0
- package/src/lint/post-synth/gha033.ts +47 -0
- package/src/lint/post-synth/gha034.test.ts +86 -0
- package/src/lint/post-synth/gha034.ts +41 -0
- package/src/lint/post-synth/gha035.test.ts +85 -0
- package/src/lint/post-synth/gha035.ts +66 -0
- package/src/lint/post-synth/gha036.test.ts +68 -0
- package/src/lint/post-synth/gha036.ts +42 -0
- package/src/lint/post-synth/gha037.test.ts +66 -0
- package/src/lint/post-synth/gha037.ts +46 -0
- package/src/lint/post-synth/gha038.test.ts +64 -0
- package/src/lint/post-synth/gha038.ts +41 -0
- package/src/lint/post-synth/gha039.test.ts +66 -0
- package/src/lint/post-synth/gha039.ts +41 -0
- package/src/lint/post-synth/gha040.test.ts +77 -0
- package/src/lint/post-synth/gha040.ts +43 -0
- package/src/lint/post-synth/gha041.test.ts +48 -0
- package/src/lint/post-synth/gha041.ts +38 -0
- package/src/lint/post-synth/gha042.test.ts +53 -0
- package/src/lint/post-synth/gha042.ts +40 -0
- package/src/lint/post-synth/gha043.test.ts +69 -0
- package/src/lint/post-synth/gha043.ts +41 -0
- package/src/lint/post-synth/gha044.test.ts +56 -0
- package/src/lint/post-synth/gha044.ts +41 -0
- package/src/lint/post-synth/gha045.test.ts +51 -0
- package/src/lint/post-synth/gha045.ts +38 -0
- package/src/lint/post-synth/gha046.test.ts +59 -0
- package/src/lint/post-synth/gha046.ts +56 -0
- package/src/lint/post-synth/gha047.test.ts +66 -0
- package/src/lint/post-synth/gha047.ts +51 -0
- package/src/lint/post-synth/gha048.test.ts +51 -0
- package/src/lint/post-synth/gha048.ts +41 -0
- package/src/lint/post-synth/gha049.test.ts +72 -0
- package/src/lint/post-synth/gha049.ts +42 -0
- package/src/lint/post-synth/gha050.test.ts +55 -0
- package/src/lint/post-synth/gha050.ts +44 -0
- package/src/lint/post-synth/gha051.test.ts +72 -0
- package/src/lint/post-synth/gha051.ts +45 -0
- package/src/lint/post-synth/gha052.test.ts +52 -0
- package/src/lint/post-synth/gha052.ts +42 -0
- package/src/lint/post-synth/gha053.test.ts +65 -0
- package/src/lint/post-synth/gha053.ts +41 -0
- package/src/lint/post-synth/gha054.test.ts +49 -0
- package/src/lint/post-synth/gha054.ts +41 -0
- package/src/lint/post-synth/gha055.test.ts +49 -0
- package/src/lint/post-synth/gha055.ts +50 -0
- package/src/lint/post-synth/gha056.test.ts +47 -0
- package/src/lint/post-synth/gha056.ts +37 -0
- package/src/lint/post-synth/gha057.test.ts +61 -0
- package/src/lint/post-synth/gha057.ts +40 -0
- package/src/lint/post-synth/gha058.test.ts +67 -0
- package/src/lint/post-synth/gha058.ts +48 -0
- package/src/lint/post-synth/yaml-helpers.ts +335 -0
- package/src/lint/rules/data/flagged-actions.ts +36 -0
- package/src/lint/rules/data/known-action-slugs.ts +38 -0
- package/src/lint/rules/data/preinstalled-tools.ts +36 -0
- package/src/lint/rules/data/risky-features.ts +39 -0
- package/src/lint/rules/data/trusted-action-owners.ts +12 -0
- package/src/lint/rules/data/untrusted-contexts.ts +44 -0
- package/src/plugin.test.ts +47 -1
- package/src/serializer.ts +38 -5
|
@@ -2,8 +2,32 @@
|
|
|
2
2
|
* Helpers for parsing serialized GitHub Actions YAML in post-synth checks.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { parseYAML } from "@intentius/chant/yaml";
|
|
6
|
+
import type { SerializerResult } from "@intentius/chant/serializer";
|
|
7
|
+
|
|
5
8
|
export { getPrimaryOutput } from "@intentius/chant/lint/post-synth";
|
|
6
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Return the emitted `.github/dependabot.yml` content from a serializer output,
|
|
12
|
+
* if present (it rides as an additional file alongside the workflow output).
|
|
13
|
+
*/
|
|
14
|
+
export function getDependabotYaml(output: string | SerializerResult): string | undefined {
|
|
15
|
+
if (typeof output === "string") return undefined;
|
|
16
|
+
return output.files?.["dependabot.yml"];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Parse the `updates:` array from a dependabot.yml document. */
|
|
20
|
+
export function extractDependabotUpdates(dependabotYaml: string): Array<Record<string, unknown>> {
|
|
21
|
+
let doc: Record<string, unknown>;
|
|
22
|
+
try {
|
|
23
|
+
doc = parseYAML(dependabotYaml);
|
|
24
|
+
} catch {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
const updates = doc.updates;
|
|
28
|
+
return Array.isArray(updates) ? (updates as Array<Record<string, unknown>>) : [];
|
|
29
|
+
}
|
|
30
|
+
|
|
7
31
|
export interface ParsedJob {
|
|
8
32
|
name: string;
|
|
9
33
|
needs?: string[];
|
|
@@ -127,3 +151,314 @@ export function extractWorkflowName(yaml: string): string | undefined {
|
|
|
127
151
|
export function hasPermissions(yaml: string): boolean {
|
|
128
152
|
return /^permissions:/m.test(yaml);
|
|
129
153
|
}
|
|
154
|
+
|
|
155
|
+
/** A single `uses:` reference found in a workflow. */
|
|
156
|
+
export interface ActionRef {
|
|
157
|
+
/** Owning job name. */
|
|
158
|
+
job: string;
|
|
159
|
+
/** The raw `uses:` value (e.g. `actions/setup-node@v4`). */
|
|
160
|
+
ref: string;
|
|
161
|
+
/** Whether it is a step-level action or a job-level reusable workflow. */
|
|
162
|
+
level: "step" | "job";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** A container/service/docker image reference found in a workflow. */
|
|
166
|
+
export interface ImageRef {
|
|
167
|
+
/** Owning job name. */
|
|
168
|
+
job: string;
|
|
169
|
+
/** The raw image reference (e.g. `node:20`). */
|
|
170
|
+
image: string;
|
|
171
|
+
/** Where the image was declared. */
|
|
172
|
+
source: "container" | "service" | "step";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function parseDoc(yaml: string): Record<string, unknown> | undefined {
|
|
176
|
+
try {
|
|
177
|
+
return parseYAML(yaml);
|
|
178
|
+
} catch {
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function jobEntries(yaml: string): Array<[string, Record<string, unknown>]> {
|
|
184
|
+
const doc = parseDoc(yaml);
|
|
185
|
+
const jobs = doc?.jobs;
|
|
186
|
+
if (!jobs || typeof jobs !== "object") return [];
|
|
187
|
+
const out: Array<[string, Record<string, unknown>]> = [];
|
|
188
|
+
for (const [name, val] of Object.entries(jobs as Record<string, unknown>)) {
|
|
189
|
+
if (val && typeof val === "object" && !Array.isArray(val)) {
|
|
190
|
+
out.push([name, val as Record<string, unknown>]);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Extract every `uses:` reference from the workflow — both step-level actions
|
|
198
|
+
* and job-level reusable-workflow calls. Uses the structural YAML parser so
|
|
199
|
+
* nested jobs/steps are handled reliably.
|
|
200
|
+
*/
|
|
201
|
+
export function extractActionRefs(yaml: string): ActionRef[] {
|
|
202
|
+
const refs: ActionRef[] = [];
|
|
203
|
+
for (const [job, jobObj] of jobEntries(yaml)) {
|
|
204
|
+
if (typeof jobObj.uses === "string") {
|
|
205
|
+
refs.push({ job, ref: jobObj.uses, level: "job" });
|
|
206
|
+
}
|
|
207
|
+
const steps = jobObj.steps;
|
|
208
|
+
if (Array.isArray(steps)) {
|
|
209
|
+
for (const step of steps) {
|
|
210
|
+
if (step && typeof step === "object" && typeof (step as Record<string, unknown>).uses === "string") {
|
|
211
|
+
refs.push({ job, ref: (step as Record<string, unknown>).uses as string, level: "step" });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return refs;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Extract every container/service/`docker://` image reference from the workflow.
|
|
221
|
+
*/
|
|
222
|
+
export function extractImageRefs(yaml: string): ImageRef[] {
|
|
223
|
+
const refs: ImageRef[] = [];
|
|
224
|
+
for (const [job, jobObj] of jobEntries(yaml)) {
|
|
225
|
+
const container = jobObj.container;
|
|
226
|
+
if (typeof container === "string") {
|
|
227
|
+
refs.push({ job, image: container, source: "container" });
|
|
228
|
+
} else if (container && typeof container === "object" && typeof (container as Record<string, unknown>).image === "string") {
|
|
229
|
+
refs.push({ job, image: (container as Record<string, unknown>).image as string, source: "container" });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const services = jobObj.services;
|
|
233
|
+
if (services && typeof services === "object" && !Array.isArray(services)) {
|
|
234
|
+
for (const svc of Object.values(services as Record<string, unknown>)) {
|
|
235
|
+
if (typeof svc === "string") {
|
|
236
|
+
refs.push({ job, image: svc, source: "service" });
|
|
237
|
+
} else if (svc && typeof svc === "object" && typeof (svc as Record<string, unknown>).image === "string") {
|
|
238
|
+
refs.push({ job, image: (svc as Record<string, unknown>).image as string, source: "service" });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const steps = jobObj.steps;
|
|
244
|
+
if (Array.isArray(steps)) {
|
|
245
|
+
for (const step of steps) {
|
|
246
|
+
const uses = step && typeof step === "object" ? (step as Record<string, unknown>).uses : undefined;
|
|
247
|
+
if (typeof uses === "string" && uses.startsWith("docker://")) {
|
|
248
|
+
refs.push({ job, image: uses.slice("docker://".length), source: "step" });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return refs;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Walk the `jobs:` block line-by-line, tracking the current job. Robust to `|`
|
|
258
|
+
* block scalars (which the structural parser does not handle), so it is the
|
|
259
|
+
* right tool for scanning `run:`/`if:`/`runs-on:` content.
|
|
260
|
+
*/
|
|
261
|
+
function scanJobLines(yaml: string, onLine: (job: string, line: string, indent: number, lineNo: number, lines: string[]) => void): void {
|
|
262
|
+
const lines = yaml.split("\n");
|
|
263
|
+
let inJobs = false;
|
|
264
|
+
let currentJob = "";
|
|
265
|
+
for (let i = 0; i < lines.length; i++) {
|
|
266
|
+
const line = lines[i];
|
|
267
|
+
if (/^jobs:\s*$/.test(line)) { inJobs = true; continue; }
|
|
268
|
+
if (!inJobs) continue;
|
|
269
|
+
if (/^\S/.test(line)) { inJobs = false; continue; } // dedented to a new top-level key
|
|
270
|
+
const jobHeader = line.match(/^ {2}([A-Za-z0-9_-]+):\s*$/);
|
|
271
|
+
if (jobHeader) { currentJob = jobHeader[1]; continue; }
|
|
272
|
+
if (!currentJob) continue;
|
|
273
|
+
const indent = line.search(/\S/);
|
|
274
|
+
if (indent < 0) continue;
|
|
275
|
+
onLine(currentJob, line, indent, i, lines);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** A `run:` script block with its owning job. */
|
|
280
|
+
export interface RunBlock {
|
|
281
|
+
job: string;
|
|
282
|
+
run: string;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Extract every `run:` script (inline and `|`/`>` block scalars) with its
|
|
287
|
+
* owning job.
|
|
288
|
+
*/
|
|
289
|
+
export function extractRunBlocks(yaml: string): RunBlock[] {
|
|
290
|
+
const out: RunBlock[] = [];
|
|
291
|
+
const consumed = new Set<number>();
|
|
292
|
+
scanJobLines(yaml, (job, line, _indent, lineNo, lines) => {
|
|
293
|
+
if (consumed.has(lineNo)) return;
|
|
294
|
+
const m = line.match(/^(\s*)(- )?run:\s*(.*)$/);
|
|
295
|
+
if (!m) return;
|
|
296
|
+
const keyIndent = m[1].length + (m[2] ? 2 : 0); // column where `run:` begins
|
|
297
|
+
const value = m[3].trim();
|
|
298
|
+
if (value === "|" || value === "|-" || value === ">" || value === ">-" || value === "|+" || value === ">+") {
|
|
299
|
+
const blockLines: string[] = [];
|
|
300
|
+
for (let j = lineNo + 1; j < lines.length; j++) {
|
|
301
|
+
const bl = lines[j];
|
|
302
|
+
if (bl.trim() === "") { blockLines.push(""); consumed.add(j); continue; }
|
|
303
|
+
const bi = bl.search(/\S/);
|
|
304
|
+
if (bi <= keyIndent) break;
|
|
305
|
+
blockLines.push(bl.trimStart());
|
|
306
|
+
consumed.add(j);
|
|
307
|
+
}
|
|
308
|
+
out.push({ job, run: blockLines.join("\n") });
|
|
309
|
+
} else {
|
|
310
|
+
out.push({ job, run: value.replace(/^['"]|['"]$/g, "") });
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** An `if:` condition with its owning job. */
|
|
317
|
+
export interface IfCondition {
|
|
318
|
+
job: string;
|
|
319
|
+
expr: string;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Extract every `if:` condition (job-level and step-level) with its job. */
|
|
323
|
+
export function extractIfConditions(yaml: string): IfCondition[] {
|
|
324
|
+
const out: IfCondition[] = [];
|
|
325
|
+
scanJobLines(yaml, (job, line) => {
|
|
326
|
+
const m = line.match(/^\s+(?:- )?if:\s*(.+)$/);
|
|
327
|
+
if (m) out.push({ job, expr: m[1].trim().replace(/^['"]|['"]$/g, "") });
|
|
328
|
+
});
|
|
329
|
+
return out;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Extract each job's `runs-on:` value(s) as a flat list of labels. */
|
|
333
|
+
export function extractRunsOnByJob(yaml: string): Map<string, string[]> {
|
|
334
|
+
const out = new Map<string, string[]>();
|
|
335
|
+
scanJobLines(yaml, (job, line, indent, lineNo, lines) => {
|
|
336
|
+
const m = line.match(/^\s+runs-on:\s*(.*)$/);
|
|
337
|
+
if (!m) return;
|
|
338
|
+
const value = m[1].trim();
|
|
339
|
+
const labels: string[] = [];
|
|
340
|
+
if (value === "") {
|
|
341
|
+
for (let j = lineNo + 1; j < lines.length; j++) {
|
|
342
|
+
const item = lines[j].match(/^\s+- (.+)$/);
|
|
343
|
+
if (!item) break;
|
|
344
|
+
labels.push(item[1].trim().replace(/^['"]|['"]$/g, ""));
|
|
345
|
+
}
|
|
346
|
+
} else if (value.startsWith("[")) {
|
|
347
|
+
for (const part of value.replace(/^\[|\]$/g, "").split(",")) {
|
|
348
|
+
const t = part.trim().replace(/^['"]|['"]$/g, "");
|
|
349
|
+
if (t) labels.push(t);
|
|
350
|
+
}
|
|
351
|
+
} else {
|
|
352
|
+
labels.push(value.replace(/^['"]|['"]$/g, ""));
|
|
353
|
+
}
|
|
354
|
+
out.set(job, labels);
|
|
355
|
+
});
|
|
356
|
+
return out;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Pull every `${{ ... }}` expression body out of a string. */
|
|
360
|
+
export function extractExpressions(text: string): string[] {
|
|
361
|
+
const out: string[] = [];
|
|
362
|
+
const re = /\$\{\{(.+?)\}\}/g;
|
|
363
|
+
let m: RegExpExecArray | null;
|
|
364
|
+
while ((m = re.exec(text)) !== null) {
|
|
365
|
+
out.push(m[1].trim());
|
|
366
|
+
}
|
|
367
|
+
return out;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Every line inside the `jobs:` block paired with its owning job name. */
|
|
371
|
+
export function jobLines(yaml: string): Array<{ job: string; line: string }> {
|
|
372
|
+
const out: Array<{ job: string; line: string }> = [];
|
|
373
|
+
scanJobLines(yaml, (job, line) => out.push({ job, line }));
|
|
374
|
+
return out;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** All lines inside the `jobs:` block grouped by owning job. */
|
|
378
|
+
export function linesByJob(yaml: string): Map<string, string[]> {
|
|
379
|
+
const out = new Map<string, string[]>();
|
|
380
|
+
scanJobLines(yaml, (job, line) => {
|
|
381
|
+
const arr = out.get(job) ?? [];
|
|
382
|
+
arr.push(line);
|
|
383
|
+
out.set(job, arr);
|
|
384
|
+
});
|
|
385
|
+
return out;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Set of jobs that declare an `environment:`. */
|
|
389
|
+
export function extractJobEnvironments(yaml: string): Set<string> {
|
|
390
|
+
const set = new Set<string>();
|
|
391
|
+
scanJobLines(yaml, (job, line) => {
|
|
392
|
+
if (/^\s+environment:/.test(line)) set.add(job);
|
|
393
|
+
});
|
|
394
|
+
return set;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Set of jobs whose body references a `secrets.` context. */
|
|
398
|
+
export function jobsReferencingSecrets(yaml: string): Set<string> {
|
|
399
|
+
const set = new Set<string>();
|
|
400
|
+
scanJobLines(yaml, (job, line) => {
|
|
401
|
+
if (/secrets\./.test(line)) set.add(job);
|
|
402
|
+
});
|
|
403
|
+
return set;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** A permissions value as it appears in YAML: a string preset or a scope map. */
|
|
407
|
+
export type PermissionsValue = string | Record<string, string>;
|
|
408
|
+
|
|
409
|
+
/** The workflow-level `permissions:` value, if present. */
|
|
410
|
+
export function extractWorkflowPermissions(yaml: string): PermissionsValue | undefined {
|
|
411
|
+
const doc = parseDoc(yaml);
|
|
412
|
+
const perms = doc?.permissions;
|
|
413
|
+
if (typeof perms === "string") return perms;
|
|
414
|
+
if (perms && typeof perms === "object" && !Array.isArray(perms)) return perms as Record<string, string>;
|
|
415
|
+
return undefined;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Per-job `permissions:` values, keyed by job name. Only jobs that declare one. */
|
|
419
|
+
export function extractJobPermissions(yaml: string): Map<string, PermissionsValue> {
|
|
420
|
+
const out = new Map<string, PermissionsValue>();
|
|
421
|
+
for (const [job, jobObj] of jobEntries(yaml)) {
|
|
422
|
+
const perms = jobObj.permissions;
|
|
423
|
+
if (typeof perms === "string") out.set(job, perms);
|
|
424
|
+
else if (perms && typeof perms === "object" && !Array.isArray(perms)) out.set(job, perms as Record<string, string>);
|
|
425
|
+
}
|
|
426
|
+
return out;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Classify a permissions value's write surface.
|
|
431
|
+
* `writeAll` is the `write-all` blanket preset; `scopes` lists the individual
|
|
432
|
+
* scopes granted `write` in map form.
|
|
433
|
+
*/
|
|
434
|
+
export function writeSurface(perms: PermissionsValue): { writeAll: boolean; scopes: string[] } {
|
|
435
|
+
if (typeof perms === "string") {
|
|
436
|
+
return { writeAll: perms === "write-all", scopes: [] };
|
|
437
|
+
}
|
|
438
|
+
const scopes: string[] = [];
|
|
439
|
+
for (const [scope, level] of Object.entries(perms)) {
|
|
440
|
+
if (level === "write") scopes.push(scope);
|
|
441
|
+
}
|
|
442
|
+
return { writeAll: false, scopes };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** True if the permissions value grants any write access (blanket or scoped). */
|
|
446
|
+
export function grantsWrite(perms: PermissionsValue): boolean {
|
|
447
|
+
const { writeAll, scopes } = writeSurface(perms);
|
|
448
|
+
return writeAll || scopes.length > 0;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Split an action `uses:` value into its `owner/repo` slug and git ref.
|
|
453
|
+
* Returns undefined for local (`./`, `../`) and `docker://` references, which
|
|
454
|
+
* are not GitHub action repository references.
|
|
455
|
+
*/
|
|
456
|
+
export function parseActionUses(uses: string): { owner: string; repo: string; slug: string; gitRef: string } | undefined {
|
|
457
|
+
if (uses.startsWith("./") || uses.startsWith("../") || uses.startsWith("docker://")) return undefined;
|
|
458
|
+
const at = uses.lastIndexOf("@");
|
|
459
|
+
const path = at === -1 ? uses : uses.slice(0, at);
|
|
460
|
+
const gitRef = at === -1 ? "" : uses.slice(at + 1);
|
|
461
|
+
const segments = path.split("/");
|
|
462
|
+
if (segments.length < 2 || !segments[0] || !segments[1]) return undefined;
|
|
463
|
+
return { owner: segments[0], repo: segments[1], slug: `${segments[0]}/${segments[1]}`, gitRef };
|
|
464
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vendored snapshot of action `owner/repo` slugs that are archived/abandoned or
|
|
3
|
+
* carry a disclosed security issue. Used by GHA032.
|
|
4
|
+
*
|
|
5
|
+
* Advisory only, and necessarily incomplete. Refresh source: GitHub Security
|
|
6
|
+
* Advisories (GHSA), CVE feeds, and upstream "archived" repo status. Accept
|
|
7
|
+
* staleness — regenerate periodically by editing this map. A missing entry only
|
|
8
|
+
* means a known-bad upstream goes unflagged; it never blocks a build.
|
|
9
|
+
*
|
|
10
|
+
* Keep entries factual and dated so the advice stays actionable.
|
|
11
|
+
*/
|
|
12
|
+
export interface FlaggedAction {
|
|
13
|
+
/** Why the upstream is flagged. */
|
|
14
|
+
reason: string;
|
|
15
|
+
/** What to do instead. */
|
|
16
|
+
remediation: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const FLAGGED_ACTIONS: Readonly<Record<string, FlaggedAction>> = {
|
|
20
|
+
"tj-actions/changed-files": {
|
|
21
|
+
reason: "supply-chain compromise disclosed March 2025 (CVE-2025-30066) — tags were repointed to leak CI secrets",
|
|
22
|
+
remediation: "pin to a vetted commit SHA from before the compromise, or migrate to an audited alternative",
|
|
23
|
+
},
|
|
24
|
+
"actions/setup-ruby": {
|
|
25
|
+
reason: "archived and unmaintained",
|
|
26
|
+
remediation: "use ruby/setup-ruby",
|
|
27
|
+
},
|
|
28
|
+
"actions/create-release": {
|
|
29
|
+
reason: "archived and unmaintained",
|
|
30
|
+
remediation: "use softprops/action-gh-release or the gh CLI",
|
|
31
|
+
},
|
|
32
|
+
"actions/upload-release-asset": {
|
|
33
|
+
reason: "archived and unmaintained",
|
|
34
|
+
remediation: "use softprops/action-gh-release or the gh CLI",
|
|
35
|
+
},
|
|
36
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vendored snapshot of widely-used GitHub Action `owner/repo` slugs.
|
|
3
|
+
*
|
|
4
|
+
* Used by the look-alike check (GHA031) as the reference set: a `uses:` slug
|
|
5
|
+
* that is a near-miss (edit distance 1–2) of one of these — but not an exact
|
|
6
|
+
* match — is likely a typo or an impersonation of the popular action.
|
|
7
|
+
*
|
|
8
|
+
* Advisory only. Refresh source: the GitHub Marketplace "most-used" actions and
|
|
9
|
+
* the actions/* org. Accept staleness — regenerate periodically by editing this
|
|
10
|
+
* list; a stale entry only weakens look-alike detection, it never blocks a build.
|
|
11
|
+
*/
|
|
12
|
+
export const KNOWN_ACTION_SLUGS: readonly string[] = [
|
|
13
|
+
"actions/checkout",
|
|
14
|
+
"actions/setup-node",
|
|
15
|
+
"actions/setup-python",
|
|
16
|
+
"actions/setup-go",
|
|
17
|
+
"actions/setup-java",
|
|
18
|
+
"actions/setup-dotnet",
|
|
19
|
+
"actions/cache",
|
|
20
|
+
"actions/upload-artifact",
|
|
21
|
+
"actions/download-artifact",
|
|
22
|
+
"actions/github-script",
|
|
23
|
+
"actions/stale",
|
|
24
|
+
"actions/labeler",
|
|
25
|
+
"actions/dependency-review-action",
|
|
26
|
+
"docker/build-push-action",
|
|
27
|
+
"docker/login-action",
|
|
28
|
+
"docker/setup-buildx-action",
|
|
29
|
+
"docker/setup-qemu-action",
|
|
30
|
+
"docker/metadata-action",
|
|
31
|
+
"aws-actions/configure-aws-credentials",
|
|
32
|
+
"azure/login",
|
|
33
|
+
"google-github-actions/auth",
|
|
34
|
+
"hashicorp/setup-terraform",
|
|
35
|
+
"codecov/codecov-action",
|
|
36
|
+
"softprops/action-gh-release",
|
|
37
|
+
"peter-evans/create-pull-request",
|
|
38
|
+
];
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vendored snapshot of tools already present on GitHub-hosted runner images,
|
|
3
|
+
* used by GHA056 to flag redundant runtime installs that add third-party
|
|
4
|
+
* surface for no benefit.
|
|
5
|
+
*
|
|
6
|
+
* Advisory. Refresh source: the runner-images repository's installed-software
|
|
7
|
+
* manifests. Conservative on purpose — only tools that are reliably present
|
|
8
|
+
* across the common ubuntu/macos/windows images are listed.
|
|
9
|
+
*/
|
|
10
|
+
export const PREINSTALLED_TOOLS: readonly string[] = [
|
|
11
|
+
"git",
|
|
12
|
+
"curl",
|
|
13
|
+
"wget",
|
|
14
|
+
"jq",
|
|
15
|
+
"make",
|
|
16
|
+
"gcc",
|
|
17
|
+
"g++",
|
|
18
|
+
"zip",
|
|
19
|
+
"unzip",
|
|
20
|
+
"tar",
|
|
21
|
+
"docker",
|
|
22
|
+
"docker-compose",
|
|
23
|
+
"python3",
|
|
24
|
+
"pip3",
|
|
25
|
+
"node",
|
|
26
|
+
"npm",
|
|
27
|
+
"yarn",
|
|
28
|
+
"go",
|
|
29
|
+
"gh",
|
|
30
|
+
"aws",
|
|
31
|
+
"az",
|
|
32
|
+
"gcloud",
|
|
33
|
+
"kubectl",
|
|
34
|
+
"helm",
|
|
35
|
+
"terraform",
|
|
36
|
+
];
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vendored snapshot of GitHub Actions features with a known security or
|
|
3
|
+
* safety footgun, matched against emitted workflow YAML by GHA054.
|
|
4
|
+
*
|
|
5
|
+
* Advisory and necessarily incomplete. Refresh source: GitHub changelog
|
|
6
|
+
* deprecations and security hardening guidance. Editing this list is the
|
|
7
|
+
* refresh mechanism; a stale entry only weakens detection, never blocks a build.
|
|
8
|
+
*/
|
|
9
|
+
export interface RiskyFeature {
|
|
10
|
+
/** Pattern matched against the emitted YAML. */
|
|
11
|
+
pattern: RegExp;
|
|
12
|
+
/** Short label for the footgun. */
|
|
13
|
+
label: string;
|
|
14
|
+
/** What to do instead. */
|
|
15
|
+
advice: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const RISKY_FEATURES: readonly RiskyFeature[] = [
|
|
19
|
+
{
|
|
20
|
+
pattern: /::set-output\s/,
|
|
21
|
+
label: "deprecated ::set-output:: workflow command",
|
|
22
|
+
advice: "write to $GITHUB_OUTPUT instead",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
pattern: /::save-state\s/,
|
|
26
|
+
label: "deprecated ::save-state:: workflow command",
|
|
27
|
+
advice: "write to $GITHUB_STATE instead",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
pattern: /ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION/,
|
|
31
|
+
label: "opt-in to an end-of-life Node.js runtime",
|
|
32
|
+
advice: "upgrade the action to a supported Node runtime instead of forcing the unsupported one",
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
pattern: /::add-mask::\$\{\{/,
|
|
36
|
+
label: "masking a dynamic value after it may have already been logged",
|
|
37
|
+
advice: "mask secrets at their source; ::add-mask:: on an interpolated value can leak before the mask applies",
|
|
38
|
+
},
|
|
39
|
+
];
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Allowlist of trusted action owners (GitHub orgs) whose `uses:` references are
|
|
3
|
+
* exempt from the SHA-pinning check (GHA029).
|
|
4
|
+
*
|
|
5
|
+
* Ships EMPTY by default: chant recommends pinning every external reference to a
|
|
6
|
+
* full commit SHA, including first-party ones. Populate this set to silence the
|
|
7
|
+
* pinning check for orgs you have decided to trust by tag (e.g. your own org).
|
|
8
|
+
*
|
|
9
|
+
* This is a vendored, code-level allowlist — `PostSynthContext` carries no
|
|
10
|
+
* per-check runtime config channel today. Refresh it by editing this file.
|
|
11
|
+
*/
|
|
12
|
+
export const TRUSTED_ACTION_OWNERS: ReadonlySet<string> = new Set<string>([]);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vendored snapshot of GitHub Actions expression contexts that are
|
|
3
|
+
* attacker-controllable — their values come from event payloads a fork or an
|
|
4
|
+
* outside contributor can set. Interpolating them into a shell (`run:`), into
|
|
5
|
+
* `GITHUB_ENV`/`GITHUB_PATH`, or into another execution sink turns data into
|
|
6
|
+
* code (script injection).
|
|
7
|
+
*
|
|
8
|
+
* Source: GitHub's "Security hardening for GitHub Actions — understanding the
|
|
9
|
+
* risk of script injections" plus the commonly-cited untrusted-context list.
|
|
10
|
+
* Advisory and necessarily incomplete; refresh by editing this list.
|
|
11
|
+
*/
|
|
12
|
+
export const UNTRUSTED_CONTEXTS: readonly string[] = [
|
|
13
|
+
"github.event.issue.title",
|
|
14
|
+
"github.event.issue.body",
|
|
15
|
+
"github.event.pull_request.title",
|
|
16
|
+
"github.event.pull_request.body",
|
|
17
|
+
"github.event.pull_request.head.ref",
|
|
18
|
+
"github.event.pull_request.head.label",
|
|
19
|
+
"github.event.pull_request.head.repo.default_branch",
|
|
20
|
+
"github.event.comment.body",
|
|
21
|
+
"github.event.review.body",
|
|
22
|
+
"github.event.review_comment.body",
|
|
23
|
+
"github.event.discussion.title",
|
|
24
|
+
"github.event.discussion.body",
|
|
25
|
+
"github.event.head_commit.message",
|
|
26
|
+
"github.event.head_commit.author.email",
|
|
27
|
+
"github.event.head_commit.author.name",
|
|
28
|
+
"github.event.commits", // .*.message / .*.author.* (array — matched by prefix)
|
|
29
|
+
"github.event.pages", // .*.page_name
|
|
30
|
+
"github.head_ref",
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Return the first untrusted context referenced inside an expression body, or
|
|
35
|
+
* undefined. Matches by substring so array element accesses
|
|
36
|
+
* (`github.event.commits[0].message`) are caught via their prefix.
|
|
37
|
+
*/
|
|
38
|
+
export function matchUntrustedContext(exprBody: string): string | undefined {
|
|
39
|
+
const normalized = exprBody.replace(/\s+/g, "");
|
|
40
|
+
for (const ctx of UNTRUSTED_CONTEXTS) {
|
|
41
|
+
if (normalized.includes(ctx.replace(/\s+/g, ""))) return ctx;
|
|
42
|
+
}
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
package/src/plugin.test.ts
CHANGED
|
@@ -24,15 +24,61 @@ describe("githubPlugin", () => {
|
|
|
24
24
|
|
|
25
25
|
test("provides post-synth checks", () => {
|
|
26
26
|
const checks = githubPlugin.postSynthChecks!();
|
|
27
|
-
expect(checks.length).toBe(
|
|
27
|
+
expect(checks.length).toBe(45);
|
|
28
28
|
|
|
29
29
|
const checkIds = checks.map((c) => c.id);
|
|
30
30
|
expect(checkIds).toContain("GHA006");
|
|
31
31
|
expect(checkIds).toContain("GHA009");
|
|
32
32
|
expect(checkIds).toContain("GHA011");
|
|
33
|
+
expect(checkIds).toContain("GHA013");
|
|
33
34
|
expect(checkIds).toContain("GHA017");
|
|
34
35
|
expect(checkIds).toContain("GHA018");
|
|
35
36
|
expect(checkIds).toContain("GHA019");
|
|
37
|
+
expect(checkIds).toContain("GHA029");
|
|
38
|
+
expect(checkIds).toContain("GHA030");
|
|
39
|
+
expect(checkIds).toContain("GHA031");
|
|
40
|
+
expect(checkIds).toContain("GHA032");
|
|
41
|
+
expect(checkIds).toContain("GHA033");
|
|
42
|
+
expect(checkIds).toContain("GHA034");
|
|
43
|
+
expect(checkIds).toContain("GHA035");
|
|
44
|
+
expect(checkIds).toContain("GHA036");
|
|
45
|
+
expect(checkIds).toContain("GHA037");
|
|
46
|
+
expect(checkIds).toContain("GHA038");
|
|
47
|
+
expect(checkIds).toContain("GHA039");
|
|
48
|
+
expect(checkIds).toContain("GHA040");
|
|
49
|
+
expect(checkIds).toContain("GHA041");
|
|
50
|
+
expect(checkIds).toContain("GHA042");
|
|
51
|
+
expect(checkIds).toContain("GHA043");
|
|
52
|
+
expect(checkIds).toContain("GHA044");
|
|
53
|
+
expect(checkIds).toContain("GHA045");
|
|
54
|
+
expect(checkIds).toContain("GHA046");
|
|
55
|
+
expect(checkIds).toContain("GHA047");
|
|
56
|
+
expect(checkIds).toContain("GHA048");
|
|
57
|
+
expect(checkIds).toContain("GHA049");
|
|
58
|
+
expect(checkIds).toContain("GHA050");
|
|
59
|
+
expect(checkIds).toContain("GHA051");
|
|
60
|
+
expect(checkIds).toContain("GHA052");
|
|
61
|
+
expect(checkIds).toContain("GHA053");
|
|
62
|
+
expect(checkIds).toContain("GHA054");
|
|
63
|
+
expect(checkIds).toContain("GHA055");
|
|
64
|
+
expect(checkIds).toContain("GHA056");
|
|
65
|
+
expect(checkIds).toContain("GHA057");
|
|
66
|
+
expect(checkIds).toContain("GHA058");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("lint IDs are unique across rules and post-synth checks", () => {
|
|
70
|
+
const ruleIds = githubPlugin.lintRules!().map((r) => r.id);
|
|
71
|
+
const checkIds = githubPlugin.postSynthChecks!().map((c) => c.id);
|
|
72
|
+
const allIds = [...ruleIds, ...checkIds];
|
|
73
|
+
|
|
74
|
+
const seen = new Set<string>();
|
|
75
|
+
const duplicates = new Set<string>();
|
|
76
|
+
for (const id of allIds) {
|
|
77
|
+
if (seen.has(id)) duplicates.add(id);
|
|
78
|
+
seen.add(id);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
expect(Array.from(duplicates)).toEqual([]);
|
|
36
82
|
});
|
|
37
83
|
|
|
38
84
|
test("provides intrinsics", () => {
|
package/src/serializer.ts
CHANGED
|
@@ -233,6 +233,7 @@ export const githubSerializer: Serializer = {
|
|
|
233
233
|
const workflows: Array<[string, Declarable]> = [];
|
|
234
234
|
const jobs: Array<[string, Declarable]> = [];
|
|
235
235
|
const triggers: Array<[string, Declarable]> = [];
|
|
236
|
+
const dependabot: Array<[string, Declarable]> = [];
|
|
236
237
|
const others: Array<[string, Declarable]> = [];
|
|
237
238
|
|
|
238
239
|
for (const [name, entity] of entities) {
|
|
@@ -245,21 +246,53 @@ export const githubSerializer: Serializer = {
|
|
|
245
246
|
jobs.push([name, entity]);
|
|
246
247
|
} else if (isTriggerType(entityType)) {
|
|
247
248
|
triggers.push([name, entity]);
|
|
249
|
+
} else if (entityType === "GitHub::Dependabot::Config") {
|
|
250
|
+
dependabot.push([name, entity]);
|
|
248
251
|
} else {
|
|
249
252
|
others.push([name, entity]);
|
|
250
253
|
}
|
|
251
254
|
}
|
|
252
255
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
+
const workflowOut =
|
|
257
|
+
workflows.length > 1
|
|
258
|
+
? serializeMultiWorkflow(workflows, jobs, triggers, entities, entityNames)
|
|
259
|
+
: serializeSingleWorkflow(workflows, jobs, triggers, entities, entityNames);
|
|
260
|
+
|
|
261
|
+
// A DependabotConfig emits an additional `.github/dependabot.yml` file
|
|
262
|
+
// alongside (or instead of) any workflow output.
|
|
263
|
+
if (dependabot.length > 0) {
|
|
264
|
+
const [, dep] = dependabot[0];
|
|
265
|
+
const depProps = (dep as unknown as Record<string, unknown>).props as Record<string, unknown>;
|
|
266
|
+
const dependabotYaml = emitDependabotYaml(depProps);
|
|
267
|
+
if (typeof workflowOut === "string") {
|
|
268
|
+
return { primary: workflowOut, files: { "dependabot.yml": dependabotYaml } };
|
|
269
|
+
}
|
|
270
|
+
return { ...workflowOut, files: { ...(workflowOut.files ?? {}), "dependabot.yml": dependabotYaml } };
|
|
256
271
|
}
|
|
257
272
|
|
|
258
|
-
|
|
259
|
-
return serializeSingleWorkflow(workflows, jobs, triggers, entities, entityNames);
|
|
273
|
+
return workflowOut;
|
|
260
274
|
},
|
|
261
275
|
};
|
|
262
276
|
|
|
277
|
+
/** Deep camelCase → kebab-case key conversion for dependabot props. */
|
|
278
|
+
function deepKebabKeys(value: unknown): unknown {
|
|
279
|
+
if (Array.isArray(value)) return value.map(deepKebabKeys);
|
|
280
|
+
if (value !== null && typeof value === "object") {
|
|
281
|
+
const out: Record<string, unknown> = {};
|
|
282
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
283
|
+
out[toKebabCase(k)] = deepKebabKeys(v);
|
|
284
|
+
}
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
return value;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Serialize a DependabotConfig's props into `.github/dependabot.yml`. */
|
|
291
|
+
function emitDependabotYaml(props: Record<string, unknown>): string {
|
|
292
|
+
const doc = deepKebabKeys(props) as Record<string, unknown>;
|
|
293
|
+
return emitYAMLDocument(doc);
|
|
294
|
+
}
|
|
295
|
+
|
|
263
296
|
function serializeSingleWorkflow(
|
|
264
297
|
workflows: Array<[string, Declarable]>,
|
|
265
298
|
jobs: Array<[string, Declarable]>,
|