@intentius/chant-lexicon-github 0.0.18 → 0.0.24
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 +14 -4
- package/dist/manifest.json +1 -1
- package/dist/rules/gha020.ts +40 -0
- package/dist/rules/gha021.ts +48 -0
- package/dist/rules/gha022.ts +50 -0
- package/dist/rules/gha023.ts +44 -0
- package/dist/rules/gha024.ts +42 -0
- package/dist/rules/gha025.ts +42 -0
- package/dist/rules/gha026.ts +40 -0
- package/dist/rules/gha027.ts +57 -0
- package/dist/rules/gha028.ts +37 -0
- package/dist/skills/{github-actions-patterns.md → chant-github-patterns.md} +2 -1
- package/dist/skills/chant-github-security.md +88 -0
- package/dist/skills/chant-github.md +569 -4
- package/package.json +20 -2
- package/src/codegen/docs.test.ts +19 -0
- package/src/codegen/generate.test.ts +12 -0
- package/src/codegen/package.test.ts +8 -0
- package/src/composites/cache.ts +8 -3
- package/src/composites/checkout.ts +8 -3
- package/src/composites/composites.test.ts +106 -0
- package/src/composites/deploy-environment.ts +11 -5
- package/src/composites/docker-build.ts +11 -5
- package/src/composites/download-artifact.ts +8 -3
- package/src/composites/go-ci.ts +17 -9
- package/src/composites/node-ci.ts +11 -5
- package/src/composites/node-pipeline.ts +14 -7
- package/src/composites/python-ci.ts +14 -7
- package/src/composites/setup-go.ts +8 -3
- package/src/composites/setup-node.ts +8 -3
- package/src/composites/setup-python.ts +8 -3
- package/src/composites/upload-artifact.ts +8 -3
- package/src/coverage.test.ts +23 -0
- package/src/import/roundtrip.test.ts +206 -0
- package/src/lint/post-synth/gha006.test.ts +56 -0
- package/src/lint/post-synth/gha009.test.ts +56 -0
- package/src/lint/post-synth/gha011.test.ts +61 -0
- package/src/lint/post-synth/gha017.test.ts +51 -0
- package/src/lint/post-synth/gha018.test.ts +66 -0
- package/src/lint/post-synth/gha019.test.ts +66 -0
- package/src/lint/post-synth/gha020.test.ts +66 -0
- package/src/lint/post-synth/gha020.ts +40 -0
- package/src/lint/post-synth/gha021.test.ts +67 -0
- package/src/lint/post-synth/gha021.ts +48 -0
- package/src/lint/post-synth/gha022.test.ts +45 -0
- package/src/lint/post-synth/gha022.ts +50 -0
- package/src/lint/post-synth/gha023.test.ts +52 -0
- package/src/lint/post-synth/gha023.ts +44 -0
- package/src/lint/post-synth/gha024.test.ts +67 -0
- package/src/lint/post-synth/gha024.ts +42 -0
- package/src/lint/post-synth/gha025.test.ts +65 -0
- package/src/lint/post-synth/gha025.ts +42 -0
- package/src/lint/post-synth/gha026.test.ts +65 -0
- package/src/lint/post-synth/gha026.ts +40 -0
- package/src/lint/post-synth/gha027.test.ts +48 -0
- package/src/lint/post-synth/gha027.ts +57 -0
- package/src/lint/post-synth/gha028.test.ts +48 -0
- package/src/lint/post-synth/gha028.ts +37 -0
- package/src/lint/rules/deprecated-action-version.test.ts +26 -0
- package/src/lint/rules/detect-secrets.test.ts +25 -0
- package/src/lint/rules/extract-inline-structs.test.ts +25 -0
- package/src/lint/rules/file-job-limit.test.ts +28 -0
- package/src/lint/rules/job-timeout.test.ts +31 -0
- package/src/lint/rules/missing-recommended-inputs.test.ts +26 -0
- package/src/lint/rules/no-hardcoded-secrets.test.ts +31 -0
- package/src/lint/rules/no-raw-expressions.test.ts +31 -0
- package/src/lint/rules/suggest-cache.test.ts +25 -0
- package/src/lint/rules/use-condition-builders.test.ts +31 -0
- package/src/lint/rules/use-matrix-builder.test.ts +25 -0
- package/src/lint/rules/use-typed-actions.test.ts +39 -0
- package/src/lint/rules/validate-concurrency.test.ts +31 -0
- package/src/plugin.test.ts +1 -1
- package/src/plugin.ts +70 -145
- package/src/skills/{github-actions-patterns.md → chant-github-patterns.md} +2 -1
- package/src/skills/chant-github-security.md +88 -0
- package/src/skills/chant-github.md +594 -0
- package/src/validate.ts +14 -1
- package/src/variables.test.ts +48 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA020: Missing Job-Level Permissions for Sensitive Triggers
|
|
3
|
+
*
|
|
4
|
+
* Flags jobs without explicit `permissions:` when the workflow uses
|
|
5
|
+
* `pull_request_target` or `workflow_dispatch` triggers.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
9
|
+
import { getPrimaryOutput, extractJobs, extractTriggers } from "./yaml-helpers";
|
|
10
|
+
|
|
11
|
+
export const gha020: PostSynthCheck = {
|
|
12
|
+
id: "GHA020",
|
|
13
|
+
description: "Missing job-level permissions for sensitive triggers",
|
|
14
|
+
|
|
15
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
16
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
17
|
+
|
|
18
|
+
for (const [, output] of ctx.outputs) {
|
|
19
|
+
const yaml = getPrimaryOutput(output);
|
|
20
|
+
const triggers = extractTriggers(yaml);
|
|
21
|
+
|
|
22
|
+
if (!triggers["pull_request_target"] && !triggers["workflow_dispatch"]) continue;
|
|
23
|
+
|
|
24
|
+
const jobs = extractJobs(yaml);
|
|
25
|
+
for (const [jobName, job] of jobs) {
|
|
26
|
+
if (!job.permissions) {
|
|
27
|
+
diagnostics.push({
|
|
28
|
+
checkId: "GHA020",
|
|
29
|
+
severity: "warning",
|
|
30
|
+
message: `Job "${jobName}" lacks explicit permissions but workflow uses a sensitive trigger. Add job-level permissions for least-privilege security.`,
|
|
31
|
+
entity: jobName,
|
|
32
|
+
lexicon: "github",
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return diagnostics;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { gha021 } from "./gha021";
|
|
4
|
+
|
|
5
|
+
function makeCtx(yaml: string): PostSynthContext {
|
|
6
|
+
return {
|
|
7
|
+
outputs: new Map([["github", yaml]]),
|
|
8
|
+
entities: new Map(),
|
|
9
|
+
buildResult: {
|
|
10
|
+
outputs: new Map([["github", yaml]]),
|
|
11
|
+
entities: new Map(),
|
|
12
|
+
warnings: [],
|
|
13
|
+
errors: [],
|
|
14
|
+
sourceFileCount: 1,
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("GHA021: checkout without pinned SHA", () => {
|
|
20
|
+
test("flags checkout with tag ref", () => {
|
|
21
|
+
const yaml = `name: CI
|
|
22
|
+
on:
|
|
23
|
+
push:
|
|
24
|
+
jobs:
|
|
25
|
+
build:
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
steps:
|
|
28
|
+
- uses: actions/checkout@v4
|
|
29
|
+
- run: echo test
|
|
30
|
+
`;
|
|
31
|
+
const diags = gha021.check(makeCtx(yaml));
|
|
32
|
+
expect(diags).toHaveLength(1);
|
|
33
|
+
expect(diags[0].checkId).toBe("GHA021");
|
|
34
|
+
expect(diags[0].severity).toBe("warning");
|
|
35
|
+
expect(diags[0].message).toContain("v4");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("does not flag checkout with pinned SHA", () => {
|
|
39
|
+
const yaml = `name: CI
|
|
40
|
+
on:
|
|
41
|
+
push:
|
|
42
|
+
jobs:
|
|
43
|
+
build:
|
|
44
|
+
runs-on: ubuntu-latest
|
|
45
|
+
steps:
|
|
46
|
+
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
|
|
47
|
+
- run: echo test
|
|
48
|
+
`;
|
|
49
|
+
const diags = gha021.check(makeCtx(yaml));
|
|
50
|
+
expect(diags).toHaveLength(0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("does not flag non-checkout actions", () => {
|
|
54
|
+
const yaml = `name: CI
|
|
55
|
+
on:
|
|
56
|
+
push:
|
|
57
|
+
jobs:
|
|
58
|
+
build:
|
|
59
|
+
runs-on: ubuntu-latest
|
|
60
|
+
steps:
|
|
61
|
+
- uses: actions/setup-node@v4
|
|
62
|
+
- run: echo test
|
|
63
|
+
`;
|
|
64
|
+
const diags = gha021.check(makeCtx(yaml));
|
|
65
|
+
expect(diags).toHaveLength(0);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA021: Checkout Action Without Pinned SHA
|
|
3
|
+
*
|
|
4
|
+
* Flags `actions/checkout` usage that references a tag (e.g. v4) instead of
|
|
5
|
+
* a pinned commit SHA.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
9
|
+
import { getPrimaryOutput, extractJobs } from "./yaml-helpers";
|
|
10
|
+
|
|
11
|
+
export const gha021: PostSynthCheck = {
|
|
12
|
+
id: "GHA021",
|
|
13
|
+
description: "actions/checkout used without pinned SHA",
|
|
14
|
+
|
|
15
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
16
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
17
|
+
|
|
18
|
+
for (const [, output] of ctx.outputs) {
|
|
19
|
+
const yaml = getPrimaryOutput(output);
|
|
20
|
+
const jobs = extractJobs(yaml);
|
|
21
|
+
|
|
22
|
+
for (const [jobName, job] of jobs) {
|
|
23
|
+
if (!job.steps) continue;
|
|
24
|
+
|
|
25
|
+
for (const step of job.steps) {
|
|
26
|
+
if (!step.uses) continue;
|
|
27
|
+
|
|
28
|
+
const match = step.uses.match(/^actions\/checkout@(.+)$/);
|
|
29
|
+
if (!match) continue;
|
|
30
|
+
|
|
31
|
+
const ref = match[1];
|
|
32
|
+
// A pinned SHA is 40 hex characters
|
|
33
|
+
if (/^[0-9a-f]{40}$/.test(ref)) continue;
|
|
34
|
+
|
|
35
|
+
diagnostics.push({
|
|
36
|
+
checkId: "GHA021",
|
|
37
|
+
severity: "warning",
|
|
38
|
+
message: `Job "${jobName}" uses actions/checkout@${ref} — pin to a full commit SHA for supply-chain security.`,
|
|
39
|
+
entity: jobName,
|
|
40
|
+
lexicon: "github",
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return diagnostics;
|
|
47
|
+
},
|
|
48
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { gha022 } from "./gha022";
|
|
4
|
+
|
|
5
|
+
function makeCtx(yaml: string): PostSynthContext {
|
|
6
|
+
return {
|
|
7
|
+
outputs: new Map([["github", yaml]]),
|
|
8
|
+
entities: new Map(),
|
|
9
|
+
buildResult: {
|
|
10
|
+
outputs: new Map([["github", yaml]]),
|
|
11
|
+
entities: new Map(),
|
|
12
|
+
warnings: [],
|
|
13
|
+
errors: [],
|
|
14
|
+
sourceFileCount: 1,
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("GHA022: job without timeout-minutes", () => {
|
|
20
|
+
test("flags job missing timeout-minutes", () => {
|
|
21
|
+
const yaml = `name: CI
|
|
22
|
+
on:
|
|
23
|
+
push:
|
|
24
|
+
jobs:
|
|
25
|
+
build:
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
steps:
|
|
28
|
+
- run: echo test
|
|
29
|
+
`;
|
|
30
|
+
const diags = gha022.check(makeCtx(yaml));
|
|
31
|
+
expect(diags).toHaveLength(1);
|
|
32
|
+
expect(diags[0].checkId).toBe("GHA022");
|
|
33
|
+
expect(diags[0].severity).toBe("info");
|
|
34
|
+
expect(diags[0].message).toContain("build");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("does not flag workflow without jobs", () => {
|
|
38
|
+
const yaml = `name: CI
|
|
39
|
+
on:
|
|
40
|
+
push:
|
|
41
|
+
`;
|
|
42
|
+
const diags = gha022.check(makeCtx(yaml));
|
|
43
|
+
expect(diags).toHaveLength(0);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA022: Job Without timeout-minutes
|
|
3
|
+
*
|
|
4
|
+
* Flags jobs that do not specify `timeout-minutes`, which can lead to
|
|
5
|
+
* hung workflows consuming runner minutes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
9
|
+
import { getPrimaryOutput, extractJobs } from "./yaml-helpers";
|
|
10
|
+
|
|
11
|
+
export const gha022: PostSynthCheck = {
|
|
12
|
+
id: "GHA022",
|
|
13
|
+
description: "Job without timeout-minutes",
|
|
14
|
+
|
|
15
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
16
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
17
|
+
|
|
18
|
+
for (const [, output] of ctx.outputs) {
|
|
19
|
+
const yaml = getPrimaryOutput(output);
|
|
20
|
+
const jobs = extractJobs(yaml);
|
|
21
|
+
|
|
22
|
+
// Find job sections in the raw YAML to check for timeout-minutes
|
|
23
|
+
const jobsIdx = yaml.search(/^jobs:\s*$/m);
|
|
24
|
+
if (jobsIdx === -1) continue;
|
|
25
|
+
|
|
26
|
+
const afterJobs = yaml.slice(jobsIdx + yaml.slice(jobsIdx).indexOf("\n") + 1);
|
|
27
|
+
const endMatch = afterJobs.search(/^[a-z]/m);
|
|
28
|
+
const jobsContent = endMatch === -1 ? afterJobs : afterJobs.slice(0, endMatch);
|
|
29
|
+
|
|
30
|
+
for (const [jobName] of jobs) {
|
|
31
|
+
// Find this job's section in the raw YAML
|
|
32
|
+
const jobPattern = new RegExp(`^ ${jobName}:\\n([\\s\\S]*?)(?=\\n [a-z]|$)`, "m");
|
|
33
|
+
const jobSection = jobsContent.match(jobPattern);
|
|
34
|
+
const section = jobSection ? jobSection[0] : "";
|
|
35
|
+
|
|
36
|
+
if (!/timeout-minutes:/m.test(section)) {
|
|
37
|
+
diagnostics.push({
|
|
38
|
+
checkId: "GHA022",
|
|
39
|
+
severity: "info",
|
|
40
|
+
message: `Job "${jobName}" does not specify timeout-minutes. Consider adding a timeout to prevent hung workflows.`,
|
|
41
|
+
entity: jobName,
|
|
42
|
+
lexicon: "github",
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return diagnostics;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { gha023 } from "./gha023";
|
|
4
|
+
|
|
5
|
+
function makeCtx(yaml: string): PostSynthContext {
|
|
6
|
+
return {
|
|
7
|
+
outputs: new Map([["github", yaml]]),
|
|
8
|
+
entities: new Map(),
|
|
9
|
+
buildResult: {
|
|
10
|
+
outputs: new Map([["github", yaml]]),
|
|
11
|
+
entities: new Map(),
|
|
12
|
+
warnings: [],
|
|
13
|
+
errors: [],
|
|
14
|
+
sourceFileCount: 1,
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("GHA023: deprecated ::set-output command", () => {
|
|
20
|
+
test("flags step using ::set-output", () => {
|
|
21
|
+
const yaml = `name: CI
|
|
22
|
+
on:
|
|
23
|
+
push:
|
|
24
|
+
jobs:
|
|
25
|
+
build:
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
steps:
|
|
28
|
+
- run: echo "::set-output name=version::1.0.0"
|
|
29
|
+
- run: echo done
|
|
30
|
+
`;
|
|
31
|
+
const diags = gha023.check(makeCtx(yaml));
|
|
32
|
+
expect(diags).toHaveLength(1);
|
|
33
|
+
expect(diags[0].checkId).toBe("GHA023");
|
|
34
|
+
expect(diags[0].severity).toBe("warning");
|
|
35
|
+
expect(diags[0].message).toContain("set-output");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("does not flag step using GITHUB_OUTPUT", () => {
|
|
39
|
+
const yaml = `name: CI
|
|
40
|
+
on:
|
|
41
|
+
push:
|
|
42
|
+
jobs:
|
|
43
|
+
build:
|
|
44
|
+
runs-on: ubuntu-latest
|
|
45
|
+
steps:
|
|
46
|
+
- run: echo "version=1.0.0" >> $GITHUB_OUTPUT
|
|
47
|
+
- run: echo done
|
|
48
|
+
`;
|
|
49
|
+
const diags = gha023.check(makeCtx(yaml));
|
|
50
|
+
expect(diags).toHaveLength(0);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA023: Deprecated set-output Command
|
|
3
|
+
*
|
|
4
|
+
* Flags usage of `::set-output` in run steps, which has been deprecated
|
|
5
|
+
* in favor of `$GITHUB_OUTPUT`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
9
|
+
import { getPrimaryOutput, extractJobs } from "./yaml-helpers";
|
|
10
|
+
|
|
11
|
+
export const gha023: PostSynthCheck = {
|
|
12
|
+
id: "GHA023",
|
|
13
|
+
description: "Deprecated ::set-output command usage",
|
|
14
|
+
|
|
15
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
16
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
17
|
+
|
|
18
|
+
for (const [, output] of ctx.outputs) {
|
|
19
|
+
const yaml = getPrimaryOutput(output);
|
|
20
|
+
const jobs = extractJobs(yaml);
|
|
21
|
+
|
|
22
|
+
for (const [jobName, job] of jobs) {
|
|
23
|
+
if (!job.steps) continue;
|
|
24
|
+
|
|
25
|
+
for (const step of job.steps) {
|
|
26
|
+
if (!step.run) continue;
|
|
27
|
+
|
|
28
|
+
if (step.run.includes("::set-output")) {
|
|
29
|
+
const stepLabel = step.name ?? "unnamed step";
|
|
30
|
+
diagnostics.push({
|
|
31
|
+
checkId: "GHA023",
|
|
32
|
+
severity: "warning",
|
|
33
|
+
message: `Job "${jobName}" step "${stepLabel}" uses deprecated ::set-output. Use $GITHUB_OUTPUT instead.`,
|
|
34
|
+
entity: jobName,
|
|
35
|
+
lexicon: "github",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return diagnostics;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { gha024 } from "./gha024";
|
|
4
|
+
|
|
5
|
+
function makeCtx(yaml: string): PostSynthContext {
|
|
6
|
+
return {
|
|
7
|
+
outputs: new Map([["github", yaml]]),
|
|
8
|
+
entities: new Map(),
|
|
9
|
+
buildResult: {
|
|
10
|
+
outputs: new Map([["github", yaml]]),
|
|
11
|
+
entities: new Map(),
|
|
12
|
+
warnings: [],
|
|
13
|
+
errors: [],
|
|
14
|
+
sourceFileCount: 1,
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("GHA024: missing concurrency for deploy workflow", () => {
|
|
20
|
+
test("flags deploy workflow without concurrency", () => {
|
|
21
|
+
const yaml = `name: Deploy
|
|
22
|
+
on:
|
|
23
|
+
push:
|
|
24
|
+
jobs:
|
|
25
|
+
deploy:
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
steps:
|
|
28
|
+
- run: echo deploy
|
|
29
|
+
`;
|
|
30
|
+
const diags = gha024.check(makeCtx(yaml));
|
|
31
|
+
expect(diags).toHaveLength(1);
|
|
32
|
+
expect(diags[0].checkId).toBe("GHA024");
|
|
33
|
+
expect(diags[0].severity).toBe("info");
|
|
34
|
+
expect(diags[0].message).toContain("concurrency");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("does not flag deploy workflow with concurrency", () => {
|
|
38
|
+
const yaml = `name: Deploy
|
|
39
|
+
on:
|
|
40
|
+
push:
|
|
41
|
+
concurrency:
|
|
42
|
+
group: deploy
|
|
43
|
+
cancel-in-progress: true
|
|
44
|
+
jobs:
|
|
45
|
+
deploy:
|
|
46
|
+
runs-on: ubuntu-latest
|
|
47
|
+
steps:
|
|
48
|
+
- run: echo deploy
|
|
49
|
+
`;
|
|
50
|
+
const diags = gha024.check(makeCtx(yaml));
|
|
51
|
+
expect(diags).toHaveLength(0);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("does not flag non-deploy workflow without concurrency", () => {
|
|
55
|
+
const yaml = `name: CI
|
|
56
|
+
on:
|
|
57
|
+
push:
|
|
58
|
+
jobs:
|
|
59
|
+
build:
|
|
60
|
+
runs-on: ubuntu-latest
|
|
61
|
+
steps:
|
|
62
|
+
- run: echo build
|
|
63
|
+
`;
|
|
64
|
+
const diags = gha024.check(makeCtx(yaml));
|
|
65
|
+
expect(diags).toHaveLength(0);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA024: Missing Concurrency for Deploy Workflows
|
|
3
|
+
*
|
|
4
|
+
* Flags deploy workflows that lack a `concurrency:` block, which risks
|
|
5
|
+
* overlapping deployments.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
9
|
+
import { getPrimaryOutput, extractJobs, extractWorkflowName } from "./yaml-helpers";
|
|
10
|
+
|
|
11
|
+
export const gha024: PostSynthCheck = {
|
|
12
|
+
id: "GHA024",
|
|
13
|
+
description: "Missing concurrency block for deploy workflow",
|
|
14
|
+
|
|
15
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
16
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
17
|
+
|
|
18
|
+
for (const [, output] of ctx.outputs) {
|
|
19
|
+
const yaml = getPrimaryOutput(output);
|
|
20
|
+
|
|
21
|
+
const workflowName = extractWorkflowName(yaml) ?? "";
|
|
22
|
+
const jobs = extractJobs(yaml);
|
|
23
|
+
const jobNames = [...jobs.keys()];
|
|
24
|
+
|
|
25
|
+
const isDeployWorkflow =
|
|
26
|
+
/deploy/i.test(workflowName) || jobNames.some((name) => /deploy/i.test(name));
|
|
27
|
+
|
|
28
|
+
if (!isDeployWorkflow) continue;
|
|
29
|
+
|
|
30
|
+
if (!/^concurrency:/m.test(yaml)) {
|
|
31
|
+
diagnostics.push({
|
|
32
|
+
checkId: "GHA024",
|
|
33
|
+
severity: "info",
|
|
34
|
+
message: "Deploy workflow does not specify concurrency. Add a concurrency block to prevent overlapping deployments.",
|
|
35
|
+
lexicon: "github",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return diagnostics;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { gha025 } from "./gha025";
|
|
4
|
+
|
|
5
|
+
function makeCtx(yaml: string): PostSynthContext {
|
|
6
|
+
return {
|
|
7
|
+
outputs: new Map([["github", yaml]]),
|
|
8
|
+
entities: new Map(),
|
|
9
|
+
buildResult: {
|
|
10
|
+
outputs: new Map([["github", yaml]]),
|
|
11
|
+
entities: new Map(),
|
|
12
|
+
warnings: [],
|
|
13
|
+
errors: [],
|
|
14
|
+
sourceFileCount: 1,
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("GHA025: pull_request_target without restrictions", () => {
|
|
20
|
+
test("flags pull_request_target without types filter", () => {
|
|
21
|
+
const yaml = `name: CI
|
|
22
|
+
on:
|
|
23
|
+
pull_request_target:
|
|
24
|
+
jobs:
|
|
25
|
+
build:
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
steps:
|
|
28
|
+
- run: echo test
|
|
29
|
+
`;
|
|
30
|
+
const diags = gha025.check(makeCtx(yaml));
|
|
31
|
+
expect(diags).toHaveLength(1);
|
|
32
|
+
expect(diags[0].checkId).toBe("GHA025");
|
|
33
|
+
expect(diags[0].severity).toBe("warning");
|
|
34
|
+
expect(diags[0].message).toContain("types");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("does not flag pull_request_target with types filter", () => {
|
|
38
|
+
const yaml = `name: CI
|
|
39
|
+
on:
|
|
40
|
+
pull_request_target:
|
|
41
|
+
types: [labeled, opened]
|
|
42
|
+
jobs:
|
|
43
|
+
build:
|
|
44
|
+
runs-on: ubuntu-latest
|
|
45
|
+
steps:
|
|
46
|
+
- run: echo test
|
|
47
|
+
`;
|
|
48
|
+
const diags = gha025.check(makeCtx(yaml));
|
|
49
|
+
expect(diags).toHaveLength(0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("does not flag workflow without pull_request_target", () => {
|
|
53
|
+
const yaml = `name: CI
|
|
54
|
+
on:
|
|
55
|
+
push:
|
|
56
|
+
jobs:
|
|
57
|
+
build:
|
|
58
|
+
runs-on: ubuntu-latest
|
|
59
|
+
steps:
|
|
60
|
+
- run: echo test
|
|
61
|
+
`;
|
|
62
|
+
const diags = gha025.check(makeCtx(yaml));
|
|
63
|
+
expect(diags).toHaveLength(0);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA025: Using `pull_request_target` Without Restrictions
|
|
3
|
+
*
|
|
4
|
+
* Flags workflows that use `pull_request_target` without a `types:` filter,
|
|
5
|
+
* which can expose secrets to untrusted fork PRs.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
9
|
+
import { getPrimaryOutput, extractTriggers } from "./yaml-helpers";
|
|
10
|
+
|
|
11
|
+
export const gha025: PostSynthCheck = {
|
|
12
|
+
id: "GHA025",
|
|
13
|
+
description: "Using pull_request_target without restrictions",
|
|
14
|
+
|
|
15
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
16
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
17
|
+
|
|
18
|
+
for (const [, output] of ctx.outputs) {
|
|
19
|
+
const yaml = getPrimaryOutput(output);
|
|
20
|
+
const triggers = extractTriggers(yaml);
|
|
21
|
+
|
|
22
|
+
if (!triggers["pull_request_target"]) continue;
|
|
23
|
+
|
|
24
|
+
// Check if pull_request_target section has a types: filter
|
|
25
|
+
const prtSection = yaml.match(/^\s{2}pull_request_target:\s*\n((?:\s{4,}.+\n)*)/m);
|
|
26
|
+
const hasTypes = prtSection?.[1]?.match(/^\s+types:/m);
|
|
27
|
+
|
|
28
|
+
if (!hasTypes) {
|
|
29
|
+
diagnostics.push({
|
|
30
|
+
checkId: "GHA025",
|
|
31
|
+
severity: "warning",
|
|
32
|
+
message:
|
|
33
|
+
"Workflow uses `pull_request_target` without a `types:` filter. This exposes secrets to all fork PRs. Add a `types:` restriction (e.g., [labeled, opened]) to limit exposure.",
|
|
34
|
+
entity: "pull_request_target",
|
|
35
|
+
lexicon: "github",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return diagnostics;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { gha026 } from "./gha026";
|
|
4
|
+
|
|
5
|
+
function makeCtx(yaml: string): PostSynthContext {
|
|
6
|
+
return {
|
|
7
|
+
outputs: new Map([["github", yaml]]),
|
|
8
|
+
entities: new Map(),
|
|
9
|
+
buildResult: {
|
|
10
|
+
outputs: new Map([["github", yaml]]),
|
|
11
|
+
entities: new Map(),
|
|
12
|
+
warnings: [],
|
|
13
|
+
errors: [],
|
|
14
|
+
sourceFileCount: 1,
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("GHA026: secret without environment protection", () => {
|
|
20
|
+
test("flags secrets usage without environment", () => {
|
|
21
|
+
const yaml = `name: Deploy
|
|
22
|
+
on:
|
|
23
|
+
push:
|
|
24
|
+
jobs:
|
|
25
|
+
deploy:
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
steps:
|
|
28
|
+
- run: echo secrets.DEPLOY_KEY
|
|
29
|
+
`;
|
|
30
|
+
const diags = gha026.check(makeCtx(yaml));
|
|
31
|
+
expect(diags).toHaveLength(1);
|
|
32
|
+
expect(diags[0].checkId).toBe("GHA026");
|
|
33
|
+
expect(diags[0].severity).toBe("info");
|
|
34
|
+
expect(diags[0].message).toContain("secrets");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("does not flag secrets with environment", () => {
|
|
38
|
+
const yaml = `name: Deploy
|
|
39
|
+
on:
|
|
40
|
+
push:
|
|
41
|
+
jobs:
|
|
42
|
+
deploy:
|
|
43
|
+
runs-on: ubuntu-latest
|
|
44
|
+
environment: production
|
|
45
|
+
steps:
|
|
46
|
+
- run: echo secrets.DEPLOY_KEY
|
|
47
|
+
`;
|
|
48
|
+
const diags = gha026.check(makeCtx(yaml));
|
|
49
|
+
expect(diags).toHaveLength(0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("does not flag workflow without secrets", () => {
|
|
53
|
+
const yaml = `name: CI
|
|
54
|
+
on:
|
|
55
|
+
push:
|
|
56
|
+
jobs:
|
|
57
|
+
build:
|
|
58
|
+
runs-on: ubuntu-latest
|
|
59
|
+
steps:
|
|
60
|
+
- run: echo test
|
|
61
|
+
`;
|
|
62
|
+
const diags = gha026.check(makeCtx(yaml));
|
|
63
|
+
expect(diags).toHaveLength(0);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA026: Secret Passed to Action Without `environment` Protection
|
|
3
|
+
*
|
|
4
|
+
* Flags workflows that reference `secrets.` in steps but have no
|
|
5
|
+
* `environment:` key in any job, meaning secrets lack deployment
|
|
6
|
+
* protection rules.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
10
|
+
import { getPrimaryOutput } from "./yaml-helpers";
|
|
11
|
+
|
|
12
|
+
export const gha026: PostSynthCheck = {
|
|
13
|
+
id: "GHA026",
|
|
14
|
+
description: "Secret passed to action without environment protection",
|
|
15
|
+
|
|
16
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
17
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
18
|
+
|
|
19
|
+
for (const [, output] of ctx.outputs) {
|
|
20
|
+
const yaml = getPrimaryOutput(output);
|
|
21
|
+
|
|
22
|
+
const usesSecrets = /secrets\./m.test(yaml);
|
|
23
|
+
if (!usesSecrets) continue;
|
|
24
|
+
|
|
25
|
+
const hasEnvironment = /^\s+environment:/m.test(yaml);
|
|
26
|
+
if (hasEnvironment) continue;
|
|
27
|
+
|
|
28
|
+
diagnostics.push({
|
|
29
|
+
checkId: "GHA026",
|
|
30
|
+
severity: "info",
|
|
31
|
+
message:
|
|
32
|
+
"Workflow references secrets but no job defines an `environment:`. Consider using environment protection rules to gate secret access with required reviewers or wait timers.",
|
|
33
|
+
entity: "secrets",
|
|
34
|
+
lexicon: "github",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return diagnostics;
|
|
39
|
+
},
|
|
40
|
+
};
|