@intentius/chant-lexicon-github 0.49.0 → 0.50.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 (92) hide show
  1. package/dist/components/generate-op-pipeline.d.ts +81 -0
  2. package/dist/components/generate-op-pipeline.d.ts.map +1 -0
  3. package/dist/integrity.json +15 -5
  4. package/dist/lint/audit-catalog.d.ts.map +1 -1
  5. package/dist/lint/post-synth/gha059.d.ts +40 -0
  6. package/dist/lint/post-synth/gha059.d.ts.map +1 -0
  7. package/dist/lint/post-synth/gha060.d.ts +34 -0
  8. package/dist/lint/post-synth/gha060.d.ts.map +1 -0
  9. package/dist/lint/post-synth/gha061.d.ts +27 -0
  10. package/dist/lint/post-synth/gha061.d.ts.map +1 -0
  11. package/dist/lint/post-synth/gha062.d.ts +24 -0
  12. package/dist/lint/post-synth/gha062.d.ts.map +1 -0
  13. package/dist/lint/post-synth/gha063.d.ts +12 -0
  14. package/dist/lint/post-synth/gha063.d.ts.map +1 -0
  15. package/dist/lint/post-synth/gha064.d.ts +12 -0
  16. package/dist/lint/post-synth/gha064.d.ts.map +1 -0
  17. package/dist/lint/post-synth/gha065.d.ts +19 -0
  18. package/dist/lint/post-synth/gha065.d.ts.map +1 -0
  19. package/dist/lint/post-synth/gha066.d.ts +12 -0
  20. package/dist/lint/post-synth/gha066.d.ts.map +1 -0
  21. package/dist/lint/post-synth/gha067.d.ts +12 -0
  22. package/dist/lint/post-synth/gha067.d.ts.map +1 -0
  23. package/dist/lint/post-synth/gha068.d.ts +13 -0
  24. package/dist/lint/post-synth/gha068.d.ts.map +1 -0
  25. package/dist/lint/post-synth/index.d.ts.map +1 -1
  26. package/dist/lint/post-synth/yaml-helpers.d.ts +20 -0
  27. package/dist/lint/post-synth/yaml-helpers.d.ts.map +1 -1
  28. package/dist/lint/rules/data/action-usage-policy.d.ts +30 -0
  29. package/dist/lint/rules/data/action-usage-policy.d.ts.map +1 -0
  30. package/dist/lint/rules/data/advisory-feed.d.ts +36 -0
  31. package/dist/lint/rules/data/advisory-feed.d.ts.map +1 -0
  32. package/dist/manifest.json +109 -1
  33. package/dist/meta.json +5 -0
  34. package/dist/okf/index.md +10 -0
  35. package/dist/okf/rules/GHA059.md +15 -0
  36. package/dist/okf/rules/GHA060.md +15 -0
  37. package/dist/okf/rules/GHA061.md +15 -0
  38. package/dist/okf/rules/GHA062.md +15 -0
  39. package/dist/okf/rules/GHA063.md +15 -0
  40. package/dist/okf/rules/GHA064.md +15 -0
  41. package/dist/okf/rules/GHA065.md +15 -0
  42. package/dist/okf/rules/GHA066.md +15 -0
  43. package/dist/okf/rules/GHA067.md +15 -0
  44. package/dist/okf/rules/GHA068.md +15 -0
  45. package/dist/okf/types/Job.md +9 -0
  46. package/dist/okf/types/Workflow.md +1 -0
  47. package/dist/plugin.d.ts.map +1 -1
  48. package/dist/rules/gha059.ts +132 -0
  49. package/dist/rules/gha060.ts +146 -0
  50. package/dist/rules/gha061.ts +85 -0
  51. package/dist/rules/gha062.ts +68 -0
  52. package/dist/rules/gha063.ts +60 -0
  53. package/dist/rules/gha064.ts +55 -0
  54. package/dist/rules/gha065.ts +129 -0
  55. package/dist/rules/gha066.ts +47 -0
  56. package/dist/rules/gha067.ts +76 -0
  57. package/dist/rules/gha068.ts +51 -0
  58. package/dist/rules/yaml-helpers.ts +37 -2
  59. package/dist/types/index.d.ts +1 -0
  60. package/package.json +2 -2
  61. package/src/codegen/docs.ts +1 -1
  62. package/src/components/generate-op-pipeline.test.ts +146 -0
  63. package/src/components/generate-op-pipeline.ts +183 -0
  64. package/src/generated/index.d.ts +1 -0
  65. package/src/generated/lexicon-github.json +5 -0
  66. package/src/lint/audit-catalog.ts +16 -1
  67. package/src/lint/post-synth/gha059.test.ts +147 -0
  68. package/src/lint/post-synth/gha059.ts +132 -0
  69. package/src/lint/post-synth/gha060.test.ts +144 -0
  70. package/src/lint/post-synth/gha060.ts +146 -0
  71. package/src/lint/post-synth/gha061.test.ts +85 -0
  72. package/src/lint/post-synth/gha061.ts +85 -0
  73. package/src/lint/post-synth/gha062.test.ts +88 -0
  74. package/src/lint/post-synth/gha062.ts +68 -0
  75. package/src/lint/post-synth/gha063.test.ts +86 -0
  76. package/src/lint/post-synth/gha063.ts +60 -0
  77. package/src/lint/post-synth/gha064.test.ts +73 -0
  78. package/src/lint/post-synth/gha064.ts +55 -0
  79. package/src/lint/post-synth/gha065.test.ts +85 -0
  80. package/src/lint/post-synth/gha065.ts +129 -0
  81. package/src/lint/post-synth/gha066.test.ts +66 -0
  82. package/src/lint/post-synth/gha066.ts +47 -0
  83. package/src/lint/post-synth/gha067.test.ts +85 -0
  84. package/src/lint/post-synth/gha067.ts +76 -0
  85. package/src/lint/post-synth/gha068.test.ts +71 -0
  86. package/src/lint/post-synth/gha068.ts +51 -0
  87. package/src/lint/post-synth/index.ts +20 -0
  88. package/src/lint/post-synth/yaml-helpers.ts +37 -2
  89. package/src/lint/rules/data/action-usage-policy.ts +31 -0
  90. package/src/lint/rules/data/advisory-feed.ts +38 -0
  91. package/src/plugin.test.ts +24 -3
  92. package/src/plugin.ts +28 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github",
3
- "version": "0.49.0",
3
+ "version": "0.50.0",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "GitHub",
6
6
  "intrinsics": [
@@ -9,6 +9,114 @@
9
9
  "description": "Typed class wrapping a raw GitHub Actions expression, serializing to `${{ }}` (chant #1069 — was registered as \"expression\", which src/index.ts never exported)",
10
10
  "outputKey": "expression",
11
11
  "isTag": false
12
+ },
13
+ {
14
+ "name": "secrets",
15
+ "description": "Access a secret by name.",
16
+ "isTag": false,
17
+ "foldsEagerly": true
18
+ },
19
+ {
20
+ "name": "matrix",
21
+ "description": "Access a matrix value by key.",
22
+ "isTag": false,
23
+ "foldsEagerly": true
24
+ },
25
+ {
26
+ "name": "steps",
27
+ "description": "Access a step output.",
28
+ "isTag": false,
29
+ "foldsEagerly": true
30
+ },
31
+ {
32
+ "name": "needs",
33
+ "description": "Access a job output from a needed job.",
34
+ "isTag": false,
35
+ "foldsEagerly": true
36
+ },
37
+ {
38
+ "name": "inputs",
39
+ "description": "Access a workflow input.",
40
+ "isTag": false,
41
+ "foldsEagerly": true
42
+ },
43
+ {
44
+ "name": "vars",
45
+ "description": "Access a configuration variable.",
46
+ "isTag": false,
47
+ "foldsEagerly": true
48
+ },
49
+ {
50
+ "name": "env",
51
+ "description": "Access an environment variable.",
52
+ "isTag": false,
53
+ "foldsEagerly": true
54
+ },
55
+ {
56
+ "name": "always",
57
+ "description": "Always run, regardless of status.",
58
+ "isTag": false,
59
+ "foldsEagerly": true
60
+ },
61
+ {
62
+ "name": "failure",
63
+ "description": "Run only if a previous step has failed.",
64
+ "isTag": false,
65
+ "foldsEagerly": true
66
+ },
67
+ {
68
+ "name": "success",
69
+ "description": "Run only if all previous steps succeeded.",
70
+ "isTag": false,
71
+ "foldsEagerly": true
72
+ },
73
+ {
74
+ "name": "cancelled",
75
+ "description": "Run only if the workflow was cancelled.",
76
+ "isTag": false,
77
+ "foldsEagerly": true
78
+ },
79
+ {
80
+ "name": "contains",
81
+ "description": "Check if a string contains a substring.",
82
+ "isTag": false,
83
+ "foldsEagerly": true
84
+ },
85
+ {
86
+ "name": "startsWith",
87
+ "description": "Check if a string starts with a prefix.",
88
+ "isTag": false,
89
+ "foldsEagerly": true
90
+ },
91
+ {
92
+ "name": "toJSON",
93
+ "description": "Convert a value to JSON.",
94
+ "isTag": false,
95
+ "foldsEagerly": true
96
+ },
97
+ {
98
+ "name": "fromJSON",
99
+ "description": "Parse a JSON string.",
100
+ "isTag": false,
101
+ "foldsEagerly": true
102
+ },
103
+ {
104
+ "name": "format",
105
+ "description": "Format a string with placeholders.",
106
+ "isTag": false,
107
+ "foldsEagerly": true
108
+ },
109
+ {
110
+ "name": "branch",
111
+ "description": "Check if the ref matches a branch name.",
112
+ "isTag": false,
113
+ "foldsEagerly": true
114
+ },
115
+ {
116
+ "name": "tag",
117
+ "description": "Check if the ref matches a tag prefix.",
118
+ "isTag": false,
119
+ "foldsEagerly": true
12
120
  }
13
121
  ],
14
122
  "pseudoParameters": {}
package/dist/meta.json CHANGED
@@ -53,6 +53,11 @@
53
53
  "read",
54
54
  "none"
55
55
  ]
56
+ },
57
+ "copilot-requests": {
58
+ "enum": [
59
+ "write"
60
+ ]
56
61
  }
57
62
  }
58
63
  },
package/dist/okf/index.md CHANGED
@@ -68,3 +68,13 @@ okf_version: '0.2'
68
68
  * [GHA056](/rules/GHA056.md) - Workflow without a name
69
69
  * [GHA057](/rules/GHA057.md) - Dependency update allows executing untrusted external code
70
70
  * [GHA058](/rules/GHA058.md) - Dependency update has no cooldown window
71
+ * [GHA059](/rules/GHA059.md) - SHA-pinned action reference has a missing or internally-inconsistent version annotation
72
+ * [GHA060](/rules/GHA060.md) - Generated GitHub App token granted broader scope than its consuming steps evidence
73
+ * [GHA061](/rules/GHA061.md) - Action reference outside the configured usage policy (opt-in)
74
+ * [GHA062](/rules/GHA062.md) - Pinned action reference matches a known-vulnerability advisory (feed-driven)
75
+ * [GHA063](/rules/GHA063.md) - Dependency setup action without caching enabled
76
+ * [GHA064](/rules/GHA064.md) - Job hardcoded onto an expensive runner with no sign it needs that OS
77
+ * [GHA065](/rules/GHA065.md) - Matrix combines dimensions into an uncapped, large fan-out
78
+ * [GHA066](/rules/GHA066.md) - Uploaded artifact has no explicit retention-days
79
+ * [GHA067](/rules/GHA067.md) - Unconditional docker build with no path filter or guard
80
+ * [GHA068](/rules/GHA068.md) - Pull-request workflow missing a concurrency group
@@ -0,0 +1,15 @@
1
+ ---
2
+ type: post-synth-check
3
+ title: GHA059
4
+ description: SHA-pinned action reference has a missing or internally-inconsistent version annotation
5
+ id: GHA059
6
+ severity: error
7
+ category: post-synth
8
+ lexicon: github
9
+ docs: https://intentius.io/chant/lexicons/github/rules/
10
+ ---
11
+ SHA-pinned action reference has a missing or internally-inconsistent version annotation
12
+
13
+ ## Applies to
14
+
15
+ - [Job](/types/Job.md)
@@ -0,0 +1,15 @@
1
+ ---
2
+ type: post-synth-check
3
+ title: GHA060
4
+ description: Generated GitHub App token granted broader scope than its consuming steps evidence
5
+ id: GHA060
6
+ severity: error
7
+ category: post-synth
8
+ lexicon: github
9
+ docs: https://intentius.io/chant/lexicons/github/rules/
10
+ ---
11
+ Generated GitHub App token granted broader scope than its consuming steps evidence
12
+
13
+ ## Applies to
14
+
15
+ - [Job](/types/Job.md)
@@ -0,0 +1,15 @@
1
+ ---
2
+ type: post-synth-check
3
+ title: GHA061
4
+ description: Action reference outside the configured usage policy (opt-in)
5
+ id: GHA061
6
+ severity: error
7
+ category: post-synth
8
+ lexicon: github
9
+ docs: https://intentius.io/chant/lexicons/github/rules/
10
+ ---
11
+ Action reference outside the configured usage policy (opt-in)
12
+
13
+ ## Applies to
14
+
15
+ - [Job](/types/Job.md)
@@ -0,0 +1,15 @@
1
+ ---
2
+ type: post-synth-check
3
+ title: GHA062
4
+ description: Pinned action reference matches a known-vulnerability advisory (feed-driven)
5
+ id: GHA062
6
+ severity: error
7
+ category: post-synth
8
+ lexicon: github
9
+ docs: https://intentius.io/chant/lexicons/github/rules/
10
+ ---
11
+ Pinned action reference matches a known-vulnerability advisory (feed-driven)
12
+
13
+ ## Applies to
14
+
15
+ - [Job](/types/Job.md)
@@ -0,0 +1,15 @@
1
+ ---
2
+ type: post-synth-check
3
+ title: GHA063
4
+ description: Dependency setup action without caching enabled
5
+ id: GHA063
6
+ severity: error
7
+ category: post-synth
8
+ lexicon: github
9
+ docs: https://intentius.io/chant/lexicons/github/rules/
10
+ ---
11
+ Dependency setup action without caching enabled
12
+
13
+ ## Applies to
14
+
15
+ - [Job](/types/Job.md)
@@ -0,0 +1,15 @@
1
+ ---
2
+ type: post-synth-check
3
+ title: GHA064
4
+ description: Job hardcoded onto an expensive runner with no sign it needs that OS
5
+ id: GHA064
6
+ severity: error
7
+ category: post-synth
8
+ lexicon: github
9
+ docs: https://intentius.io/chant/lexicons/github/rules/
10
+ ---
11
+ Job hardcoded onto an expensive runner with no sign it needs that OS
12
+
13
+ ## Applies to
14
+
15
+ - [Job](/types/Job.md)
@@ -0,0 +1,15 @@
1
+ ---
2
+ type: post-synth-check
3
+ title: GHA065
4
+ description: Matrix combines dimensions into an uncapped, large fan-out
5
+ id: GHA065
6
+ severity: error
7
+ category: post-synth
8
+ lexicon: github
9
+ docs: https://intentius.io/chant/lexicons/github/rules/
10
+ ---
11
+ Matrix combines dimensions into an uncapped, large fan-out
12
+
13
+ ## Applies to
14
+
15
+ - [Job](/types/Job.md)
@@ -0,0 +1,15 @@
1
+ ---
2
+ type: post-synth-check
3
+ title: GHA066
4
+ description: Uploaded artifact has no explicit retention-days
5
+ id: GHA066
6
+ severity: error
7
+ category: post-synth
8
+ lexicon: github
9
+ docs: https://intentius.io/chant/lexicons/github/rules/
10
+ ---
11
+ Uploaded artifact has no explicit retention-days
12
+
13
+ ## Applies to
14
+
15
+ - [Job](/types/Job.md)
@@ -0,0 +1,15 @@
1
+ ---
2
+ type: post-synth-check
3
+ title: GHA067
4
+ description: Unconditional docker build with no path filter or guard
5
+ id: GHA067
6
+ severity: error
7
+ category: post-synth
8
+ lexicon: github
9
+ docs: https://intentius.io/chant/lexicons/github/rules/
10
+ ---
11
+ Unconditional docker build with no path filter or guard
12
+
13
+ ## Applies to
14
+
15
+ - [Job](/types/Job.md)
@@ -0,0 +1,15 @@
1
+ ---
2
+ type: post-synth-check
3
+ title: GHA068
4
+ description: Pull-request workflow missing a concurrency group
5
+ id: GHA068
6
+ severity: error
7
+ category: post-synth
8
+ lexicon: github
9
+ docs: https://intentius.io/chant/lexicons/github/rules/
10
+ ---
11
+ Pull-request workflow missing a concurrency group
12
+
13
+ ## Applies to
14
+
15
+ - [Workflow](/types/Workflow.md)
@@ -67,3 +67,12 @@ resource_type: GitHub::Actions::Job
67
67
  - [GHA053](/rules/GHA053.md): Re-enables unsafe set-env / add-path workflow commands
68
68
  - [GHA054](/rules/GHA054.md): Use of a feature with a known security footgun
69
69
  - [GHA055](/rules/GHA055.md): Runtime install of a tool already present on the runner
70
+ - [GHA059](/rules/GHA059.md): SHA-pinned action reference has a missing or internally-inconsistent version annotation
71
+ - [GHA060](/rules/GHA060.md): Generated GitHub App token granted broader scope than its consuming steps evidence
72
+ - [GHA061](/rules/GHA061.md): Action reference outside the configured usage policy (opt-in)
73
+ - [GHA062](/rules/GHA062.md): Pinned action reference matches a known-vulnerability advisory (feed-driven)
74
+ - [GHA063](/rules/GHA063.md): Dependency setup action without caching enabled
75
+ - [GHA064](/rules/GHA064.md): Job hardcoded onto an expensive runner with no sign it needs that OS
76
+ - [GHA065](/rules/GHA065.md): Matrix combines dimensions into an uncapped, large fan-out
77
+ - [GHA066](/rules/GHA066.md): Uploaded artifact has no explicit retention-days
78
+ - [GHA067](/rules/GHA067.md): Unconditional docker build with no path filter or guard
@@ -34,3 +34,4 @@ resource_type: GitHub::Actions::Workflow
34
34
  - [GHA041](/rules/GHA041.md): Blanket secrets: inherit into a reusable workflow
35
35
  - [GHA053](/rules/GHA053.md): Re-enables unsafe set-env / add-path workflow commands
36
36
  - [GHA056](/rules/GHA056.md): Workflow without a name
37
+ - [GHA068](/rules/GHA068.md): Pull-request workflow missing a concurrency group
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,0BAA0B,CAAC;AA4B7F,eAAO,MAAM,YAAY,EAAE,aA+S1B,CAAC"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,0BAA0B,CAAC;AA6B7F,eAAO,MAAM,YAAY,EAAE,aA0U1B,CAAC"}
@@ -0,0 +1,132 @@
1
+ /**
2
+ * GHA059: Stale or Missing Pin Annotation
3
+ *
4
+ * A ref pinned to a commit SHA is only as trustworthy as the label a reviewer
5
+ * reads next to it — `uses: actions/setup-node@1a2b…9a0b # v4.0.2` lets a
6
+ * reviewer sanity-check the digest without resolving it themselves. This check
7
+ * flags two ways that label goes stale:
8
+ *
9
+ * 1. Missing — a SHA-pinned ref with no trailing version comment at all.
10
+ * 2. Mismatched — inferred without any network call, from internal
11
+ * inconsistency in the workflow itself: the same action is pinned to the
12
+ * same commit SHA in one place and a *different* label in another, or the
13
+ * same label is attached to two *different* commit SHAs. Either shape
14
+ * proves at least one of the labels is wrong — a real digest has exactly
15
+ * one correct human-readable name.
16
+ *
17
+ * Cross-referencing a label against the actual git history (to catch a lone,
18
+ * internally-consistent but still-wrong annotation) needs a network call and
19
+ * is out of scope here — see GHA062 for the feed-based advisory check, which
20
+ * takes the same "no network in the rule itself" stance via an injected feed.
21
+ */
22
+
23
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
24
+ import { getPrimaryOutput, extractActionRefs, extractUsesComment, parseActionUses } from "./yaml-helpers";
25
+
26
+ const SHA_RE = /^[0-9a-f]{40}$/;
27
+
28
+ export interface StalePinFinding {
29
+ job: string;
30
+ ref: string;
31
+ slug: string;
32
+ sha: string;
33
+ kind: "missing" | "mismatched";
34
+ /** For "mismatched": the other label/SHA this one conflicts with. */
35
+ conflict?: string;
36
+ }
37
+
38
+ /**
39
+ * Find every SHA-pinned `uses:` with a missing or internally-inconsistent
40
+ * version annotation. Scoped to one workflow document — cross-file
41
+ * consistency isn't attempted since two independent workflows pinning the
42
+ * same action differently isn't evidence of drift.
43
+ */
44
+ export function findStalePinAnnotations(yaml: string): StalePinFinding[] {
45
+ const refs = extractActionRefs(yaml)
46
+ .map(({ job, ref }) => ({ job, ref, parsed: parseActionUses(ref) }))
47
+ .filter((r): r is { job: string; ref: string; parsed: NonNullable<ReturnType<typeof parseActionUses>> } => !!r.parsed && SHA_RE.test(r.parsed.gitRef));
48
+
49
+ const findings: StalePinFinding[] = [];
50
+
51
+ // First pass: missing annotations.
52
+ const withComment: Array<{ job: string; ref: string; slug: string; sha: string; comment: string }> = [];
53
+ for (const { job, ref, parsed } of refs) {
54
+ const comment = extractUsesComment(ref);
55
+ if (!comment) {
56
+ findings.push({ job, ref, slug: parsed.slug, sha: parsed.gitRef, kind: "missing" });
57
+ } else {
58
+ withComment.push({ job, ref, slug: parsed.slug, sha: parsed.gitRef, comment });
59
+ }
60
+ }
61
+
62
+ // Second pass: internal mismatch. Group by slug — the same action's
63
+ // commit-SHA↔label mapping must be one-to-one across the whole file.
64
+ const bySlug = new Map<string, Array<{ job: string; ref: string; slug: string; sha: string; comment: string }>>();
65
+ for (const entry of withComment) {
66
+ const list = bySlug.get(entry.slug) ?? [];
67
+ list.push(entry);
68
+ bySlug.set(entry.slug, list);
69
+ }
70
+
71
+ for (const [, entries] of bySlug) {
72
+ const shaToComments = new Map<string, Set<string>>();
73
+ const commentToShas = new Map<string, Set<string>>();
74
+ for (const e of entries) {
75
+ (shaToComments.get(e.sha) ?? shaToComments.set(e.sha, new Set()).get(e.sha)!).add(e.comment);
76
+ (commentToShas.get(e.comment) ?? commentToShas.set(e.comment, new Set()).get(e.comment)!).add(e.sha);
77
+ }
78
+ for (const e of entries) {
79
+ const otherComments = [...(shaToComments.get(e.sha) ?? [])].filter((c) => c !== e.comment);
80
+ const otherShas = [...(commentToShas.get(e.comment) ?? [])].filter((s) => s !== e.sha);
81
+ if (otherComments.length > 0) {
82
+ findings.push({
83
+ job: e.job,
84
+ ref: e.ref,
85
+ slug: e.slug,
86
+ sha: e.sha,
87
+ kind: "mismatched",
88
+ conflict: `commit ${e.sha} is also annotated "# ${otherComments[0]}" elsewhere`,
89
+ });
90
+ } else if (otherShas.length > 0) {
91
+ findings.push({
92
+ job: e.job,
93
+ ref: e.ref,
94
+ slug: e.slug,
95
+ sha: e.sha,
96
+ kind: "mismatched",
97
+ conflict: `label "# ${e.comment}" is also attached to commit ${otherShas[0]} elsewhere`,
98
+ });
99
+ }
100
+ }
101
+ }
102
+
103
+ return findings;
104
+ }
105
+
106
+ export const gha059: PostSynthCheck = {
107
+ id: "GHA059",
108
+ description: "SHA-pinned action reference has a missing or internally-inconsistent version annotation",
109
+
110
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
111
+ const diagnostics: PostSynthDiagnostic[] = [];
112
+
113
+ for (const [, output] of ctx.outputs) {
114
+ const yaml = getPrimaryOutput(output);
115
+ for (const finding of findStalePinAnnotations(yaml)) {
116
+ const message =
117
+ finding.kind === "missing"
118
+ ? `Job "${finding.job}" pins ${finding.ref} to a commit SHA with no trailing version comment (e.g. "# v4.0.2") — add one so reviewers can sanity-check the digest.`
119
+ : `Job "${finding.job}" pins ${finding.ref} whose annotation is internally inconsistent (${finding.conflict}) — one of these labels no longer matches the digest it's attached to.`;
120
+ diagnostics.push({
121
+ checkId: "GHA059",
122
+ severity: "warning",
123
+ message,
124
+ entity: finding.job,
125
+ lexicon: "github",
126
+ });
127
+ }
128
+ }
129
+
130
+ return diagnostics;
131
+ },
132
+ };
@@ -0,0 +1,146 @@
1
+ /**
2
+ * GHA060: Over-Scoped Generated Token
3
+ *
4
+ * Flags a GitHub App installation token minted with a `permission-<scope>:
5
+ * write`/`admin` input broader than what the job's consuming steps evidence.
6
+ * Targets the well-known GitHub App token actions (`actions/create-github-app-
7
+ * token`, `tibdex/github-app-token`, `getsentry/action-github-app-token`),
8
+ * which take `permission-<resource>: read|write|admin` inputs and hand back
9
+ * the minted token as a step output.
10
+ *
11
+ * Two ways a generated token is over-scoped:
12
+ * 1. Unused — the token's output (`steps.<id>.outputs.token`) is never
13
+ * referenced by any other step in the job, so every write/admin scope
14
+ * granted is pure waste.
15
+ * 2. No signal — the token IS consumed, but no other step in the job shows
16
+ * any evidence (a `gh`/`git` invocation, an API path, a keyword) of
17
+ * exercising a scope granted `write`/`admin`.
18
+ *
19
+ * "No signal" is a heuristic, not a proof — a step could exercise a scope
20
+ * through machinery this doesn't recognize (a custom script calling `octokit`
21
+ * directly, say). It reports `guidance`, not a `deterministic` fix, for
22
+ * exactly that reason: a human confirms before narrowing the grant.
23
+ */
24
+
25
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
26
+ import { getPrimaryOutput, extractStepsByJob, parseActionUses } from "./yaml-helpers";
27
+
28
+ const APP_TOKEN_SLUGS = new Set([
29
+ "actions/create-github-app-token",
30
+ "tibdex/github-app-token",
31
+ "getsentry/action-github-app-token",
32
+ ]);
33
+
34
+ const WRITE_LEVELS = new Set(["write", "admin"]);
35
+
36
+ /** Textual evidence that a step exercises a given permission scope. Not exhaustive — a fallback covers unlisted scopes. */
37
+ const SCOPE_SIGNALS: Record<string, RegExp> = {
38
+ contents: /git\s+push|gh\s+release|\/(?:git\/)?contents\b/i,
39
+ issues: /gh\s+issue|\/issues\b/i,
40
+ "pull-requests": /gh\s+pr\b|\/pulls\b/i,
41
+ packages: /npm\s+publish|docker\s+push|\/packages\b/i,
42
+ administration: /\/admin\b|administration/i,
43
+ actions: /\/actions\/runs|gh\s+run\b/i,
44
+ checks: /check-runs|\bchecks\b/i,
45
+ deployments: /\/deployments\b/i,
46
+ statuses: /\/statuses\b/i,
47
+ workflows: /\.github\/workflows/i,
48
+ environments: /\/environments\b/i,
49
+ };
50
+
51
+ function scopeSignalMatches(scope: string, text: string): boolean {
52
+ const signal = SCOPE_SIGNALS[scope];
53
+ if (signal) return signal.test(text);
54
+ return new RegExp(scope.replace(/-/g, "[-_ ]?"), "i").test(text);
55
+ }
56
+
57
+ function stepText(step: Record<string, unknown>): string {
58
+ const parts: string[] = [];
59
+ if (typeof step.run === "string") parts.push(step.run);
60
+ if (step.with && typeof step.with === "object") parts.push(JSON.stringify(step.with));
61
+ if (step.env && typeof step.env === "object") parts.push(JSON.stringify(step.env));
62
+ if (typeof step.uses === "string") parts.push(step.uses);
63
+ return parts.join("\n");
64
+ }
65
+
66
+ export interface OverScopedTokenFinding {
67
+ job: string;
68
+ stepId: string;
69
+ scopes: string[];
70
+ reason: "unused" | "no-signal";
71
+ }
72
+
73
+ /** Find every generated GitHub App token whose granted write/admin scopes exceed what the job's other steps evidence. */
74
+ export function findOverScopedTokens(yaml: string): OverScopedTokenFinding[] {
75
+ const findings: OverScopedTokenFinding[] = [];
76
+
77
+ for (const [job, steps] of extractStepsByJob(yaml)) {
78
+ steps.forEach((step, idx) => {
79
+ const uses = typeof step.uses === "string" ? step.uses : undefined;
80
+ if (!uses) return;
81
+ const parsed = parseActionUses(uses);
82
+ if (!parsed || !APP_TOKEN_SLUGS.has(parsed.slug)) return;
83
+
84
+ const withBlock = step.with && typeof step.with === "object" ? (step.with as Record<string, unknown>) : {};
85
+ const stepId = typeof step.id === "string" ? step.id : `#${idx}`;
86
+
87
+ const writeScopes: string[] = [];
88
+ for (const [key, value] of Object.entries(withBlock)) {
89
+ const m = /^permission-([a-z-]+)$/i.exec(key);
90
+ if (!m) continue;
91
+ if (WRITE_LEVELS.has(String(value).toLowerCase())) writeScopes.push(m[1]);
92
+ }
93
+ if (writeScopes.length === 0) return;
94
+
95
+ // Evidence comes only from OTHER steps — the minting step's own `with:`
96
+ // block names the scopes being granted, so it can't also count as
97
+ // evidence they're used.
98
+ const otherStepsText = steps
99
+ .filter((_, i) => i !== idx)
100
+ .map(stepText)
101
+ .join("\n");
102
+
103
+ const outputRef = `steps.${stepId}.outputs.token`;
104
+ if (!otherStepsText.includes(outputRef)) {
105
+ findings.push({ job, stepId, scopes: writeScopes, reason: "unused" });
106
+ return;
107
+ }
108
+
109
+ const unsignaled = writeScopes.filter((scope) => !scopeSignalMatches(scope, otherStepsText));
110
+ if (unsignaled.length > 0) {
111
+ findings.push({ job, stepId, scopes: unsignaled, reason: "no-signal" });
112
+ }
113
+ });
114
+ }
115
+
116
+ return findings;
117
+ }
118
+
119
+ export const gha060: PostSynthCheck = {
120
+ id: "GHA060",
121
+ description: "Generated GitHub App token granted broader scope than its consuming steps evidence",
122
+
123
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
124
+ const diagnostics: PostSynthDiagnostic[] = [];
125
+
126
+ for (const [, output] of ctx.outputs) {
127
+ const yaml = getPrimaryOutput(output);
128
+ for (const finding of findOverScopedTokens(yaml)) {
129
+ const scopeList = finding.scopes.map((s) => `permission-${s}`).join(", ");
130
+ const message =
131
+ finding.reason === "unused"
132
+ ? `Job "${finding.job}" step "${finding.stepId}" mints a GitHub App token with ${scopeList} but no other step references its output — remove the unused scope(s) or the token step entirely.`
133
+ : `Job "${finding.job}" step "${finding.stepId}" mints a GitHub App token with ${scopeList}, but no consuming step shows evidence of exercising that scope — narrow the token to the scopes actually used.`;
134
+ diagnostics.push({
135
+ checkId: "GHA060",
136
+ severity: "warning",
137
+ message,
138
+ entity: finding.job,
139
+ lexicon: "github",
140
+ });
141
+ }
142
+ }
143
+
144
+ return diagnostics;
145
+ },
146
+ };