@intentius/chant-lexicon-github 0.101.0 → 0.102.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/action-pins.d.ts +53 -0
- package/dist/action-pins.d.ts.map +1 -0
- package/dist/codegen/naming.d.ts.map +1 -1
- package/dist/components/generate-op-pipeline.d.ts.map +1 -1
- package/dist/components/generate-pipeline.d.ts.map +1 -1
- package/dist/composites/checkout.d.ts +7 -0
- package/dist/composites/checkout.d.ts.map +1 -1
- package/dist/composites/dependabot.d.ts.map +1 -1
- package/dist/composites/deploy-environment.d.ts.map +1 -1
- package/dist/composites/docker-build.d.ts.map +1 -1
- package/dist/composites/go-ci.d.ts.map +1 -1
- package/dist/composites/node-ci.d.ts.map +1 -1
- package/dist/composites/node-pipeline.d.ts +30 -30
- package/dist/composites/node-pipeline.d.ts.map +1 -1
- package/dist/composites/python-ci.d.ts.map +1 -1
- package/dist/composites/setup-node.d.ts +7 -0
- package/dist/composites/setup-node.d.ts.map +1 -1
- package/dist/expression.d.ts.map +1 -1
- package/dist/generated/index.d.ts +22 -22
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/import/generator.d.ts.map +1 -1
- package/dist/import/parser.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +7 -6
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/audit-lineage.d.ts +13 -0
- package/dist/lint/audit-lineage.d.ts.map +1 -1
- package/dist/lint/post-synth/gha021.d.ts.map +1 -1
- package/dist/lint/post-synth/gha029.d.ts.map +1 -1
- package/dist/lint/post-synth/gha059.d.ts.map +1 -1
- package/dist/lint/post-synth/gha069.d.ts +33 -0
- package/dist/lint/post-synth/gha069.d.ts.map +1 -0
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/yaml-helpers.d.ts +20 -0
- package/dist/lint/post-synth/yaml-helpers.d.ts.map +1 -1
- package/dist/lint/rules/data/deprecated-versions.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +1 -0
- package/dist/okf/rules/GHA069.md +16 -0
- package/dist/okf/types/Job.md +1 -0
- package/dist/okf/types/Workflow.md +1 -0
- package/dist/rules/gha021.ts +24 -24
- package/dist/rules/gha029.ts +3 -2
- package/dist/rules/gha059.ts +10 -2
- package/dist/rules/gha069.ts +111 -0
- package/dist/rules/yaml-helpers.ts +41 -47
- package/dist/variables.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/action-pins.test.ts +87 -0
- package/src/action-pins.ts +75 -0
- package/src/codegen/docs.ts +1 -1
- package/src/components/generate-op-pipeline.test.ts +4 -4
- package/src/components/generate-op-pipeline.ts +8 -7
- package/src/components/generate-pipeline.test.ts +1 -1
- package/src/components/generate-pipeline.ts +8 -14
- package/src/composites/checkout.ts +8 -1
- package/src/composites/composites.test.ts +4 -4
- package/src/composites/deploy-environment.ts +3 -2
- package/src/composites/docker-build.ts +2 -1
- package/src/composites/go-ci.ts +4 -3
- package/src/composites/node-ci.ts +3 -2
- package/src/composites/node-pipeline.ts +8 -7
- package/src/composites/python-ci.ts +3 -2
- package/src/composites/setup-node.ts +8 -1
- package/src/index.ts +4 -0
- package/src/lint/audit-catalog.ts +1 -0
- package/src/lint/audit-lineage.ts +13 -0
- package/src/lint/post-synth/fixtures/GHA069/negative-no-job-block.yml +16 -0
- package/src/lint/post-synth/fixtures/GHA069/negative-no-oidc-step.yml +22 -0
- package/src/lint/post-synth/fixtures/GHA069/negative.yml +21 -0
- package/src/lint/post-synth/fixtures/GHA069/positive.yml +20 -0
- package/src/lint/post-synth/gha011.test.ts +16 -0
- package/src/lint/post-synth/gha018.test.ts +18 -0
- package/src/lint/post-synth/gha019.test.ts +42 -0
- package/src/lint/post-synth/gha021.test.ts +18 -0
- package/src/lint/post-synth/gha021.ts +24 -24
- package/src/lint/post-synth/gha022.test.ts +15 -0
- package/src/lint/post-synth/gha023.test.ts +19 -0
- package/src/lint/post-synth/gha029.ts +3 -2
- package/src/lint/post-synth/gha038.test.ts +20 -0
- package/src/lint/post-synth/gha059.ts +10 -2
- package/src/lint/post-synth/gha069.test.ts +173 -0
- package/src/lint/post-synth/gha069.ts +111 -0
- package/src/lint/post-synth/index.ts +2 -0
- package/src/lint/post-synth/yaml-helpers.test.ts +83 -0
- package/src/lint/post-synth/yaml-helpers.ts +41 -47
- package/src/lint/rules/data/deprecated-versions.ts +5 -2
- package/src/lint/rules/deprecated-action-version.test.ts +1 -1
- package/src/lint/rules/rules.test.ts +1 -1
- package/src/mcp/context-tools.test.ts +17 -0
- package/src/plugin.test.ts +1 -1
- package/src/serializer.ts +15 -4
|
@@ -79,4 +79,22 @@ jobs:
|
|
|
79
79
|
const diags = gha021.check(makeCtx(yaml));
|
|
80
80
|
expect(diags).toHaveLength(0);
|
|
81
81
|
});
|
|
82
|
+
|
|
83
|
+
test("flags a checkout step that starts with name: (the composite shape)", () => {
|
|
84
|
+
const yaml = `name: CI
|
|
85
|
+
on:
|
|
86
|
+
push:
|
|
87
|
+
jobs:
|
|
88
|
+
build:
|
|
89
|
+
runs-on: ubuntu-latest
|
|
90
|
+
steps:
|
|
91
|
+
- name: Checkout
|
|
92
|
+
uses: actions/checkout@v7
|
|
93
|
+
- name: Test
|
|
94
|
+
run: echo test
|
|
95
|
+
`;
|
|
96
|
+
const diags = gha021.check(makeCtx(yaml));
|
|
97
|
+
expect(diags).toHaveLength(1);
|
|
98
|
+
expect(diags[0].message).toContain('Checkout({ pin: "sha" })');
|
|
99
|
+
});
|
|
82
100
|
});
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
9
|
-
import { getPrimaryOutput,
|
|
9
|
+
import { getPrimaryOutput, extractActionRefs, stripUsesComment } from "./yaml-helpers";
|
|
10
|
+
import { pinFixHint } from "../../action-pins";
|
|
10
11
|
|
|
11
12
|
export const gha021: PostSynthCheck = {
|
|
12
13
|
id: "GHA021",
|
|
@@ -17,29 +18,28 @@ export const gha021: PostSynthCheck = {
|
|
|
17
18
|
|
|
18
19
|
for (const [, output] of ctx.outputs) {
|
|
19
20
|
const yaml = getPrimaryOutput(output);
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
21
|
+
// The structural parser sees every step. The line-based extractJobs
|
|
22
|
+
// only reads a step's first line, so it missed `uses:` whenever a step
|
|
23
|
+
// starts with `name:`, which is how every composite emits it.
|
|
24
|
+
for (const { job: jobName, ref: uses, level } of extractActionRefs(yaml)) {
|
|
25
|
+
if (level !== "step") continue;
|
|
26
|
+
|
|
27
|
+
// A pinned ref carries its version as a trailing comment
|
|
28
|
+
// (`actions/checkout@<sha> # v7.0.1`); only the ref is judged.
|
|
29
|
+
const match = stripUsesComment(uses).match(/^actions\/checkout@(.+)$/);
|
|
30
|
+
if (!match) continue;
|
|
31
|
+
|
|
32
|
+
const ref = match[1];
|
|
33
|
+
// A pinned SHA is 40 hex characters
|
|
34
|
+
if (/^[0-9a-f]{40}$/.test(ref)) continue;
|
|
35
|
+
|
|
36
|
+
diagnostics.push({
|
|
37
|
+
checkId: "GHA021",
|
|
38
|
+
severity: "warning",
|
|
39
|
+
message: `Job "${jobName}" uses actions/checkout@${ref} — pin to a full commit SHA for supply-chain security.${pinFixHint("actions/checkout")}`,
|
|
40
|
+
entity: jobName,
|
|
41
|
+
lexicon: "github",
|
|
42
|
+
});
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
@@ -80,4 +80,19 @@ on:
|
|
|
80
80
|
const diags = gha022.check(makeCtx(yaml));
|
|
81
81
|
expect(diags).toHaveLength(0);
|
|
82
82
|
});
|
|
83
|
+
|
|
84
|
+
test("flags a job whose id has capitals or underscores (#3201)", () => {
|
|
85
|
+
const yaml = `name: CI
|
|
86
|
+
on: push
|
|
87
|
+
jobs:
|
|
88
|
+
Build_App:
|
|
89
|
+
runs-on: ubuntu-latest
|
|
90
|
+
steps:
|
|
91
|
+
- name: Build
|
|
92
|
+
run: npm run build
|
|
93
|
+
`;
|
|
94
|
+
const diags = gha022.check(makeCtx(yaml));
|
|
95
|
+
expect(diags).toHaveLength(1);
|
|
96
|
+
expect(diags[0].entity).toBe("Build_App");
|
|
97
|
+
});
|
|
83
98
|
});
|
|
@@ -49,4 +49,23 @@ jobs:
|
|
|
49
49
|
const diags = gha023.check(makeCtx(yaml));
|
|
50
50
|
expect(diags).toHaveLength(0);
|
|
51
51
|
});
|
|
52
|
+
|
|
53
|
+
test("flags ::set-output in a later name:-first step's block script (#3201)", () => {
|
|
54
|
+
const yaml = `name: CI
|
|
55
|
+
on: push
|
|
56
|
+
jobs:
|
|
57
|
+
build:
|
|
58
|
+
runs-on: ubuntu-latest
|
|
59
|
+
steps:
|
|
60
|
+
- name: Checkout
|
|
61
|
+
uses: actions/checkout@v7
|
|
62
|
+
- name: Version
|
|
63
|
+
run: |
|
|
64
|
+
v=$(cat VERSION)
|
|
65
|
+
echo "::set-output name=version::$v"
|
|
66
|
+
`;
|
|
67
|
+
const diags = gha023.check(makeCtx(yaml));
|
|
68
|
+
expect(diags).toHaveLength(1);
|
|
69
|
+
expect(diags[0].message).toContain('step "Version"');
|
|
70
|
+
});
|
|
52
71
|
});
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
15
15
|
import { getPrimaryOutput, extractActionRefs, parseActionUses } from "./yaml-helpers";
|
|
16
16
|
import { TRUSTED_ACTION_OWNERS } from "../rules/data/trusted-action-owners";
|
|
17
|
+
import { pinFixHint } from "../../action-pins";
|
|
17
18
|
|
|
18
19
|
const SHA_RE = /^[0-9a-f]{40}$/;
|
|
19
20
|
|
|
@@ -52,11 +53,11 @@ export const gha029: PostSynthCheck = {
|
|
|
52
53
|
|
|
53
54
|
for (const [, output] of ctx.outputs) {
|
|
54
55
|
const yaml = getPrimaryOutput(output);
|
|
55
|
-
for (const { job, ref } of findUnpinnedActions(yaml)) {
|
|
56
|
+
for (const { job, ref, slug } of findUnpinnedActions(yaml)) {
|
|
56
57
|
diagnostics.push({
|
|
57
58
|
checkId: "GHA029",
|
|
58
59
|
severity: "warning",
|
|
59
|
-
message: `Job "${job}" uses ${ref} pinned to a tag or branch — pin to a full commit SHA for supply-chain security
|
|
60
|
+
message: `Job "${job}" uses ${ref} pinned to a tag or branch — pin to a full commit SHA for supply-chain security.${pinFixHint(slug)}`,
|
|
60
61
|
entity: job,
|
|
61
62
|
lexicon: "github",
|
|
62
63
|
});
|
|
@@ -61,4 +61,24 @@ jobs:
|
|
|
61
61
|
const diags = gha038.check(makeCtx(yaml));
|
|
62
62
|
expect(diags).toHaveLength(0);
|
|
63
63
|
});
|
|
64
|
+
|
|
65
|
+
test("flags a checkout step that starts with name: (#3201)", () => {
|
|
66
|
+
const yaml = `name: After CI
|
|
67
|
+
on:
|
|
68
|
+
workflow_run:
|
|
69
|
+
workflows: [CI]
|
|
70
|
+
types: [completed]
|
|
71
|
+
jobs:
|
|
72
|
+
report:
|
|
73
|
+
runs-on: ubuntu-latest
|
|
74
|
+
steps:
|
|
75
|
+
- name: Checkout
|
|
76
|
+
uses: actions/checkout@v7
|
|
77
|
+
- name: Report
|
|
78
|
+
run: ./report.sh
|
|
79
|
+
`;
|
|
80
|
+
const diags = gha038.check(makeCtx(yaml));
|
|
81
|
+
expect(diags).toHaveLength(1);
|
|
82
|
+
expect(diags[0].entity).toBe("report");
|
|
83
|
+
});
|
|
64
84
|
});
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
24
|
-
import { getPrimaryOutput, extractActionRefs, extractUsesComment, parseActionUses } from "./yaml-helpers";
|
|
24
|
+
import { getPrimaryOutput, extractActionRefs, extractUsesComment, parseActionUses, usesLineComments } from "./yaml-helpers";
|
|
25
25
|
|
|
26
26
|
const SHA_RE = /^[0-9a-f]{40}$/;
|
|
27
27
|
|
|
@@ -49,9 +49,17 @@ export function findStalePinAnnotations(yaml: string): StalePinFinding[] {
|
|
|
49
49
|
const findings: StalePinFinding[] = [];
|
|
50
50
|
|
|
51
51
|
// First pass: missing annotations.
|
|
52
|
+
// The parsed value carries no comment, so match each ref to its `uses:`
|
|
53
|
+
// line, in document order, to read the label.
|
|
54
|
+
const lines = usesLineComments(yaml);
|
|
55
|
+
const commentFor = (ref: string): string | undefined => {
|
|
56
|
+
const k = lines.findIndex((l) => l.ref === ref);
|
|
57
|
+
if (k === -1) return extractUsesComment(ref);
|
|
58
|
+
return lines.splice(k, 1)[0].comment;
|
|
59
|
+
};
|
|
52
60
|
const withComment: Array<{ job: string; ref: string; slug: string; sha: string; comment: string }> = [];
|
|
53
61
|
for (const { job, ref, parsed } of refs) {
|
|
54
|
-
const comment =
|
|
62
|
+
const comment = commentFor(ref);
|
|
55
63
|
if (!comment) {
|
|
56
64
|
findings.push({ job, ref, slug: parsed.slug, sha: parsed.gitRef, kind: "missing" });
|
|
57
65
|
} else {
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
6
|
+
import { gha069 } from "./gha069";
|
|
7
|
+
|
|
8
|
+
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), "fixtures", "GHA069");
|
|
9
|
+
|
|
10
|
+
function makeCtx(yaml: string): PostSynthContext {
|
|
11
|
+
return {
|
|
12
|
+
outputs: new Map([["github", yaml]]),
|
|
13
|
+
entities: new Map(),
|
|
14
|
+
buildResult: {
|
|
15
|
+
outputs: new Map([["github", yaml]]),
|
|
16
|
+
entities: new Map(),
|
|
17
|
+
warnings: [],
|
|
18
|
+
errors: [],
|
|
19
|
+
sourceFileCount: 1,
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function fixture(name: string): PostSynthContext {
|
|
25
|
+
return makeCtx(readFileSync(join(FIXTURES, `${name}.yml`), "utf-8"));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The issue's fixture with the workflow block, the job block and the step swapped in. */
|
|
29
|
+
function workflow(opts: { wf?: string; job?: string; step: string }): string {
|
|
30
|
+
const wf = opts.wf ?? "permissions:\n id-token: write\n contents: read\n";
|
|
31
|
+
const job = opts.job === undefined ? "" : ` permissions: ${opts.job}\n`;
|
|
32
|
+
return `name: Deploy
|
|
33
|
+
on:
|
|
34
|
+
push:
|
|
35
|
+
${wf}jobs:
|
|
36
|
+
deploy:
|
|
37
|
+
runs-on: ubuntu-latest
|
|
38
|
+
${job} steps:
|
|
39
|
+
${opts.step}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const AWS_STEP = ` - uses: aws-actions/configure-aws-credentials@v4
|
|
43
|
+
with:
|
|
44
|
+
role-to-assume: arn:aws:iam::123456789012:role/deploy
|
|
45
|
+
aws-region: us-east-1
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
describe("GHA069: job-level permissions block drops the workflow's id-token: write", () => {
|
|
49
|
+
test("fires on the issue's fixture and names the scope, the job and the fix", () => {
|
|
50
|
+
const diags = gha069.check(fixture("positive"));
|
|
51
|
+
expect(diags).toHaveLength(1);
|
|
52
|
+
expect(diags[0].checkId).toBe("GHA069");
|
|
53
|
+
expect(diags[0].severity).toBe("warning");
|
|
54
|
+
expect(diags[0].entity).toBe("deploy");
|
|
55
|
+
expect(diags[0].message).toContain("id-token: write");
|
|
56
|
+
expect(diags[0].message).toContain('Job "deploy"');
|
|
57
|
+
expect(diags[0].message).toContain("aws-actions/configure-aws-credentials");
|
|
58
|
+
expect(diags[0].message).toContain("Add `id-token: write` to job \"deploy\"'s permissions block");
|
|
59
|
+
expect(diags[0].message).toContain("rather than removing it");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("does not fire when the job block carries the scope", () => {
|
|
63
|
+
expect(gha069.check(fixture("negative"))).toHaveLength(0);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("does not fire when there is no job-level block", () => {
|
|
67
|
+
expect(gha069.check(fixture("negative-no-job-block"))).toHaveLength(0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("does not fire on a job block whose job needs no OIDC token", () => {
|
|
71
|
+
expect(gha069.check(fixture("negative-no-oidc-step"))).toHaveLength(0);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("does not fire when the workflow block never granted id-token", () => {
|
|
75
|
+
const yaml = workflow({ wf: "permissions:\n contents: read\n", job: "\n contents: read\n", step: AWS_STEP });
|
|
76
|
+
expect(gha069.check(makeCtx(yaml))).toHaveLength(0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("does not fire when there is no workflow block at all", () => {
|
|
80
|
+
const yaml = workflow({ wf: "", job: "\n contents: read\n", step: AWS_STEP });
|
|
81
|
+
expect(gha069.check(makeCtx(yaml))).toHaveLength(0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test.each([
|
|
85
|
+
["azure/login", "azure/login@v2", "client-id: abc"],
|
|
86
|
+
["Azure/login, as usually written", "Azure/login@v2", "client-id: abc"],
|
|
87
|
+
["google-github-actions/auth", "google-github-actions/auth@v2", "workload_identity_provider: projects/1/locations/global/workloadIdentityPools/p/providers/gh"],
|
|
88
|
+
[
|
|
89
|
+
"a full SHA pin with a version comment",
|
|
90
|
+
"aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2",
|
|
91
|
+
"role-to-assume: arn:aws:iam::123456789012:role/deploy",
|
|
92
|
+
],
|
|
93
|
+
["a major-only ref", "aws-actions/configure-aws-credentials@v4", "role-to-assume: arn:aws:iam::123456789012:role/deploy"],
|
|
94
|
+
["an exact tag", "aws-actions/configure-aws-credentials@v4.0.2", "role-to-assume: arn:aws:iam::123456789012:role/deploy"],
|
|
95
|
+
])("matches %s on the action path whatever the ref", (_label, uses, input) => {
|
|
96
|
+
const step = ` - uses: ${uses}\n with:\n ${input}\n`;
|
|
97
|
+
const diags = gha069.check(makeCtx(workflow({ job: "\n contents: read\n", step })));
|
|
98
|
+
expect(diags).toHaveLength(1);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("a job block of {} drops id-token too", () => {
|
|
102
|
+
const diags = gha069.check(makeCtx(workflow({ job: "{}", step: AWS_STEP })));
|
|
103
|
+
expect(diags).toHaveLength(1);
|
|
104
|
+
expect(diags[0].message).toContain("`permissions: {}`");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("a job block of read-all drops id-token too", () => {
|
|
108
|
+
const diags = gha069.check(makeCtx(workflow({ job: "read-all", step: AWS_STEP })));
|
|
109
|
+
expect(diags).toHaveLength(1);
|
|
110
|
+
expect(diags[0].message).toContain("`permissions: read-all`");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("a job block of write-all grants id-token, so it does not fire", () => {
|
|
114
|
+
expect(gha069.check(makeCtx(workflow({ job: "write-all", step: AWS_STEP })))).toHaveLength(0);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("a workflow block of write-all grants id-token, so a narrower job block drops it", () => {
|
|
118
|
+
const yaml = workflow({ wf: "permissions: write-all\n", job: "\n contents: read\n", step: AWS_STEP });
|
|
119
|
+
expect(gha069.check(makeCtx(yaml))).toHaveLength(1);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("a workflow block of read-all never granted id-token, so it does not fire", () => {
|
|
123
|
+
const yaml = workflow({ wf: "permissions: read-all\n", job: "\n contents: read\n", step: AWS_STEP });
|
|
124
|
+
expect(gha069.check(makeCtx(yaml))).toHaveLength(0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("a job block setting id-token: none still drops it", () => {
|
|
128
|
+
const diags = gha069.check(makeCtx(workflow({ job: "\n id-token: none\n", step: AWS_STEP })));
|
|
129
|
+
expect(diags).toHaveLength(1);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test.each([
|
|
133
|
+
["aws-actions/configure-aws-credentials@v4", "aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}"],
|
|
134
|
+
["azure/login@v2", "creds: ${{ secrets.AZURE_CREDENTIALS }}"],
|
|
135
|
+
["google-github-actions/auth@v2", "credentials_json: ${{ secrets.GCP_SA_KEY }}"],
|
|
136
|
+
])("does not fire when %s is given a static credential", (uses, input) => {
|
|
137
|
+
const step = ` - uses: ${uses}\n with:\n ${input}\n`;
|
|
138
|
+
expect(gha069.check(makeCtx(workflow({ job: "\n contents: read\n", step })))).toHaveLength(0);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("reports each affected job once, and only those", () => {
|
|
142
|
+
const yaml = `name: Deploy
|
|
143
|
+
on: push
|
|
144
|
+
permissions:
|
|
145
|
+
id-token: write
|
|
146
|
+
jobs:
|
|
147
|
+
staging:
|
|
148
|
+
runs-on: ubuntu-latest
|
|
149
|
+
permissions:
|
|
150
|
+
contents: read
|
|
151
|
+
steps:
|
|
152
|
+
- uses: aws-actions/configure-aws-credentials@v4
|
|
153
|
+
with:
|
|
154
|
+
role-to-assume: arn:aws:iam::123456789012:role/staging
|
|
155
|
+
- uses: google-github-actions/auth@v2
|
|
156
|
+
with:
|
|
157
|
+
workload_identity_provider: projects/1/locations/global/workloadIdentityPools/p/providers/gh
|
|
158
|
+
prod:
|
|
159
|
+
runs-on: ubuntu-latest
|
|
160
|
+
permissions:
|
|
161
|
+
contents: read
|
|
162
|
+
id-token: write
|
|
163
|
+
steps:
|
|
164
|
+
- uses: aws-actions/configure-aws-credentials@v4
|
|
165
|
+
with:
|
|
166
|
+
role-to-assume: arn:aws:iam::123456789012:role/prod
|
|
167
|
+
`;
|
|
168
|
+
const diags = gha069.check(makeCtx(yaml));
|
|
169
|
+
expect(diags).toHaveLength(1);
|
|
170
|
+
expect(diags[0].entity).toBe("staging");
|
|
171
|
+
expect(diags[0].message).toContain("aws-actions/configure-aws-credentials, google-github-actions/auth");
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA069: Job-Level Permissions Block Drops the Workflow's `id-token: write`
|
|
3
|
+
*
|
|
4
|
+
* A job-level `permissions:` block replaces the workflow-level one; GitHub
|
|
5
|
+
* does not merge the two. A workflow that grants `id-token: write` at the top
|
|
6
|
+
* and then gives a job its own block without that scope leaves the job with
|
|
7
|
+
* no OIDC token, and the job's cloud-credential action fails with an error
|
|
8
|
+
* that names neither the scope nor the job block (#2273).
|
|
9
|
+
*
|
|
10
|
+
* Fires when all three hold:
|
|
11
|
+
* - the workflow-level block grants `id-token: write` (directly, or through
|
|
12
|
+
* `write-all`);
|
|
13
|
+
* - a job declares its own block that does not (a map without
|
|
14
|
+
* `id-token: write`, `{}`, `read-all`, or any other preset but `write-all`);
|
|
15
|
+
* - that job has a step using a known OIDC credential action, matched on the
|
|
16
|
+
* action path whatever the ref (tag, major or full SHA), with no static
|
|
17
|
+
* credential input that would make the step skip OIDC.
|
|
18
|
+
*
|
|
19
|
+
* Only `id-token` is checked. The reasoning for not generalising to every
|
|
20
|
+
* scope a job block narrows is recorded on #2273.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
24
|
+
import {
|
|
25
|
+
getPrimaryOutput,
|
|
26
|
+
extractWorkflowPermissions,
|
|
27
|
+
extractJobPermissions,
|
|
28
|
+
extractStepsByJob,
|
|
29
|
+
parseActionUses,
|
|
30
|
+
type PermissionsValue,
|
|
31
|
+
} from "./yaml-helpers";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Actions that exchange the job's OIDC token for cloud credentials, keyed by
|
|
35
|
+
* lowercased `owner/repo`, each with the inputs that select a static
|
|
36
|
+
* credential instead (when one is set, the step does not need `id-token`).
|
|
37
|
+
*/
|
|
38
|
+
export const OIDC_CREDENTIAL_ACTIONS: ReadonlyMap<string, readonly string[]> = new Map([
|
|
39
|
+
["aws-actions/configure-aws-credentials", ["aws-access-key-id", "web-identity-token-file"]],
|
|
40
|
+
["azure/login", ["creds"]],
|
|
41
|
+
["google-github-actions/auth", ["credentials_json"]],
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
/** True when the permissions value grants `id-token: write`. */
|
|
45
|
+
export function grantsIdTokenWrite(perms: PermissionsValue): boolean {
|
|
46
|
+
if (typeof perms === "string") return perms.trim() === "write-all";
|
|
47
|
+
return perms["id-token"] === "write";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The OIDC credential action a step uses, as written, or undefined. */
|
|
51
|
+
function oidcActionOf(step: Record<string, unknown>): string | undefined {
|
|
52
|
+
if (typeof step.uses !== "string") return undefined;
|
|
53
|
+
const parsed = parseActionUses(step.uses);
|
|
54
|
+
if (!parsed) return undefined;
|
|
55
|
+
const staticInputs = OIDC_CREDENTIAL_ACTIONS.get(parsed.slug.toLowerCase());
|
|
56
|
+
if (!staticInputs) return undefined;
|
|
57
|
+
const withInputs = step.with;
|
|
58
|
+
if (withInputs && typeof withInputs === "object" && !Array.isArray(withInputs)) {
|
|
59
|
+
const inputs = withInputs as Record<string, unknown>;
|
|
60
|
+
if (staticInputs.some((k) => inputs[k] !== undefined && inputs[k] !== null && inputs[k] !== "")) return undefined;
|
|
61
|
+
}
|
|
62
|
+
return parsed.slug;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function describeJobBlock(perms: PermissionsValue): string {
|
|
66
|
+
if (typeof perms === "string") return `\`permissions: ${perms}\``;
|
|
67
|
+
if (Object.keys(perms).length === 0) return "`permissions: {}`";
|
|
68
|
+
return "its own `permissions:` block";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const gha069: PostSynthCheck = {
|
|
72
|
+
id: "GHA069",
|
|
73
|
+
description: "Job-level permissions block drops the workflow's id-token: write that an OIDC credential step needs",
|
|
74
|
+
|
|
75
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
76
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
77
|
+
|
|
78
|
+
for (const [, output] of ctx.outputs) {
|
|
79
|
+
const yaml = getPrimaryOutput(output);
|
|
80
|
+
|
|
81
|
+
const wf = extractWorkflowPermissions(yaml);
|
|
82
|
+
if (!wf || !grantsIdTokenWrite(wf)) continue;
|
|
83
|
+
|
|
84
|
+
const jobPermissions = extractJobPermissions(yaml);
|
|
85
|
+
if (jobPermissions.size === 0) continue;
|
|
86
|
+
|
|
87
|
+
const stepsByJob = extractStepsByJob(yaml);
|
|
88
|
+
for (const [job, perms] of jobPermissions) {
|
|
89
|
+
if (grantsIdTokenWrite(perms)) continue;
|
|
90
|
+
|
|
91
|
+
const actions = [
|
|
92
|
+
...new Set((stepsByJob.get(job) ?? []).map(oidcActionOf).filter((a): a is string => a !== undefined)),
|
|
93
|
+
];
|
|
94
|
+
if (actions.length === 0) continue;
|
|
95
|
+
|
|
96
|
+
diagnostics.push({
|
|
97
|
+
checkId: "GHA069",
|
|
98
|
+
severity: "warning",
|
|
99
|
+
message:
|
|
100
|
+
`Job "${job}" declares ${describeJobBlock(perms)}, which replaces the workflow-level block instead of merging with it, ` +
|
|
101
|
+
`so the workflow's \`id-token: write\` does not reach the job and ${actions.join(", ")} cannot request an OIDC token. ` +
|
|
102
|
+
`Add \`id-token: write\` to job "${job}"'s permissions block; keep the job block rather than removing it.`,
|
|
103
|
+
entity: job,
|
|
104
|
+
lexicon: "github",
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return diagnostics;
|
|
110
|
+
},
|
|
111
|
+
};
|
|
@@ -55,6 +55,7 @@ import { gha065 } from "./gha065";
|
|
|
55
55
|
import { gha066 } from "./gha066";
|
|
56
56
|
import { gha067 } from "./gha067";
|
|
57
57
|
import { gha068 } from "./gha068";
|
|
58
|
+
import { gha069 } from "./gha069";
|
|
58
59
|
|
|
59
60
|
export const postSynthChecks: PostSynthCheck[] = [
|
|
60
61
|
gha006,
|
|
@@ -112,4 +113,5 @@ export const postSynthChecks: PostSynthCheck[] = [
|
|
|
112
113
|
gha066,
|
|
113
114
|
gha067,
|
|
114
115
|
gha068,
|
|
116
|
+
gha069,
|
|
115
117
|
];
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { extractJobs, buildNeedsGraph } from "./yaml-helpers";
|
|
3
|
+
|
|
4
|
+
// The shape chant's serializer emits: every step starts with `name:`, and
|
|
5
|
+
// `run:` is often a block scalar. The line-based extractJobs read only the
|
|
6
|
+
// first line of a job's first step, so it saw none of these steps (#3201).
|
|
7
|
+
const NAME_FIRST = `name: CI
|
|
8
|
+
on:
|
|
9
|
+
pull_request_target:
|
|
10
|
+
branches:
|
|
11
|
+
- main
|
|
12
|
+
jobs:
|
|
13
|
+
build:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- name: Checkout
|
|
17
|
+
uses: actions/checkout@v7 # v7.0.1
|
|
18
|
+
- name: Setup Node
|
|
19
|
+
uses: actions/setup-node@v7
|
|
20
|
+
with:
|
|
21
|
+
node-version: "22"
|
|
22
|
+
- name: Test
|
|
23
|
+
run: |
|
|
24
|
+
npm ci
|
|
25
|
+
npm test
|
|
26
|
+
Publish_Docs:
|
|
27
|
+
needs: build
|
|
28
|
+
runs-on: ubuntu-latest
|
|
29
|
+
steps:
|
|
30
|
+
- name: Publish
|
|
31
|
+
run: echo publish
|
|
32
|
+
deploy:
|
|
33
|
+
needs:
|
|
34
|
+
- build
|
|
35
|
+
- Publish_Docs
|
|
36
|
+
runs-on: ubuntu-latest
|
|
37
|
+
steps:
|
|
38
|
+
- id: go
|
|
39
|
+
name: Deploy
|
|
40
|
+
run: echo deploy
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
describe("extractJobs (#3201)", () => {
|
|
44
|
+
test("reads every step of a job whose steps start with name:", () => {
|
|
45
|
+
const build = extractJobs(NAME_FIRST).get("build");
|
|
46
|
+
expect(build?.steps).toEqual([
|
|
47
|
+
{ name: "Checkout", uses: "actions/checkout@v7", run: undefined },
|
|
48
|
+
{ name: "Setup Node", uses: "actions/setup-node@v7", run: undefined },
|
|
49
|
+
{ name: "Test", uses: undefined, run: "npm ci\nnpm test\n" },
|
|
50
|
+
]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("reads a step whose first key is neither name, uses nor run", () => {
|
|
54
|
+
expect(extractJobs(NAME_FIRST).get("deploy")?.steps).toEqual([
|
|
55
|
+
{ name: "Deploy", uses: undefined, run: "echo deploy" },
|
|
56
|
+
]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("reads job ids with capitals and underscores", () => {
|
|
60
|
+
expect([...extractJobs(NAME_FIRST).keys()]).toEqual(["build", "Publish_Docs", "deploy"]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("reads needs as a scalar, a block list and a flow list", () => {
|
|
64
|
+
const jobs = extractJobs(NAME_FIRST);
|
|
65
|
+
expect(jobs.get("build")?.needs).toBeUndefined();
|
|
66
|
+
expect(jobs.get("Publish_Docs")?.needs).toEqual(["build"]);
|
|
67
|
+
expect(jobs.get("deploy")?.needs).toEqual(["build", "Publish_Docs"]);
|
|
68
|
+
expect(extractJobs(`jobs:\n a:\n needs: [x, "y"]\n runs-on: ubuntu-latest\n`).get("a")?.needs).toEqual(["x", "y"]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("buildNeedsGraph sees scalar needs", () => {
|
|
72
|
+
expect(buildNeedsGraph(NAME_FIRST).get("Publish_Docs")).toEqual(["build"]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("a reusable-workflow job has no steps", () => {
|
|
76
|
+
const jobs = extractJobs(`jobs:\n call:\n uses: ./.github/workflows/x.yml\n`);
|
|
77
|
+
expect(jobs.get("call")).toEqual({ name: "call" });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("unparseable YAML yields no jobs", () => {
|
|
81
|
+
expect(extractJobs("jobs:\n a: [\n").size).toBe(0);
|
|
82
|
+
});
|
|
83
|
+
});
|