@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.
Files changed (78) hide show
  1. package/dist/integrity.json +14 -4
  2. package/dist/manifest.json +1 -1
  3. package/dist/rules/gha020.ts +40 -0
  4. package/dist/rules/gha021.ts +48 -0
  5. package/dist/rules/gha022.ts +50 -0
  6. package/dist/rules/gha023.ts +44 -0
  7. package/dist/rules/gha024.ts +42 -0
  8. package/dist/rules/gha025.ts +42 -0
  9. package/dist/rules/gha026.ts +40 -0
  10. package/dist/rules/gha027.ts +57 -0
  11. package/dist/rules/gha028.ts +37 -0
  12. package/dist/skills/{github-actions-patterns.md → chant-github-patterns.md} +2 -1
  13. package/dist/skills/chant-github-security.md +88 -0
  14. package/dist/skills/chant-github.md +569 -4
  15. package/package.json +20 -2
  16. package/src/codegen/docs.test.ts +19 -0
  17. package/src/codegen/generate.test.ts +12 -0
  18. package/src/codegen/package.test.ts +8 -0
  19. package/src/composites/cache.ts +8 -3
  20. package/src/composites/checkout.ts +8 -3
  21. package/src/composites/composites.test.ts +106 -0
  22. package/src/composites/deploy-environment.ts +11 -5
  23. package/src/composites/docker-build.ts +11 -5
  24. package/src/composites/download-artifact.ts +8 -3
  25. package/src/composites/go-ci.ts +17 -9
  26. package/src/composites/node-ci.ts +11 -5
  27. package/src/composites/node-pipeline.ts +14 -7
  28. package/src/composites/python-ci.ts +14 -7
  29. package/src/composites/setup-go.ts +8 -3
  30. package/src/composites/setup-node.ts +8 -3
  31. package/src/composites/setup-python.ts +8 -3
  32. package/src/composites/upload-artifact.ts +8 -3
  33. package/src/coverage.test.ts +23 -0
  34. package/src/import/roundtrip.test.ts +206 -0
  35. package/src/lint/post-synth/gha006.test.ts +56 -0
  36. package/src/lint/post-synth/gha009.test.ts +56 -0
  37. package/src/lint/post-synth/gha011.test.ts +61 -0
  38. package/src/lint/post-synth/gha017.test.ts +51 -0
  39. package/src/lint/post-synth/gha018.test.ts +66 -0
  40. package/src/lint/post-synth/gha019.test.ts +66 -0
  41. package/src/lint/post-synth/gha020.test.ts +66 -0
  42. package/src/lint/post-synth/gha020.ts +40 -0
  43. package/src/lint/post-synth/gha021.test.ts +67 -0
  44. package/src/lint/post-synth/gha021.ts +48 -0
  45. package/src/lint/post-synth/gha022.test.ts +45 -0
  46. package/src/lint/post-synth/gha022.ts +50 -0
  47. package/src/lint/post-synth/gha023.test.ts +52 -0
  48. package/src/lint/post-synth/gha023.ts +44 -0
  49. package/src/lint/post-synth/gha024.test.ts +67 -0
  50. package/src/lint/post-synth/gha024.ts +42 -0
  51. package/src/lint/post-synth/gha025.test.ts +65 -0
  52. package/src/lint/post-synth/gha025.ts +42 -0
  53. package/src/lint/post-synth/gha026.test.ts +65 -0
  54. package/src/lint/post-synth/gha026.ts +40 -0
  55. package/src/lint/post-synth/gha027.test.ts +48 -0
  56. package/src/lint/post-synth/gha027.ts +57 -0
  57. package/src/lint/post-synth/gha028.test.ts +48 -0
  58. package/src/lint/post-synth/gha028.ts +37 -0
  59. package/src/lint/rules/deprecated-action-version.test.ts +26 -0
  60. package/src/lint/rules/detect-secrets.test.ts +25 -0
  61. package/src/lint/rules/extract-inline-structs.test.ts +25 -0
  62. package/src/lint/rules/file-job-limit.test.ts +28 -0
  63. package/src/lint/rules/job-timeout.test.ts +31 -0
  64. package/src/lint/rules/missing-recommended-inputs.test.ts +26 -0
  65. package/src/lint/rules/no-hardcoded-secrets.test.ts +31 -0
  66. package/src/lint/rules/no-raw-expressions.test.ts +31 -0
  67. package/src/lint/rules/suggest-cache.test.ts +25 -0
  68. package/src/lint/rules/use-condition-builders.test.ts +31 -0
  69. package/src/lint/rules/use-matrix-builder.test.ts +25 -0
  70. package/src/lint/rules/use-typed-actions.test.ts +39 -0
  71. package/src/lint/rules/validate-concurrency.test.ts +31 -0
  72. package/src/plugin.test.ts +1 -1
  73. package/src/plugin.ts +70 -145
  74. package/src/skills/{github-actions-patterns.md → chant-github-patterns.md} +2 -1
  75. package/src/skills/chant-github-security.md +88 -0
  76. package/src/skills/chant-github.md +594 -0
  77. package/src/validate.ts +14 -1
  78. 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
+ };