@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,69 @@
1
+ /**
2
+ * The github lexicon's chant audit catalog — metadata for its post-synth rules
3
+ * (GHA GitHub Actions rules). Contributed via githubPlugin.auditCatalog() (#687).
4
+ */
5
+ import { auditRule, GH_INJECTION, GH_PWN, GH_SECRETS, GH_THIRD_PARTY, GH_TOKEN, SCORECARD_PINNED, SCORECARD_TOKEN, SCORECARD_VULN, applyLineage } from "@intentius/chant/audit/catalog";
6
+ import { githubAuditLineage } from "./audit-lineage.js";
7
+ export const githubAuditCatalog = {
8
+ GHA006: auditRule("GHA006", "report-only", "guidance", "Duplicate workflow name", "Give each workflow a unique `name:`.", { category: "correctness" }),
9
+ GHA009: auditRule("GHA009", "merge-worthy", "guidance", "Empty matrix dimension", "Remove the empty matrix axis or give it values; an empty axis produces zero jobs.", { category: "correctness" }),
10
+ GHA011: auditRule("GHA011", "merge-worthy", "guidance", "needs references a non-existent job", "Fix the `needs:` target to name a real job.", { category: "correctness" }),
11
+ GHA013: auditRule("GHA013", "merge-worthy", "guidance", "Missing job permissions on a sensitive trigger", "Add an explicit least-privilege `permissions:` block to jobs under `pull_request_target`/`workflow_dispatch`.", { authority: [SCORECARD_TOKEN, GH_TOKEN] }),
12
+ GHA017: auditRule("GHA017", "merge-worthy", "deterministic", "No explicit permissions block", "Add a top-level `permissions: { contents: read }` and widen only where a job needs it.", { authority: [SCORECARD_TOKEN, GH_TOKEN] }),
13
+ GHA018: auditRule("GHA018", "merge-worthy", "guidance", "pull_request_target checks out untrusted code", "Don't check out / run PR head code under `pull_request_target`; split into a privileged + unprivileged workflow.", { authority: [GH_PWN] }),
14
+ GHA019: auditRule("GHA019", "merge-worthy", "guidance", "Circular needs chain", "Break the cycle in the job dependency graph.", { category: "correctness" }),
15
+ GHA021: auditRule("GHA021", "merge-worthy", "deterministic", "actions/checkout not pinned to a SHA", "Pin `actions/checkout` to a full 40-char commit SHA.", { authority: [SCORECARD_PINNED, GH_THIRD_PARTY] }),
16
+ GHA022: auditRule("GHA022", "report-only", "guidance", "Job without timeout-minutes", "Add `timeout-minutes:` to bound runaway jobs.", { category: "best-practice" }),
17
+ GHA023: auditRule("GHA023", "report-only", "guidance", "Deprecated ::set-output", "Replace `::set-output` with `$GITHUB_OUTPUT`.", { category: "best-practice" }),
18
+ GHA024: auditRule("GHA024", "report-only", "guidance", "Missing concurrency block", "Add a `concurrency:` group to deploy workflows.", { category: "best-practice" }),
19
+ GHA025: auditRule("GHA025", "merge-worthy", "guidance", "Unrestricted pull_request_target", "Gate `pull_request_target` jobs and avoid running untrusted code with elevated scope.", { authority: [GH_PWN] }),
20
+ GHA026: auditRule("GHA026", "report-only", "guidance", "Secret used without environment protection", "Move secret-consuming jobs behind a protected `environment:`.", { category: "best-practice" }),
21
+ GHA027: auditRule("GHA027", "report-only", "guidance", "Cleanup step without if: always()", "Add `if: always()` to cleanup steps.", { category: "best-practice" }),
22
+ GHA028: auditRule("GHA028", "merge-worthy", "guidance", "Workflow with no triggers", "Add an `on:` trigger; the workflow never runs without one.", { category: "correctness" }),
23
+ GHA029: auditRule("GHA029", "merge-worthy", "deterministic", "Action not pinned to a commit SHA", "Pin the action to a full commit SHA instead of a tag/branch.", { authority: [SCORECARD_PINNED, GH_THIRD_PARTY] }),
24
+ GHA030: auditRule("GHA030", "merge-worthy", "deterministic", "Container image not pinned to a digest", "Pin the image to an immutable `@sha256:` digest.", { authority: [SCORECARD_PINNED] }),
25
+ GHA031: auditRule("GHA031", "merge-worthy", "guidance", "Possible action impersonation", "Verify the action owner/slug; it resembles a well-known action.", { authority: [SCORECARD_PINNED] }),
26
+ GHA032: auditRule("GHA032", "merge-worthy", "guidance", "Archived/abandoned or vulnerable action", "Replace the archived action or one with a disclosed security issue.", { category: "security" }),
27
+ GHA033: auditRule("GHA033", "merge-worthy", "deterministic", "Blanket write-all permissions", "Replace `write-all` with the specific scopes the jobs need (default `contents: read`).", { authority: [SCORECARD_TOKEN, GH_TOKEN] }),
28
+ GHA034: auditRule("GHA034", "merge-worthy", "guidance", "Write permissions granted workflow-wide", "Move write scopes to the single job that needs them; keep the workflow least-privilege.", { authority: [SCORECARD_TOKEN, GH_TOKEN] }),
29
+ GHA035: auditRule("GHA035", "merge-worthy", "guidance", "Elevated token on an untrusted-code trigger", "Drop the elevated `permissions:` on triggers that can run untrusted code.", { authority: [GH_PWN, SCORECARD_TOKEN] }),
30
+ GHA036: auditRule("GHA036", "merge-worthy", "guidance", "Untrusted input interpolated into run:", "Pass untrusted `${{ }}` values via an `env:` var and reference `\"$VAR\"`, never inline in the script.", { authority: [GH_INJECTION] }),
31
+ GHA037: auditRule("GHA037", "merge-worthy", "guidance", "Untrusted input written to GITHUB_ENV/GITHUB_PATH", "Don't write untrusted input to `$GITHUB_ENV`/`$GITHUB_PATH`; sanitize or avoid.", { authority: [GH_INJECTION] }),
32
+ GHA038: auditRule("GHA038", "merge-worthy", "guidance", "workflow_run checks out untrusted code in a privileged context", "Avoid checking out untrusted code under `workflow_run`; treat it as privileged.", { authority: [GH_PWN] }),
33
+ GHA039: auditRule("GHA039", "merge-worthy", "guidance", "Auth gate on a spoofable author field", "Gate on a non-spoofable identity, not a commit-author field.", { authority: [GH_PWN] }),
34
+ GHA040: auditRule("GHA040", "merge-worthy", "guidance", "Self-hosted runner on an untrusted-code trigger", "Don't run untrusted-code triggers on self-hosted runners.", { authority: [GH_PWN] }),
35
+ GHA041: auditRule("GHA041", "merge-worthy", "guidance", "Blanket secrets: inherit", "Pass only the specific secrets the reusable workflow needs.", { authority: [GH_SECRETS] }),
36
+ GHA042: auditRule("GHA042", "merge-worthy", "guidance", "Entire secrets context passed", "Pass named secrets instead of the whole `secrets` context.", { authority: [GH_SECRETS] }),
37
+ GHA043: auditRule("GHA043", "merge-worthy", "guidance", "Secret consumed without an environment gate", "Put secret-consuming jobs behind a protected environment.", { authority: [GH_SECRETS] }),
38
+ GHA044: auditRule("GHA044", "merge-worthy", "guidance", "Hardcoded registry/container credential", "Remove the hardcoded credential, move it to a secret, and rotate it (responsible disclosure first).", { authority: [GH_SECRETS] }),
39
+ GHA045: auditRule("GHA045", "merge-worthy", "guidance", "Secret interpolated into run:", "Reference secrets via `env:`, not inline in the shell command.", { authority: [GH_INJECTION, GH_SECRETS] }),
40
+ GHA046: auditRule("GHA046", "merge-worthy", "guidance", "Constant/unsound guard condition", "Fix the always-true/false `if:` — it may neutralize a security gate.", { category: "correctness" }),
41
+ GHA047: auditRule("GHA047", "merge-worthy", "guidance", "Ineffective contains() guard (reversed args)", "Swap the `contains()` arguments so the guard actually filters.", { category: "correctness" }),
42
+ GHA048: auditRule("GHA048", "merge-worthy", "guidance", "Obfuscated guard condition", "Simplify the indirect `if:` so its effect is reviewable.", { category: "correctness" }),
43
+ GHA049: auditRule("GHA049", "merge-worthy", "guidance", "Persisted checkout credentials reachable by an artifact", "Use `persist-credentials: false` or exclude `.git` from uploaded artifacts.", { authority: [GH_SECRETS] }),
44
+ GHA050: auditRule("GHA050", "merge-worthy", "guidance", "Cache populated in a privileged context", "Don't populate caches from untrusted code paths (poisoning risk).", { authority: [GH_PWN] }),
45
+ GHA051: auditRule("GHA051", "report-only", "guidance", "Long-lived token instead of OIDC", "Migrate publish/release to OIDC short-lived credentials.", { category: "best-practice" }),
46
+ GHA052: auditRule("GHA052", "merge-worthy", "guidance", "Software piped to a shell without verification", "Verify a checksum/signature before executing fetched scripts.", { authority: [SCORECARD_PINNED] }),
47
+ GHA053: auditRule("GHA053", "merge-worthy", "guidance", "Re-enables unsafe set-env/add-path", "Remove `ACTIONS_ALLOW_UNSECURE_COMMANDS`; use `$GITHUB_ENV`/`$GITHUB_PATH`.", { authority: [GH_INJECTION] }),
48
+ GHA054: auditRule("GHA054", "merge-worthy", "guidance", "Feature with a known security footgun", "Replace the flagged feature with the safe alternative.", { category: "security" }),
49
+ GHA055: auditRule("GHA055", "report-only", "guidance", "Runtime install of a tool already on the runner", "Drop the redundant install to save time.", { category: "best-practice" }),
50
+ GHA056: auditRule("GHA056", "report-only", "guidance", "Workflow without a name", "Add a `name:` to the workflow.", { category: "best-practice" }),
51
+ GHA057: auditRule("GHA057", "merge-worthy", "guidance", "Dependency update can execute untrusted code", "Disable the option that lets dependency updates run external code.", { authority: [GH_PWN] }),
52
+ GHA058: auditRule("GHA058", "report-only", "guidance", "Dependency update has no cooldown window", "Add a cooldown so new releases aren't merged instantly.", { category: "best-practice" }),
53
+ // Supply-chain and token-scoping expansion (#445).
54
+ GHA059: auditRule("GHA059", "merge-worthy", "guidance", "Stale or missing pin annotation", "Add or correct the trailing version comment on the SHA-pinned reference so it matches the digest.", { authority: [SCORECARD_PINNED, GH_THIRD_PARTY] }),
55
+ GHA060: auditRule("GHA060", "merge-worthy", "guidance", "Over-scoped generated token", "Narrow the GitHub App token's permission-* inputs to the scopes its consuming steps actually use, or remove the unused ones.", { authority: [SCORECARD_TOKEN, GH_TOKEN] }),
56
+ GHA061: auditRule("GHA061", "merge-worthy", "guidance", "Action reference outside the configured usage policy", "Replace the reference with one from the approved allowlist, or get it added if it should be trusted.", { category: "security" }),
57
+ GHA062: auditRule("GHA062", "merge-worthy", "guidance", "Pinned reference matches a known-vulnerability advisory", "Bump the reference to a patched ref/commit named by the advisory.", { authority: [SCORECARD_VULN] }),
58
+ // Efficiency (#444) — waste, not a safety/correctness issue. Always
59
+ // report-only: none of these warrant a merge on their own.
60
+ GHA063: auditRule("GHA063", "report-only", "guidance", "Dependency setup action without caching enabled", "Enable the setup action's `cache:` option or add an actions/cache step.", { category: "efficiency" }),
61
+ GHA064: auditRule("GHA064", "report-only", "guidance", "Expensive runner without justification", "Confirm the job needs that OS, or move it to a Linux runner.", { category: "efficiency" }),
62
+ GHA065: auditRule("GHA065", "report-only", "guidance", "Unbounded matrix fan-out", "Trim the matrix with include/exclude, or cap it with max-parallel.", { category: "efficiency" }),
63
+ GHA066: auditRule("GHA066", "report-only", "guidance", "Unbounded artifact retention", "Set a retention-days sized to how long the artifact is actually needed.", { category: "efficiency" }),
64
+ GHA067: auditRule("GHA067", "report-only", "guidance", "Unconditional heavy step with no path filter", "Scope the trigger's paths, or add an if: guard to the step.", { category: "efficiency" }),
65
+ GHA068: auditRule("GHA068", "report-only", "guidance", "Pull-request workflow missing a concurrency group", "Add a concurrency group with cancel-in-progress: true.", { category: "efficiency" }),
66
+ GHA069: auditRule("GHA069", "merge-worthy", "guidance", "Job permissions block drops the workflow's id-token: write", "Add `id-token: write` to the job's `permissions:` block; keep the job block rather than removing it.", { category: "correctness" }),
67
+ };
68
+ // Prior art credits live beside the rules in ./audit-lineage.ts (see core audit/prior-art.ts).
69
+ applyLineage(githubAuditCatalog, githubAuditLineage);
@@ -0,0 +1,168 @@
1
+ export const githubAuditLineage = {
2
+ GHA009: [
3
+ { tool: "actionlint", rule: "Unexpected empty mappings", url: "https://github.com/rhysd/actionlint/blob/main/docs/checks.md#unexpected-empty-mappings", relation: "overlaps" },
4
+ ],
5
+ GHA011: [
6
+ { tool: "actionlint", rule: "Job dependencies validation", url: "https://github.com/rhysd/actionlint/blob/main/docs/checks.md#job-dependencies-validation", relation: "equivalent" },
7
+ ],
8
+ GHA013: [
9
+ { tool: "poutine", rule: "default_permissions_on_risky_events", url: "https://boostsecurityio.github.io/poutine/rules/default_permissions_on_risky_events/", relation: "overlaps" },
10
+ { tool: "zizmor", rule: "excessive-permissions", url: "https://docs.zizmor.sh/audits/#excessive-permissions", relation: "overlaps" },
11
+ { tool: "scorecard", rule: "Token-Permissions", url: "https://github.com/ossf/scorecard/blob/main/docs/checks.md#token-permissions", relation: "overlaps" },
12
+ ],
13
+ GHA017: [
14
+ { tool: "zizmor", rule: "excessive-permissions", url: "https://docs.zizmor.sh/audits/#excessive-permissions", relation: "overlaps" },
15
+ { tool: "scorecard", rule: "Token-Permissions", url: "https://github.com/ossf/scorecard/blob/main/docs/checks.md#token-permissions", relation: "overlaps" },
16
+ ],
17
+ GHA018: [
18
+ { tool: "scorecard", rule: "Dangerous-Workflow", url: "https://github.com/ossf/scorecard/blob/main/docs/checks.md#dangerous-workflow", relation: "overlaps" },
19
+ { tool: "octoscan", rule: "dangerous-checkout", url: "https://github.com/synacktiv/octoscan#dangerous-checkout", relation: "overlaps" },
20
+ { tool: "poutine", rule: "untrusted_checkout_exec", url: "https://boostsecurityio.github.io/poutine/rules/untrusted_checkout_exec/", relation: "overlaps" },
21
+ ],
22
+ GHA019: [
23
+ { tool: "actionlint", rule: "Job dependencies validation", url: "https://github.com/rhysd/actionlint/blob/main/docs/checks.md#job-dependencies-validation", relation: "equivalent" },
24
+ ],
25
+ GHA021: [
26
+ { tool: "zizmor", rule: "unpinned-uses", url: "https://docs.zizmor.sh/audits/#unpinned-uses", relation: "overlaps" },
27
+ { tool: "scorecard", rule: "Pinned-Dependencies", url: "https://github.com/ossf/scorecard/blob/main/docs/checks.md#pinned-dependencies", relation: "overlaps" },
28
+ ],
29
+ GHA023: [
30
+ { tool: "actionlint", rule: "Check deprecated workflow commands", url: "https://github.com/rhysd/actionlint/blob/main/docs/checks.md#check-deprecated-workflow-commands", relation: "overlaps" },
31
+ ],
32
+ GHA024: [
33
+ { tool: "zizmor", rule: "concurrency-limits", url: "https://docs.zizmor.sh/audits/#concurrency-limits", relation: "overlaps" },
34
+ ],
35
+ GHA025: [
36
+ { tool: "zizmor", rule: "dangerous-triggers", url: "https://docs.zizmor.sh/audits/#dangerous-triggers", relation: "overlaps" },
37
+ ],
38
+ GHA026: [
39
+ { tool: "zizmor", rule: "secrets-outside-env", url: "https://docs.zizmor.sh/audits/#secrets-outside-env", relation: "equivalent" },
40
+ ],
41
+ GHA028: [
42
+ { tool: "actionlint", rule: "Missing required keys and key duplicates", url: "https://github.com/rhysd/actionlint/blob/main/docs/checks.md#missing-required-keys-and-key-duplicates", relation: "overlaps" },
43
+ ],
44
+ GHA029: [
45
+ { tool: "zizmor", rule: "unpinned-uses", url: "https://docs.zizmor.sh/audits/#unpinned-uses", relation: "overlaps" },
46
+ { tool: "scorecard", rule: "Pinned-Dependencies", url: "https://github.com/ossf/scorecard/blob/main/docs/checks.md#pinned-dependencies", relation: "overlaps" },
47
+ ],
48
+ GHA030: [
49
+ { tool: "zizmor", rule: "unpinned-images", url: "https://docs.zizmor.sh/audits/#unpinned-images", relation: "overlaps" },
50
+ ],
51
+ GHA031: [
52
+ { tool: "zizmor", rule: "typosquat-uses", url: "https://docs.zizmor.sh/audits/#typosquat-uses", relation: "equivalent" },
53
+ ],
54
+ GHA032: [
55
+ { tool: "zizmor", rule: "archived-uses", url: "https://docs.zizmor.sh/audits/#archived-uses", relation: "overlaps" },
56
+ { tool: "zizmor", rule: "known-vulnerable-actions", url: "https://docs.zizmor.sh/audits/#known-vulnerable-actions", relation: "overlaps" },
57
+ ],
58
+ GHA033: [
59
+ { tool: "zizmor", rule: "excessive-permissions", url: "https://docs.zizmor.sh/audits/#excessive-permissions", relation: "overlaps" },
60
+ { tool: "scorecard", rule: "Token-Permissions", url: "https://github.com/ossf/scorecard/blob/main/docs/checks.md#token-permissions", relation: "overlaps" },
61
+ ],
62
+ GHA034: [
63
+ { tool: "zizmor", rule: "excessive-permissions", url: "https://docs.zizmor.sh/audits/#excessive-permissions", relation: "overlaps" },
64
+ { tool: "scorecard", rule: "Token-Permissions", url: "https://github.com/ossf/scorecard/blob/main/docs/checks.md#token-permissions", relation: "overlaps" },
65
+ ],
66
+ GHA036: [
67
+ { tool: "zizmor", rule: "template-injection", url: "https://docs.zizmor.sh/audits/#template-injection", relation: "equivalent" },
68
+ { tool: "poutine", rule: "injection", url: "https://boostsecurityio.github.io/poutine/rules/injection/", relation: "equivalent" },
69
+ { tool: "actionlint", rule: "Script injection by potentially untrusted inputs", url: "https://github.com/rhysd/actionlint/blob/main/docs/checks.md#script-injection-by-potentially-untrusted-inputs", relation: "equivalent" },
70
+ { tool: "octoscan", rule: "expression-injection", url: "https://github.com/synacktiv/octoscan#expression-injection", relation: "equivalent" },
71
+ { tool: "scorecard", rule: "Dangerous-Workflow", url: "https://github.com/ossf/scorecard/blob/main/docs/checks.md#dangerous-workflow", relation: "overlaps" },
72
+ ],
73
+ GHA037: [
74
+ { tool: "zizmor", rule: "github-env", url: "https://docs.zizmor.sh/audits/#github-env", relation: "equivalent" },
75
+ { tool: "octoscan", rule: "dangerous-write", url: "https://github.com/synacktiv/octoscan#dangerous-write", relation: "overlaps" },
76
+ ],
77
+ GHA038: [
78
+ { tool: "scorecard", rule: "Dangerous-Workflow", url: "https://github.com/ossf/scorecard/blob/main/docs/checks.md#dangerous-workflow", relation: "overlaps" },
79
+ { tool: "octoscan", rule: "dangerous-checkout", url: "https://github.com/synacktiv/octoscan#dangerous-checkout", relation: "overlaps" },
80
+ ],
81
+ GHA039: [
82
+ { tool: "zizmor", rule: "bot-conditions", url: "https://docs.zizmor.sh/audits/#bot-conditions", relation: "overlaps" },
83
+ { tool: "octoscan", rule: "bot-check", url: "https://github.com/synacktiv/octoscan#bot-check", relation: "overlaps" },
84
+ { tool: "poutine", rule: "confused_deputy_auto_merge", url: "https://boostsecurityio.github.io/poutine/rules/confused_deputy_auto_merge/", relation: "overlaps" },
85
+ ],
86
+ GHA040: [
87
+ { tool: "poutine", rule: "pr_runs_on_self_hosted", url: "https://boostsecurityio.github.io/poutine/rules/pr_runs_on_self_hosted/", relation: "equivalent" },
88
+ { tool: "zizmor", rule: "self-hosted-runner", url: "https://docs.zizmor.sh/audits/#self-hosted-runner", relation: "overlaps" },
89
+ { tool: "octoscan", rule: "runner-label", url: "https://github.com/synacktiv/octoscan#runner-label", relation: "overlaps" },
90
+ ],
91
+ GHA041: [
92
+ { tool: "zizmor", rule: "secrets-inherit", url: "https://docs.zizmor.sh/audits/#secrets-inherit", relation: "equivalent" },
93
+ ],
94
+ GHA042: [
95
+ { tool: "zizmor", rule: "overprovisioned-secrets", url: "https://docs.zizmor.sh/audits/#overprovisioned-secrets", relation: "equivalent" },
96
+ { tool: "poutine", rule: "job_all_secrets", url: "https://boostsecurityio.github.io/poutine/rules/job_all_secrets/", relation: "equivalent" },
97
+ ],
98
+ GHA043: [
99
+ { tool: "zizmor", rule: "secrets-outside-env", url: "https://docs.zizmor.sh/audits/#secrets-outside-env", relation: "equivalent" },
100
+ ],
101
+ GHA044: [
102
+ { tool: "zizmor", rule: "hardcoded-container-credentials", url: "https://docs.zizmor.sh/audits/#hardcoded-container-credentials", relation: "equivalent" },
103
+ { tool: "actionlint", rule: "Hardcoded credentials", url: "https://github.com/rhysd/actionlint/blob/main/docs/checks.md#hardcoded-credentials", relation: "equivalent" },
104
+ { tool: "octoscan", rule: "credentials", url: "https://github.com/synacktiv/octoscan#credentials", relation: "equivalent" },
105
+ ],
106
+ GHA046: [
107
+ { tool: "actionlint", rule: "Constant conditions at `if:`", url: "https://github.com/rhysd/actionlint/blob/main/docs/checks.md#constant-conditions-at-if", relation: "equivalent" },
108
+ { tool: "zizmor", rule: "unsound-condition", url: "https://docs.zizmor.sh/audits/#unsound-condition", relation: "overlaps" },
109
+ { tool: "poutine", rule: "if_always_true", url: "https://boostsecurityio.github.io/poutine/rules/if_always_true/", relation: "overlaps" },
110
+ ],
111
+ GHA047: [
112
+ { tool: "zizmor", rule: "unsound-contains", url: "https://docs.zizmor.sh/audits/#unsound-contains", relation: "equivalent" },
113
+ ],
114
+ GHA048: [
115
+ { tool: "zizmor", rule: "obfuscation", url: "https://docs.zizmor.sh/audits/#obfuscation", relation: "overlaps" },
116
+ ],
117
+ GHA049: [
118
+ { tool: "zizmor", rule: "artipacked", url: "https://docs.zizmor.sh/audits/#artipacked", relation: "equivalent" },
119
+ { tool: "octoscan", rule: "dangerous-artefact", url: "https://github.com/synacktiv/octoscan#dangerous-artefact", relation: "overlaps" },
120
+ ],
121
+ GHA050: [
122
+ { tool: "zizmor", rule: "cache-poisoning", url: "https://docs.zizmor.sh/audits/#cache-poisoning", relation: "overlaps" },
123
+ ],
124
+ GHA051: [
125
+ { tool: "zizmor", rule: "use-trusted-publishing", url: "https://docs.zizmor.sh/audits/#use-trusted-publishing", relation: "overlaps" },
126
+ ],
127
+ GHA052: [
128
+ { tool: "poutine", rule: "unverified_script_exec", url: "https://boostsecurityio.github.io/poutine/rules/unverified_script_exec/", relation: "equivalent" },
129
+ { tool: "scorecard", rule: "Pinned-Dependencies", url: "https://github.com/ossf/scorecard/blob/main/docs/checks.md#pinned-dependencies", relation: "overlaps" },
130
+ ],
131
+ GHA053: [
132
+ { tool: "zizmor", rule: "insecure-commands", url: "https://docs.zizmor.sh/audits/#insecure-commands", relation: "equivalent" },
133
+ { tool: "octoscan", rule: "unsecure-commands", url: "https://github.com/synacktiv/octoscan#unsecure-commands", relation: "equivalent" },
134
+ ],
135
+ GHA054: [
136
+ { tool: "zizmor", rule: "misfeature", url: "https://docs.zizmor.sh/audits/#misfeature", relation: "overlaps" },
137
+ ],
138
+ GHA055: [
139
+ { tool: "zizmor", rule: "superfluous-actions", url: "https://docs.zizmor.sh/audits/#superfluous-actions", relation: "overlaps" },
140
+ ],
141
+ GHA056: [
142
+ { tool: "zizmor", rule: "anonymous-definition", url: "https://docs.zizmor.sh/audits/#anonymous-definition", relation: "overlaps" },
143
+ ],
144
+ GHA057: [
145
+ { tool: "zizmor", rule: "dependabot-execution", url: "https://docs.zizmor.sh/audits/#dependabot-execution", relation: "equivalent" },
146
+ ],
147
+ GHA058: [
148
+ { tool: "zizmor", rule: "dependabot-cooldown", url: "https://docs.zizmor.sh/audits/#dependabot-cooldown", relation: "equivalent" },
149
+ ],
150
+ GHA059: [
151
+ { tool: "zizmor", rule: "ref-version-mismatch", url: "https://docs.zizmor.sh/audits/#ref-version-mismatch", relation: "equivalent" },
152
+ ],
153
+ GHA060: [
154
+ { tool: "zizmor", rule: "github-app", url: "https://docs.zizmor.sh/audits/#github-app", relation: "overlaps" },
155
+ ],
156
+ GHA061: [
157
+ { tool: "zizmor", rule: "forbidden-uses", url: "https://docs.zizmor.sh/audits/#forbidden-uses", relation: "equivalent" },
158
+ ],
159
+ GHA062: [
160
+ { tool: "zizmor", rule: "known-vulnerable-actions", url: "https://docs.zizmor.sh/audits/#known-vulnerable-actions", relation: "equivalent" },
161
+ { tool: "poutine", rule: "known_vulnerability_in_build_component", url: "https://boostsecurityio.github.io/poutine/rules/known_vulnerability_in_build_component/", relation: "equivalent" },
162
+ { tool: "octoscan", rule: "known-vulnerability", url: "https://github.com/synacktiv/octoscan#known-vulnerability", relation: "equivalent" },
163
+ { tool: "scorecard", rule: "Vulnerabilities", url: "https://github.com/ossf/scorecard/blob/main/docs/checks.md#vulnerabilities", relation: "overlaps" },
164
+ ],
165
+ GHA068: [
166
+ { tool: "zizmor", rule: "concurrency-limits", url: "https://docs.zizmor.sh/audits/#concurrency-limits", relation: "overlaps" },
167
+ ],
168
+ };
@@ -0,0 +1,50 @@
1
+ /**
2
+ * GHA006: Duplicate Workflow Name
3
+ *
4
+ * Detects multiple workflows sharing the same `name:` value.
5
+ */
6
+ import { extractWorkflowName } from "./yaml-helpers.js";
7
+ export const gha006 = {
8
+ id: "GHA006",
9
+ description: "Multiple workflows share the same name",
10
+ check(ctx) {
11
+ const diagnostics = [];
12
+ const nameMap = new Map();
13
+ for (const [outputName, output] of ctx.outputs) {
14
+ const yaml = typeof output === "string" ? output : output.primary;
15
+ // Check if this is a multi-file output
16
+ if (typeof output === "object" && "files" in output) {
17
+ const result = output;
18
+ if (result.files) {
19
+ for (const [fileName, fileContent] of Object.entries(result.files)) {
20
+ const name = extractWorkflowName(fileContent);
21
+ if (name) {
22
+ const existing = nameMap.get(name) ?? [];
23
+ existing.push(fileName);
24
+ nameMap.set(name, existing);
25
+ }
26
+ }
27
+ }
28
+ }
29
+ else {
30
+ const name = extractWorkflowName(yaml);
31
+ if (name) {
32
+ const existing = nameMap.get(name) ?? [];
33
+ existing.push(outputName);
34
+ nameMap.set(name, existing);
35
+ }
36
+ }
37
+ }
38
+ for (const [name, files] of nameMap) {
39
+ if (files.length > 1) {
40
+ diagnostics.push({
41
+ checkId: "GHA006",
42
+ severity: "error",
43
+ message: `Duplicate workflow name "${name}" found in: ${files.join(", ")}`,
44
+ lexicon: "github",
45
+ });
46
+ }
47
+ }
48
+ return diagnostics;
49
+ },
50
+ };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * GHA009: Empty Matrix Dimension
3
+ *
4
+ * Detects matrix dimensions with empty values arrays.
5
+ */
6
+ import { getPrimaryOutput } from "./yaml-helpers.js";
7
+ export const gha009 = {
8
+ id: "GHA009",
9
+ description: "Matrix dimension has empty values array",
10
+ check(ctx) {
11
+ const diagnostics = [];
12
+ for (const [, output] of ctx.outputs) {
13
+ const yaml = getPrimaryOutput(output);
14
+ // Find matrix sections and check for empty arrays
15
+ const matrixMatch = yaml.match(/matrix:\n([\s\S]*?)(?=\n\s{4}[a-z]|\n\s{2}[a-z]|\n[a-z]|$)/gm);
16
+ if (!matrixMatch)
17
+ continue;
18
+ for (const section of matrixMatch) {
19
+ const lines = section.split("\n");
20
+ for (let i = 0; i < lines.length; i++) {
21
+ const keyMatch = lines[i].match(/^\s+([a-z][a-z0-9_-]*):\s*\[\s*\]\s*$/);
22
+ if (keyMatch) {
23
+ diagnostics.push({
24
+ checkId: "GHA009",
25
+ severity: "error",
26
+ message: `Matrix dimension "${keyMatch[1]}" has an empty values array.`,
27
+ lexicon: "github",
28
+ });
29
+ }
30
+ }
31
+ }
32
+ }
33
+ return diagnostics;
34
+ },
35
+ };
@@ -0,0 +1,34 @@
1
+ /**
2
+ * GHA011: Invalid Needs Reference
3
+ *
4
+ * Detects `needs:` references to non-existent job IDs.
5
+ */
6
+ import { getPrimaryOutput, extractJobs } from "./yaml-helpers.js";
7
+ export const gha011 = {
8
+ id: "GHA011",
9
+ description: "Job needs: references non-existent job",
10
+ check(ctx) {
11
+ const diagnostics = [];
12
+ for (const [, output] of ctx.outputs) {
13
+ const yaml = getPrimaryOutput(output);
14
+ const jobs = extractJobs(yaml);
15
+ const jobNames = new Set(jobs.keys());
16
+ for (const [jobName, job] of jobs) {
17
+ if (!job.needs)
18
+ continue;
19
+ for (const need of job.needs) {
20
+ if (!jobNames.has(need)) {
21
+ diagnostics.push({
22
+ checkId: "GHA011",
23
+ severity: "error",
24
+ message: `Job "${jobName}" needs "${need}", but no such job exists.`,
25
+ entity: jobName,
26
+ lexicon: "github",
27
+ });
28
+ }
29
+ }
30
+ }
31
+ }
32
+ return diagnostics;
33
+ },
34
+ };
@@ -0,0 +1,40 @@
1
+ /**
2
+ * GHA013: 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
+ import { getPrimaryOutput, extractJobs, extractTriggers, extractJobPermissions } from "./yaml-helpers.js";
8
+ export const gha013 = {
9
+ id: "GHA013",
10
+ description: "Missing job-level permissions for sensitive triggers",
11
+ check(ctx) {
12
+ const diagnostics = [];
13
+ for (const [, output] of ctx.outputs) {
14
+ const yaml = getPrimaryOutput(output);
15
+ const triggers = extractTriggers(yaml);
16
+ if (!triggers["pull_request_target"] && !triggers["workflow_dispatch"])
17
+ continue;
18
+ // chant #1544 — `extractJobs`' `ParsedJob.permissions` field is never
19
+ // populated (its regex-based per-job scan only fills `needs`/`steps`),
20
+ // so this rule read `job.permissions` as always undefined and warned
21
+ // on every job regardless of whether it declared one. `extractJobPermissions`
22
+ // (below) is the structural, `parseYAML`-backed extractor that other
23
+ // permissions-aware checks already use — it actually sees the block.
24
+ const jobs = extractJobs(yaml);
25
+ const jobPermissions = extractJobPermissions(yaml);
26
+ for (const [jobName] of jobs) {
27
+ if (!jobPermissions.has(jobName)) {
28
+ diagnostics.push({
29
+ checkId: "GHA013",
30
+ severity: "warning",
31
+ message: `Job "${jobName}" lacks explicit permissions but workflow uses a sensitive trigger. Add job-level permissions for least-privilege security.`,
32
+ entity: jobName,
33
+ lexicon: "github",
34
+ });
35
+ }
36
+ }
37
+ }
38
+ return diagnostics;
39
+ },
40
+ };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * GHA017: Missing Permissions
3
+ *
4
+ * Flags workflows without an explicit `permissions:` block.
5
+ */
6
+ import { getPrimaryOutput, hasPermissions } from "./yaml-helpers.js";
7
+ export const gha017 = {
8
+ id: "GHA017",
9
+ description: "Workflow without explicit permissions block",
10
+ check(ctx) {
11
+ const diagnostics = [];
12
+ for (const [, output] of ctx.outputs) {
13
+ const yaml = getPrimaryOutput(output);
14
+ if (!hasPermissions(yaml)) {
15
+ diagnostics.push({
16
+ checkId: "GHA017",
17
+ severity: "info",
18
+ message: "Workflow does not specify permissions. Consider adding explicit permissions for least-privilege security.",
19
+ lexicon: "github",
20
+ });
21
+ }
22
+ }
23
+ return diagnostics;
24
+ },
25
+ };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * GHA018: Pull Request Target + Checkout Security Risk
3
+ *
4
+ * Flags workflows using `pull_request_target` trigger with a checkout action
5
+ * in steps — this is a known security anti-pattern.
6
+ */
7
+ import { getPrimaryOutput, extractJobs, extractTriggers, hasCheckoutAction } from "./yaml-helpers.js";
8
+ export const gha018 = {
9
+ id: "GHA018",
10
+ description: "pull_request_target with checkout action is a security risk",
11
+ check(ctx) {
12
+ const diagnostics = [];
13
+ for (const [, output] of ctx.outputs) {
14
+ const yaml = getPrimaryOutput(output);
15
+ const triggers = extractTriggers(yaml);
16
+ if (!triggers["pull_request_target"])
17
+ continue;
18
+ const jobs = extractJobs(yaml);
19
+ for (const [jobName, job] of jobs) {
20
+ if (job.steps && hasCheckoutAction(job.steps)) {
21
+ diagnostics.push({
22
+ checkId: "GHA018",
23
+ severity: "warning",
24
+ message: `Job "${jobName}" uses checkout with pull_request_target trigger. This runs untrusted PR code with write permissions — a security risk.`,
25
+ entity: jobName,
26
+ lexicon: "github",
27
+ });
28
+ }
29
+ }
30
+ }
31
+ return diagnostics;
32
+ },
33
+ };
@@ -0,0 +1,58 @@
1
+ /**
2
+ * GHA019: Circular Needs Chain
3
+ *
4
+ * DFS-based cycle detection on the job `needs:` dependency graph.
5
+ */
6
+ import { getPrimaryOutput, buildNeedsGraph } from "./yaml-helpers.js";
7
+ export function checkCircularNeeds(ctx) {
8
+ const diagnostics = [];
9
+ for (const [, output] of ctx.outputs) {
10
+ const yaml = getPrimaryOutput(output);
11
+ const graph = buildNeedsGraph(yaml);
12
+ const visited = new Set();
13
+ const inStack = new Set();
14
+ const reportedInCycle = new Set();
15
+ function dfs(node, path) {
16
+ if (inStack.has(node)) {
17
+ const cycleStart = path.indexOf(node);
18
+ const cycle = path.slice(cycleStart);
19
+ cycle.push(node);
20
+ const cycleKey = [...cycle].sort().join(",");
21
+ if (!reportedInCycle.has(cycleKey)) {
22
+ reportedInCycle.add(cycleKey);
23
+ diagnostics.push({
24
+ checkId: "GHA019",
25
+ severity: "error",
26
+ message: `Circular needs: chain detected: ${cycle.join(" → ")}`,
27
+ entity: node,
28
+ lexicon: "github",
29
+ });
30
+ }
31
+ return;
32
+ }
33
+ if (visited.has(node))
34
+ return;
35
+ visited.add(node);
36
+ inStack.add(node);
37
+ for (const neighbor of graph.get(node) ?? []) {
38
+ if (graph.has(neighbor)) {
39
+ dfs(neighbor, [...path, node]);
40
+ }
41
+ }
42
+ inStack.delete(node);
43
+ }
44
+ for (const jobName of graph.keys()) {
45
+ if (!visited.has(jobName)) {
46
+ dfs(jobName, []);
47
+ }
48
+ }
49
+ }
50
+ return diagnostics;
51
+ }
52
+ export const gha019 = {
53
+ id: "GHA019",
54
+ description: "Circular needs: chain — cycle in job dependency graph",
55
+ check(ctx) {
56
+ return checkCircularNeeds(ctx);
57
+ },
58
+ };
@@ -0,0 +1,42 @@
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
+ import { getPrimaryOutput, extractActionRefs, stripUsesComment } from "./yaml-helpers.js";
8
+ import { pinFixHint } from "../../action-pins.js";
9
+ export const gha021 = {
10
+ id: "GHA021",
11
+ description: "actions/checkout used without pinned SHA",
12
+ check(ctx) {
13
+ const diagnostics = [];
14
+ for (const [, output] of ctx.outputs) {
15
+ const yaml = getPrimaryOutput(output);
16
+ // The structural parser sees every step. The line-based extractJobs
17
+ // only reads a step's first line, so it missed `uses:` whenever a step
18
+ // starts with `name:`, which is how every composite emits it.
19
+ for (const { job: jobName, ref: uses, level } of extractActionRefs(yaml)) {
20
+ if (level !== "step")
21
+ continue;
22
+ // A pinned ref carries its version as a trailing comment
23
+ // (`actions/checkout@<sha> # v7.0.1`); only the ref is judged.
24
+ const match = stripUsesComment(uses).match(/^actions\/checkout@(.+)$/);
25
+ if (!match)
26
+ continue;
27
+ const ref = match[1];
28
+ // A pinned SHA is 40 hex characters
29
+ if (/^[0-9a-f]{40}$/.test(ref))
30
+ continue;
31
+ diagnostics.push({
32
+ checkId: "GHA021",
33
+ severity: "warning",
34
+ message: `Job "${jobName}" uses actions/checkout@${ref} — pin to a full commit SHA for supply-chain security.${pinFixHint("actions/checkout")}`,
35
+ entity: jobName,
36
+ lexicon: "github",
37
+ });
38
+ }
39
+ }
40
+ return diagnostics;
41
+ },
42
+ };
@@ -0,0 +1,44 @@
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
+ import { getPrimaryOutput, extractJobs } from "./yaml-helpers.js";
8
+ export const gha022 = {
9
+ id: "GHA022",
10
+ description: "Job without timeout-minutes",
11
+ check(ctx) {
12
+ const diagnostics = [];
13
+ for (const [, output] of ctx.outputs) {
14
+ const yaml = getPrimaryOutput(output);
15
+ const jobs = extractJobs(yaml);
16
+ // Find job sections in the raw YAML to check for timeout-minutes
17
+ const jobsIdx = yaml.search(/^jobs:\s*$/m);
18
+ if (jobsIdx === -1)
19
+ continue;
20
+ const jobsContent = yaml.slice(jobsIdx + yaml.slice(jobsIdx).indexOf("\n") + 1);
21
+ for (const [jobName] of jobs) {
22
+ const jobHeader = ` ${jobName}:\n`;
23
+ const start = jobsContent.indexOf(jobHeader);
24
+ if (start === -1)
25
+ continue;
26
+ // Slice from after the job header to find its content.
27
+ // Use literal \n \w (no m flag) to locate the next sibling job at 2-space indent.
28
+ const rest = jobsContent.slice(start + jobHeader.length);
29
+ const nextJobMatch = rest.search(/\n \w/);
30
+ const section = nextJobMatch === -1 ? rest : rest.slice(0, nextJobMatch);
31
+ if (!/timeout-minutes:/.test(section)) {
32
+ diagnostics.push({
33
+ checkId: "GHA022",
34
+ severity: "info",
35
+ message: `Job "${jobName}" does not specify timeout-minutes. Consider adding a timeout to prevent hung workflows.`,
36
+ entity: jobName,
37
+ lexicon: "github",
38
+ });
39
+ }
40
+ }
41
+ }
42
+ return diagnostics;
43
+ },
44
+ };