@intentius/chant-lexicon-github 0.1.22 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/dist/integrity.json +34 -4
  2. package/dist/manifest.json +1 -1
  3. package/dist/rules/{gha020.ts → gha013.ts} +4 -4
  4. package/dist/rules/gha029.ts +68 -0
  5. package/dist/rules/gha030.ts +41 -0
  6. package/dist/rules/gha031.ts +77 -0
  7. package/dist/rules/gha032.ts +39 -0
  8. package/dist/rules/gha033.ts +47 -0
  9. package/dist/rules/gha034.ts +41 -0
  10. package/dist/rules/gha035.ts +66 -0
  11. package/dist/rules/gha036.ts +42 -0
  12. package/dist/rules/gha037.ts +46 -0
  13. package/dist/rules/gha038.ts +41 -0
  14. package/dist/rules/gha039.ts +41 -0
  15. package/dist/rules/gha040.ts +43 -0
  16. package/dist/rules/gha041.ts +38 -0
  17. package/dist/rules/gha042.ts +40 -0
  18. package/dist/rules/gha043.ts +41 -0
  19. package/dist/rules/gha044.ts +41 -0
  20. package/dist/rules/gha045.ts +38 -0
  21. package/dist/rules/gha046.ts +56 -0
  22. package/dist/rules/gha047.ts +51 -0
  23. package/dist/rules/gha048.ts +41 -0
  24. package/dist/rules/gha049.ts +42 -0
  25. package/dist/rules/gha050.ts +44 -0
  26. package/dist/rules/gha051.ts +45 -0
  27. package/dist/rules/gha052.ts +42 -0
  28. package/dist/rules/gha053.ts +41 -0
  29. package/dist/rules/gha054.ts +41 -0
  30. package/dist/rules/gha055.ts +50 -0
  31. package/dist/rules/gha056.ts +37 -0
  32. package/dist/rules/gha057.ts +40 -0
  33. package/dist/rules/gha058.ts +48 -0
  34. package/dist/rules/yaml-helpers.ts +335 -0
  35. package/package.json +1 -1
  36. package/src/codegen/docs.ts +188 -0
  37. package/src/composites/dependabot.test.ts +48 -0
  38. package/src/composites/dependabot.ts +91 -0
  39. package/src/composites/index.ts +2 -0
  40. package/src/index.ts +2 -0
  41. package/src/lint/post-synth/{gha020.test.ts → gha013.test.ts} +7 -7
  42. package/src/lint/post-synth/{gha020.ts → gha013.ts} +4 -4
  43. package/src/lint/post-synth/gha029.test.ts +108 -0
  44. package/src/lint/post-synth/gha029.ts +68 -0
  45. package/src/lint/post-synth/gha030.test.ts +87 -0
  46. package/src/lint/post-synth/gha030.ts +41 -0
  47. package/src/lint/post-synth/gha031.test.ts +75 -0
  48. package/src/lint/post-synth/gha031.ts +77 -0
  49. package/src/lint/post-synth/gha032.test.ts +65 -0
  50. package/src/lint/post-synth/gha032.ts +39 -0
  51. package/src/lint/post-synth/gha033.test.ts +68 -0
  52. package/src/lint/post-synth/gha033.ts +47 -0
  53. package/src/lint/post-synth/gha034.test.ts +86 -0
  54. package/src/lint/post-synth/gha034.ts +41 -0
  55. package/src/lint/post-synth/gha035.test.ts +85 -0
  56. package/src/lint/post-synth/gha035.ts +66 -0
  57. package/src/lint/post-synth/gha036.test.ts +68 -0
  58. package/src/lint/post-synth/gha036.ts +42 -0
  59. package/src/lint/post-synth/gha037.test.ts +66 -0
  60. package/src/lint/post-synth/gha037.ts +46 -0
  61. package/src/lint/post-synth/gha038.test.ts +64 -0
  62. package/src/lint/post-synth/gha038.ts +41 -0
  63. package/src/lint/post-synth/gha039.test.ts +66 -0
  64. package/src/lint/post-synth/gha039.ts +41 -0
  65. package/src/lint/post-synth/gha040.test.ts +77 -0
  66. package/src/lint/post-synth/gha040.ts +43 -0
  67. package/src/lint/post-synth/gha041.test.ts +48 -0
  68. package/src/lint/post-synth/gha041.ts +38 -0
  69. package/src/lint/post-synth/gha042.test.ts +53 -0
  70. package/src/lint/post-synth/gha042.ts +40 -0
  71. package/src/lint/post-synth/gha043.test.ts +69 -0
  72. package/src/lint/post-synth/gha043.ts +41 -0
  73. package/src/lint/post-synth/gha044.test.ts +56 -0
  74. package/src/lint/post-synth/gha044.ts +41 -0
  75. package/src/lint/post-synth/gha045.test.ts +51 -0
  76. package/src/lint/post-synth/gha045.ts +38 -0
  77. package/src/lint/post-synth/gha046.test.ts +59 -0
  78. package/src/lint/post-synth/gha046.ts +56 -0
  79. package/src/lint/post-synth/gha047.test.ts +66 -0
  80. package/src/lint/post-synth/gha047.ts +51 -0
  81. package/src/lint/post-synth/gha048.test.ts +51 -0
  82. package/src/lint/post-synth/gha048.ts +41 -0
  83. package/src/lint/post-synth/gha049.test.ts +72 -0
  84. package/src/lint/post-synth/gha049.ts +42 -0
  85. package/src/lint/post-synth/gha050.test.ts +55 -0
  86. package/src/lint/post-synth/gha050.ts +44 -0
  87. package/src/lint/post-synth/gha051.test.ts +72 -0
  88. package/src/lint/post-synth/gha051.ts +45 -0
  89. package/src/lint/post-synth/gha052.test.ts +52 -0
  90. package/src/lint/post-synth/gha052.ts +42 -0
  91. package/src/lint/post-synth/gha053.test.ts +65 -0
  92. package/src/lint/post-synth/gha053.ts +41 -0
  93. package/src/lint/post-synth/gha054.test.ts +49 -0
  94. package/src/lint/post-synth/gha054.ts +41 -0
  95. package/src/lint/post-synth/gha055.test.ts +49 -0
  96. package/src/lint/post-synth/gha055.ts +50 -0
  97. package/src/lint/post-synth/gha056.test.ts +47 -0
  98. package/src/lint/post-synth/gha056.ts +37 -0
  99. package/src/lint/post-synth/gha057.test.ts +61 -0
  100. package/src/lint/post-synth/gha057.ts +40 -0
  101. package/src/lint/post-synth/gha058.test.ts +67 -0
  102. package/src/lint/post-synth/gha058.ts +48 -0
  103. package/src/lint/post-synth/yaml-helpers.ts +335 -0
  104. package/src/lint/rules/data/flagged-actions.ts +36 -0
  105. package/src/lint/rules/data/known-action-slugs.ts +38 -0
  106. package/src/lint/rules/data/preinstalled-tools.ts +36 -0
  107. package/src/lint/rules/data/risky-features.ts +39 -0
  108. package/src/lint/rules/data/trusted-action-owners.ts +12 -0
  109. package/src/lint/rules/data/untrusted-contexts.ts +44 -0
  110. package/src/plugin.test.ts +47 -1
  111. package/src/serializer.ts +38 -5
@@ -952,6 +952,12 @@ Flags matrix strategy dimensions with an empty values array. An empty dimension
952
952
 
953
953
  Flags jobs whose \`needs:\` entries reference a job not defined in the workflow. This causes a workflow validation error on GitHub.
954
954
 
955
+ ### GHA013 — Missing job-level permissions for sensitive triggers
956
+
957
+ **Severity:** warning
958
+
959
+ 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.
960
+
955
961
  ### GHA017 — Missing permissions block
956
962
 
957
963
  **Severity:** info
@@ -970,6 +976,188 @@ Flags workflows triggered by \`pull_request_target\` that include \`actions/chec
970
976
 
971
977
  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
978
 
979
+ ### GHA029 — Action or reusable workflow not pinned to a commit SHA
980
+
981
+ **Severity:** warning
982
+
983
+ 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.
984
+
985
+ ### GHA030 — Container image not pinned to a digest
986
+
987
+ **Severity:** warning
988
+
989
+ 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.
990
+
991
+ ### GHA031 — Action resembles a well-known action
992
+
993
+ **Severity:** warning
994
+
995
+ 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.
996
+
997
+ ### GHA032 — Archived or compromised action
998
+
999
+ **Severity:** warning
1000
+
1001
+ Flags a \`uses:\` slug that a vendored snapshot marks as archived/abandoned or carrying a disclosed security issue, with remediation. Advisory and necessarily incomplete.
1002
+
1003
+ ### GHA033 — Blanket write-all permissions
1004
+
1005
+ **Severity:** warning
1006
+
1007
+ 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.
1008
+
1009
+ ### GHA034 — Write permissions granted workflow-wide
1010
+
1011
+ **Severity:** warning
1012
+
1013
+ 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.)
1014
+
1015
+ ### GHA035 — Elevated scope on an untrusted-code trigger
1016
+
1017
+ **Severity:** error
1018
+
1019
+ 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.
1020
+
1021
+ ### GHA036 — Untrusted input in a run: command
1022
+
1023
+ **Severity:** error
1024
+
1025
+ 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.
1026
+
1027
+ ### GHA037 — Untrusted input written to GITHUB_ENV / GITHUB_PATH
1028
+
1029
+ **Severity:** error
1030
+
1031
+ 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.
1032
+
1033
+ ### GHA038 — workflow_run trigger checking out untrusted code
1034
+
1035
+ **Severity:** warning
1036
+
1037
+ 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.
1038
+
1039
+ ### GHA039 — Authorization gate on a spoofable identity
1040
+
1041
+ **Severity:** warning
1042
+
1043
+ 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).
1044
+
1045
+ ### GHA040 — Self-hosted runner on an untrusted-code trigger
1046
+
1047
+ **Severity:** warning
1048
+
1049
+ 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.
1050
+
1051
+ ### GHA041 — Blanket secrets: inherit into a reusable workflow
1052
+
1053
+ **Severity:** warning
1054
+
1055
+ Flags a reusable-workflow call passing \`secrets: inherit\`, which hands the called workflow every caller secret. Pass through only the specific secrets it needs.
1056
+
1057
+ ### GHA042 — Entire secrets context passed
1058
+
1059
+ **Severity:** warning
1060
+
1061
+ Flags \`toJSON(secrets)\` passed into a step or reusable workflow, serializing every secret where one or two specific references would do.
1062
+
1063
+ ### GHA043 — Secret consumed without an environment gate
1064
+
1065
+ **Severity:** warning
1066
+
1067
+ 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.
1068
+
1069
+ ### GHA044 — Hardcoded registry/container credential
1070
+
1071
+ **Severity:** error
1072
+
1073
+ Flags a \`password:\` / \`token:\` / \`registry-password:\` set to a literal rather than a \`\${{ secrets.* }}\` reference. Move the credential into a secret.
1074
+
1075
+ ### GHA045 — Secret interpolated into a run: command
1076
+
1077
+ **Severity:** warning
1078
+
1079
+ 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.
1080
+
1081
+ ### GHA046 — Logically unsound guard condition
1082
+
1083
+ **Severity:** warning
1084
+
1085
+ 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.
1086
+
1087
+ ### GHA047 — Ineffective contains() guard
1088
+
1089
+ **Severity:** warning
1090
+
1091
+ 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.
1092
+
1093
+ ### GHA048 — Obfuscated guard condition
1094
+
1095
+ **Severity:** warning
1096
+
1097
+ 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.
1098
+
1099
+ ### GHA049 — Persisted checkout credentials reachable by an artifact
1100
+
1101
+ **Severity:** warning
1102
+
1103
+ 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.
1104
+
1105
+ ### GHA050 — Cache populated in a privileged context
1106
+
1107
+ **Severity:** warning
1108
+
1109
+ 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.
1110
+
1111
+ ### GHA051 — Publish step using a long-lived token instead of OIDC
1112
+
1113
+ **Severity:** info
1114
+
1115
+ 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.
1116
+
1117
+ ### GHA052 — Software fetched and piped to a shell
1118
+
1119
+ **Severity:** warning
1120
+
1121
+ 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.
1122
+
1123
+ ### GHA053 — Unsafe set-env / add-path opt-in
1124
+
1125
+ **Severity:** error
1126
+
1127
+ 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.
1128
+
1129
+ ### GHA054 — Known-bad feature usage
1130
+
1131
+ **Severity:** warning
1132
+
1133
+ 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.
1134
+
1135
+ ### GHA055 — Redundant runtime tool install
1136
+
1137
+ **Severity:** info
1138
+
1139
+ 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).
1140
+
1141
+ ### GHA056 — Workflow without a name
1142
+
1143
+ **Severity:** info
1144
+
1145
+ 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.
1146
+
1147
+ > 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).
1148
+
1149
+ ### GHA057 — Dependency update executes untrusted code
1150
+
1151
+ **Severity:** error
1152
+
1153
+ 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\`).
1154
+
1155
+ ### GHA058 — Dependency update without a cooldown
1156
+
1157
+ **Severity:** warning
1158
+
1159
+ 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.
1160
+
973
1161
  ## Running lint
974
1162
 
975
1163
  \`\`\`bash
@@ -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
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * GHA029: Unpinned Action or Reusable-Workflow Reference
3
+ *
4
+ * Flags any `uses:` (step action or job-level reusable workflow) that is pinned
5
+ * to a mutable tag or branch instead of a full 40-character commit SHA. A tag
6
+ * can be repointed to malicious code after review, so every external reference
7
+ * is a supply-chain trust decision.
8
+ *
9
+ * `actions/checkout` is intentionally left to the more specific GHA021. Local
10
+ * references (`./`, `../`) and `docker://` images (GHA030) are out of scope.
11
+ * Owners in the trusted allowlist are exempt.
12
+ */
13
+
14
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
15
+ import { getPrimaryOutput, extractActionRefs, parseActionUses } from "./yaml-helpers";
16
+ import { TRUSTED_ACTION_OWNERS } from "../rules/data/trusted-action-owners";
17
+
18
+ const SHA_RE = /^[0-9a-f]{40}$/;
19
+
20
+ export interface UnpinnedRef {
21
+ job: string;
22
+ ref: string;
23
+ slug: string;
24
+ }
25
+
26
+ /**
27
+ * Find every action/reusable-workflow `uses:` that is not pinned to a commit
28
+ * SHA. Exposed as a pure function so the allowlist behaviour is testable.
29
+ */
30
+ export function findUnpinnedActions(
31
+ yaml: string,
32
+ trustedOwners: ReadonlySet<string> = TRUSTED_ACTION_OWNERS,
33
+ ): UnpinnedRef[] {
34
+ const result: UnpinnedRef[] = [];
35
+ for (const { job, ref } of extractActionRefs(yaml)) {
36
+ const parsed = parseActionUses(ref);
37
+ if (!parsed) continue; // local or docker:// reference
38
+ if (parsed.slug === "actions/checkout") continue; // owned by GHA021
39
+ if (trustedOwners.has(parsed.owner)) continue;
40
+ if (SHA_RE.test(parsed.gitRef)) continue; // already pinned
41
+ result.push({ job, ref, slug: parsed.slug });
42
+ }
43
+ return result;
44
+ }
45
+
46
+ export const gha029: PostSynthCheck = {
47
+ id: "GHA029",
48
+ description: "Action or reusable workflow not pinned to a commit SHA",
49
+
50
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
51
+ const diagnostics: PostSynthDiagnostic[] = [];
52
+
53
+ for (const [, output] of ctx.outputs) {
54
+ const yaml = getPrimaryOutput(output);
55
+ for (const { job, ref } of findUnpinnedActions(yaml)) {
56
+ diagnostics.push({
57
+ checkId: "GHA029",
58
+ severity: "warning",
59
+ message: `Job "${job}" uses ${ref} pinned to a tag or branch — pin to a full commit SHA for supply-chain security.`,
60
+ entity: job,
61
+ lexicon: "github",
62
+ });
63
+ }
64
+ }
65
+
66
+ return diagnostics;
67
+ },
68
+ };