@intentius/chant-lexicon-github 0.1.23 → 0.3.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.
Files changed (114) hide show
  1. package/dist/integrity.json +34 -4
  2. package/dist/manifest.json +1 -1
  3. package/dist/rules/{gha020.ts → gha013.ts} +4 -4
  4. package/dist/rules/gha029.ts +68 -0
  5. package/dist/rules/gha030.ts +41 -0
  6. package/dist/rules/gha031.ts +77 -0
  7. package/dist/rules/gha032.ts +39 -0
  8. package/dist/rules/gha033.ts +47 -0
  9. package/dist/rules/gha034.ts +41 -0
  10. package/dist/rules/gha035.ts +66 -0
  11. package/dist/rules/gha036.ts +42 -0
  12. package/dist/rules/gha037.ts +46 -0
  13. package/dist/rules/gha038.ts +41 -0
  14. package/dist/rules/gha039.ts +41 -0
  15. package/dist/rules/gha040.ts +43 -0
  16. package/dist/rules/gha041.ts +38 -0
  17. package/dist/rules/gha042.ts +40 -0
  18. package/dist/rules/gha043.ts +41 -0
  19. package/dist/rules/gha044.ts +41 -0
  20. package/dist/rules/gha045.ts +38 -0
  21. package/dist/rules/gha046.ts +56 -0
  22. package/dist/rules/gha047.ts +51 -0
  23. package/dist/rules/gha048.ts +41 -0
  24. package/dist/rules/gha049.ts +42 -0
  25. package/dist/rules/gha050.ts +44 -0
  26. package/dist/rules/gha051.ts +45 -0
  27. package/dist/rules/gha052.ts +42 -0
  28. package/dist/rules/gha053.ts +41 -0
  29. package/dist/rules/gha054.ts +41 -0
  30. package/dist/rules/gha055.ts +50 -0
  31. package/dist/rules/gha056.ts +37 -0
  32. package/dist/rules/gha057.ts +40 -0
  33. package/dist/rules/gha058.ts +48 -0
  34. package/dist/rules/yaml-helpers.ts +335 -0
  35. package/package.json +1 -1
  36. package/src/codegen/docs.ts +273 -4
  37. package/src/composites/dependabot.test.ts +48 -0
  38. package/src/composites/dependabot.ts +91 -0
  39. package/src/composites/index.ts +2 -0
  40. package/src/index.ts +2 -0
  41. package/src/lint/post-synth/{gha020.test.ts → gha013.test.ts} +7 -7
  42. package/src/lint/post-synth/{gha020.ts → gha013.ts} +4 -4
  43. package/src/lint/post-synth/gha029.test.ts +108 -0
  44. package/src/lint/post-synth/gha029.ts +68 -0
  45. package/src/lint/post-synth/gha030.test.ts +87 -0
  46. package/src/lint/post-synth/gha030.ts +41 -0
  47. package/src/lint/post-synth/gha031.test.ts +75 -0
  48. package/src/lint/post-synth/gha031.ts +77 -0
  49. package/src/lint/post-synth/gha032.test.ts +65 -0
  50. package/src/lint/post-synth/gha032.ts +39 -0
  51. package/src/lint/post-synth/gha033.test.ts +68 -0
  52. package/src/lint/post-synth/gha033.ts +47 -0
  53. package/src/lint/post-synth/gha034.test.ts +86 -0
  54. package/src/lint/post-synth/gha034.ts +41 -0
  55. package/src/lint/post-synth/gha035.test.ts +85 -0
  56. package/src/lint/post-synth/gha035.ts +66 -0
  57. package/src/lint/post-synth/gha036.test.ts +68 -0
  58. package/src/lint/post-synth/gha036.ts +42 -0
  59. package/src/lint/post-synth/gha037.test.ts +66 -0
  60. package/src/lint/post-synth/gha037.ts +46 -0
  61. package/src/lint/post-synth/gha038.test.ts +64 -0
  62. package/src/lint/post-synth/gha038.ts +41 -0
  63. package/src/lint/post-synth/gha039.test.ts +66 -0
  64. package/src/lint/post-synth/gha039.ts +41 -0
  65. package/src/lint/post-synth/gha040.test.ts +77 -0
  66. package/src/lint/post-synth/gha040.ts +43 -0
  67. package/src/lint/post-synth/gha041.test.ts +48 -0
  68. package/src/lint/post-synth/gha041.ts +38 -0
  69. package/src/lint/post-synth/gha042.test.ts +53 -0
  70. package/src/lint/post-synth/gha042.ts +40 -0
  71. package/src/lint/post-synth/gha043.test.ts +69 -0
  72. package/src/lint/post-synth/gha043.ts +41 -0
  73. package/src/lint/post-synth/gha044.test.ts +56 -0
  74. package/src/lint/post-synth/gha044.ts +41 -0
  75. package/src/lint/post-synth/gha045.test.ts +51 -0
  76. package/src/lint/post-synth/gha045.ts +38 -0
  77. package/src/lint/post-synth/gha046.test.ts +59 -0
  78. package/src/lint/post-synth/gha046.ts +56 -0
  79. package/src/lint/post-synth/gha047.test.ts +66 -0
  80. package/src/lint/post-synth/gha047.ts +51 -0
  81. package/src/lint/post-synth/gha048.test.ts +51 -0
  82. package/src/lint/post-synth/gha048.ts +41 -0
  83. package/src/lint/post-synth/gha049.test.ts +72 -0
  84. package/src/lint/post-synth/gha049.ts +42 -0
  85. package/src/lint/post-synth/gha050.test.ts +55 -0
  86. package/src/lint/post-synth/gha050.ts +44 -0
  87. package/src/lint/post-synth/gha051.test.ts +72 -0
  88. package/src/lint/post-synth/gha051.ts +45 -0
  89. package/src/lint/post-synth/gha052.test.ts +52 -0
  90. package/src/lint/post-synth/gha052.ts +42 -0
  91. package/src/lint/post-synth/gha053.test.ts +65 -0
  92. package/src/lint/post-synth/gha053.ts +41 -0
  93. package/src/lint/post-synth/gha054.test.ts +49 -0
  94. package/src/lint/post-synth/gha054.ts +41 -0
  95. package/src/lint/post-synth/gha055.test.ts +49 -0
  96. package/src/lint/post-synth/gha055.ts +50 -0
  97. package/src/lint/post-synth/gha056.test.ts +47 -0
  98. package/src/lint/post-synth/gha056.ts +37 -0
  99. package/src/lint/post-synth/gha057.test.ts +61 -0
  100. package/src/lint/post-synth/gha057.ts +40 -0
  101. package/src/lint/post-synth/gha058.test.ts +67 -0
  102. package/src/lint/post-synth/gha058.ts +48 -0
  103. package/src/lint/post-synth/yaml-helpers.ts +335 -0
  104. package/src/lint/rules/data/flagged-actions.ts +36 -0
  105. package/src/lint/rules/data/known-action-slugs.ts +38 -0
  106. package/src/lint/rules/data/preinstalled-tools.ts +36 -0
  107. package/src/lint/rules/data/risky-features.ts +39 -0
  108. package/src/lint/rules/data/trusted-action-owners.ts +12 -0
  109. package/src/lint/rules/data/untrusted-contexts.ts +44 -0
  110. package/src/mcp/context-tools.test.ts +108 -0
  111. package/src/mcp/context-tools.ts +172 -0
  112. package/src/plugin.test.ts +58 -3
  113. package/src/plugin.ts +5 -1
  114. package/src/serializer.ts +38 -5
@@ -0,0 +1,85 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { gha035 } from "./gha035";
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("GHA035: elevated scope on untrusted-code trigger", () => {
20
+ test("errors on workflow-level write with pull_request_target", () => {
21
+ const yaml = `name: Review
22
+ on:
23
+ pull_request_target:
24
+ permissions:
25
+ contents: write
26
+ jobs:
27
+ build:
28
+ runs-on: ubuntu-latest
29
+ steps:
30
+ - run: echo build
31
+ `;
32
+ const diags = gha035.check(makeCtx(yaml));
33
+ expect(diags).toHaveLength(1);
34
+ expect(diags[0].checkId).toBe("GHA035");
35
+ expect(diags[0].severity).toBe("error");
36
+ expect(diags[0].message).toContain("pull_request_target");
37
+ });
38
+
39
+ test("errors on job-level write-all with workflow_run", () => {
40
+ const yaml = `name: Followup
41
+ on:
42
+ workflow_run:
43
+ jobs:
44
+ build:
45
+ runs-on: ubuntu-latest
46
+ permissions: write-all
47
+ steps:
48
+ - run: echo build
49
+ `;
50
+ const diags = gha035.check(makeCtx(yaml));
51
+ expect(diags.some((d) => d.severity === "error" && d.entity === "build")).toBe(true);
52
+ });
53
+
54
+ test("does not flag write scope on a safe trigger", () => {
55
+ const yaml = `name: CI
56
+ on:
57
+ push:
58
+ permissions:
59
+ contents: write
60
+ jobs:
61
+ build:
62
+ runs-on: ubuntu-latest
63
+ steps:
64
+ - run: echo build
65
+ `;
66
+ const diags = gha035.check(makeCtx(yaml));
67
+ expect(diags).toHaveLength(0);
68
+ });
69
+
70
+ test("does not flag read-only scope on untrusted trigger", () => {
71
+ const yaml = `name: Review
72
+ on:
73
+ pull_request_target:
74
+ permissions:
75
+ contents: read
76
+ jobs:
77
+ build:
78
+ runs-on: ubuntu-latest
79
+ steps:
80
+ - run: echo build
81
+ `;
82
+ const diags = gha035.check(makeCtx(yaml));
83
+ expect(diags).toHaveLength(0);
84
+ });
85
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * GHA035: Elevated Token Scope on an Untrusted-Code Trigger
3
+ *
4
+ * Raises the stakes of GHA033/GHA034: when a workflow can run untrusted code
5
+ * (`pull_request_target`, `workflow_run`) and also grants the token write
6
+ * access, an injected step runs with standing write credentials. This is the
7
+ * combination behind most CI privilege-escalation incidents, so it is an error.
8
+ *
9
+ * Cross-references the trust-boundary checks (GHA018 / GHA025 / GHA038).
10
+ */
11
+
12
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
13
+ import {
14
+ getPrimaryOutput,
15
+ extractTriggers,
16
+ extractWorkflowPermissions,
17
+ extractJobPermissions,
18
+ writeSurface,
19
+ grantsWrite,
20
+ } from "./yaml-helpers";
21
+
22
+ const UNTRUSTED_TRIGGERS = ["pull_request_target", "workflow_run"];
23
+
24
+ function describeWrite(perms: ReturnType<typeof writeSurface>): string {
25
+ return perms.writeAll ? "write-all" : perms.scopes.map((s) => `${s}: write`).join(", ");
26
+ }
27
+
28
+ export const gha035: PostSynthCheck = {
29
+ id: "GHA035",
30
+ description: "Elevated token scope on a trigger that can run untrusted code",
31
+
32
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
33
+ const diagnostics: PostSynthDiagnostic[] = [];
34
+
35
+ for (const [, output] of ctx.outputs) {
36
+ const yaml = getPrimaryOutput(output);
37
+ const triggers = extractTriggers(yaml);
38
+ const untrusted = UNTRUSTED_TRIGGERS.filter((t) => triggers[t]);
39
+ if (untrusted.length === 0) continue;
40
+
41
+ const wf = extractWorkflowPermissions(yaml);
42
+ if (wf && grantsWrite(wf)) {
43
+ diagnostics.push({
44
+ checkId: "GHA035",
45
+ severity: "error",
46
+ message: `Workflow grants write token scope (${describeWrite(writeSurface(wf))}) while using the ${untrusted.join("/")} trigger, which can run untrusted code. Drop write scope or gate the privileged work behind a separate trusted workflow.`,
47
+ lexicon: "github",
48
+ });
49
+ }
50
+
51
+ for (const [job, perms] of extractJobPermissions(yaml)) {
52
+ if (grantsWrite(perms)) {
53
+ diagnostics.push({
54
+ checkId: "GHA035",
55
+ severity: "error",
56
+ message: `Job "${job}" grants write token scope (${describeWrite(writeSurface(perms))}) while the workflow uses the ${untrusted.join("/")} trigger, which can run untrusted code. Drop write scope or isolate the privileged work.`,
57
+ entity: job,
58
+ lexicon: "github",
59
+ });
60
+ }
61
+ }
62
+ }
63
+
64
+ return diagnostics;
65
+ },
66
+ };
@@ -0,0 +1,68 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { gha036 } from "./gha036";
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("GHA036: untrusted input in run:", () => {
20
+ test("flags PR title interpolated into a block-scalar run", () => {
21
+ const yaml = `name: CI
22
+ on:
23
+ pull_request_target:
24
+ jobs:
25
+ build:
26
+ runs-on: ubuntu-latest
27
+ steps:
28
+ - name: greet
29
+ run: |
30
+ echo "Title: \${{ github.event.pull_request.title }}"
31
+ `;
32
+ const diags = gha036.check(makeCtx(yaml));
33
+ expect(diags).toHaveLength(1);
34
+ expect(diags[0].checkId).toBe("GHA036");
35
+ expect(diags[0].severity).toBe("error");
36
+ expect(diags[0].entity).toBe("build");
37
+ expect(diags[0].message).toContain("github.event.pull_request.title");
38
+ });
39
+
40
+ test("flags head_ref interpolated into an inline run", () => {
41
+ const yaml = `name: CI
42
+ on:
43
+ pull_request:
44
+ jobs:
45
+ build:
46
+ runs-on: ubuntu-latest
47
+ steps:
48
+ - run: echo \${{ github.head_ref }}
49
+ `;
50
+ const diags = gha036.check(makeCtx(yaml));
51
+ expect(diags).toHaveLength(1);
52
+ expect(diags[0].message).toContain("github.head_ref");
53
+ });
54
+
55
+ test("does not flag a trusted context in run:", () => {
56
+ const yaml = `name: CI
57
+ on:
58
+ push:
59
+ jobs:
60
+ build:
61
+ runs-on: ubuntu-latest
62
+ steps:
63
+ - run: echo \${{ github.sha }}
64
+ `;
65
+ const diags = gha036.check(makeCtx(yaml));
66
+ expect(diags).toHaveLength(0);
67
+ });
68
+ });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * GHA036: Untrusted Input Interpolated into a Shell Command
3
+ *
4
+ * Flags attacker-controllable expression contexts (PR titles, branch names,
5
+ * issue/comment bodies, commit messages) interpolated directly into a `run:`
6
+ * script. The value is expanded into the shell before the script runs, so a
7
+ * crafted payload becomes command execution. Pass untrusted input through an
8
+ * `env:` variable and reference it quoted instead.
9
+ */
10
+
11
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
12
+ import { getPrimaryOutput, extractRunBlocks, extractExpressions } from "./yaml-helpers";
13
+ import { matchUntrustedContext } from "../rules/data/untrusted-contexts";
14
+
15
+ export const gha036: PostSynthCheck = {
16
+ id: "GHA036",
17
+ description: "Untrusted input interpolated into a run: shell command",
18
+
19
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
20
+ const diagnostics: PostSynthDiagnostic[] = [];
21
+
22
+ for (const [, output] of ctx.outputs) {
23
+ const yaml = getPrimaryOutput(output);
24
+ for (const { job, run } of extractRunBlocks(yaml)) {
25
+ for (const expr of extractExpressions(run)) {
26
+ const ctxName = matchUntrustedContext(expr);
27
+ if (!ctxName) continue;
28
+ diagnostics.push({
29
+ checkId: "GHA036",
30
+ severity: "error",
31
+ message: `Job "${job}" interpolates untrusted input \${{ ${ctxName} ... }} into a run: script — this is a script-injection sink. Pass it through an env: variable and reference "$VAR" quoted instead.`,
32
+ entity: job,
33
+ lexicon: "github",
34
+ });
35
+ break; // one diagnostic per run block is enough
36
+ }
37
+ }
38
+ }
39
+
40
+ return diagnostics;
41
+ },
42
+ };
@@ -0,0 +1,66 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { gha037 } from "./gha037";
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("GHA037: untrusted input to GITHUB_ENV / GITHUB_PATH", () => {
20
+ test("flags untrusted input written to GITHUB_ENV", () => {
21
+ const yaml = `name: CI
22
+ on:
23
+ pull_request_target:
24
+ jobs:
25
+ build:
26
+ runs-on: ubuntu-latest
27
+ steps:
28
+ - run: |
29
+ echo "BRANCH=\${{ github.event.pull_request.head.ref }}" >> "$GITHUB_ENV"
30
+ `;
31
+ const diags = gha037.check(makeCtx(yaml));
32
+ expect(diags).toHaveLength(1);
33
+ expect(diags[0].checkId).toBe("GHA037");
34
+ expect(diags[0].severity).toBe("error");
35
+ expect(diags[0].message).toContain("GITHUB_ENV");
36
+ });
37
+
38
+ test("does not flag a trusted value written to GITHUB_ENV", () => {
39
+ const yaml = `name: CI
40
+ on:
41
+ push:
42
+ jobs:
43
+ build:
44
+ runs-on: ubuntu-latest
45
+ steps:
46
+ - run: |
47
+ echo "SHA=\${{ github.sha }}" >> "$GITHUB_ENV"
48
+ `;
49
+ const diags = gha037.check(makeCtx(yaml));
50
+ expect(diags).toHaveLength(0);
51
+ });
52
+
53
+ test("does not flag untrusted input that does not touch env files", () => {
54
+ const yaml = `name: CI
55
+ on:
56
+ pull_request_target:
57
+ jobs:
58
+ build:
59
+ runs-on: ubuntu-latest
60
+ steps:
61
+ - run: echo done
62
+ `;
63
+ const diags = gha037.check(makeCtx(yaml));
64
+ expect(diags).toHaveLength(0);
65
+ });
66
+ });
@@ -0,0 +1,46 @@
1
+ /**
2
+ * GHA037: Untrusted Input Written to GITHUB_ENV / GITHUB_PATH
3
+ *
4
+ * Flags a `run:` step that writes attacker-controllable input into the
5
+ * `$GITHUB_ENV` or `$GITHUB_PATH` files. Those files set environment variables
6
+ * and PATH entries for *later* steps, so injecting into them escalates a data
7
+ * value into influence over subsequent privileged steps (a known path to
8
+ * `LD_PRELOAD`/`NODE_OPTIONS`-style takeover).
9
+ */
10
+
11
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
12
+ import { getPrimaryOutput, extractRunBlocks, extractExpressions } from "./yaml-helpers";
13
+ import { matchUntrustedContext } from "../rules/data/untrusted-contexts";
14
+
15
+ const ENV_FILE_RE = /GITHUB_ENV|GITHUB_PATH/;
16
+
17
+ export const gha037: PostSynthCheck = {
18
+ id: "GHA037",
19
+ description: "Untrusted input written to GITHUB_ENV / GITHUB_PATH",
20
+
21
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
22
+ const diagnostics: PostSynthDiagnostic[] = [];
23
+
24
+ for (const [, output] of ctx.outputs) {
25
+ const yaml = getPrimaryOutput(output);
26
+ for (const { job, run } of extractRunBlocks(yaml)) {
27
+ if (!ENV_FILE_RE.test(run)) continue;
28
+ for (const expr of extractExpressions(run)) {
29
+ const ctxName = matchUntrustedContext(expr);
30
+ if (!ctxName) continue;
31
+ const file = run.includes("GITHUB_PATH") ? "GITHUB_PATH" : "GITHUB_ENV";
32
+ diagnostics.push({
33
+ checkId: "GHA037",
34
+ severity: "error",
35
+ message: `Job "${job}" writes untrusted input \${{ ${ctxName} ... }} into $${file}, which sets state for later steps. Sanitize/validate the value or avoid persisting untrusted input across steps.`,
36
+ entity: job,
37
+ lexicon: "github",
38
+ });
39
+ break;
40
+ }
41
+ }
42
+ }
43
+
44
+ return diagnostics;
45
+ },
46
+ };
@@ -0,0 +1,64 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { gha038 } from "./gha038";
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("GHA038: workflow_run with checkout", () => {
20
+ test("flags checkout under workflow_run", () => {
21
+ const yaml = `name: Followup
22
+ on:
23
+ workflow_run:
24
+ jobs:
25
+ build:
26
+ runs-on: ubuntu-latest
27
+ steps:
28
+ - uses: actions/checkout@v4
29
+ - run: echo build
30
+ `;
31
+ const diags = gha038.check(makeCtx(yaml));
32
+ expect(diags).toHaveLength(1);
33
+ expect(diags[0].checkId).toBe("GHA038");
34
+ expect(diags[0].entity).toBe("build");
35
+ });
36
+
37
+ test("does not flag workflow_run without checkout", () => {
38
+ const yaml = `name: Followup
39
+ on:
40
+ workflow_run:
41
+ jobs:
42
+ build:
43
+ runs-on: ubuntu-latest
44
+ steps:
45
+ - run: echo build
46
+ `;
47
+ const diags = gha038.check(makeCtx(yaml));
48
+ expect(diags).toHaveLength(0);
49
+ });
50
+
51
+ test("does not flag checkout on a safe trigger", () => {
52
+ const yaml = `name: CI
53
+ on:
54
+ push:
55
+ jobs:
56
+ build:
57
+ runs-on: ubuntu-latest
58
+ steps:
59
+ - uses: actions/checkout@v4
60
+ `;
61
+ const diags = gha038.check(makeCtx(yaml));
62
+ expect(diags).toHaveLength(0);
63
+ });
64
+ });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * GHA038: Privileged `workflow_run` Trigger Checking Out Untrusted Code
3
+ *
4
+ * Generalizes GHA018 (pull_request_target + checkout) to the other privileged
5
+ * trigger that runs in the base-repo context: `workflow_run`. A `workflow_run`
6
+ * workflow has repo write scope and secrets, and checking out the artifact/head
7
+ * of the triggering run pulls untrusted code into that privileged context.
8
+ */
9
+
10
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
11
+ import { getPrimaryOutput, extractTriggers, extractJobs, hasCheckoutAction } from "./yaml-helpers";
12
+
13
+ export const gha038: PostSynthCheck = {
14
+ id: "GHA038",
15
+ description: "workflow_run trigger with checkout runs untrusted code in a privileged context",
16
+
17
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
18
+ const diagnostics: PostSynthDiagnostic[] = [];
19
+
20
+ for (const [, output] of ctx.outputs) {
21
+ const yaml = getPrimaryOutput(output);
22
+ const triggers = extractTriggers(yaml);
23
+ if (!triggers["workflow_run"]) continue;
24
+
25
+ const jobs = extractJobs(yaml);
26
+ for (const [jobName, job] of jobs) {
27
+ if (job.steps && hasCheckoutAction(job.steps)) {
28
+ diagnostics.push({
29
+ checkId: "GHA038",
30
+ severity: "warning",
31
+ message: `Job "${jobName}" checks out code under a workflow_run trigger, which runs with repo write scope and secrets. Treat the checked-out code as untrusted — avoid executing it, or move execution to an unprivileged workflow.`,
32
+ entity: jobName,
33
+ lexicon: "github",
34
+ });
35
+ }
36
+ }
37
+ }
38
+
39
+ return diagnostics;
40
+ },
41
+ };
@@ -0,0 +1,66 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { gha039 } from "./gha039";
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("GHA039: spoofable identity gate", () => {
20
+ test("flags an if: gating on commit author email", () => {
21
+ const yaml = `name: CI
22
+ on:
23
+ push:
24
+ jobs:
25
+ build:
26
+ runs-on: ubuntu-latest
27
+ if: \${{ github.event.head_commit.author.email == 'maintainer@example.com' }}
28
+ steps:
29
+ - run: echo build
30
+ `;
31
+ const diags = gha039.check(makeCtx(yaml));
32
+ expect(diags).toHaveLength(1);
33
+ expect(diags[0].checkId).toBe("GHA039");
34
+ expect(diags[0].entity).toBe("build");
35
+ });
36
+
37
+ test("does not flag an if: on a trustworthy signal", () => {
38
+ const yaml = `name: CI
39
+ on:
40
+ push:
41
+ jobs:
42
+ build:
43
+ runs-on: ubuntu-latest
44
+ if: \${{ github.ref == 'refs/heads/main' }}
45
+ steps:
46
+ - run: echo build
47
+ `;
48
+ const diags = gha039.check(makeCtx(yaml));
49
+ expect(diags).toHaveLength(0);
50
+ });
51
+
52
+ test("does not flag a non-author field referenced without comparison", () => {
53
+ const yaml = `name: CI
54
+ on:
55
+ push:
56
+ jobs:
57
+ build:
58
+ runs-on: ubuntu-latest
59
+ if: \${{ always() }}
60
+ steps:
61
+ - run: echo build
62
+ `;
63
+ const diags = gha039.check(makeCtx(yaml));
64
+ expect(diags).toHaveLength(0);
65
+ });
66
+ });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * GHA039: Spoofable Identity Used as an Authorization Gate
3
+ *
4
+ * Flags an `if:` condition that gates execution on a commit-author identity
5
+ * field (`...author.name` / `...author.email`). Those values come straight from
6
+ * git metadata the committer sets freely, so an attacker can forge them to pass
7
+ * the gate. Use a trustworthy signal — environment protection rules, CODEOWNERS,
8
+ * or membership checks against a verified actor.
9
+ */
10
+
11
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
12
+ import { getPrimaryOutput, extractIfConditions } from "./yaml-helpers";
13
+
14
+ const SPOOFABLE_FIELD_RE = /\.author\.(name|email)\b/;
15
+ const COMPARISON_RE = /==|!=|contains\s*\(/;
16
+
17
+ export const gha039: PostSynthCheck = {
18
+ id: "GHA039",
19
+ description: "Authorization gate on a spoofable commit-author identity field",
20
+
21
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
22
+ const diagnostics: PostSynthDiagnostic[] = [];
23
+
24
+ for (const [, output] of ctx.outputs) {
25
+ const yaml = getPrimaryOutput(output);
26
+ for (const { job, expr } of extractIfConditions(yaml)) {
27
+ if (SPOOFABLE_FIELD_RE.test(expr) && COMPARISON_RE.test(expr)) {
28
+ diagnostics.push({
29
+ checkId: "GHA039",
30
+ severity: "warning",
31
+ message: `Job "${job}" gates on a commit-author identity field in an if: condition (${expr}). Author name/email are attacker-controlled and can be spoofed — gate on a verified signal (environment protection, CODEOWNERS, verified actor) instead.`,
32
+ entity: job,
33
+ lexicon: "github",
34
+ });
35
+ }
36
+ }
37
+ }
38
+
39
+ return diagnostics;
40
+ },
41
+ };
@@ -0,0 +1,77 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { gha040 } from "./gha040";
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("GHA040: self-hosted runner on untrusted trigger", () => {
20
+ test("flags self-hosted runner under pull_request", () => {
21
+ const yaml = `name: CI
22
+ on:
23
+ pull_request:
24
+ jobs:
25
+ build:
26
+ runs-on: self-hosted
27
+ steps:
28
+ - run: echo build
29
+ `;
30
+ const diags = gha040.check(makeCtx(yaml));
31
+ expect(diags).toHaveLength(1);
32
+ expect(diags[0].checkId).toBe("GHA040");
33
+ expect(diags[0].entity).toBe("build");
34
+ });
35
+
36
+ test("flags self-hosted in an array runs-on under workflow_run", () => {
37
+ const yaml = `name: CI
38
+ on:
39
+ workflow_run:
40
+ jobs:
41
+ build:
42
+ runs-on: [self-hosted, linux]
43
+ steps:
44
+ - run: echo build
45
+ `;
46
+ const diags = gha040.check(makeCtx(yaml));
47
+ expect(diags).toHaveLength(1);
48
+ });
49
+
50
+ test("does not flag GitHub-hosted runner under untrusted trigger", () => {
51
+ const yaml = `name: CI
52
+ on:
53
+ pull_request:
54
+ jobs:
55
+ build:
56
+ runs-on: ubuntu-latest
57
+ steps:
58
+ - run: echo build
59
+ `;
60
+ const diags = gha040.check(makeCtx(yaml));
61
+ expect(diags).toHaveLength(0);
62
+ });
63
+
64
+ test("does not flag self-hosted on a safe trigger", () => {
65
+ const yaml = `name: CI
66
+ on:
67
+ push:
68
+ jobs:
69
+ build:
70
+ runs-on: self-hosted
71
+ steps:
72
+ - run: echo build
73
+ `;
74
+ const diags = gha040.check(makeCtx(yaml));
75
+ expect(diags).toHaveLength(0);
76
+ });
77
+ });