@intentius/chant-lexicon-github 0.1.22 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) 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 +188 -0
  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/plugin.test.ts +47 -1
  111. package/src/serializer.ts +38 -5
@@ -0,0 +1,43 @@
1
+ /**
2
+ * GHA040: Self-Hosted Runner on a Trigger That Can Run Untrusted Code
3
+ *
4
+ * Flags a job that runs on a self-hosted runner under a trigger reachable by a
5
+ * fork or outside contributor (`pull_request`, `pull_request_target`,
6
+ * `workflow_run`). Self-hosted runners are non-ephemeral by default and share a
7
+ * host, so untrusted code can persist on the runner and compromise later jobs.
8
+ */
9
+
10
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
11
+ import { getPrimaryOutput, extractTriggers, extractRunsOnByJob } from "./yaml-helpers";
12
+
13
+ const UNTRUSTED_TRIGGERS = ["pull_request", "pull_request_target", "workflow_run"];
14
+
15
+ export const gha040: PostSynthCheck = {
16
+ id: "GHA040",
17
+ description: "Self-hosted runner on a trigger that can run untrusted code",
18
+
19
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
20
+ const diagnostics: PostSynthDiagnostic[] = [];
21
+
22
+ for (const [, output] of ctx.outputs) {
23
+ const yaml = getPrimaryOutput(output);
24
+ const triggers = extractTriggers(yaml);
25
+ const untrusted = UNTRUSTED_TRIGGERS.filter((t) => triggers[t]);
26
+ if (untrusted.length === 0) continue;
27
+
28
+ for (const [job, labels] of extractRunsOnByJob(yaml)) {
29
+ if (labels.some((l) => l === "self-hosted")) {
30
+ diagnostics.push({
31
+ checkId: "GHA040",
32
+ severity: "warning",
33
+ message: `Job "${job}" runs on a self-hosted runner under the ${untrusted.join("/")} trigger, which a fork can reach. Use ephemeral GitHub-hosted runners for untrusted code, or require approval before the job runs.`,
34
+ entity: job,
35
+ lexicon: "github",
36
+ });
37
+ }
38
+ }
39
+ }
40
+
41
+ return diagnostics;
42
+ },
43
+ };
@@ -0,0 +1,48 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { gha041 } from "./gha041";
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("GHA041: secrets: inherit", () => {
20
+ test("flags a reusable call with secrets: inherit", () => {
21
+ const yaml = `name: CI
22
+ on:
23
+ push:
24
+ jobs:
25
+ call:
26
+ uses: org/repo/.github/workflows/deploy.yml@v1
27
+ secrets: inherit
28
+ `;
29
+ const diags = gha041.check(makeCtx(yaml));
30
+ expect(diags).toHaveLength(1);
31
+ expect(diags[0].checkId).toBe("GHA041");
32
+ expect(diags[0].entity).toBe("call");
33
+ });
34
+
35
+ test("does not flag explicit secret pass-through", () => {
36
+ const yaml = `name: CI
37
+ on:
38
+ push:
39
+ jobs:
40
+ call:
41
+ uses: org/repo/.github/workflows/deploy.yml@v1
42
+ secrets:
43
+ token: \${{ secrets.DEPLOY_TOKEN }}
44
+ `;
45
+ const diags = gha041.check(makeCtx(yaml));
46
+ expect(diags).toHaveLength(0);
47
+ });
48
+ });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * GHA041: Blanket `secrets: inherit` into a Reusable Workflow
3
+ *
4
+ * Flags a reusable-workflow call that passes `secrets: inherit`. Inheritance
5
+ * hands the called workflow every secret the caller can see, regardless of what
6
+ * it needs. Pass through only the specific secrets the reusable workflow uses.
7
+ */
8
+
9
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
10
+ import { getPrimaryOutput, jobLines } from "./yaml-helpers";
11
+
12
+ export const gha041: PostSynthCheck = {
13
+ id: "GHA041",
14
+ description: "Blanket secrets: inherit into a reusable workflow",
15
+
16
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
17
+ const diagnostics: PostSynthDiagnostic[] = [];
18
+
19
+ for (const [, output] of ctx.outputs) {
20
+ const yaml = getPrimaryOutput(output);
21
+ const seen = new Set<string>();
22
+ for (const { job, line } of jobLines(yaml)) {
23
+ if (/^\s+secrets:\s*inherit\s*$/.test(line) && !seen.has(job)) {
24
+ seen.add(job);
25
+ diagnostics.push({
26
+ checkId: "GHA041",
27
+ severity: "warning",
28
+ message: `Job "${job}" calls a reusable workflow with secrets: inherit, passing every caller secret. Pass through only the specific secrets it needs.`,
29
+ entity: job,
30
+ lexicon: "github",
31
+ });
32
+ }
33
+ }
34
+ }
35
+
36
+ return diagnostics;
37
+ },
38
+ };
@@ -0,0 +1,53 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { gha042 } from "./gha042";
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("GHA042: whole secrets context passed", () => {
20
+ test("flags toJSON(secrets)", () => {
21
+ const yaml = `name: CI
22
+ on:
23
+ push:
24
+ jobs:
25
+ build:
26
+ runs-on: ubuntu-latest
27
+ steps:
28
+ - uses: some/action@v1
29
+ with:
30
+ blob: \${{ toJSON(secrets) }}
31
+ `;
32
+ const diags = gha042.check(makeCtx(yaml));
33
+ expect(diags).toHaveLength(1);
34
+ expect(diags[0].checkId).toBe("GHA042");
35
+ expect(diags[0].entity).toBe("build");
36
+ });
37
+
38
+ test("does not flag a specific secret reference", () => {
39
+ const yaml = `name: CI
40
+ on:
41
+ push:
42
+ jobs:
43
+ build:
44
+ runs-on: ubuntu-latest
45
+ steps:
46
+ - uses: some/action@v1
47
+ with:
48
+ token: \${{ secrets.API_TOKEN }}
49
+ `;
50
+ const diags = gha042.check(makeCtx(yaml));
51
+ expect(diags).toHaveLength(0);
52
+ });
53
+ });
@@ -0,0 +1,40 @@
1
+ /**
2
+ * GHA042: Whole Secrets Context Passed Where Specific Secrets Would Do
3
+ *
4
+ * Flags `toJSON(secrets)` (serializing the entire secrets context) passed into
5
+ * a step's `with:`/`env:` or to a reusable workflow. It hands the consumer every
6
+ * secret instead of the one or two it needs. Reference specific secrets.
7
+ */
8
+
9
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
10
+ import { getPrimaryOutput, jobLines } from "./yaml-helpers";
11
+
12
+ const TO_JSON_SECRETS = /to_?json\s*\(\s*secrets\s*\)/i;
13
+
14
+ export const gha042: PostSynthCheck = {
15
+ id: "GHA042",
16
+ description: "Entire secrets context passed where specific secrets would do",
17
+
18
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
19
+ const diagnostics: PostSynthDiagnostic[] = [];
20
+
21
+ for (const [, output] of ctx.outputs) {
22
+ const yaml = getPrimaryOutput(output);
23
+ const seen = new Set<string>();
24
+ for (const { job, line } of jobLines(yaml)) {
25
+ if (TO_JSON_SECRETS.test(line) && !seen.has(job)) {
26
+ seen.add(job);
27
+ diagnostics.push({
28
+ checkId: "GHA042",
29
+ severity: "warning",
30
+ message: `Job "${job}" passes the entire secrets context via toJSON(secrets). Reference only the specific secrets the consumer needs.`,
31
+ entity: job,
32
+ lexicon: "github",
33
+ });
34
+ }
35
+ }
36
+ }
37
+
38
+ return diagnostics;
39
+ },
40
+ };
@@ -0,0 +1,69 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { gha043 } from "./gha043";
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("GHA043: secret without environment gate (inconsistent gating)", () => {
20
+ test("flags a secret-using job with no environment when others are gated", () => {
21
+ const yaml = `name: CD
22
+ on:
23
+ push:
24
+ jobs:
25
+ deploy:
26
+ runs-on: ubuntu-latest
27
+ environment: production
28
+ steps:
29
+ - run: echo deploy
30
+ notify:
31
+ runs-on: ubuntu-latest
32
+ steps:
33
+ - run: echo \${{ secrets.SLACK_TOKEN }}
34
+ `;
35
+ const diags = gha043.check(makeCtx(yaml));
36
+ expect(diags).toHaveLength(1);
37
+ expect(diags[0].checkId).toBe("GHA043");
38
+ expect(diags[0].entity).toBe("notify");
39
+ });
40
+
41
+ test("does not flag when the secret-using job is itself gated", () => {
42
+ const yaml = `name: CD
43
+ on:
44
+ push:
45
+ jobs:
46
+ deploy:
47
+ runs-on: ubuntu-latest
48
+ environment: production
49
+ steps:
50
+ - run: echo \${{ secrets.DEPLOY_TOKEN }}
51
+ `;
52
+ const diags = gha043.check(makeCtx(yaml));
53
+ expect(diags).toHaveLength(0);
54
+ });
55
+
56
+ test("does not fire when no job uses an environment (GHA026 territory)", () => {
57
+ const yaml = `name: CI
58
+ on:
59
+ push:
60
+ jobs:
61
+ build:
62
+ runs-on: ubuntu-latest
63
+ steps:
64
+ - run: echo \${{ secrets.TOKEN }}
65
+ `;
66
+ const diags = gha043.check(makeCtx(yaml));
67
+ expect(diags).toHaveLength(0);
68
+ });
69
+ });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * GHA043: Secret Consumed in a Job Without an Environment Gate
3
+ *
4
+ * Extends GHA026 (which fires only when a workflow uses secrets and defines no
5
+ * environment at all). When a workflow *does* gate some jobs with an
6
+ * `environment:`, this flags the specific jobs that consume secrets without one
7
+ * — the inconsistent-gating case, where a secret-using job skips the approval
8
+ * and scoping the author applied elsewhere.
9
+ */
10
+
11
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
12
+ import { getPrimaryOutput, jobsReferencingSecrets, extractJobEnvironments } from "./yaml-helpers";
13
+
14
+ export const gha043: PostSynthCheck = {
15
+ id: "GHA043",
16
+ description: "Secret consumed in a job without an environment gate",
17
+
18
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
19
+ const diagnostics: PostSynthDiagnostic[] = [];
20
+
21
+ for (const [, output] of ctx.outputs) {
22
+ const yaml = getPrimaryOutput(output);
23
+ const gated = extractJobEnvironments(yaml);
24
+ if (gated.size === 0) continue; // no environments used anywhere — GHA026 territory
25
+
26
+ for (const job of jobsReferencingSecrets(yaml)) {
27
+ if (!gated.has(job)) {
28
+ diagnostics.push({
29
+ checkId: "GHA043",
30
+ severity: "warning",
31
+ message: `Job "${job}" consumes a secret but declares no environment:, while other jobs in this workflow are environment-gated. Gate this job too, or confirm it should bypass the approval/scoping the others use.`,
32
+ entity: job,
33
+ lexicon: "github",
34
+ });
35
+ }
36
+ }
37
+ }
38
+
39
+ return diagnostics;
40
+ },
41
+ };
@@ -0,0 +1,56 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { gha044 } from "./gha044";
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("GHA044: hardcoded registry/container credential", () => {
20
+ test("flags a hardcoded password literal", () => {
21
+ const yaml = `name: CI
22
+ on:
23
+ push:
24
+ jobs:
25
+ build:
26
+ runs-on: ubuntu-latest
27
+ steps:
28
+ - uses: docker/login-action@v3
29
+ with:
30
+ username: myuser
31
+ password: hunter2
32
+ `;
33
+ const diags = gha044.check(makeCtx(yaml));
34
+ expect(diags).toHaveLength(1);
35
+ expect(diags[0].checkId).toBe("GHA044");
36
+ expect(diags[0].severity).toBe("error");
37
+ expect(diags[0].entity).toBe("build");
38
+ });
39
+
40
+ test("does not flag a password sourced from a secret", () => {
41
+ const yaml = `name: CI
42
+ on:
43
+ push:
44
+ jobs:
45
+ build:
46
+ runs-on: ubuntu-latest
47
+ steps:
48
+ - uses: docker/login-action@v3
49
+ with:
50
+ username: myuser
51
+ password: \${{ secrets.DOCKER_PASSWORD }}
52
+ `;
53
+ const diags = gha044.check(makeCtx(yaml));
54
+ expect(diags).toHaveLength(0);
55
+ });
56
+ });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * GHA044: Hardcoded Registry / Container Credential
3
+ *
4
+ * Flags a `password:` / `token:` / `registry-password:` value that is a literal
5
+ * rather than a `${{ secrets.* }}` reference — e.g. a registry login or service
6
+ * credential inlined in plain text. Move it into a secret.
7
+ */
8
+
9
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
10
+ import { getPrimaryOutput, jobLines } from "./yaml-helpers";
11
+
12
+ const CRED_KEY_RE = /^\s+(?:- )?(password|token|registry-password):\s*(.+)$/;
13
+
14
+ export const gha044: PostSynthCheck = {
15
+ id: "GHA044",
16
+ description: "Hardcoded registry/container credential",
17
+
18
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
19
+ const diagnostics: PostSynthDiagnostic[] = [];
20
+
21
+ for (const [, output] of ctx.outputs) {
22
+ const yaml = getPrimaryOutput(output);
23
+ for (const { job, line } of jobLines(yaml)) {
24
+ const m = line.match(CRED_KEY_RE);
25
+ if (!m) continue;
26
+ const key = m[1];
27
+ const value = m[2].trim().replace(/^['"]|['"]$/g, "");
28
+ if (value === "" || value.includes("${{")) continue; // empty or a proper expression
29
+ diagnostics.push({
30
+ checkId: "GHA044",
31
+ severity: "error",
32
+ message: `Job "${job}" sets ${key}: to a hardcoded literal. Reference a secret (\${{ secrets.NAME }}) instead of inlining the credential.`,
33
+ entity: job,
34
+ lexicon: "github",
35
+ });
36
+ }
37
+ }
38
+
39
+ return diagnostics;
40
+ },
41
+ };
@@ -0,0 +1,51 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { gha045 } from "./gha045";
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("GHA045: secret interpolated into run:", () => {
20
+ test("flags a secret interpolated into a run command", () => {
21
+ const yaml = `name: CI
22
+ on:
23
+ push:
24
+ jobs:
25
+ build:
26
+ runs-on: ubuntu-latest
27
+ steps:
28
+ - run: curl -H "Authorization: \${{ secrets.API_TOKEN }}" https://example.com
29
+ `;
30
+ const diags = gha045.check(makeCtx(yaml));
31
+ expect(diags).toHaveLength(1);
32
+ expect(diags[0].checkId).toBe("GHA045");
33
+ expect(diags[0].entity).toBe("build");
34
+ });
35
+
36
+ test("does not flag a secret passed via env (the recommended form)", () => {
37
+ const yaml = `name: CI
38
+ on:
39
+ push:
40
+ jobs:
41
+ build:
42
+ runs-on: ubuntu-latest
43
+ steps:
44
+ - env:
45
+ TOKEN: \${{ secrets.API_TOKEN }}
46
+ run: curl -H "Authorization: $TOKEN" https://example.com
47
+ `;
48
+ const diags = gha045.check(makeCtx(yaml));
49
+ expect(diags).toHaveLength(0);
50
+ });
51
+ });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * GHA045: Secret Interpolated Directly into a Shell Command
3
+ *
4
+ * Flags `${{ secrets.* }}` expanded straight into a `run:` script. The value is
5
+ * substituted into the command line before the shell runs, where a transform
6
+ * (base64, reversing, splitting) can defeat GitHub's log masking and leak it,
7
+ * and the raw value is exposed to argument-injection. Pass secrets through an
8
+ * `env:` variable and reference "$VAR" 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
+
14
+ export const gha045: PostSynthCheck = {
15
+ id: "GHA045",
16
+ description: "Secret interpolated directly into a run: shell command",
17
+
18
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
19
+ const diagnostics: PostSynthDiagnostic[] = [];
20
+
21
+ for (const [, output] of ctx.outputs) {
22
+ const yaml = getPrimaryOutput(output);
23
+ for (const { job, run } of extractRunBlocks(yaml)) {
24
+ const leaked = extractExpressions(run).some((e) => /\bsecrets\./.test(e));
25
+ if (!leaked) continue;
26
+ diagnostics.push({
27
+ checkId: "GHA045",
28
+ severity: "warning",
29
+ message: `Job "${job}" interpolates a secret directly into a run: script, where a transform can defeat log masking. Pass it through an env: variable and reference "$VAR" quoted instead.`,
30
+ entity: job,
31
+ lexicon: "github",
32
+ });
33
+ }
34
+ }
35
+
36
+ return diagnostics;
37
+ },
38
+ };
@@ -0,0 +1,59 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { gha046, unsoundReason } from "./gha046";
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("GHA046: unsound guard condition", () => {
20
+ test("unsoundReason classifies constants", () => {
21
+ expect(unsoundReason("true")).toContain("always true");
22
+ expect(unsoundReason("${{ false }}")).toContain("always false");
23
+ expect(unsoundReason("${{ success() || true }}")).toContain("always true");
24
+ expect(unsoundReason("${{ github.ref == github.ref }}")).toContain("always true");
25
+ expect(unsoundReason("${{ github.ref == 'refs/heads/main' }}")).toBeUndefined();
26
+ });
27
+
28
+ test("flags an always-true literal gate", () => {
29
+ const yaml = `name: CI
30
+ on:
31
+ push:
32
+ jobs:
33
+ build:
34
+ runs-on: ubuntu-latest
35
+ if: true
36
+ steps:
37
+ - run: echo build
38
+ `;
39
+ const diags = gha046.check(makeCtx(yaml));
40
+ expect(diags).toHaveLength(1);
41
+ expect(diags[0].checkId).toBe("GHA046");
42
+ expect(diags[0].entity).toBe("build");
43
+ });
44
+
45
+ test("does not flag a real condition", () => {
46
+ const yaml = `name: CI
47
+ on:
48
+ push:
49
+ jobs:
50
+ build:
51
+ runs-on: ubuntu-latest
52
+ if: \${{ github.ref == 'refs/heads/main' }}
53
+ steps:
54
+ - run: echo build
55
+ `;
56
+ const diags = gha046.check(makeCtx(yaml));
57
+ expect(diags).toHaveLength(0);
58
+ });
59
+ });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * GHA046: Logically Unsound Guard Condition
3
+ *
4
+ * Flags an `if:` condition that reads like a security/flow gate but evaluates to
5
+ * a constant — `true`/`false` literals, an `X == X` tautology, or an expression
6
+ * that collapses via `|| true` / `&& false`. These pass review by looking like a
7
+ * control while gating nothing (or everything).
8
+ */
9
+
10
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
11
+ import { getPrimaryOutput, extractIfConditions } from "./yaml-helpers";
12
+
13
+ /** Strip a leading `${{` / trailing `}}` wrapper from an expression. */
14
+ function inner(expr: string): string {
15
+ return expr.replace(/^\$\{\{/, "").replace(/\}\}$/, "").trim();
16
+ }
17
+
18
+ /** Classify a condition as a constant guard, returning a reason or undefined. */
19
+ export function unsoundReason(expr: string): string | undefined {
20
+ const e = inner(expr);
21
+ if (e === "true") return "always true (true literal)";
22
+ if (e === "false") return "always false (false literal)";
23
+ if (/\|\|\s*true\b/.test(e)) return "always true (|| true collapses the condition)";
24
+ if (/&&\s*false\b/.test(e)) return "always false (&& false collapses the condition)";
25
+ const eq = e.match(/^([\w.$'"[\]-]+)\s*==\s*([\w.$'"[\]-]+)$/);
26
+ if (eq && eq[1] === eq[2]) return "always true (both sides of == are identical)";
27
+ const ne = e.match(/^([\w.$'"[\]-]+)\s*!=\s*([\w.$'"[\]-]+)$/);
28
+ if (ne && ne[1] === ne[2]) return "always false (both sides of != are identical)";
29
+ return undefined;
30
+ }
31
+
32
+ export const gha046: PostSynthCheck = {
33
+ id: "GHA046",
34
+ description: "Logically unsound (constant) guard condition",
35
+
36
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
37
+ const diagnostics: PostSynthDiagnostic[] = [];
38
+
39
+ for (const [, output] of ctx.outputs) {
40
+ const yaml = getPrimaryOutput(output);
41
+ for (const { job, expr } of extractIfConditions(yaml)) {
42
+ const reason = unsoundReason(expr);
43
+ if (!reason) continue;
44
+ diagnostics.push({
45
+ checkId: "GHA046",
46
+ severity: "warning",
47
+ message: `Job "${job}" has an if: condition that is ${reason}: "${expr}". A gate that does not constrain anything is misleading — remove it or write the real condition.`,
48
+ entity: job,
49
+ lexicon: "github",
50
+ });
51
+ }
52
+ }
53
+
54
+ return diagnostics;
55
+ },
56
+ };