@intentius/chant-lexicon-github 0.1.23 → 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.
- package/dist/integrity.json +34 -4
- package/dist/manifest.json +1 -1
- package/dist/rules/{gha020.ts → gha013.ts} +4 -4
- package/dist/rules/gha029.ts +68 -0
- package/dist/rules/gha030.ts +41 -0
- package/dist/rules/gha031.ts +77 -0
- package/dist/rules/gha032.ts +39 -0
- package/dist/rules/gha033.ts +47 -0
- package/dist/rules/gha034.ts +41 -0
- package/dist/rules/gha035.ts +66 -0
- package/dist/rules/gha036.ts +42 -0
- package/dist/rules/gha037.ts +46 -0
- package/dist/rules/gha038.ts +41 -0
- package/dist/rules/gha039.ts +41 -0
- package/dist/rules/gha040.ts +43 -0
- package/dist/rules/gha041.ts +38 -0
- package/dist/rules/gha042.ts +40 -0
- package/dist/rules/gha043.ts +41 -0
- package/dist/rules/gha044.ts +41 -0
- package/dist/rules/gha045.ts +38 -0
- package/dist/rules/gha046.ts +56 -0
- package/dist/rules/gha047.ts +51 -0
- package/dist/rules/gha048.ts +41 -0
- package/dist/rules/gha049.ts +42 -0
- package/dist/rules/gha050.ts +44 -0
- package/dist/rules/gha051.ts +45 -0
- package/dist/rules/gha052.ts +42 -0
- package/dist/rules/gha053.ts +41 -0
- package/dist/rules/gha054.ts +41 -0
- package/dist/rules/gha055.ts +50 -0
- package/dist/rules/gha056.ts +37 -0
- package/dist/rules/gha057.ts +40 -0
- package/dist/rules/gha058.ts +48 -0
- package/dist/rules/yaml-helpers.ts +335 -0
- package/package.json +1 -1
- package/src/codegen/docs.ts +188 -0
- package/src/composites/dependabot.test.ts +48 -0
- package/src/composites/dependabot.ts +91 -0
- package/src/composites/index.ts +2 -0
- package/src/index.ts +2 -0
- package/src/lint/post-synth/{gha020.test.ts → gha013.test.ts} +7 -7
- package/src/lint/post-synth/{gha020.ts → gha013.ts} +4 -4
- package/src/lint/post-synth/gha029.test.ts +108 -0
- package/src/lint/post-synth/gha029.ts +68 -0
- package/src/lint/post-synth/gha030.test.ts +87 -0
- package/src/lint/post-synth/gha030.ts +41 -0
- package/src/lint/post-synth/gha031.test.ts +75 -0
- package/src/lint/post-synth/gha031.ts +77 -0
- package/src/lint/post-synth/gha032.test.ts +65 -0
- package/src/lint/post-synth/gha032.ts +39 -0
- package/src/lint/post-synth/gha033.test.ts +68 -0
- package/src/lint/post-synth/gha033.ts +47 -0
- package/src/lint/post-synth/gha034.test.ts +86 -0
- package/src/lint/post-synth/gha034.ts +41 -0
- package/src/lint/post-synth/gha035.test.ts +85 -0
- package/src/lint/post-synth/gha035.ts +66 -0
- package/src/lint/post-synth/gha036.test.ts +68 -0
- package/src/lint/post-synth/gha036.ts +42 -0
- package/src/lint/post-synth/gha037.test.ts +66 -0
- package/src/lint/post-synth/gha037.ts +46 -0
- package/src/lint/post-synth/gha038.test.ts +64 -0
- package/src/lint/post-synth/gha038.ts +41 -0
- package/src/lint/post-synth/gha039.test.ts +66 -0
- package/src/lint/post-synth/gha039.ts +41 -0
- package/src/lint/post-synth/gha040.test.ts +77 -0
- package/src/lint/post-synth/gha040.ts +43 -0
- package/src/lint/post-synth/gha041.test.ts +48 -0
- package/src/lint/post-synth/gha041.ts +38 -0
- package/src/lint/post-synth/gha042.test.ts +53 -0
- package/src/lint/post-synth/gha042.ts +40 -0
- package/src/lint/post-synth/gha043.test.ts +69 -0
- package/src/lint/post-synth/gha043.ts +41 -0
- package/src/lint/post-synth/gha044.test.ts +56 -0
- package/src/lint/post-synth/gha044.ts +41 -0
- package/src/lint/post-synth/gha045.test.ts +51 -0
- package/src/lint/post-synth/gha045.ts +38 -0
- package/src/lint/post-synth/gha046.test.ts +59 -0
- package/src/lint/post-synth/gha046.ts +56 -0
- package/src/lint/post-synth/gha047.test.ts +66 -0
- package/src/lint/post-synth/gha047.ts +51 -0
- package/src/lint/post-synth/gha048.test.ts +51 -0
- package/src/lint/post-synth/gha048.ts +41 -0
- package/src/lint/post-synth/gha049.test.ts +72 -0
- package/src/lint/post-synth/gha049.ts +42 -0
- package/src/lint/post-synth/gha050.test.ts +55 -0
- package/src/lint/post-synth/gha050.ts +44 -0
- package/src/lint/post-synth/gha051.test.ts +72 -0
- package/src/lint/post-synth/gha051.ts +45 -0
- package/src/lint/post-synth/gha052.test.ts +52 -0
- package/src/lint/post-synth/gha052.ts +42 -0
- package/src/lint/post-synth/gha053.test.ts +65 -0
- package/src/lint/post-synth/gha053.ts +41 -0
- package/src/lint/post-synth/gha054.test.ts +49 -0
- package/src/lint/post-synth/gha054.ts +41 -0
- package/src/lint/post-synth/gha055.test.ts +49 -0
- package/src/lint/post-synth/gha055.ts +50 -0
- package/src/lint/post-synth/gha056.test.ts +47 -0
- package/src/lint/post-synth/gha056.ts +37 -0
- package/src/lint/post-synth/gha057.test.ts +61 -0
- package/src/lint/post-synth/gha057.ts +40 -0
- package/src/lint/post-synth/gha058.test.ts +67 -0
- package/src/lint/post-synth/gha058.ts +48 -0
- package/src/lint/post-synth/yaml-helpers.ts +335 -0
- package/src/lint/rules/data/flagged-actions.ts +36 -0
- package/src/lint/rules/data/known-action-slugs.ts +38 -0
- package/src/lint/rules/data/preinstalled-tools.ts +36 -0
- package/src/lint/rules/data/risky-features.ts +39 -0
- package/src/lint/rules/data/trusted-action-owners.ts +12 -0
- package/src/lint/rules/data/untrusted-contexts.ts +44 -0
- package/src/plugin.test.ts +47 -1
- package/src/serializer.ts +38 -5
package/src/codegen/docs.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/composites/index.ts
CHANGED
|
@@ -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 {
|
|
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("
|
|
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 =
|
|
30
|
+
const diags = gha013.check(makeCtx(yaml));
|
|
31
31
|
expect(diags).toHaveLength(1);
|
|
32
|
-
expect(diags[0].checkId).toBe("
|
|
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 =
|
|
47
|
+
const diags = gha013.check(makeCtx(yaml));
|
|
48
48
|
expect(diags).toHaveLength(1);
|
|
49
|
-
expect(diags[0].checkId).toBe("
|
|
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 =
|
|
63
|
+
const diags = gha013.check(makeCtx(yaml));
|
|
64
64
|
expect(diags).toHaveLength(0);
|
|
65
65
|
});
|
|
66
66
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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
|
|
12
|
-
id: "
|
|
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: "
|
|
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
|
+
};
|