@intentius/chant-lexicon-github 0.1.22 → 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
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA055: Runtime Install of a Tool Already on the Runner
|
|
3
|
+
*
|
|
4
|
+
* Flags a `run:` step that installs (apt/brew/apk/choco) a tool that GitHub-
|
|
5
|
+
* hosted runners already ship. The redundant install adds a third-party
|
|
6
|
+
* download and supply-chain surface for no benefit and slows the job. Advisory;
|
|
7
|
+
* irrelevant on self-hosted runners that may lack the tool.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
11
|
+
import { getPrimaryOutput, extractRunBlocks } from "./yaml-helpers";
|
|
12
|
+
import { PREINSTALLED_TOOLS } from "../rules/data/preinstalled-tools";
|
|
13
|
+
|
|
14
|
+
const INSTALL_RE = /\b(?:apt(?:-get)?\s+install|brew\s+install|apk\s+add|choco\s+install)\b([^\n]*)/g;
|
|
15
|
+
|
|
16
|
+
export const gha055: PostSynthCheck = {
|
|
17
|
+
id: "GHA055",
|
|
18
|
+
description: "Runtime install of a tool already present on the runner",
|
|
19
|
+
|
|
20
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
21
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
22
|
+
const preinstalled = new Set(PREINSTALLED_TOOLS);
|
|
23
|
+
|
|
24
|
+
for (const [, output] of ctx.outputs) {
|
|
25
|
+
const yaml = getPrimaryOutput(output);
|
|
26
|
+
for (const { job, run } of extractRunBlocks(yaml)) {
|
|
27
|
+
const redundant = new Set<string>();
|
|
28
|
+
let m: RegExpExecArray | null;
|
|
29
|
+
INSTALL_RE.lastIndex = 0;
|
|
30
|
+
while ((m = INSTALL_RE.exec(run)) !== null) {
|
|
31
|
+
for (const tok of m[1].split(/\s+/)) {
|
|
32
|
+
const name = tok.replace(/[^\w.+-]/g, "");
|
|
33
|
+
if (name && preinstalled.has(name)) redundant.add(name);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (redundant.size > 0) {
|
|
37
|
+
diagnostics.push({
|
|
38
|
+
checkId: "GHA055",
|
|
39
|
+
severity: "info",
|
|
40
|
+
message: `Job "${job}" installs ${[...redundant].join(", ")} at runtime, but GitHub-hosted runners already ship ${redundant.size > 1 ? "these tools" : "this tool"}. Drop the redundant install (or rely on the preinstalled version) to reduce supply-chain surface.`,
|
|
41
|
+
entity: job,
|
|
42
|
+
lexicon: "github",
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return diagnostics;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA056: Workflow Without a `name:`
|
|
3
|
+
*
|
|
4
|
+
* Flags an emitted workflow with no top-level `name:`. Without one, GitHub falls
|
|
5
|
+
* back to the file path in the Actions UI and audit logs, making runs harder to
|
|
6
|
+
* identify and review. A clear name improves observability and audit output.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
10
|
+
import { getPrimaryOutput, extractWorkflowName } from "./yaml-helpers";
|
|
11
|
+
|
|
12
|
+
export const gha056: PostSynthCheck = {
|
|
13
|
+
id: "GHA056",
|
|
14
|
+
description: "Workflow without a name",
|
|
15
|
+
|
|
16
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
17
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
18
|
+
|
|
19
|
+
for (const [lexicon, output] of ctx.outputs) {
|
|
20
|
+
const yaml = getPrimaryOutput(output);
|
|
21
|
+
// Only inspect things that look like workflows (have a jobs: block).
|
|
22
|
+
if (!/^jobs:\s*$/m.test(yaml)) continue;
|
|
23
|
+
const name = extractWorkflowName(yaml);
|
|
24
|
+
if (!name) {
|
|
25
|
+
diagnostics.push({
|
|
26
|
+
checkId: "GHA056",
|
|
27
|
+
severity: "info",
|
|
28
|
+
message: `Workflow "${lexicon}" has no top-level name: — add one so runs are identifiable in the Actions UI and audit logs.`,
|
|
29
|
+
entity: "workflow",
|
|
30
|
+
lexicon: "github",
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return diagnostics;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA057: Dependency Update Executing Untrusted External Code
|
|
3
|
+
*
|
|
4
|
+
* Flags a dependabot `updates:` entry with
|
|
5
|
+
* `insecure-external-code-execution: allow`. That setting lets a freshly-pulled
|
|
6
|
+
* dependency run lifecycle scripts (e.g. npm `postinstall`) during the update
|
|
7
|
+
* itself — a compromised release executes in the update job before any review.
|
|
8
|
+
* Set it to `deny`, or isolate the ecosystem.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
12
|
+
import { getDependabotYaml, extractDependabotUpdates } from "./yaml-helpers";
|
|
13
|
+
|
|
14
|
+
export const gha057: PostSynthCheck = {
|
|
15
|
+
id: "GHA057",
|
|
16
|
+
description: "Dependency update allows executing untrusted external code",
|
|
17
|
+
|
|
18
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
19
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
20
|
+
|
|
21
|
+
for (const [, output] of ctx.outputs) {
|
|
22
|
+
const dependabot = getDependabotYaml(output);
|
|
23
|
+
if (!dependabot) continue;
|
|
24
|
+
for (const update of extractDependabotUpdates(dependabot)) {
|
|
25
|
+
if (update["insecure-external-code-execution"] === "allow") {
|
|
26
|
+
const eco = String(update["package-ecosystem"] ?? "?");
|
|
27
|
+
diagnostics.push({
|
|
28
|
+
checkId: "GHA057",
|
|
29
|
+
severity: "error",
|
|
30
|
+
message: `Dependabot update for "${eco}" sets insecure-external-code-execution: allow, running dependency lifecycle scripts during the update. Set it to deny.`,
|
|
31
|
+
entity: eco,
|
|
32
|
+
lexicon: "github",
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return diagnostics;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA058: Dependency Update Without a Cooldown
|
|
3
|
+
*
|
|
4
|
+
* Flags a dependabot `updates:` entry with no cooldown (or an all-zero one). A
|
|
5
|
+
* version published seconds ago — including a compromised one — is adopted
|
|
6
|
+
* immediately, with no window for the ecosystem to yank it or for anyone to
|
|
7
|
+
* react. Configure a `cooldown:` so freshly-published versions wait before they
|
|
8
|
+
* are proposed.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
12
|
+
import { getDependabotYaml, extractDependabotUpdates } from "./yaml-helpers";
|
|
13
|
+
|
|
14
|
+
const COOLDOWN_DAY_KEYS = ["default-days", "semver-major-days", "semver-minor-days", "semver-patch-days"];
|
|
15
|
+
|
|
16
|
+
function hasEffectiveCooldown(cooldown: unknown): boolean {
|
|
17
|
+
if (!cooldown || typeof cooldown !== "object") return false;
|
|
18
|
+
const c = cooldown as Record<string, unknown>;
|
|
19
|
+
return COOLDOWN_DAY_KEYS.some((k) => typeof c[k] === "number" && (c[k] as number) > 0);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const gha058: PostSynthCheck = {
|
|
23
|
+
id: "GHA058",
|
|
24
|
+
description: "Dependency update has no cooldown window",
|
|
25
|
+
|
|
26
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
27
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
28
|
+
|
|
29
|
+
for (const [, output] of ctx.outputs) {
|
|
30
|
+
const dependabot = getDependabotYaml(output);
|
|
31
|
+
if (!dependabot) continue;
|
|
32
|
+
for (const update of extractDependabotUpdates(dependabot)) {
|
|
33
|
+
if (!hasEffectiveCooldown(update.cooldown)) {
|
|
34
|
+
const eco = String(update["package-ecosystem"] ?? "?");
|
|
35
|
+
diagnostics.push({
|
|
36
|
+
checkId: "GHA058",
|
|
37
|
+
severity: "warning",
|
|
38
|
+
message: `Dependabot update for "${eco}" has no cooldown — a version published moments ago is adopted immediately. Add a cooldown window so fresh (possibly compromised) releases wait before they are proposed.`,
|
|
39
|
+
entity: eco,
|
|
40
|
+
lexicon: "github",
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return diagnostics;
|
|
47
|
+
},
|
|
48
|
+
};
|
|
@@ -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
|
+
}
|
package/package.json
CHANGED