@intentius/chant-lexicon-github 0.124.31 → 0.124.32

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 (151) hide show
  1. package/dist/action-pins.js +53 -0
  2. package/dist/codegen/docs-cli.js +2 -0
  3. package/dist/codegen/docs.js +142 -0
  4. package/dist/codegen/generate-cli.js +26 -0
  5. package/dist/codegen/generate-lexicon.js +36 -0
  6. package/dist/codegen/generate-typescript.js +108 -0
  7. package/dist/codegen/generate.js +111 -0
  8. package/dist/codegen/naming.js +49 -0
  9. package/dist/codegen/package.js +40 -0
  10. package/dist/codegen/parse.js +595 -0
  11. package/dist/codegen/patches.js +36 -0
  12. package/dist/components/ci-jobs.js +84 -0
  13. package/dist/components/generate-op-pipeline.js +440 -0
  14. package/dist/components/generate-op-waves-pipeline.js +158 -0
  15. package/dist/components/generate-pipeline.d.ts.map +1 -1
  16. package/dist/components/generate-pipeline.js +476 -0
  17. package/dist/composites/cache.js +17 -0
  18. package/dist/composites/catalog.js +859 -0
  19. package/dist/composites/checkout.js +25 -0
  20. package/dist/composites/dependabot.js +43 -0
  21. package/dist/composites/deploy-environment.js +38 -0
  22. package/dist/composites/docker-build.js +70 -0
  23. package/dist/composites/download-artifact.js +18 -0
  24. package/dist/composites/go-ci.js +62 -0
  25. package/dist/composites/index.js +15 -0
  26. package/dist/composites/node-ci.js +44 -0
  27. package/dist/composites/node-pipeline.js +96 -0
  28. package/dist/composites/pr-plan-report.js +129 -0
  29. package/dist/composites/python-ci.js +60 -0
  30. package/dist/composites/setup-go.js +18 -0
  31. package/dist/composites/setup-node.js +21 -0
  32. package/dist/composites/setup-python.js +18 -0
  33. package/dist/composites/upload-artifact.js +19 -0
  34. package/dist/coverage.js +32 -0
  35. package/dist/expression.js +177 -0
  36. package/dist/generated/index.js +26 -0
  37. package/dist/generated/runtime.js +4 -0
  38. package/dist/graph-edges.js +174 -0
  39. package/dist/import/generator.js +108 -0
  40. package/dist/import/parser.js +69 -0
  41. package/dist/index.js +22 -0
  42. package/dist/integrity.json +2 -2
  43. package/dist/lint/audit-catalog.js +69 -0
  44. package/dist/lint/audit-lineage.js +168 -0
  45. package/dist/lint/post-synth/gha006.js +50 -0
  46. package/dist/lint/post-synth/gha009.js +35 -0
  47. package/dist/lint/post-synth/gha011.js +34 -0
  48. package/dist/lint/post-synth/gha013.js +40 -0
  49. package/dist/lint/post-synth/gha017.js +25 -0
  50. package/dist/lint/post-synth/gha018.js +33 -0
  51. package/dist/lint/post-synth/gha019.js +58 -0
  52. package/dist/lint/post-synth/gha021.js +42 -0
  53. package/dist/lint/post-synth/gha022.js +44 -0
  54. package/dist/lint/post-synth/gha023.js +37 -0
  55. package/dist/lint/post-synth/gha024.js +32 -0
  56. package/dist/lint/post-synth/gha025.js +33 -0
  57. package/dist/lint/post-synth/gha026.js +32 -0
  58. package/dist/lint/post-synth/gha027.js +46 -0
  59. package/dist/lint/post-synth/gha028.js +28 -0
  60. package/dist/lint/post-synth/gha029.js +56 -0
  61. package/dist/lint/post-synth/gha030.js +35 -0
  62. package/dist/lint/post-synth/gha031.js +75 -0
  63. package/dist/lint/post-synth/gha032.js +35 -0
  64. package/dist/lint/post-synth/gha033.js +39 -0
  65. package/dist/lint/post-synth/gha034.js +35 -0
  66. package/dist/lint/post-synth/gha035.js +50 -0
  67. package/dist/lint/post-synth/gha036.js +37 -0
  68. package/dist/lint/post-synth/gha037.js +41 -0
  69. package/dist/lint/post-synth/gha038.js +35 -0
  70. package/dist/lint/post-synth/gha039.js +34 -0
  71. package/dist/lint/post-synth/gha040.js +36 -0
  72. package/dist/lint/post-synth/gha041.js +32 -0
  73. package/dist/lint/post-synth/gha042.js +33 -0
  74. package/dist/lint/post-synth/gha043.js +35 -0
  75. package/dist/lint/post-synth/gha044.js +36 -0
  76. package/dist/lint/post-synth/gha045.js +33 -0
  77. package/dist/lint/post-synth/gha046.js +55 -0
  78. package/dist/lint/post-synth/gha047.js +44 -0
  79. package/dist/lint/post-synth/gha048.js +34 -0
  80. package/dist/lint/post-synth/gha049.js +36 -0
  81. package/dist/lint/post-synth/gha050.js +37 -0
  82. package/dist/lint/post-synth/gha051.js +38 -0
  83. package/dist/lint/post-synth/gha052.js +35 -0
  84. package/dist/lint/post-synth/gha053.js +34 -0
  85. package/dist/lint/post-synth/gha054.js +35 -0
  86. package/dist/lint/post-synth/gha055.js +44 -0
  87. package/dist/lint/post-synth/gha056.js +32 -0
  88. package/dist/lint/post-synth/gha057.js +35 -0
  89. package/dist/lint/post-synth/gha058.js +42 -0
  90. package/dist/lint/post-synth/gha059.js +119 -0
  91. package/dist/lint/post-synth/gha060.js +129 -0
  92. package/dist/lint/post-synth/gha061.js +78 -0
  93. package/dist/lint/post-synth/gha062.js +65 -0
  94. package/dist/lint/post-synth/gha063.js +56 -0
  95. package/dist/lint/post-synth/gha064.js +47 -0
  96. package/dist/lint/post-synth/gha065.js +125 -0
  97. package/dist/lint/post-synth/gha066.js +40 -0
  98. package/dist/lint/post-synth/gha067.js +75 -0
  99. package/dist/lint/post-synth/gha068.js +47 -0
  100. package/dist/lint/post-synth/gha069.js +99 -0
  101. package/dist/lint/post-synth/index.js +114 -0
  102. package/dist/lint/post-synth/yaml-helpers.js +465 -0
  103. package/dist/lint/rules/data/action-usage-policy.js +16 -0
  104. package/dist/lint/rules/data/advisory-feed.js +15 -0
  105. package/dist/lint/rules/data/deprecated-versions.js +14 -0
  106. package/dist/lint/rules/data/flagged-actions.js +18 -0
  107. package/dist/lint/rules/data/known-action-slugs.js +38 -0
  108. package/dist/lint/rules/data/known-actions.js +12 -0
  109. package/dist/lint/rules/data/preinstalled-tools.js +36 -0
  110. package/dist/lint/rules/data/recommended-inputs.js +9 -0
  111. package/dist/lint/rules/data/risky-features.js +22 -0
  112. package/dist/lint/rules/data/secret-patterns.js +30 -0
  113. package/dist/lint/rules/data/trusted-action-owners.js +12 -0
  114. package/dist/lint/rules/data/untrusted-contexts.js +44 -0
  115. package/dist/lint/rules/deprecated-action-version.js +44 -0
  116. package/dist/lint/rules/detect-secrets.js +45 -0
  117. package/dist/lint/rules/extract-inline-structs.js +55 -0
  118. package/dist/lint/rules/file-job-limit.js +43 -0
  119. package/dist/lint/rules/index.js +16 -0
  120. package/dist/lint/rules/job-timeout.js +53 -0
  121. package/dist/lint/rules/missing-recommended-inputs.js +51 -0
  122. package/dist/lint/rules/no-hardcoded-secrets.js +39 -0
  123. package/dist/lint/rules/no-raw-expressions.js +44 -0
  124. package/dist/lint/rules/suggest-cache.js +60 -0
  125. package/dist/lint/rules/use-condition-builders.js +37 -0
  126. package/dist/lint/rules/use-matrix-builder.js +36 -0
  127. package/dist/lint/rules/use-typed-actions.js +41 -0
  128. package/dist/lint/rules/validate-concurrency.js +59 -0
  129. package/dist/lsp/completions.d.ts.map +1 -1
  130. package/dist/lsp/completions.js +18 -0
  131. package/dist/lsp/hover.d.ts.map +1 -1
  132. package/dist/lsp/hover.js +33 -0
  133. package/dist/manifest.json +1 -1
  134. package/dist/mcp/context-tools.js +152 -0
  135. package/dist/package-cli.js +20 -0
  136. package/dist/plugin.js +344 -0
  137. package/dist/serializer.js +572 -0
  138. package/dist/spec/fetch.js +46 -0
  139. package/dist/validate-cli.js +16 -0
  140. package/dist/validate.js +38 -0
  141. package/dist/variables.js +41 -0
  142. package/package.json +6 -6
  143. package/src/codegen/package.ts +2 -2
  144. package/src/components/__fixtures__/pr-loop-build.github.golden.yml +6 -2
  145. package/src/components/__fixtures__/pr-loop-waves.github.golden.yml +178 -0
  146. package/src/components/__fixtures__/pr-loop.github.golden.yml +6 -2
  147. package/src/components/generate-pipeline.ts +66 -29
  148. package/src/components/generate-pr-pipeline.test.ts +44 -20
  149. package/src/components/generate-pr-waves-pipeline.test.ts +117 -0
  150. package/src/lsp/completions.ts +2 -1
  151. package/src/lsp/hover.ts +2 -1
@@ -0,0 +1,34 @@
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
+ import { getPrimaryOutput, extractIfConditions } from "./yaml-helpers.js";
11
+ const SPOOFABLE_FIELD_RE = /\.author\.(name|email)\b/;
12
+ const COMPARISON_RE = /==|!=|contains\s*\(/;
13
+ export const gha039 = {
14
+ id: "GHA039",
15
+ description: "Authorization gate on a spoofable commit-author identity field",
16
+ check(ctx) {
17
+ const diagnostics = [];
18
+ for (const [, output] of ctx.outputs) {
19
+ const yaml = getPrimaryOutput(output);
20
+ for (const { job, expr } of extractIfConditions(yaml)) {
21
+ if (SPOOFABLE_FIELD_RE.test(expr) && COMPARISON_RE.test(expr)) {
22
+ diagnostics.push({
23
+ checkId: "GHA039",
24
+ severity: "warning",
25
+ 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.`,
26
+ entity: job,
27
+ lexicon: "github",
28
+ });
29
+ }
30
+ }
31
+ }
32
+ return diagnostics;
33
+ },
34
+ };
@@ -0,0 +1,36 @@
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
+ import { getPrimaryOutput, extractTriggers, extractRunsOnByJob } from "./yaml-helpers.js";
10
+ const UNTRUSTED_TRIGGERS = ["pull_request", "pull_request_target", "workflow_run"];
11
+ export const gha040 = {
12
+ id: "GHA040",
13
+ description: "Self-hosted runner on a trigger that can run untrusted code",
14
+ check(ctx) {
15
+ const diagnostics = [];
16
+ for (const [, output] of ctx.outputs) {
17
+ const yaml = getPrimaryOutput(output);
18
+ const triggers = extractTriggers(yaml);
19
+ const untrusted = UNTRUSTED_TRIGGERS.filter((t) => triggers[t]);
20
+ if (untrusted.length === 0)
21
+ continue;
22
+ for (const [job, labels] of extractRunsOnByJob(yaml)) {
23
+ if (labels.some((l) => l === "self-hosted")) {
24
+ diagnostics.push({
25
+ checkId: "GHA040",
26
+ severity: "warning",
27
+ 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.`,
28
+ entity: job,
29
+ lexicon: "github",
30
+ });
31
+ }
32
+ }
33
+ }
34
+ return diagnostics;
35
+ },
36
+ };
@@ -0,0 +1,32 @@
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
+ import { getPrimaryOutput, jobLines } from "./yaml-helpers.js";
9
+ export const gha041 = {
10
+ id: "GHA041",
11
+ description: "Blanket secrets: inherit into a reusable workflow",
12
+ check(ctx) {
13
+ const diagnostics = [];
14
+ for (const [, output] of ctx.outputs) {
15
+ const yaml = getPrimaryOutput(output);
16
+ const seen = new Set();
17
+ for (const { job, line } of jobLines(yaml)) {
18
+ if (/^\s+secrets:\s*inherit\s*$/.test(line) && !seen.has(job)) {
19
+ seen.add(job);
20
+ diagnostics.push({
21
+ checkId: "GHA041",
22
+ severity: "warning",
23
+ message: `Job "${job}" calls a reusable workflow with secrets: inherit, passing every caller secret. Pass through only the specific secrets it needs.`,
24
+ entity: job,
25
+ lexicon: "github",
26
+ });
27
+ }
28
+ }
29
+ }
30
+ return diagnostics;
31
+ },
32
+ };
@@ -0,0 +1,33 @@
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
+ import { getPrimaryOutput, jobLines } from "./yaml-helpers.js";
9
+ const TO_JSON_SECRETS = /to_?json\s*\(\s*secrets\s*\)/i;
10
+ export const gha042 = {
11
+ id: "GHA042",
12
+ description: "Entire secrets context passed where specific secrets would do",
13
+ check(ctx) {
14
+ const diagnostics = [];
15
+ for (const [, output] of ctx.outputs) {
16
+ const yaml = getPrimaryOutput(output);
17
+ const seen = new Set();
18
+ for (const { job, line } of jobLines(yaml)) {
19
+ if (TO_JSON_SECRETS.test(line) && !seen.has(job)) {
20
+ seen.add(job);
21
+ diagnostics.push({
22
+ checkId: "GHA042",
23
+ severity: "warning",
24
+ message: `Job "${job}" passes the entire secrets context via toJSON(secrets). Reference only the specific secrets the consumer needs.`,
25
+ entity: job,
26
+ lexicon: "github",
27
+ });
28
+ }
29
+ }
30
+ }
31
+ return diagnostics;
32
+ },
33
+ };
@@ -0,0 +1,35 @@
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
+ import { getPrimaryOutput, jobsReferencingSecrets, extractJobEnvironments } from "./yaml-helpers.js";
11
+ export const gha043 = {
12
+ id: "GHA043",
13
+ description: "Secret consumed in a job without an environment gate",
14
+ check(ctx) {
15
+ const diagnostics = [];
16
+ for (const [, output] of ctx.outputs) {
17
+ const yaml = getPrimaryOutput(output);
18
+ const gated = extractJobEnvironments(yaml);
19
+ if (gated.size === 0)
20
+ continue; // no environments used anywhere — GHA026 territory
21
+ for (const job of jobsReferencingSecrets(yaml)) {
22
+ if (!gated.has(job)) {
23
+ diagnostics.push({
24
+ checkId: "GHA043",
25
+ severity: "warning",
26
+ 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.`,
27
+ entity: job,
28
+ lexicon: "github",
29
+ });
30
+ }
31
+ }
32
+ }
33
+ return diagnostics;
34
+ },
35
+ };
@@ -0,0 +1,36 @@
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
+ import { getPrimaryOutput, jobLines } from "./yaml-helpers.js";
9
+ const CRED_KEY_RE = /^\s+(?:- )?(password|token|registry-password):\s*(.+)$/;
10
+ export const gha044 = {
11
+ id: "GHA044",
12
+ description: "Hardcoded registry/container credential",
13
+ check(ctx) {
14
+ const diagnostics = [];
15
+ for (const [, output] of ctx.outputs) {
16
+ const yaml = getPrimaryOutput(output);
17
+ for (const { job, line } of jobLines(yaml)) {
18
+ const m = line.match(CRED_KEY_RE);
19
+ if (!m)
20
+ continue;
21
+ const key = m[1];
22
+ const value = m[2].trim().replace(/^['"]|['"]$/g, "");
23
+ if (value === "" || value.includes("${{"))
24
+ continue; // empty or a proper expression
25
+ diagnostics.push({
26
+ checkId: "GHA044",
27
+ severity: "error",
28
+ message: `Job "${job}" sets ${key}: to a hardcoded literal. Reference a secret (\${{ secrets.NAME }}) instead of inlining the credential.`,
29
+ entity: job,
30
+ lexicon: "github",
31
+ });
32
+ }
33
+ }
34
+ return diagnostics;
35
+ },
36
+ };
@@ -0,0 +1,33 @@
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
+ import { getPrimaryOutput, extractRunBlocks, extractExpressions } from "./yaml-helpers.js";
11
+ export const gha045 = {
12
+ id: "GHA045",
13
+ description: "Secret interpolated directly into a run: shell command",
14
+ check(ctx) {
15
+ const diagnostics = [];
16
+ for (const [, output] of ctx.outputs) {
17
+ const yaml = getPrimaryOutput(output);
18
+ for (const { job, run } of extractRunBlocks(yaml)) {
19
+ const leaked = extractExpressions(run).some((e) => /\bsecrets\./.test(e));
20
+ if (!leaked)
21
+ continue;
22
+ diagnostics.push({
23
+ checkId: "GHA045",
24
+ severity: "warning",
25
+ 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.`,
26
+ entity: job,
27
+ lexicon: "github",
28
+ });
29
+ }
30
+ }
31
+ return diagnostics;
32
+ },
33
+ };
@@ -0,0 +1,55 @@
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
+ import { getPrimaryOutput, extractIfConditions } from "./yaml-helpers.js";
10
+ /** Strip a leading `${{` / trailing `}}` wrapper from an expression. */
11
+ function inner(expr) {
12
+ return expr.replace(/^\$\{\{/, "").replace(/\}\}$/, "").trim();
13
+ }
14
+ /** Classify a condition as a constant guard, returning a reason or undefined. */
15
+ export function unsoundReason(expr) {
16
+ const e = inner(expr);
17
+ if (e === "true")
18
+ return "always true (true literal)";
19
+ if (e === "false")
20
+ return "always false (false literal)";
21
+ if (/\|\|\s*true\b/.test(e))
22
+ return "always true (|| true collapses the condition)";
23
+ if (/&&\s*false\b/.test(e))
24
+ return "always false (&& false collapses the condition)";
25
+ const eq = e.match(/^([\w.$'"[\]-]+)\s*==\s*([\w.$'"[\]-]+)$/);
26
+ if (eq && eq[1] === eq[2])
27
+ return "always true (both sides of == are identical)";
28
+ const ne = e.match(/^([\w.$'"[\]-]+)\s*!=\s*([\w.$'"[\]-]+)$/);
29
+ if (ne && ne[1] === ne[2])
30
+ return "always false (both sides of != are identical)";
31
+ return undefined;
32
+ }
33
+ export const gha046 = {
34
+ id: "GHA046",
35
+ description: "Logically unsound (constant) guard condition",
36
+ check(ctx) {
37
+ const diagnostics = [];
38
+ for (const [, output] of ctx.outputs) {
39
+ const yaml = getPrimaryOutput(output);
40
+ for (const { job, expr } of extractIfConditions(yaml)) {
41
+ const reason = unsoundReason(expr);
42
+ if (!reason)
43
+ 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
+ return diagnostics;
54
+ },
55
+ };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * GHA047: Ineffective `contains()` Guard (Reversed Arguments)
3
+ *
4
+ * Flags `contains('literal', <dynamic>)` — a string literal as the haystack and
5
+ * a dynamic value as the needle. `contains(search, item)` checks whether `item`
6
+ * is in `search`, so a constant first argument makes the result depend on a
7
+ * fixed string rather than the runtime value the author intended to test. The
8
+ * usual fix is to swap the arguments.
9
+ */
10
+ import { getPrimaryOutput, extractIfConditions } from "./yaml-helpers.js";
11
+ // contains( 'literal' | "literal" , <something that is not a literal> )
12
+ const REVERSED_CONTAINS = /contains\(\s*(['"][^'"]*['"])\s*,\s*([^)]+)\)/g;
13
+ function isLiteral(arg) {
14
+ const t = arg.trim();
15
+ return (t.startsWith("'") && t.endsWith("'")) || (t.startsWith('"') && t.endsWith('"'));
16
+ }
17
+ export const gha047 = {
18
+ id: "GHA047",
19
+ description: "Ineffective contains() guard with reversed arguments",
20
+ check(ctx) {
21
+ const diagnostics = [];
22
+ for (const [, output] of ctx.outputs) {
23
+ const yaml = getPrimaryOutput(output);
24
+ for (const { job, expr } of extractIfConditions(yaml)) {
25
+ let m;
26
+ REVERSED_CONTAINS.lastIndex = 0;
27
+ while ((m = REVERSED_CONTAINS.exec(expr)) !== null) {
28
+ // First arg is a literal; flag only when the second arg is dynamic.
29
+ if (isLiteral(m[2]))
30
+ continue;
31
+ diagnostics.push({
32
+ checkId: "GHA047",
33
+ severity: "warning",
34
+ message: `Job "${job}" calls contains() with a string-literal haystack and a dynamic needle: "${m[0]}". contains(search, item) tests whether item is in search — swap the arguments so the dynamic value is searched.`,
35
+ entity: job,
36
+ lexicon: "github",
37
+ });
38
+ break;
39
+ }
40
+ }
41
+ }
42
+ return diagnostics;
43
+ },
44
+ };
@@ -0,0 +1,34 @@
1
+ /**
2
+ * GHA048: Obfuscated Guard Condition
3
+ *
4
+ * Flags an `if:` condition that builds its compared value through string
5
+ * indirection — `format()`, `join()`, or `fromJSON()` feeding a comparison.
6
+ * Constructing the operand at evaluation time hides what the gate actually
7
+ * checks, which is how an unsound condition (GHA046/GHA047) is disguised to pass
8
+ * review. Write the comparison against the value directly.
9
+ */
10
+ import { getPrimaryOutput, extractIfConditions } from "./yaml-helpers.js";
11
+ const INDIRECTION = /\b(format|join|fromJSON)\s*\(/i;
12
+ const COMPARISON = /==|!=|&&|\|\||contains\(|startsWith\(|endsWith\(/;
13
+ export const gha048 = {
14
+ id: "GHA048",
15
+ description: "Obfuscated guard condition (operand built by indirection)",
16
+ check(ctx) {
17
+ const diagnostics = [];
18
+ for (const [, output] of ctx.outputs) {
19
+ const yaml = getPrimaryOutput(output);
20
+ for (const { job, expr } of extractIfConditions(yaml)) {
21
+ if (INDIRECTION.test(expr) && COMPARISON.test(expr)) {
22
+ diagnostics.push({
23
+ checkId: "GHA048",
24
+ severity: "warning",
25
+ message: `Job "${job}" builds its if: gate through string indirection (format/join/fromJSON) feeding a comparison: "${expr}". Compare against the value directly so the condition is auditable.`,
26
+ entity: job,
27
+ lexicon: "github",
28
+ });
29
+ }
30
+ }
31
+ }
32
+ return diagnostics;
33
+ },
34
+ };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * GHA049: Persisted Checkout Credentials Reachable by an Uploaded Artifact
3
+ *
4
+ * `actions/checkout` writes the job token into `.git/config` by default
5
+ * (`persist-credentials: true`). A job that also uploads an artifact can sweep
6
+ * that `.git` directory into the artifact, leaking the token to anyone who can
7
+ * download it. Set `persist-credentials: false` on the checkout when the job
8
+ * uploads artifacts.
9
+ */
10
+ import { getPrimaryOutput, linesByJob } from "./yaml-helpers.js";
11
+ export const gha049 = {
12
+ id: "GHA049",
13
+ description: "Persisted checkout credentials reachable by an uploaded artifact",
14
+ check(ctx) {
15
+ const diagnostics = [];
16
+ for (const [, output] of ctx.outputs) {
17
+ const yaml = getPrimaryOutput(output);
18
+ for (const [job, lines] of linesByJob(yaml)) {
19
+ const text = lines.join("\n");
20
+ const hasCheckout = /uses:\s*actions\/checkout/.test(text);
21
+ const persistDisabled = /persist-credentials:\s*false/.test(text);
22
+ const uploadsArtifact = /uses:\s*actions\/upload-artifact/.test(text);
23
+ if (hasCheckout && uploadsArtifact && !persistDisabled) {
24
+ diagnostics.push({
25
+ checkId: "GHA049",
26
+ severity: "warning",
27
+ message: `Job "${job}" checks out with persisted credentials (default) and uploads an artifact — the token in .git/config can leak into the artifact. Set persist-credentials: false on the checkout.`,
28
+ entity: job,
29
+ lexicon: "github",
30
+ });
31
+ }
32
+ }
33
+ }
34
+ return diagnostics;
35
+ },
36
+ };
@@ -0,0 +1,37 @@
1
+ /**
2
+ * GHA050: Cache Populated in a Privileged Context (Poisoning Risk)
3
+ *
4
+ * Flags `actions/cache` use under a privileged trigger (`pull_request_target`,
5
+ * `workflow_run`) that runs in the base-repo context. Cache entries written from
6
+ * code influenced by a fork can later be restored by a trusted run and executed,
7
+ * a cache-poisoning path. Restrict caching to trusted triggers or scope keys so
8
+ * untrusted runs cannot write entries a privileged run will restore.
9
+ */
10
+ import { getPrimaryOutput, extractTriggers, linesByJob } from "./yaml-helpers.js";
11
+ const PRIVILEGED_TRIGGERS = ["pull_request_target", "workflow_run"];
12
+ export const gha050 = {
13
+ id: "GHA050",
14
+ description: "Cache populated in a privileged context (poisoning risk)",
15
+ check(ctx) {
16
+ const diagnostics = [];
17
+ for (const [, output] of ctx.outputs) {
18
+ const yaml = getPrimaryOutput(output);
19
+ const triggers = extractTriggers(yaml);
20
+ const privileged = PRIVILEGED_TRIGGERS.filter((t) => triggers[t]);
21
+ if (privileged.length === 0)
22
+ continue;
23
+ for (const [job, lines] of linesByJob(yaml)) {
24
+ if (lines.some((l) => /uses:\s*actions\/cache/.test(l))) {
25
+ diagnostics.push({
26
+ checkId: "GHA050",
27
+ severity: "warning",
28
+ message: `Job "${job}" populates a cache under the ${privileged.join("/")} trigger, which runs in the privileged base context. A poisoned cache entry could be restored and executed by a trusted run — restrict caching to trusted triggers.`,
29
+ entity: job,
30
+ lexicon: "github",
31
+ });
32
+ }
33
+ }
34
+ }
35
+ return diagnostics;
36
+ },
37
+ };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * GHA051: Publish/Release Step Using a Long-Lived Token Instead of OIDC
3
+ *
4
+ * Flags a job that publishes a release/package using a long-lived token secret
5
+ * (`*_TOKEN`, `*_PASSWORD`, `*_API_KEY`) while the workflow requests no
6
+ * `id-token: write` permission. Registries that support OIDC let a workflow mint
7
+ * a short-lived, audience-scoped credential per run instead of holding a
8
+ * standing secret. Advisory — not every registry supports OIDC.
9
+ */
10
+ import { getPrimaryOutput, linesByJob } from "./yaml-helpers.js";
11
+ const PUBLISH_RE = /npm publish|yarn publish|pnpm publish|twine upload|poetry publish|cargo publish|gh release create|docker push|gh-action-pypi-publish|npm-publish/;
12
+ const LONG_LIVED_SECRET_RE = /secrets\.(?!GITHUB_TOKEN\b)\w*(TOKEN|PASSWORD|API_KEY|APIKEY)/i;
13
+ export const gha051 = {
14
+ id: "GHA051",
15
+ description: "Publish/release step using a long-lived token instead of OIDC",
16
+ check(ctx) {
17
+ const diagnostics = [];
18
+ for (const [, output] of ctx.outputs) {
19
+ const yaml = getPrimaryOutput(output);
20
+ const requestsOidc = /id-token:\s*write/.test(yaml);
21
+ if (requestsOidc)
22
+ continue;
23
+ for (const [job, lines] of linesByJob(yaml)) {
24
+ const text = lines.join("\n");
25
+ if (PUBLISH_RE.test(text) && LONG_LIVED_SECRET_RE.test(text)) {
26
+ diagnostics.push({
27
+ checkId: "GHA051",
28
+ severity: "info",
29
+ message: `Job "${job}" publishes using a long-lived token secret and requests no id-token: write. If the registry supports OIDC, mint a short-lived federated credential (permissions: id-token: write) instead of holding a standing token.`,
30
+ entity: job,
31
+ lexicon: "github",
32
+ });
33
+ }
34
+ }
35
+ }
36
+ return diagnostics;
37
+ },
38
+ };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * GHA052: Software Fetched and Executed at Runtime Without Verification
3
+ *
4
+ * Flags a `run:` step that pipes a network download straight into a shell
5
+ * (`curl ... | bash`, `wget ... | sh`, `iwr ... | iex`). The fetched script is
6
+ * unpinned and unverified, so whoever controls the URL controls what executes
7
+ * in the job. Download to a file, verify a checksum/signature, then run it.
8
+ */
9
+ import { getPrimaryOutput, extractRunBlocks } from "./yaml-helpers.js";
10
+ const PIPE_TO_SHELL = [
11
+ /\b(curl|wget)\b[^\n|]*\|\s*(sudo\s+)?(ba)?sh\b/i,
12
+ /\b(iwr|invoke-webrequest|irm|invoke-restmethod)\b[^\n|]*\|\s*(iex|invoke-expression)\b/i,
13
+ ];
14
+ export const gha052 = {
15
+ id: "GHA052",
16
+ description: "Software fetched and piped to a shell without verification",
17
+ check(ctx) {
18
+ const diagnostics = [];
19
+ for (const [, output] of ctx.outputs) {
20
+ const yaml = getPrimaryOutput(output);
21
+ for (const { job, run } of extractRunBlocks(yaml)) {
22
+ if (PIPE_TO_SHELL.some((re) => re.test(run))) {
23
+ diagnostics.push({
24
+ checkId: "GHA052",
25
+ severity: "warning",
26
+ message: `Job "${job}" pipes a network download directly into a shell — the fetched code is unpinned and unverified. Download to a file, verify a checksum or signature, then execute it.`,
27
+ entity: job,
28
+ lexicon: "github",
29
+ });
30
+ }
31
+ }
32
+ }
33
+ return diagnostics;
34
+ },
35
+ };
@@ -0,0 +1,34 @@
1
+ /**
2
+ * GHA053: Re-enabling Unsafe Workflow-Command Processing
3
+ *
4
+ * Flags opt-ins that reintroduce the `set-env` / `add-path` workflow commands
5
+ * removed for security (CVE-2020-15228) — `ACTIONS_ALLOW_UNSECURE_COMMANDS` and
6
+ * direct `::set-env::` / `::add-path::` emission. These let a logged string set
7
+ * arbitrary environment/PATH state for later steps, a code-execution path.
8
+ */
9
+ import { getPrimaryOutput, linesByJob } from "./yaml-helpers.js";
10
+ const UNSAFE_COMMANDS = /ACTIONS_ALLOW_UNSECURE_COMMANDS|::set-env\s|::add-path\s/;
11
+ export const gha053 = {
12
+ id: "GHA053",
13
+ description: "Re-enables unsafe set-env / add-path workflow commands",
14
+ check(ctx) {
15
+ const diagnostics = [];
16
+ for (const [, output] of ctx.outputs) {
17
+ const yaml = getPrimaryOutput(output);
18
+ const seen = new Set();
19
+ for (const [job, lines] of linesByJob(yaml)) {
20
+ if (lines.some((l) => UNSAFE_COMMANDS.test(l)) && !seen.has(job)) {
21
+ seen.add(job);
22
+ diagnostics.push({
23
+ checkId: "GHA053",
24
+ severity: "error",
25
+ message: `Job "${job}" re-enables the unsafe set-env/add-path workflow commands (ACTIONS_ALLOW_UNSECURE_COMMANDS or ::set-env::/::add-path::). Use $GITHUB_ENV / $GITHUB_PATH files instead — the legacy commands were removed for security.`,
26
+ entity: job,
27
+ lexicon: "github",
28
+ });
29
+ }
30
+ }
31
+ }
32
+ return diagnostics;
33
+ },
34
+ };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * GHA054: Use of a Feature with a Known Security Footgun
3
+ *
4
+ * Catch-all, data-driven check: flags emitted workflow content matching a
5
+ * vendored snapshot of known-risky GitHub Actions features (deprecated workflow
6
+ * commands, unsafe runtime opt-ins, masking footguns). Advisory; the dataset is
7
+ * necessarily incomplete.
8
+ */
9
+ import { getPrimaryOutput, linesByJob } from "./yaml-helpers.js";
10
+ import { RISKY_FEATURES } from "../rules/data/risky-features.js";
11
+ export const gha054 = {
12
+ id: "GHA054",
13
+ description: "Use of a feature with a known security footgun",
14
+ check(ctx) {
15
+ const diagnostics = [];
16
+ for (const [, output] of ctx.outputs) {
17
+ const yaml = getPrimaryOutput(output);
18
+ for (const [job, lines] of linesByJob(yaml)) {
19
+ const text = lines.join("\n");
20
+ for (const feature of RISKY_FEATURES) {
21
+ if (feature.pattern.test(text)) {
22
+ diagnostics.push({
23
+ checkId: "GHA054",
24
+ severity: "warning",
25
+ message: `Job "${job}" uses ${feature.label} — ${feature.advice}.`,
26
+ entity: job,
27
+ lexicon: "github",
28
+ });
29
+ }
30
+ }
31
+ }
32
+ }
33
+ return diagnostics;
34
+ },
35
+ };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * GHA055: Runtime Install of a Tool Already on the Runner
3
+ *
4
+ * Flags a `run:` step that installs (apt/brew/apk/choco) a tool that GitHub-
5
+ * hosted runners already ship. The redundant install adds a third-party
6
+ * download and supply-chain surface for no benefit and slows the job. Advisory;
7
+ * irrelevant on self-hosted runners that may lack the tool.
8
+ */
9
+ import { getPrimaryOutput, extractRunBlocks } from "./yaml-helpers.js";
10
+ import { PREINSTALLED_TOOLS } from "../rules/data/preinstalled-tools.js";
11
+ const INSTALL_RE = /\b(?:apt(?:-get)?\s+install|brew\s+install|apk\s+add|choco\s+install)\b([^\n]*)/g;
12
+ export const gha055 = {
13
+ id: "GHA055",
14
+ description: "Runtime install of a tool already present on the runner",
15
+ check(ctx) {
16
+ const diagnostics = [];
17
+ const preinstalled = new Set(PREINSTALLED_TOOLS);
18
+ for (const [, output] of ctx.outputs) {
19
+ const yaml = getPrimaryOutput(output);
20
+ for (const { job, run } of extractRunBlocks(yaml)) {
21
+ const redundant = new Set();
22
+ let m;
23
+ INSTALL_RE.lastIndex = 0;
24
+ while ((m = INSTALL_RE.exec(run)) !== null) {
25
+ for (const tok of m[1].split(/\s+/)) {
26
+ const name = tok.replace(/[^\w.+-]/g, "");
27
+ if (name && preinstalled.has(name))
28
+ redundant.add(name);
29
+ }
30
+ }
31
+ if (redundant.size > 0) {
32
+ diagnostics.push({
33
+ checkId: "GHA055",
34
+ severity: "info",
35
+ message: `Job "${job}" installs ${[...redundant].join(", ")} at runtime, but GitHub-hosted runners already ship ${redundant.size > 1 ? "these tools" : "this tool"}. Drop the redundant install (or rely on the preinstalled version) to reduce supply-chain surface.`,
36
+ entity: job,
37
+ lexicon: "github",
38
+ });
39
+ }
40
+ }
41
+ }
42
+ return diagnostics;
43
+ },
44
+ };