@intentius/chant-lexicon-github 0.1.23 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/dist/integrity.json +34 -4
  2. package/dist/manifest.json +1 -1
  3. package/dist/rules/{gha020.ts → gha013.ts} +4 -4
  4. package/dist/rules/gha029.ts +68 -0
  5. package/dist/rules/gha030.ts +41 -0
  6. package/dist/rules/gha031.ts +77 -0
  7. package/dist/rules/gha032.ts +39 -0
  8. package/dist/rules/gha033.ts +47 -0
  9. package/dist/rules/gha034.ts +41 -0
  10. package/dist/rules/gha035.ts +66 -0
  11. package/dist/rules/gha036.ts +42 -0
  12. package/dist/rules/gha037.ts +46 -0
  13. package/dist/rules/gha038.ts +41 -0
  14. package/dist/rules/gha039.ts +41 -0
  15. package/dist/rules/gha040.ts +43 -0
  16. package/dist/rules/gha041.ts +38 -0
  17. package/dist/rules/gha042.ts +40 -0
  18. package/dist/rules/gha043.ts +41 -0
  19. package/dist/rules/gha044.ts +41 -0
  20. package/dist/rules/gha045.ts +38 -0
  21. package/dist/rules/gha046.ts +56 -0
  22. package/dist/rules/gha047.ts +51 -0
  23. package/dist/rules/gha048.ts +41 -0
  24. package/dist/rules/gha049.ts +42 -0
  25. package/dist/rules/gha050.ts +44 -0
  26. package/dist/rules/gha051.ts +45 -0
  27. package/dist/rules/gha052.ts +42 -0
  28. package/dist/rules/gha053.ts +41 -0
  29. package/dist/rules/gha054.ts +41 -0
  30. package/dist/rules/gha055.ts +50 -0
  31. package/dist/rules/gha056.ts +37 -0
  32. package/dist/rules/gha057.ts +40 -0
  33. package/dist/rules/gha058.ts +48 -0
  34. package/dist/rules/yaml-helpers.ts +335 -0
  35. package/package.json +1 -1
  36. package/src/codegen/docs.ts +273 -4
  37. package/src/composites/dependabot.test.ts +48 -0
  38. package/src/composites/dependabot.ts +91 -0
  39. package/src/composites/index.ts +2 -0
  40. package/src/index.ts +2 -0
  41. package/src/lint/post-synth/{gha020.test.ts → gha013.test.ts} +7 -7
  42. package/src/lint/post-synth/{gha020.ts → gha013.ts} +4 -4
  43. package/src/lint/post-synth/gha029.test.ts +108 -0
  44. package/src/lint/post-synth/gha029.ts +68 -0
  45. package/src/lint/post-synth/gha030.test.ts +87 -0
  46. package/src/lint/post-synth/gha030.ts +41 -0
  47. package/src/lint/post-synth/gha031.test.ts +75 -0
  48. package/src/lint/post-synth/gha031.ts +77 -0
  49. package/src/lint/post-synth/gha032.test.ts +65 -0
  50. package/src/lint/post-synth/gha032.ts +39 -0
  51. package/src/lint/post-synth/gha033.test.ts +68 -0
  52. package/src/lint/post-synth/gha033.ts +47 -0
  53. package/src/lint/post-synth/gha034.test.ts +86 -0
  54. package/src/lint/post-synth/gha034.ts +41 -0
  55. package/src/lint/post-synth/gha035.test.ts +85 -0
  56. package/src/lint/post-synth/gha035.ts +66 -0
  57. package/src/lint/post-synth/gha036.test.ts +68 -0
  58. package/src/lint/post-synth/gha036.ts +42 -0
  59. package/src/lint/post-synth/gha037.test.ts +66 -0
  60. package/src/lint/post-synth/gha037.ts +46 -0
  61. package/src/lint/post-synth/gha038.test.ts +64 -0
  62. package/src/lint/post-synth/gha038.ts +41 -0
  63. package/src/lint/post-synth/gha039.test.ts +66 -0
  64. package/src/lint/post-synth/gha039.ts +41 -0
  65. package/src/lint/post-synth/gha040.test.ts +77 -0
  66. package/src/lint/post-synth/gha040.ts +43 -0
  67. package/src/lint/post-synth/gha041.test.ts +48 -0
  68. package/src/lint/post-synth/gha041.ts +38 -0
  69. package/src/lint/post-synth/gha042.test.ts +53 -0
  70. package/src/lint/post-synth/gha042.ts +40 -0
  71. package/src/lint/post-synth/gha043.test.ts +69 -0
  72. package/src/lint/post-synth/gha043.ts +41 -0
  73. package/src/lint/post-synth/gha044.test.ts +56 -0
  74. package/src/lint/post-synth/gha044.ts +41 -0
  75. package/src/lint/post-synth/gha045.test.ts +51 -0
  76. package/src/lint/post-synth/gha045.ts +38 -0
  77. package/src/lint/post-synth/gha046.test.ts +59 -0
  78. package/src/lint/post-synth/gha046.ts +56 -0
  79. package/src/lint/post-synth/gha047.test.ts +66 -0
  80. package/src/lint/post-synth/gha047.ts +51 -0
  81. package/src/lint/post-synth/gha048.test.ts +51 -0
  82. package/src/lint/post-synth/gha048.ts +41 -0
  83. package/src/lint/post-synth/gha049.test.ts +72 -0
  84. package/src/lint/post-synth/gha049.ts +42 -0
  85. package/src/lint/post-synth/gha050.test.ts +55 -0
  86. package/src/lint/post-synth/gha050.ts +44 -0
  87. package/src/lint/post-synth/gha051.test.ts +72 -0
  88. package/src/lint/post-synth/gha051.ts +45 -0
  89. package/src/lint/post-synth/gha052.test.ts +52 -0
  90. package/src/lint/post-synth/gha052.ts +42 -0
  91. package/src/lint/post-synth/gha053.test.ts +65 -0
  92. package/src/lint/post-synth/gha053.ts +41 -0
  93. package/src/lint/post-synth/gha054.test.ts +49 -0
  94. package/src/lint/post-synth/gha054.ts +41 -0
  95. package/src/lint/post-synth/gha055.test.ts +49 -0
  96. package/src/lint/post-synth/gha055.ts +50 -0
  97. package/src/lint/post-synth/gha056.test.ts +47 -0
  98. package/src/lint/post-synth/gha056.ts +37 -0
  99. package/src/lint/post-synth/gha057.test.ts +61 -0
  100. package/src/lint/post-synth/gha057.ts +40 -0
  101. package/src/lint/post-synth/gha058.test.ts +67 -0
  102. package/src/lint/post-synth/gha058.ts +48 -0
  103. package/src/lint/post-synth/yaml-helpers.ts +335 -0
  104. package/src/lint/rules/data/flagged-actions.ts +36 -0
  105. package/src/lint/rules/data/known-action-slugs.ts +38 -0
  106. package/src/lint/rules/data/preinstalled-tools.ts +36 -0
  107. package/src/lint/rules/data/risky-features.ts +39 -0
  108. package/src/lint/rules/data/trusted-action-owners.ts +12 -0
  109. package/src/lint/rules/data/untrusted-contexts.ts +44 -0
  110. package/src/mcp/context-tools.test.ts +108 -0
  111. package/src/mcp/context-tools.ts +172 -0
  112. package/src/plugin.test.ts +58 -3
  113. package/src/plugin.ts +5 -1
  114. package/src/serializer.ts +38 -5
@@ -25,7 +25,7 @@ npm install --save-dev @intentius/chant-lexicon-github
25
25
 
26
26
  {{file:docs-snippets/src/quickstart.ts}}
27
27
 
28
- The lexicon provides **2 resources** (Workflow, Job), **13 composites** (Checkout, SetupNode, SetupGo, SetupPython, CacheAction, UploadArtifact, DownloadArtifact, NodeCI, NodePipeline, PythonCI, DockerBuild, DeployEnvironment, GoCI) + **3 presets** (BunPipeline, PnpmPipeline, YarnPipeline), a typed **Expression** system with 24 GitHub and 5 Runner context variables, and **13 lint rules** + **6 post-synth checks**.
28
+ The lexicon provides **3 resources** (Workflow, Job, Dependabot config), **14 composites** (Checkout, SetupNode, SetupGo, SetupPython, CacheAction, UploadArtifact, DownloadArtifact, NodeCI, NodePipeline, PythonCI, DockerBuild, DeployEnvironment, GoCI, Dependabot) + **3 presets** (BunPipeline, PnpmPipeline, YarnPipeline), a typed **Expression** system with 24 GitHub and 5 Runner context variables, and **13 lint rules** + **45 post-synth checks** (including a CI/CD supply-chain security pass, GHA029–058).
29
29
  `;
30
30
 
31
31
  const outputFormat = `The GitHub Actions lexicon serializes resources into **\`.github/workflows/*.yml\`** YAML files.
@@ -184,7 +184,7 @@ GitHub automatically picks up any \`.github/workflows/*.yml\` files and runs the
184
184
  - [Workflow Concepts](/chant/lexicons/github/workflow-concepts/) — resource types, triggers, permissions
185
185
  - [Expressions](/chant/lexicons/github/expressions/) — typed expression system and condition helpers
186
186
  - [Composites](/chant/lexicons/github/composites/) — pre-built action wrappers (Checkout, SetupNode, etc.)
187
- - [Lint Rules](/chant/lexicons/github/lint-rules/) — 13 lint rules and 6 post-synth checks`,
187
+ - [Lint Rules](/chant/lexicons/github/lint-rules/) — 13 lint rules and 45 post-synth checks`,
188
188
  },
189
189
  {
190
190
  slug: "workflow-concepts",
@@ -838,7 +838,27 @@ const { workflow, buildJob, testJob, lintJob } = GoCI({
838
838
  const noLint = GoCI({ lintCommand: null });
839
839
  \`\`\`
840
840
 
841
- **Props:** \`goVersion?\`, \`testCommand?\`, \`buildCommand?\`, \`lintCommand?\` (null to omit), \`runsOn?\``,
841
+ **Props:** \`goVersion?\`, \`testCommand?\`, \`buildCommand?\`, \`lintCommand?\` (null to omit), \`runsOn?\`
842
+
843
+ ## Dependabot
844
+
845
+ Models the repository's dependency-update configuration (\`.github/dependabot.yml\`) as a chant resource, so it is emitted and lintable like a workflow. The composite ships **safe defaults**: a cooldown window on every ecosystem (so a version published moments ago — including a compromised one — is not adopted before anyone can react) and external code execution explicitly denied.
846
+
847
+ \`\`\`typescript
848
+ import { Dependabot } from "@intentius/chant-lexicon-github";
849
+
850
+ export const dependabot = Dependabot({
851
+ ecosystems: [
852
+ { packageEcosystem: "npm", directory: "/" },
853
+ { packageEcosystem: "github-actions", directory: "/" },
854
+ ],
855
+ // cooldownDays defaults to 7, openPullRequestsLimit to 5
856
+ });
857
+ \`\`\`
858
+
859
+ **Props:** \`ecosystems\` (required — each \`{ packageEcosystem, directory?, interval? }\`), \`cooldownDays?\` (default 7), \`openPullRequestsLimit?\` (default 5)
860
+
861
+ For full control, construct the \`DependabotConfig\` resource directly with raw \`updates:\` entries. Two post-synth checks validate the emitted config: **GHA057** (\`insecure-external-code-execution: allow\`) and **GHA058** (no cooldown). See [Lint Rules](/chant/lexicons/github/lint-rules/).`,
842
862
  },
843
863
  {
844
864
  slug: "lint-rules",
@@ -952,6 +972,12 @@ Flags matrix strategy dimensions with an empty values array. An empty dimension
952
972
 
953
973
  Flags jobs whose \`needs:\` entries reference a job not defined in the workflow. This causes a workflow validation error on GitHub.
954
974
 
975
+ ### GHA013 — Missing job-level permissions for sensitive triggers
976
+
977
+ **Severity:** warning
978
+
979
+ Flags jobs without an explicit \`permissions:\` block when the workflow uses a sensitive trigger (\`pull_request_target\` or \`workflow_dispatch\`). Declaring job-level permissions keeps least-privilege scope on workflows that run with elevated context.
980
+
955
981
  ### GHA017 — Missing permissions block
956
982
 
957
983
  **Severity:** info
@@ -970,6 +996,242 @@ Flags workflows triggered by \`pull_request_target\` that include \`actions/chec
970
996
 
971
997
  Detects cycles in the \`needs:\` dependency graph. If job A needs B and B needs A (directly or transitively), GitHub rejects the workflow. Reports the full cycle chain in the diagnostic message.
972
998
 
999
+ ### GHA021 — Checkout action not pinned to a SHA
1000
+
1001
+ **Severity:** warning
1002
+
1003
+ Flags \`actions/checkout\` referenced by a tag (e.g. \`@v4\`) instead of a pinned commit SHA. The narrower precursor to GHA029, kept because checkout is the most common unpinned action.
1004
+
1005
+ ### GHA022 — Job without timeout-minutes
1006
+
1007
+ **Severity:** info
1008
+
1009
+ Flags jobs that omit \`timeout-minutes\`. A hung step otherwise runs to the runner's default cap, burning minutes — set an explicit ceiling.
1010
+
1011
+ ### GHA023 — Deprecated set-output command
1012
+
1013
+ **Severity:** warning
1014
+
1015
+ Flags \`::set-output\` in \`run:\` steps. The workflow command is deprecated and disabled on current runners — write to \`$GITHUB_OUTPUT\` instead.
1016
+
1017
+ ### GHA024 — Missing concurrency for deploy workflows
1018
+
1019
+ **Severity:** info
1020
+
1021
+ Flags deploy workflows without a \`concurrency:\` block. Without one, two pushes can deploy concurrently and race — add a concurrency group to serialize them.
1022
+
1023
+ ### GHA025 — pull_request_target without restrictions
1024
+
1025
+ **Severity:** warning
1026
+
1027
+ Flags \`pull_request_target\` used without a \`types:\` filter. The trigger runs with repository secrets in the base-branch context, so it should be scoped to the specific PR events that need it.
1028
+
1029
+ ### GHA026 — Secret used without environment protection
1030
+
1031
+ **Severity:** info
1032
+
1033
+ Flags workflows that reference \`secrets.\` in steps but declare no \`environment:\` on any job, so the secret skips the approval and scoping rules an environment gate provides.
1034
+
1035
+ ### GHA027 — Cleanup step missing if: always()
1036
+
1037
+ **Severity:** info
1038
+
1039
+ Flags steps named "cleanup" / "teardown" / "clean up" that lack an \`if:\` condition. Cleanup should run even when a prior step fails — add \`if: always()\`.
1040
+
1041
+ ### GHA028 — Workflow with no on: triggers
1042
+
1043
+ **Severity:** error
1044
+
1045
+ Flags a workflow file with no top-level \`on:\` key. Without a trigger the workflow can never run.
1046
+
1047
+ ## Supply-chain security pass (GHA029–058)
1048
+
1049
+ GHA029 onward are a CI/CD supply-chain security pass: pin & vet external references, enforce least-privilege token scopes, guard trust boundaries against untrusted input, contain secrets, reject unsound expressions, and keep artifacts/caches honest. They run statically on the emitted YAML — everything answerable without leaving the build.
1050
+
1051
+ The checks that need a *moving external truth* — whether a pinned SHA still maps to a real upstream tag, whether a ref still exists, whether a new advisory now covers an action in use — can't be deterministic, so they live in the operational layer instead. Schedule the [\`WorkflowAuditOp\`](/chant/guide/ops/#audit-supply-chain-drift) (temporal lexicon) for that live, always-fresh half; it reads the same emitted workflow references and reports drift via \`report | issue | pull-request\`.
1052
+
1053
+ ### GHA029 — Action or reusable workflow not pinned to a commit SHA
1054
+
1055
+ **Severity:** warning
1056
+
1057
+ Flags any \`uses:\` — step action or job-level reusable workflow — pinned to a mutable tag or branch instead of a full commit SHA. Tags can be repointed to malicious code after review, so every external reference should be pinned. \`actions/checkout\` is covered by the more specific GHA021; local (\`./\`) and \`docker://\` references are out of scope. Owners in the vendored trusted allowlist are exempt.
1058
+
1059
+ ### GHA030 — Container image not pinned to a digest
1060
+
1061
+ **Severity:** warning
1062
+
1063
+ Flags job \`container:\` images, \`services:\` images, and \`docker://\` step references that are not pinned to an immutable \`@sha256:\` digest. A mutable tag can be repointed to a different image after review.
1064
+
1065
+ ### GHA031 — Action resembles a well-known action
1066
+
1067
+ **Severity:** warning
1068
+
1069
+ Flags a \`uses:\` slug that is a near-miss (edit distance 1–2) of a popular action but not an exact match — a likely typo or impersonation under different ownership. Advisory; backed by a vendored reference list.
1070
+
1071
+ ### GHA032 — Archived or compromised action
1072
+
1073
+ **Severity:** warning
1074
+
1075
+ Flags a \`uses:\` slug that a vendored snapshot marks as archived/abandoned or carrying a disclosed security issue, with remediation. Advisory and necessarily incomplete.
1076
+
1077
+ ### GHA033 — Blanket write-all permissions
1078
+
1079
+ **Severity:** warning
1080
+
1081
+ Flags \`permissions: write-all\` at the workflow or job level. It grants the \`GITHUB_TOKEN\` every write scope regardless of need — replace it with the specific scopes the job uses.
1082
+
1083
+ ### GHA034 — Write permissions granted workflow-wide
1084
+
1085
+ **Severity:** warning
1086
+
1087
+ Flags individual write scopes declared at the workflow level, which apply to every job even when only one needs them. Move each write scope onto the specific job that uses it. (The \`write-all\` preset is covered by GHA033.)
1088
+
1089
+ ### GHA035 — Elevated scope on an untrusted-code trigger
1090
+
1091
+ **Severity:** error
1092
+
1093
+ Flags a workflow that grants the token write access while using a trigger that can run untrusted code (\`pull_request_target\`, \`workflow_run\`). An injected step would run with standing write credentials — drop the write scope or isolate the privileged work in a separate trusted workflow.
1094
+
1095
+ ### GHA036 — Untrusted input in a run: command
1096
+
1097
+ **Severity:** error
1098
+
1099
+ Flags an attacker-controllable expression context (PR title, branch name, issue/comment body, commit message) interpolated directly into a \`run:\` script — a script-injection sink. Pass the value through an \`env:\` variable and reference it quoted instead.
1100
+
1101
+ ### GHA037 — Untrusted input written to GITHUB_ENV / GITHUB_PATH
1102
+
1103
+ **Severity:** error
1104
+
1105
+ Flags a \`run:\` step that writes untrusted input into \`$GITHUB_ENV\` or \`$GITHUB_PATH\`, which set environment/PATH state for later steps and can escalate into takeover of a subsequent privileged step.
1106
+
1107
+ ### GHA038 — workflow_run trigger checking out untrusted code
1108
+
1109
+ **Severity:** warning
1110
+
1111
+ Generalizes GHA018 to the \`workflow_run\` trigger, which runs with repo write scope and secrets. Checking out the head/artifact of the triggering run pulls untrusted code into that privileged context.
1112
+
1113
+ ### GHA039 — Authorization gate on a spoofable identity
1114
+
1115
+ **Severity:** warning
1116
+
1117
+ Flags an \`if:\` condition that gates on a commit-author identity field (\`author.name\` / \`author.email\`). Those come from git metadata the committer sets freely and can be spoofed — gate on a verified signal (environment protection, CODEOWNERS, verified actor).
1118
+
1119
+ ### GHA040 — Self-hosted runner on an untrusted-code trigger
1120
+
1121
+ **Severity:** warning
1122
+
1123
+ Flags a job on a self-hosted runner under a trigger a fork can reach (\`pull_request\`, \`pull_request_target\`, \`workflow_run\`). Self-hosted runners are non-ephemeral and shared, so untrusted code can persist and compromise later jobs.
1124
+
1125
+ ### GHA041 — Blanket secrets: inherit into a reusable workflow
1126
+
1127
+ **Severity:** warning
1128
+
1129
+ Flags a reusable-workflow call passing \`secrets: inherit\`, which hands the called workflow every caller secret. Pass through only the specific secrets it needs.
1130
+
1131
+ ### GHA042 — Entire secrets context passed
1132
+
1133
+ **Severity:** warning
1134
+
1135
+ Flags \`toJSON(secrets)\` passed into a step or reusable workflow, serializing every secret where one or two specific references would do.
1136
+
1137
+ ### GHA043 — Secret consumed without an environment gate
1138
+
1139
+ **Severity:** warning
1140
+
1141
+ Extends GHA026: when a workflow gates some jobs with an \`environment:\`, flags the specific secret-using jobs that have none — the inconsistent-gating case where a job skips the approval/scoping applied elsewhere.
1142
+
1143
+ ### GHA044 — Hardcoded registry/container credential
1144
+
1145
+ **Severity:** error
1146
+
1147
+ Flags a \`password:\` / \`token:\` / \`registry-password:\` set to a literal rather than a \`\${{ secrets.* }}\` reference. Move the credential into a secret.
1148
+
1149
+ ### GHA045 — Secret interpolated into a run: command
1150
+
1151
+ **Severity:** warning
1152
+
1153
+ Flags \`\${{ secrets.* }}\` expanded directly into a \`run:\` script, where a transform can defeat log masking and the raw value is exposed to argument injection. Pass it through an \`env:\` variable and reference \`"$VAR"\` quoted.
1154
+
1155
+ ### GHA046 — Logically unsound guard condition
1156
+
1157
+ **Severity:** warning
1158
+
1159
+ Flags an \`if:\` condition that reads like a gate but evaluates to a constant — \`true\`/\`false\` literals, an \`X == X\` tautology, or a collapse via \`|| true\` / \`&& false\`. A gate that constrains nothing is misleading.
1160
+
1161
+ ### GHA047 — Ineffective contains() guard
1162
+
1163
+ **Severity:** warning
1164
+
1165
+ Flags \`contains('literal', <dynamic>)\` — a constant haystack with a dynamic needle. \`contains(search, item)\` tests whether \`item\` is in \`search\`, so reversed arguments make the result depend on a fixed string. Swap them.
1166
+
1167
+ ### GHA048 — Obfuscated guard condition
1168
+
1169
+ **Severity:** warning
1170
+
1171
+ Flags an \`if:\` gate whose compared operand is built through \`format()\` / \`join()\` / \`fromJSON()\` indirection. Constructing the operand at evaluation time hides what the gate checks — compare against the value directly.
1172
+
1173
+ ### GHA049 — Persisted checkout credentials reachable by an artifact
1174
+
1175
+ **Severity:** warning
1176
+
1177
+ Flags a job that checks out with persisted credentials (the default) and uploads an artifact — the token in \`.git/config\` can be swept into the artifact. Set \`persist-credentials: false\` on the checkout.
1178
+
1179
+ ### GHA050 — Cache populated in a privileged context
1180
+
1181
+ **Severity:** warning
1182
+
1183
+ Flags \`actions/cache\` under a privileged trigger (\`pull_request_target\`, \`workflow_run\`). A cache entry influenced by a fork can be restored and executed by a later trusted run (cache poisoning) — restrict caching to trusted triggers.
1184
+
1185
+ ### GHA051 — Publish step using a long-lived token instead of OIDC
1186
+
1187
+ **Severity:** info
1188
+
1189
+ Flags a publish/release job that uses a long-lived token secret while requesting no \`id-token: write\`. If the registry supports OIDC, mint a short-lived federated credential per run instead of holding a standing token.
1190
+
1191
+ ### GHA052 — Software fetched and piped to a shell
1192
+
1193
+ **Severity:** warning
1194
+
1195
+ Flags a \`run:\` step that pipes a network download straight into a shell (\`curl ... | bash\`). The fetched code is unpinned and unverified — download to a file, verify a checksum/signature, then run it.
1196
+
1197
+ ### GHA053 — Unsafe set-env / add-path opt-in
1198
+
1199
+ **Severity:** error
1200
+
1201
+ Flags re-enabling the \`set-env\` / \`add-path\` workflow commands removed for security (CVE-2020-15228) via \`ACTIONS_ALLOW_UNSECURE_COMMANDS\` or direct \`::set-env::\` / \`::add-path::\`. Use the \`$GITHUB_ENV\` / \`$GITHUB_PATH\` files instead.
1202
+
1203
+ ### GHA054 — Known-bad feature usage
1204
+
1205
+ **Severity:** warning
1206
+
1207
+ Catch-all, data-driven check flagging emitted content matching a vendored snapshot of risky features (deprecated workflow commands, unsafe runtime opt-ins). Advisory and necessarily incomplete.
1208
+
1209
+ ### GHA055 — Redundant runtime tool install
1210
+
1211
+ **Severity:** info
1212
+
1213
+ Flags a \`run:\` step that installs a tool GitHub-hosted runners already ship. The redundant install adds supply-chain surface for no benefit (irrelevant on self-hosted runners that may lack the tool).
1214
+
1215
+ ### GHA056 — Workflow without a name
1216
+
1217
+ **Severity:** info
1218
+
1219
+ Flags a workflow with no top-level \`name:\`. Without one, GitHub falls back to the file path in the UI and audit logs, making runs harder to identify.
1220
+
1221
+ > Two items from issue #295 are intentionally not implemented at the post-synth layer: over-broad/unrevoked app-installation tokens (whether a minted token is scoped wider than used cannot be determined from emitted YAML) and a reference allowlist/denylist policy (configuration, overlapping the GHA029 trusted-owner allowlist).
1222
+
1223
+ ### GHA057 — Dependency update executes untrusted code
1224
+
1225
+ **Severity:** error
1226
+
1227
+ Flags a Dependabot \`updates:\` entry with \`insecure-external-code-execution: allow\`, which runs a freshly-pulled dependency's lifecycle scripts during the update itself — a compromised release executes before any review. Set it to \`deny\`. Requires the \`Dependabot\` resource so the config is emitted (\`.github/dependabot.yml\`).
1228
+
1229
+ ### GHA058 — Dependency update without a cooldown
1230
+
1231
+ **Severity:** warning
1232
+
1233
+ Flags a Dependabot \`updates:\` entry with no cooldown — a version published moments ago (including a compromised one) is adopted immediately. Configure a \`cooldown:\` window. The \`Dependabot\` composite ships a 7-day default.
1234
+
973
1235
  ## Running lint
974
1236
 
975
1237
  \`\`\`bash
@@ -1099,7 +1361,7 @@ The inline \`chant-github\` skill covers the full workflow lifecycle:
1099
1361
  - **Validate** — \`chant lint src/\`
1100
1362
  - **Deploy** — commit and push the generated YAML
1101
1363
  - **Status** — GitHub Actions UI or \`gh run list\`
1102
- - **Troubleshooting** — lint rule codes (GHA001–GHA020), post-synth checks (GHA006–GHA019)
1364
+ - **Troubleshooting** — lint rule codes (GHA001–GHA020), post-synth checks (GHA006–GHA058)
1103
1365
 
1104
1366
  The skill is invocable as a slash command: \`/chant-github\`
1105
1367
 
@@ -1123,6 +1385,13 @@ The lexicon provides MCP (Model Context Protocol) tools and resources that AI ag
1123
1385
  | MCP tool | Description |
1124
1386
  |----------|-------------|
1125
1387
  | \`diff\` | Compare current build output against previous output |
1388
+ | \`github:checks\` | Build the workflow and return its security findings (the GHA checks) |
1389
+ | \`github:workflow\` | Triggers and jobs as written — name, run order, step count |
1390
+ | \`github:references\` | Actions and images pulled in, and whether each is pinned to a commit SHA |
1391
+ | \`github:affected\` | Given a job, the jobs that would re-run because they depend on it |
1392
+ | \`github:workflow-yaml\` | The generated workflow YAML |
1393
+
1394
+ The \`github:*\` tools are **read-only context tools** (#327): each builds from your source and returns what chant already computes — before the workflow runs or merges. None touch the live GitHub instance, run history, or write anything.
1126
1395
 
1127
1396
  | MCP resource | Description |
1128
1397
  |--------------|-------------|
@@ -0,0 +1,48 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { Dependabot, DependabotConfig } from "./dependabot";
3
+ import { githubSerializer } from "../serializer";
4
+ import type { SerializerResult } from "@intentius/chant/serializer";
5
+
6
+ describe("Dependabot composite (#294)", () => {
7
+ test("ships safe defaults: cooldown + external code execution denied", () => {
8
+ const cfg = Dependabot({ ecosystems: [{ packageEcosystem: "npm" }, { packageEcosystem: "github-actions" }] });
9
+ expect(cfg).toBeInstanceOf(DependabotConfig);
10
+ expect(cfg.props.version).toBe(2);
11
+ expect(cfg.props.updates).toHaveLength(2);
12
+ for (const u of cfg.props.updates) {
13
+ expect(u.cooldown?.defaultDays).toBe(7);
14
+ expect(u.insecureExternalCodeExecution).toBe("deny");
15
+ expect(u.directory).toBe("/");
16
+ expect(u.schedule.interval).toBe("weekly");
17
+ }
18
+ });
19
+
20
+ test("serializes to a .github/dependabot.yml file (kebab-case keys)", () => {
21
+ const cfg = Dependabot({ ecosystems: [{ packageEcosystem: "npm" }] });
22
+ const result = githubSerializer.serialize(new Map([["deps", cfg]])) as SerializerResult;
23
+ expect(typeof result).toBe("object");
24
+ const yaml = result.files?.["dependabot.yml"];
25
+ expect(yaml).toBeDefined();
26
+ expect(yaml).toContain("version: 2");
27
+ expect(yaml).toContain("package-ecosystem: npm");
28
+ expect(yaml).toContain("open-pull-requests-limit: 5");
29
+ expect(yaml).toContain("insecure-external-code-execution: deny");
30
+ expect(yaml).toContain("default-days: 7");
31
+ });
32
+
33
+ test("emits dependabot.yml alongside a workflow", () => {
34
+ const cfg = Dependabot({ ecosystems: [{ packageEcosystem: "pip", interval: "daily" }] });
35
+ const wf = {
36
+ [Symbol.for("chant.declarable")]: true,
37
+ lexicon: "github",
38
+ entityType: "GitHub::Actions::Workflow",
39
+ kind: "resource" as const,
40
+ props: { name: "CI" },
41
+ };
42
+ const result = githubSerializer.serialize(
43
+ new Map<string, never>([["ci", wf as never], ["deps", cfg as never]]),
44
+ ) as SerializerResult;
45
+ expect(result.primary).toContain("name: CI");
46
+ expect(result.files?.["dependabot.yml"]).toContain("package-ecosystem: pip");
47
+ });
48
+ });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Dependency-update configuration (`.github/dependabot.yml`) as a chant
3
+ * resource, so it is emitted and lintable like workflows (#294).
4
+ *
5
+ * Two failure modes the post-synth checks target:
6
+ * - an update that executes untrusted code from a freshly-pulled dependency
7
+ * during the update itself (`insecure-external-code-execution: allow`), and
8
+ * - a configuration with no cooldown — a version published seconds ago
9
+ * (including a compromised one) is adopted before anyone can react.
10
+ *
11
+ * The `Dependabot` composite ships safe defaults: a cooldown window and an
12
+ * explicit deny on external code execution.
13
+ */
14
+
15
+ import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable";
16
+
17
+ /** A single `updates:` entry in dependabot.yml. */
18
+ export interface DependabotUpdate {
19
+ /** e.g. "npm", "pip", "github-actions", "docker". */
20
+ packageEcosystem: string;
21
+ /** Directory to scan (defaults to "/"). */
22
+ directory?: string;
23
+ directories?: string[];
24
+ /** Update schedule. */
25
+ schedule: { interval: "daily" | "weekly" | "monthly"; day?: string; time?: string };
26
+ /** Cooldown window — delay adopting freshly-published versions. */
27
+ cooldown?: {
28
+ defaultDays?: number;
29
+ semverMajorDays?: number;
30
+ semverMinorDays?: number;
31
+ semverPatchDays?: number;
32
+ };
33
+ /** Cap on concurrent update PRs. */
34
+ openPullRequestsLimit?: number;
35
+ /** Whether to run a dependency's lifecycle scripts during the update. */
36
+ insecureExternalCodeExecution?: "allow" | "deny";
37
+ /** Passthrough for the rest of the dependabot update schema. */
38
+ [key: string]: unknown;
39
+ }
40
+
41
+ export interface DependabotConfigProps {
42
+ version?: number;
43
+ updates: DependabotUpdate[];
44
+ registries?: Record<string, unknown>;
45
+ }
46
+
47
+ /** The `.github/dependabot.yml` resource. */
48
+ export class DependabotConfig implements Declarable {
49
+ readonly [DECLARABLE_MARKER] = true as const;
50
+ readonly lexicon = "github";
51
+ readonly entityType = "GitHub::Dependabot::Config";
52
+ readonly kind = "resource" as const;
53
+ readonly props: DependabotConfigProps;
54
+
55
+ constructor(props: DependabotConfigProps) {
56
+ this.props = { version: 2, ...props };
57
+ }
58
+ }
59
+
60
+ export interface DependabotEcosystem {
61
+ packageEcosystem: string;
62
+ directory?: string;
63
+ interval?: "daily" | "weekly" | "monthly";
64
+ }
65
+
66
+ export interface DependabotProps {
67
+ /** Ecosystems to keep updated. */
68
+ ecosystems: DependabotEcosystem[];
69
+ /** Cooldown window in days before adopting a freshly-published version (default 7). */
70
+ cooldownDays?: number;
71
+ /** Cap on concurrent update PRs per ecosystem (default 5). */
72
+ openPullRequestsLimit?: number;
73
+ }
74
+
75
+ /**
76
+ * Build a `DependabotConfig` with safe defaults: a cooldown window on every
77
+ * ecosystem and external code execution explicitly denied.
78
+ */
79
+ export function Dependabot(props: DependabotProps): DependabotConfig {
80
+ const cooldownDays = props.cooldownDays ?? 7;
81
+ const limit = props.openPullRequestsLimit ?? 5;
82
+ const updates: DependabotUpdate[] = props.ecosystems.map((e) => ({
83
+ packageEcosystem: e.packageEcosystem,
84
+ directory: e.directory ?? "/",
85
+ schedule: { interval: e.interval ?? "weekly" },
86
+ cooldown: { defaultDays: cooldownDays },
87
+ openPullRequestsLimit: limit,
88
+ insecureExternalCodeExecution: "deny",
89
+ }));
90
+ return new DependabotConfig({ version: 2, updates });
91
+ }
@@ -24,3 +24,5 @@ export { DeployEnvironment } from "./deploy-environment";
24
24
  export type { DeployEnvironmentProps } from "./deploy-environment";
25
25
  export { GoCI } from "./go-ci";
26
26
  export type { GoCIProps } from "./go-ci";
27
+ export { Dependabot, DependabotConfig } from "./dependabot";
28
+ export type { DependabotProps, DependabotConfigProps, DependabotUpdate, DependabotEcosystem } from "./dependabot";
package/src/index.ts CHANGED
@@ -30,6 +30,7 @@ export {
30
30
  DockerBuild,
31
31
  DeployEnvironment,
32
32
  GoCI,
33
+ Dependabot, DependabotConfig,
33
34
  } from "./composites/index";
34
35
  export type {
35
36
  CheckoutProps, SetupNodeProps, SetupGoProps, SetupPythonProps,
@@ -40,6 +41,7 @@ export type {
40
41
  DockerBuildProps,
41
42
  DeployEnvironmentProps,
42
43
  GoCIProps,
44
+ DependabotProps, DependabotConfigProps, DependabotUpdate, DependabotEcosystem,
43
45
  } from "./composites/index";
44
46
 
45
47
  // Spec utilities (for tooling)
@@ -1,6 +1,6 @@
1
1
  import { describe, test, expect } from "vitest";
2
2
  import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
- import { gha020 } from "./gha020";
3
+ import { gha013 } from "./gha013";
4
4
 
5
5
  function makeCtx(yaml: string): PostSynthContext {
6
6
  return {
@@ -16,7 +16,7 @@ function makeCtx(yaml: string): PostSynthContext {
16
16
  };
17
17
  }
18
18
 
19
- describe("GHA020: missing job-level permissions for sensitive triggers", () => {
19
+ describe("GHA013: missing job-level permissions for sensitive triggers", () => {
20
20
  test("flags job without permissions when using pull_request_target", () => {
21
21
  const yaml = `name: Review
22
22
  on:
@@ -27,9 +27,9 @@ jobs:
27
27
  steps:
28
28
  - run: echo review
29
29
  `;
30
- const diags = gha020.check(makeCtx(yaml));
30
+ const diags = gha013.check(makeCtx(yaml));
31
31
  expect(diags).toHaveLength(1);
32
- expect(diags[0].checkId).toBe("GHA020");
32
+ expect(diags[0].checkId).toBe("GHA013");
33
33
  expect(diags[0].severity).toBe("warning");
34
34
  expect(diags[0].message).toContain("review");
35
35
  });
@@ -44,9 +44,9 @@ jobs:
44
44
  steps:
45
45
  - run: echo deploy
46
46
  `;
47
- const diags = gha020.check(makeCtx(yaml));
47
+ const diags = gha013.check(makeCtx(yaml));
48
48
  expect(diags).toHaveLength(1);
49
- expect(diags[0].checkId).toBe("GHA020");
49
+ expect(diags[0].checkId).toBe("GHA013");
50
50
  expect(diags[0].message).toContain("deploy");
51
51
  });
52
52
 
@@ -60,7 +60,7 @@ jobs:
60
60
  steps:
61
61
  - run: echo build
62
62
  `;
63
- const diags = gha020.check(makeCtx(yaml));
63
+ const diags = gha013.check(makeCtx(yaml));
64
64
  expect(diags).toHaveLength(0);
65
65
  });
66
66
  });
@@ -1,5 +1,5 @@
1
1
  /**
2
- * GHA020: Missing Job-Level Permissions for Sensitive Triggers
2
+ * GHA013: Missing Job-Level Permissions for Sensitive Triggers
3
3
  *
4
4
  * Flags jobs without explicit `permissions:` when the workflow uses
5
5
  * `pull_request_target` or `workflow_dispatch` triggers.
@@ -8,8 +8,8 @@
8
8
  import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
9
9
  import { getPrimaryOutput, extractJobs, extractTriggers } from "./yaml-helpers";
10
10
 
11
- export const gha020: PostSynthCheck = {
12
- id: "GHA020",
11
+ export const gha013: PostSynthCheck = {
12
+ id: "GHA013",
13
13
  description: "Missing job-level permissions for sensitive triggers",
14
14
 
15
15
  check(ctx: PostSynthContext): PostSynthDiagnostic[] {
@@ -25,7 +25,7 @@ export const gha020: PostSynthCheck = {
25
25
  for (const [jobName, job] of jobs) {
26
26
  if (!job.permissions) {
27
27
  diagnostics.push({
28
- checkId: "GHA020",
28
+ checkId: "GHA013",
29
29
  severity: "warning",
30
30
  message: `Job "${jobName}" lacks explicit permissions but workflow uses a sensitive trigger. Add job-level permissions for least-privilege security.`,
31
31
  entity: jobName,
@@ -0,0 +1,108 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { gha029, findUnpinnedActions } from "./gha029";
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("GHA029: unpinned action / reusable workflow", () => {
20
+ test("flags a step action pinned to a tag", () => {
21
+ const yaml = `name: CI
22
+ on:
23
+ push:
24
+ jobs:
25
+ build:
26
+ runs-on: ubuntu-latest
27
+ steps:
28
+ - uses: actions/setup-node@v4
29
+ - run: echo build
30
+ `;
31
+ const diags = gha029.check(makeCtx(yaml));
32
+ expect(diags).toHaveLength(1);
33
+ expect(diags[0].checkId).toBe("GHA029");
34
+ expect(diags[0].severity).toBe("warning");
35
+ expect(diags[0].entity).toBe("build");
36
+ expect(diags[0].message).toContain("actions/setup-node@v4");
37
+ });
38
+
39
+ test("flags a job-level reusable workflow pinned to a tag", () => {
40
+ const yaml = `name: CI
41
+ on:
42
+ push:
43
+ jobs:
44
+ call:
45
+ uses: org/repo/.github/workflows/reusable.yml@v1
46
+ `;
47
+ const diags = gha029.check(makeCtx(yaml));
48
+ expect(diags).toHaveLength(1);
49
+ expect(diags[0].message).toContain("reusable.yml@v1");
50
+ });
51
+
52
+ test("does not flag an action pinned to a full SHA", () => {
53
+ const yaml = `name: CI
54
+ on:
55
+ push:
56
+ jobs:
57
+ build:
58
+ runs-on: ubuntu-latest
59
+ steps:
60
+ - uses: actions/setup-node@1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b
61
+ - run: echo build
62
+ `;
63
+ const diags = gha029.check(makeCtx(yaml));
64
+ expect(diags).toHaveLength(0);
65
+ });
66
+
67
+ test("does not flag actions/checkout (owned by GHA021)", () => {
68
+ const yaml = `name: CI
69
+ on:
70
+ push:
71
+ jobs:
72
+ build:
73
+ runs-on: ubuntu-latest
74
+ steps:
75
+ - uses: actions/checkout@v4
76
+ `;
77
+ const diags = gha029.check(makeCtx(yaml));
78
+ expect(diags).toHaveLength(0);
79
+ });
80
+
81
+ test("does not flag local actions", () => {
82
+ const yaml = `name: CI
83
+ on:
84
+ push:
85
+ jobs:
86
+ build:
87
+ runs-on: ubuntu-latest
88
+ steps:
89
+ - uses: ./.github/actions/local
90
+ `;
91
+ const diags = gha029.check(makeCtx(yaml));
92
+ expect(diags).toHaveLength(0);
93
+ });
94
+
95
+ test("allowlist exempts a trusted owner", () => {
96
+ const yaml = `name: CI
97
+ on:
98
+ push:
99
+ jobs:
100
+ build:
101
+ runs-on: ubuntu-latest
102
+ steps:
103
+ - uses: trustedco/action@v1
104
+ `;
105
+ expect(findUnpinnedActions(yaml)).toHaveLength(1);
106
+ expect(findUnpinnedActions(yaml, new Set(["trustedco"]))).toHaveLength(0);
107
+ });
108
+ });