@intentius/chant-lexicon-github 0.46.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 (96) 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 +16 -6
  4. package/dist/lint/audit-catalog.d.ts.map +1 -1
  5. package/dist/lint/post-synth/gha013.d.ts.map +1 -1
  6. package/dist/lint/post-synth/gha059.d.ts +40 -0
  7. package/dist/lint/post-synth/gha059.d.ts.map +1 -0
  8. package/dist/lint/post-synth/gha060.d.ts +34 -0
  9. package/dist/lint/post-synth/gha060.d.ts.map +1 -0
  10. package/dist/lint/post-synth/gha061.d.ts +27 -0
  11. package/dist/lint/post-synth/gha061.d.ts.map +1 -0
  12. package/dist/lint/post-synth/gha062.d.ts +24 -0
  13. package/dist/lint/post-synth/gha062.d.ts.map +1 -0
  14. package/dist/lint/post-synth/gha063.d.ts +12 -0
  15. package/dist/lint/post-synth/gha063.d.ts.map +1 -0
  16. package/dist/lint/post-synth/gha064.d.ts +12 -0
  17. package/dist/lint/post-synth/gha064.d.ts.map +1 -0
  18. package/dist/lint/post-synth/gha065.d.ts +19 -0
  19. package/dist/lint/post-synth/gha065.d.ts.map +1 -0
  20. package/dist/lint/post-synth/gha066.d.ts +12 -0
  21. package/dist/lint/post-synth/gha066.d.ts.map +1 -0
  22. package/dist/lint/post-synth/gha067.d.ts +12 -0
  23. package/dist/lint/post-synth/gha067.d.ts.map +1 -0
  24. package/dist/lint/post-synth/gha068.d.ts +13 -0
  25. package/dist/lint/post-synth/gha068.d.ts.map +1 -0
  26. package/dist/lint/post-synth/index.d.ts.map +1 -1
  27. package/dist/lint/post-synth/yaml-helpers.d.ts +20 -0
  28. package/dist/lint/post-synth/yaml-helpers.d.ts.map +1 -1
  29. package/dist/lint/rules/data/action-usage-policy.d.ts +30 -0
  30. package/dist/lint/rules/data/action-usage-policy.d.ts.map +1 -0
  31. package/dist/lint/rules/data/advisory-feed.d.ts +36 -0
  32. package/dist/lint/rules/data/advisory-feed.d.ts.map +1 -0
  33. package/dist/manifest.json +109 -1
  34. package/dist/meta.json +5 -0
  35. package/dist/okf/index.md +10 -0
  36. package/dist/okf/rules/GHA059.md +15 -0
  37. package/dist/okf/rules/GHA060.md +15 -0
  38. package/dist/okf/rules/GHA061.md +15 -0
  39. package/dist/okf/rules/GHA062.md +15 -0
  40. package/dist/okf/rules/GHA063.md +15 -0
  41. package/dist/okf/rules/GHA064.md +15 -0
  42. package/dist/okf/rules/GHA065.md +15 -0
  43. package/dist/okf/rules/GHA066.md +15 -0
  44. package/dist/okf/rules/GHA067.md +15 -0
  45. package/dist/okf/rules/GHA068.md +15 -0
  46. package/dist/okf/types/Job.md +9 -0
  47. package/dist/okf/types/Workflow.md +1 -0
  48. package/dist/plugin.d.ts.map +1 -1
  49. package/dist/rules/gha013.ts +10 -3
  50. package/dist/rules/gha059.ts +132 -0
  51. package/dist/rules/gha060.ts +146 -0
  52. package/dist/rules/gha061.ts +85 -0
  53. package/dist/rules/gha062.ts +68 -0
  54. package/dist/rules/gha063.ts +60 -0
  55. package/dist/rules/gha064.ts +55 -0
  56. package/dist/rules/gha065.ts +129 -0
  57. package/dist/rules/gha066.ts +47 -0
  58. package/dist/rules/gha067.ts +76 -0
  59. package/dist/rules/gha068.ts +51 -0
  60. package/dist/rules/yaml-helpers.ts +37 -2
  61. package/dist/types/index.d.ts +1 -0
  62. package/package.json +2 -2
  63. package/src/codegen/docs.ts +1 -1
  64. package/src/components/generate-op-pipeline.test.ts +146 -0
  65. package/src/components/generate-op-pipeline.ts +183 -0
  66. package/src/generated/index.d.ts +1 -0
  67. package/src/generated/lexicon-github.json +5 -0
  68. package/src/lint/audit-catalog.ts +16 -1
  69. package/src/lint/post-synth/gha013.test.ts +17 -0
  70. package/src/lint/post-synth/gha013.ts +10 -3
  71. package/src/lint/post-synth/gha059.test.ts +147 -0
  72. package/src/lint/post-synth/gha059.ts +132 -0
  73. package/src/lint/post-synth/gha060.test.ts +144 -0
  74. package/src/lint/post-synth/gha060.ts +146 -0
  75. package/src/lint/post-synth/gha061.test.ts +85 -0
  76. package/src/lint/post-synth/gha061.ts +85 -0
  77. package/src/lint/post-synth/gha062.test.ts +88 -0
  78. package/src/lint/post-synth/gha062.ts +68 -0
  79. package/src/lint/post-synth/gha063.test.ts +86 -0
  80. package/src/lint/post-synth/gha063.ts +60 -0
  81. package/src/lint/post-synth/gha064.test.ts +73 -0
  82. package/src/lint/post-synth/gha064.ts +55 -0
  83. package/src/lint/post-synth/gha065.test.ts +85 -0
  84. package/src/lint/post-synth/gha065.ts +129 -0
  85. package/src/lint/post-synth/gha066.test.ts +66 -0
  86. package/src/lint/post-synth/gha066.ts +47 -0
  87. package/src/lint/post-synth/gha067.test.ts +85 -0
  88. package/src/lint/post-synth/gha067.ts +76 -0
  89. package/src/lint/post-synth/gha068.test.ts +71 -0
  90. package/src/lint/post-synth/gha068.ts +51 -0
  91. package/src/lint/post-synth/index.ts +20 -0
  92. package/src/lint/post-synth/yaml-helpers.ts +37 -2
  93. package/src/lint/rules/data/action-usage-policy.ts +31 -0
  94. package/src/lint/rules/data/advisory-feed.ts +38 -0
  95. package/src/plugin.test.ts +24 -3
  96. package/src/plugin.ts +28 -0
@@ -0,0 +1,85 @@
1
+ /**
2
+ * GHA061: Action Reference Outside the Configured Usage Policy
3
+ *
4
+ * An opt-in allow/deny check over third-party `uses:` references, for
5
+ * environments that constrain which external components may run at all —
6
+ * distinct from GHA029/031/032, which judge a reference on its own pinning/
7
+ * naming/health, not against an organizational decision about who is trusted.
8
+ *
9
+ * Ships silent: `DEFAULT_ACTION_USAGE_POLICY` (../rules/data/action-usage-
10
+ * policy.ts) is empty, and {@link evaluateUsagePolicy} returns no findings
11
+ * for an empty policy — see that module's doc for why (`PostSynthContext`
12
+ * carries no per-check runtime config channel) and how a project actually
13
+ * opts in: author a `lint.policies` entry that calls `evaluateUsagePolicy`
14
+ * with its own policy, wrapped in a `PostSynthCheck`.
15
+ */
16
+
17
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
18
+ import { getPrimaryOutput, extractActionRefs, parseActionUses } from "./yaml-helpers";
19
+ import { DEFAULT_ACTION_USAGE_POLICY, type ActionUsagePolicy } from "../rules/data/action-usage-policy";
20
+
21
+ export type { ActionUsagePolicy } from "../rules/data/action-usage-policy";
22
+
23
+ function matches(entry: string, owner: string, slug: string): boolean {
24
+ if (entry === slug) return true;
25
+ if (entry === owner) return true;
26
+ if (entry.endsWith("/*") && entry.slice(0, -2) === owner) return true;
27
+ return false;
28
+ }
29
+
30
+ function matchesAny(entries: string[] | undefined, owner: string, slug: string): boolean {
31
+ return (entries ?? []).some((e) => matches(e, owner, slug));
32
+ }
33
+
34
+ /**
35
+ * Evaluate a workflow's `uses:` references against a usage policy. Pure: no
36
+ * fs, no network, no default policy baked in — an empty/unset policy (no
37
+ * `allow` and no `deny`) always yields no findings, which is what makes this
38
+ * genuinely opt-in rather than "opt-in by omission of one flag."
39
+ */
40
+ export function evaluateUsagePolicy(yaml: string, policy: ActionUsagePolicy): PostSynthDiagnostic[] {
41
+ const { allow, deny } = policy;
42
+ if ((!allow || allow.length === 0) && (!deny || deny.length === 0)) return [];
43
+
44
+ const diagnostics: PostSynthDiagnostic[] = [];
45
+ for (const { job, ref } of extractActionRefs(yaml)) {
46
+ const parsed = parseActionUses(ref);
47
+ if (!parsed) continue; // local or docker:// reference — not a registry policy carries slugs for
48
+
49
+ if (matchesAny(deny, parsed.owner, parsed.slug)) {
50
+ diagnostics.push({
51
+ checkId: "GHA061",
52
+ severity: "error",
53
+ message: `Job "${job}" uses "${parsed.slug}", which is denied by the configured action-usage policy.`,
54
+ entity: job,
55
+ lexicon: "github",
56
+ });
57
+ continue;
58
+ }
59
+
60
+ if (allow && allow.length > 0 && !matchesAny(allow, parsed.owner, parsed.slug)) {
61
+ diagnostics.push({
62
+ checkId: "GHA061",
63
+ severity: "warning",
64
+ message: `Job "${job}" uses "${parsed.slug}", which is not in the configured action-usage allowlist.`,
65
+ entity: job,
66
+ lexicon: "github",
67
+ });
68
+ }
69
+ }
70
+ return diagnostics;
71
+ }
72
+
73
+ export const gha061: PostSynthCheck = {
74
+ id: "GHA061",
75
+ description: "Action reference outside the configured usage policy (opt-in)",
76
+
77
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
78
+ const diagnostics: PostSynthDiagnostic[] = [];
79
+ for (const [, output] of ctx.outputs) {
80
+ const yaml = getPrimaryOutput(output);
81
+ diagnostics.push(...evaluateUsagePolicy(yaml, DEFAULT_ACTION_USAGE_POLICY));
82
+ }
83
+ return diagnostics;
84
+ },
85
+ };
@@ -0,0 +1,68 @@
1
+ /**
2
+ * GHA062: Pinned Reference Matches a Known-Vulnerability Advisory
3
+ *
4
+ * Cross-references every pinned `uses:` (commit SHA or tag/branch) against a
5
+ * caller-supplied advisory feed and flags a match. Unlike GHA031/032's
6
+ * vendored, committed reference lists, a vulnerability feed is meant to be
7
+ * refreshed continuously — so this rule takes it as data, not code: the pure
8
+ * core never fetches (epic #350's design spine), and an absent/empty feed
9
+ * degrades to "no findings," never an error or a thrown exception. That is
10
+ * what "unreachable" means for a rule that makes no network call of its own:
11
+ * whatever couldn't reach the feed already failed upstream of this function,
12
+ * and this function's job is to not compound that with a crash.
13
+ */
14
+
15
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
16
+ import { getPrimaryOutput, extractActionRefs, parseActionUses } from "./yaml-helpers";
17
+ import { DEFAULT_ADVISORY_FEED, type AdvisoryFeed, type AdvisoryEntry } from "../rules/data/advisory-feed";
18
+
19
+ export type { AdvisoryFeed, AdvisoryEntry } from "../rules/data/advisory-feed";
20
+
21
+ function matchesEntry(entry: AdvisoryEntry, slug: string, gitRef: string): boolean {
22
+ if (entry.slug !== slug) return false;
23
+ if (entry.shas?.includes(gitRef)) return true;
24
+ if (entry.refs?.includes(gitRef)) return true;
25
+ return false;
26
+ }
27
+
28
+ /**
29
+ * Check a workflow's pinned action references against an advisory feed. Pure
30
+ * and total: a missing/empty feed (`entries.length === 0`, including the
31
+ * `undefined` default) always yields `[]`, never a throw.
32
+ */
33
+ export function checkAdvisories(yaml: string, feed: AdvisoryFeed | undefined = DEFAULT_ADVISORY_FEED): PostSynthDiagnostic[] {
34
+ if (!feed || !feed.entries || feed.entries.length === 0) return [];
35
+
36
+ const diagnostics: PostSynthDiagnostic[] = [];
37
+ for (const { job, ref } of extractActionRefs(yaml)) {
38
+ const parsed = parseActionUses(ref);
39
+ if (!parsed) continue;
40
+ for (const entry of feed.entries) {
41
+ if (!matchesEntry(entry, parsed.slug, parsed.gitRef)) continue;
42
+ const patch = entry.patchedRef ? ` A patched ref is available: ${entry.patchedRef}.` : "";
43
+ const link = entry.url ? ` (${entry.url})` : "";
44
+ diagnostics.push({
45
+ checkId: "GHA062",
46
+ severity: "error",
47
+ message: `Job "${job}" uses "${parsed.slug}@${parsed.gitRef}", which matches disclosed advisory ${entry.id}${link}: ${entry.summary}.${patch}`,
48
+ entity: job,
49
+ lexicon: "github",
50
+ });
51
+ }
52
+ }
53
+ return diagnostics;
54
+ }
55
+
56
+ export const gha062: PostSynthCheck = {
57
+ id: "GHA062",
58
+ description: "Pinned action reference matches a known-vulnerability advisory (feed-driven)",
59
+
60
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
61
+ const diagnostics: PostSynthDiagnostic[] = [];
62
+ for (const [, output] of ctx.outputs) {
63
+ const yaml = getPrimaryOutput(output);
64
+ diagnostics.push(...checkAdvisories(yaml, DEFAULT_ADVISORY_FEED));
65
+ }
66
+ return diagnostics;
67
+ },
68
+ };
@@ -0,0 +1,60 @@
1
+ /**
2
+ * GHA063: Dependency Setup Without Caching
3
+ *
4
+ * Flags a `actions/setup-{node,python,java,ruby,dotnet}` step that leaves its
5
+ * built-in `cache:` option unset, with no adjacent `actions/cache` step to
6
+ * cover the gap. Every run re-downloads the same dependencies from the
7
+ * registry from a cold cache — pure wasted time and bandwidth, not a
8
+ * correctness or security issue (efficiency, #444).
9
+ */
10
+
11
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
12
+ import { getPrimaryOutput, jobEntries, parseActionUses } from "./yaml-helpers";
13
+
14
+ /** Setup actions whose `with.cache` option enables built-in dependency caching. */
15
+ const CACHEABLE_SETUP_ACTIONS = new Set(["actions/setup-node", "actions/setup-python", "actions/setup-java", "actions/setup-ruby", "actions/setup-dotnet"]);
16
+
17
+ function cacheEnabled(withBlock: unknown): boolean {
18
+ if (!withBlock || typeof withBlock !== "object") return false;
19
+ const cache = (withBlock as Record<string, unknown>).cache;
20
+ if (typeof cache === "boolean") return cache;
21
+ if (typeof cache === "string") return cache.trim().length > 0 && cache.trim() !== "false";
22
+ return false;
23
+ }
24
+
25
+ export const gha063: PostSynthCheck = {
26
+ id: "GHA063",
27
+ description: "Dependency setup action without caching enabled",
28
+
29
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
30
+ const diagnostics: PostSynthDiagnostic[] = [];
31
+
32
+ for (const [, output] of ctx.outputs) {
33
+ const yaml = getPrimaryOutput(output);
34
+
35
+ for (const [jobName, jobObj] of jobEntries(yaml)) {
36
+ const steps = Array.isArray(jobObj.steps) ? (jobObj.steps as Array<Record<string, unknown>>) : [];
37
+ const hasGenericCacheStep = steps.some((s) => parseActionUses(String(s.uses ?? ""))?.slug === "actions/cache");
38
+ if (hasGenericCacheStep) continue;
39
+
40
+ for (const step of steps) {
41
+ const uses = typeof step.uses === "string" ? step.uses : undefined;
42
+ if (!uses) continue;
43
+ const parsed = parseActionUses(uses);
44
+ if (!parsed || !CACHEABLE_SETUP_ACTIONS.has(parsed.slug)) continue;
45
+ if (cacheEnabled(step.with)) continue;
46
+
47
+ diagnostics.push({
48
+ checkId: "GHA063",
49
+ severity: "info",
50
+ message: `Job "${jobName}" uses ${parsed.slug} without enabling its \`cache:\` option, and no separate actions/cache step covers it — dependencies are re-fetched from a cold cache on every run. Set \`with.cache\` (e.g. \`cache: npm\`) or add an actions/cache step.`,
51
+ entity: jobName,
52
+ lexicon: "github",
53
+ });
54
+ }
55
+ }
56
+ }
57
+
58
+ return diagnostics;
59
+ },
60
+ };
@@ -0,0 +1,55 @@
1
+ /**
2
+ * GHA064: Expensive Runner Without Justification
3
+ *
4
+ * Flags a job hardcoded onto a pricier macOS/Windows runner (billed at a
5
+ * multiple of Linux minutes) when none of its `run:` steps show any sign the
6
+ * job actually needs that OS. A matrix-driven `runs-on: ${{ matrix.os }}`
7
+ * (intentional cross-platform testing) is never flagged — only a literal,
8
+ * hardcoded label. Efficiency (#444), not a correctness/security issue.
9
+ */
10
+
11
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
12
+ import { getPrimaryOutput, extractRunsOnByJob, extractRunBlocks } from "./yaml-helpers";
13
+
14
+ const OS_JUSTIFICATION: Array<{ prefix: string; label: string; keywords: RegExp }> = [
15
+ { prefix: "macos-", label: "macOS", keywords: /xcodebuild|xcode-select|\.xcodeproj|\.xcworkspace|carthage|fastlane|cocoapods|pod install|swiftpm|codesign|notarize/i },
16
+ { prefix: "windows-", label: "Windows", keywords: /msbuild|\.sln\b|\.ps1\b|vcvarsall|nuget\s|choco\s|Set-ItemProperty|Get-ChildItem/i },
17
+ ];
18
+
19
+ export const gha064: PostSynthCheck = {
20
+ id: "GHA064",
21
+ description: "Job hardcoded onto an expensive runner with no sign it needs that OS",
22
+
23
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
24
+ const diagnostics: PostSynthDiagnostic[] = [];
25
+
26
+ for (const [, output] of ctx.outputs) {
27
+ const yaml = getPrimaryOutput(output);
28
+ const runsOnByJob = extractRunsOnByJob(yaml);
29
+ const runBlocks = extractRunBlocks(yaml);
30
+
31
+ for (const [jobName, labels] of runsOnByJob) {
32
+ if (labels.some((l) => l.includes("${{"))) continue; // matrix/expression-driven — intentional
33
+
34
+ for (const { prefix, label, keywords } of OS_JUSTIFICATION) {
35
+ const matched = labels.find((l) => l.toLowerCase().startsWith(prefix));
36
+ if (!matched) continue;
37
+
38
+ const justified = runBlocks.some((r) => r.job === jobName && keywords.test(r.run));
39
+ if (justified) break;
40
+
41
+ diagnostics.push({
42
+ checkId: "GHA064",
43
+ severity: "info",
44
+ message: `Job "${jobName}" runs on "${matched}" (a pricier ${label} runner) but no step looks ${label}-specific. Confirm it needs ${label}, or move it to a Linux runner.`,
45
+ entity: jobName,
46
+ lexicon: "github",
47
+ });
48
+ break;
49
+ }
50
+ }
51
+ }
52
+
53
+ return diagnostics;
54
+ },
55
+ };
@@ -0,0 +1,129 @@
1
+ /**
2
+ * GHA065: Unbounded Matrix Fan-Out
3
+ *
4
+ * Flags a `strategy.matrix` that combines two or more dimensions into more
5
+ * jobs than a `max-parallel:` cap bounds. Two orthogonal axes each with a
6
+ * handful of values multiply, not add — `os` x `node` x `arch` can produce
7
+ * dozens of jobs from a config that reads like a short list. Unbounded, that
8
+ * is capacity spent on a combination nobody asked for, not a correctness or
9
+ * security issue (efficiency, #444).
10
+ *
11
+ * Parses the `matrix:` block by indentation, not the structural YAML parser
12
+ * — a flow list of bare words (`os: [ubuntu-latest, macos-latest]`, the
13
+ * overwhelmingly common hand-written form) is not one it round-trips, only
14
+ * a flow list of numbers. Mirrors GHA009's regex-based matrix handling for
15
+ * the same reason.
16
+ */
17
+
18
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
19
+ import { getPrimaryOutput, extractJobs } from "./yaml-helpers";
20
+
21
+ /** Above this many combinations, an uncapped matrix is flagged. */
22
+ const FANOUT_THRESHOLD = 12;
23
+
24
+ /** The raw text of one job's block within `jobs:`, or undefined if not found. */
25
+ function jobSection(yaml: string, jobName: string): string | undefined {
26
+ const jobsIdx = yaml.search(/^jobs:\s*$/m);
27
+ if (jobsIdx === -1) return undefined;
28
+ const afterJobs = yaml.slice(jobsIdx + yaml.slice(jobsIdx).indexOf("\n") + 1);
29
+ const header = ` ${jobName}:\n`;
30
+ const start = afterJobs.indexOf(header);
31
+ if (start === -1) return undefined;
32
+ const rest = afterJobs.slice(start + header.length);
33
+ const nextJobMatch = rest.search(/\n {2}\S/);
34
+ return nextJobMatch === -1 ? rest : rest.slice(0, nextJobMatch);
35
+ }
36
+
37
+ interface MatrixInfo {
38
+ dims: Array<{ key: string; size: number }>;
39
+ maxParallel: boolean;
40
+ }
41
+
42
+ /** Parse a job section's `strategy.matrix` dimensions and whether max-parallel caps it. */
43
+ function parseMatrix(section: string): MatrixInfo | undefined {
44
+ const lines = section.split("\n");
45
+ const matrixLineIdx = lines.findIndex((l) => /^\s*matrix:\s*$/.test(l));
46
+ if (matrixLineIdx === -1) return undefined;
47
+ const matrixIndent = lines[matrixLineIdx].search(/\S/);
48
+
49
+ const maxParallel = lines.some((l) => {
50
+ const m = l.match(/^(\s*)max-parallel:/);
51
+ return !!m && m[1].length === matrixIndent;
52
+ });
53
+
54
+ const dims: Array<{ key: string; size: number }> = [];
55
+ let i = matrixLineIdx + 1;
56
+ while (i < lines.length) {
57
+ const line = lines[i];
58
+ if (line.trim() === "") { i++; continue; }
59
+ const indent = line.search(/\S/);
60
+ if (indent <= matrixIndent) break; // dedented out of the matrix: block
61
+
62
+ const kv = line.match(/^\s*([a-zA-Z0-9_-]+):\s*(.*)$/);
63
+ if (!kv) { i++; continue; }
64
+ const [, key, rest] = kv;
65
+ const value = rest.trim();
66
+
67
+ if (value.startsWith("[")) {
68
+ const inner = value.replace(/^\[|\]\s*$/g, "").trim();
69
+ dims.push({ key, size: inner === "" ? 0 : inner.split(",").length });
70
+ i++;
71
+ continue;
72
+ }
73
+ if (value !== "") {
74
+ dims.push({ key, size: 1 }); // a bare scalar dimension
75
+ i++;
76
+ continue;
77
+ }
78
+ // Block-list value on subsequent, deeper-indented lines.
79
+ let size = 0;
80
+ i++;
81
+ while (i < lines.length) {
82
+ const l2 = lines[i];
83
+ if (l2.trim() === "") { i++; continue; }
84
+ const ind2 = l2.search(/\S/);
85
+ if (ind2 <= indent) break;
86
+ if (/^\s*-\s/.test(l2)) size++;
87
+ i++;
88
+ }
89
+ dims.push({ key, size });
90
+ }
91
+
92
+ return { dims, maxParallel };
93
+ }
94
+
95
+ export const gha065: PostSynthCheck = {
96
+ id: "GHA065",
97
+ description: "Matrix combines dimensions into an uncapped, large fan-out",
98
+
99
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
100
+ const diagnostics: PostSynthDiagnostic[] = [];
101
+
102
+ for (const [, output] of ctx.outputs) {
103
+ const yaml = getPrimaryOutput(output);
104
+
105
+ for (const [jobName] of extractJobs(yaml)) {
106
+ const section = jobSection(yaml, jobName);
107
+ if (!section) continue;
108
+ const matrix = parseMatrix(section);
109
+ if (!matrix) continue;
110
+
111
+ const dims = matrix.dims.filter((d) => d.key !== "include" && d.key !== "exclude" && d.size > 0);
112
+ if (dims.length < 2) continue; // a single axis isn't "combined" fan-out
113
+
114
+ const combos = dims.reduce((a, d) => a * d.size, 1);
115
+ if (combos <= FANOUT_THRESHOLD || matrix.maxParallel) continue;
116
+
117
+ diagnostics.push({
118
+ checkId: "GHA065",
119
+ severity: "info",
120
+ message: `Job "${jobName}"'s matrix combines ${dims.length} dimensions (${dims.map((d) => d.key).join(" x ")}) into ${combos} jobs with no \`max-parallel:\` cap. Confirm the full cross-product is intended, trim it with \`include\`/\`exclude\`, or add \`max-parallel:\`.`,
121
+ entity: jobName,
122
+ lexicon: "github",
123
+ });
124
+ }
125
+ }
126
+
127
+ return diagnostics;
128
+ },
129
+ };
@@ -0,0 +1,47 @@
1
+ /**
2
+ * GHA066: Unbounded Artifact Retention
3
+ *
4
+ * Flags an `actions/upload-artifact` step with no `retention-days`, which
5
+ * falls back to the repository's default (up to 90 days). Build logs, test
6
+ * output, and intermediate artifacts rarely need to outlive the PR they came
7
+ * from; an unset retention silently accumulates storage. Efficiency (#444),
8
+ * not a correctness or security issue.
9
+ */
10
+
11
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
12
+ import { getPrimaryOutput, jobEntries, parseActionUses } from "./yaml-helpers";
13
+
14
+ export const gha066: PostSynthCheck = {
15
+ id: "GHA066",
16
+ description: "Uploaded artifact has no explicit retention-days",
17
+
18
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
19
+ const diagnostics: PostSynthDiagnostic[] = [];
20
+
21
+ for (const [, output] of ctx.outputs) {
22
+ const yaml = getPrimaryOutput(output);
23
+
24
+ for (const [jobName, jobObj] of jobEntries(yaml)) {
25
+ const steps = Array.isArray(jobObj.steps) ? (jobObj.steps as Array<Record<string, unknown>>) : [];
26
+ for (const step of steps) {
27
+ const uses = typeof step.uses === "string" ? step.uses : undefined;
28
+ if (!uses || parseActionUses(uses)?.slug !== "actions/upload-artifact") continue;
29
+
30
+ const withBlock = step.with as Record<string, unknown> | undefined;
31
+ if (withBlock && withBlock["retention-days"] !== undefined) continue;
32
+
33
+ const name = typeof withBlock?.name === "string" ? withBlock.name : undefined;
34
+ diagnostics.push({
35
+ checkId: "GHA066",
36
+ severity: "info",
37
+ message: `Job "${jobName}" uploads${name ? ` artifact "${name}"` : " an artifact"} with no \`retention-days\` — it falls back to the repository's default (up to 90 days). Set a \`retention-days\` sized to how long the artifact is actually needed.`,
38
+ entity: jobName,
39
+ lexicon: "github",
40
+ });
41
+ }
42
+ }
43
+ }
44
+
45
+ return diagnostics;
46
+ },
47
+ };
@@ -0,0 +1,76 @@
1
+ /**
2
+ * GHA067: Unconditional Heavy Step With No Path Filter
3
+ *
4
+ * Flags a `docker build`/`docker buildx build` step that has no `if:` guard,
5
+ * in a workflow that triggers on `push`/`pull_request` with no `paths:` or
6
+ * `paths-ignore:` filter. A docs-only or unrelated change still pays for a
7
+ * full image build because nothing scopes the trigger or guards the step.
8
+ * Efficiency (#444), not a correctness or security issue.
9
+ */
10
+
11
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
12
+ import { getPrimaryOutput, parseDoc, jobEntries } from "./yaml-helpers";
13
+
14
+ const DOCKER_BUILD_RE = /\bdocker(\s+buildx)?\s+build\b/i;
15
+
16
+ function triggerObject(on: unknown, key: string): Record<string, unknown> | undefined {
17
+ if (!on || typeof on !== "object" || Array.isArray(on)) return undefined;
18
+ const trig = (on as Record<string, unknown>)[key];
19
+ return trig && typeof trig === "object" && !Array.isArray(trig) ? (trig as Record<string, unknown>) : undefined;
20
+ }
21
+
22
+ function hasPushOrPRTrigger(on: unknown): boolean {
23
+ if (typeof on === "string") return on === "push" || on === "pull_request";
24
+ if (Array.isArray(on)) return on.includes("push") || on.includes("pull_request");
25
+ if (on && typeof on === "object") {
26
+ const keys = Object.keys(on as Record<string, unknown>);
27
+ return keys.includes("push") || keys.includes("pull_request");
28
+ }
29
+ return false;
30
+ }
31
+
32
+ function hasPathFilter(on: unknown): boolean {
33
+ for (const key of ["push", "pull_request"]) {
34
+ const trig = triggerObject(on, key);
35
+ if (!trig) continue;
36
+ if (Array.isArray(trig.paths) && trig.paths.length > 0) return true;
37
+ if (Array.isArray(trig["paths-ignore"]) && trig["paths-ignore"].length > 0) return true;
38
+ }
39
+ return false;
40
+ }
41
+
42
+ export const gha067: PostSynthCheck = {
43
+ id: "GHA067",
44
+ description: "Unconditional docker build with no path filter or guard",
45
+
46
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
47
+ const diagnostics: PostSynthDiagnostic[] = [];
48
+
49
+ for (const [, output] of ctx.outputs) {
50
+ const yaml = getPrimaryOutput(output);
51
+ const doc = parseDoc(yaml);
52
+ if (!doc) continue;
53
+ if (!hasPushOrPRTrigger(doc.on)) continue;
54
+ if (hasPathFilter(doc.on)) continue;
55
+
56
+ for (const [jobName, jobObj] of jobEntries(yaml)) {
57
+ const steps = Array.isArray(jobObj.steps) ? (jobObj.steps as Array<Record<string, unknown>>) : [];
58
+ for (const step of steps) {
59
+ const run = typeof step.run === "string" ? step.run : undefined;
60
+ if (!run || !DOCKER_BUILD_RE.test(run)) continue;
61
+ if (step.if !== undefined) continue; // already guarded
62
+
63
+ diagnostics.push({
64
+ checkId: "GHA067",
65
+ severity: "info",
66
+ message: `Job "${jobName}" runs a Docker build on every push/pull_request with no \`paths:\`/\`paths-ignore:\` filter and no \`if:\` guard — unrelated changes (docs, etc.) still pay for a full image build. Scope the trigger's paths or add a guard.`,
67
+ entity: jobName,
68
+ lexicon: "github",
69
+ });
70
+ }
71
+ }
72
+ }
73
+
74
+ return diagnostics;
75
+ },
76
+ };
@@ -0,0 +1,51 @@
1
+ /**
2
+ * GHA068: Pull-Request Workflow Missing a Concurrency Group
3
+ *
4
+ * Flags a workflow triggered on `pull_request` with no top-level
5
+ * `concurrency:` block. Without one, a superseded push keeps its old run
6
+ * going instead of cancelling it, so every commit on a PR pays for the full
7
+ * pipeline instead of just the latest. Deploy workflows are left to GHA024,
8
+ * which already covers them (overlapping-deployment risk, not capacity).
9
+ * Efficiency (#444), not a correctness or security issue.
10
+ */
11
+
12
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
13
+ import { getPrimaryOutput, parseDoc, extractJobs, extractWorkflowName } from "./yaml-helpers";
14
+
15
+ function hasPullRequestTrigger(on: unknown): boolean {
16
+ if (typeof on === "string") return on === "pull_request";
17
+ if (Array.isArray(on)) return on.includes("pull_request");
18
+ if (on && typeof on === "object") return "pull_request" in (on as Record<string, unknown>);
19
+ return false;
20
+ }
21
+
22
+ export const gha068: PostSynthCheck = {
23
+ id: "GHA068",
24
+ description: "Pull-request workflow missing a concurrency group",
25
+
26
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
27
+ const diagnostics: PostSynthDiagnostic[] = [];
28
+
29
+ for (const [, output] of ctx.outputs) {
30
+ const yaml = getPrimaryOutput(output);
31
+ const doc = parseDoc(yaml);
32
+ if (!doc || !hasPullRequestTrigger(doc.on)) continue;
33
+
34
+ const workflowName = extractWorkflowName(yaml) ?? "";
35
+ const jobNames = [...extractJobs(yaml).keys()];
36
+ const isDeployWorkflow = /deploy/i.test(workflowName) || jobNames.some((n) => /deploy/i.test(n));
37
+ if (isDeployWorkflow) continue; // GHA024 already covers deploy workflows
38
+
39
+ if (!/^\s*concurrency:/m.test(yaml)) {
40
+ diagnostics.push({
41
+ checkId: "GHA068",
42
+ severity: "info",
43
+ message: `Workflow${workflowName ? ` "${workflowName}"` : ""} triggers on pull_request with no \`concurrency:\` group — a new push doesn't cancel the superseded run, so every commit on the PR consumes a full run's worth of runner capacity. Add a \`concurrency:\` group with \`cancel-in-progress: true\`.`,
44
+ lexicon: "github",
45
+ });
46
+ }
47
+ }
48
+
49
+ return diagnostics;
50
+ },
51
+ };
@@ -172,7 +172,8 @@ export interface ImageRef {
172
172
  source: "container" | "service" | "step";
173
173
  }
174
174
 
175
- function parseDoc(yaml: string): Record<string, unknown> | undefined {
175
+ /** Parse a workflow YAML into a structured document, tolerant of parse errors. */
176
+ export function parseDoc(yaml: string): Record<string, unknown> | undefined {
176
177
  try {
177
178
  return parseYAML(yaml);
178
179
  } catch {
@@ -180,7 +181,8 @@ function parseDoc(yaml: string): Record<string, unknown> | undefined {
180
181
  }
181
182
  }
182
183
 
183
- function jobEntries(yaml: string): Array<[string, Record<string, unknown>]> {
184
+ /** Every job in the workflow as `[name, rawJobObject]`, via the structural parser. */
185
+ export function jobEntries(yaml: string): Array<[string, Record<string, unknown>]> {
184
186
  const doc = parseDoc(yaml);
185
187
  const jobs = doc?.jobs;
186
188
  if (!jobs || typeof jobs !== "object") return [];
@@ -193,6 +195,26 @@ function jobEntries(yaml: string): Array<[string, Record<string, unknown>]> {
193
195
  return out;
194
196
  }
195
197
 
198
+ /**
199
+ * Every step object (raw, as parsed from YAML — `uses`, `with`, `id`, `run`,
200
+ * `env`, etc.) grouped by owning job. Unlike {@link extractJobs} (which only
201
+ * lifts `uses`/`run`/`name`), this hands back each step's full object so a
202
+ * check can read arbitrary keys (`with:` inputs, `id:`) without growing a new
203
+ * bespoke regex extractor per key. See GHA060.
204
+ */
205
+ export function extractStepsByJob(yaml: string): Map<string, Array<Record<string, unknown>>> {
206
+ const out = new Map<string, Array<Record<string, unknown>>>();
207
+ for (const [job, jobObj] of jobEntries(yaml)) {
208
+ const steps = jobObj.steps;
209
+ if (!Array.isArray(steps)) continue;
210
+ out.set(
211
+ job,
212
+ steps.filter((s): s is Record<string, unknown> => !!s && typeof s === "object" && !Array.isArray(s)),
213
+ );
214
+ }
215
+ return out;
216
+ }
217
+
196
218
  /**
197
219
  * Extract every `uses:` reference from the workflow — both step-level actions
198
220
  * and job-level reusable-workflow calls. Uses the structural YAML parser so
@@ -462,6 +484,19 @@ export function stripUsesComment(uses: string): string {
462
484
  return uses.replace(/\s+#.*$/, "").trim();
463
485
  }
464
486
 
487
+ /**
488
+ * Extract a `uses:` value's trailing inline comment, if any — the
489
+ * human-readable version label reviewers rely on when a ref is pinned to an
490
+ * opaque commit SHA (e.g. `actions/setup-node@1a2b…9a0b # v4.0.2` → `v4.0.2`).
491
+ * Returns undefined for no comment or an empty (`# ` with nothing after it)
492
+ * one, since both mean "no annotation" to a reviewer. See GHA059.
493
+ */
494
+ export function extractUsesComment(rawUses: string): string | undefined {
495
+ const m = rawUses.match(/\s+#\s*(.*)$/);
496
+ const comment = m?.[1]?.trim();
497
+ return comment ? comment : undefined;
498
+ }
499
+
465
500
  export function parseActionUses(rawUses: string): { owner: string; repo: string; slug: string; gitRef: string } | undefined {
466
501
  const uses = stripUsesComment(rawUses);
467
502
  if (uses.startsWith("./") || uses.startsWith("../") || uses.startsWith("docker://")) return undefined;
@@ -100,6 +100,7 @@ export declare class Permissions {
100
100
  checks?: "read" | "write" | "none";
101
101
  "code-quality"?: "read" | "write" | "none";
102
102
  contents?: "read" | "write" | "none";
103
+ "copilot-requests"?: "write";
103
104
  deployments?: "read" | "write" | "none";
104
105
  discussions?: "read" | "write" | "none";
105
106
  "id-token"?: "read" | "write" | "none";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-github",
3
- "version": "0.46.0",
3
+ "version": "0.50.0",
4
4
  "description": "GitHub Actions lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -61,7 +61,7 @@
61
61
  "typescript": "^5.9.3"
62
62
  },
63
63
  "peerDependencies": {
64
- "@intentius/chant": "^0.46.0",
64
+ "@intentius/chant": "^0.50.0",
65
65
  "typescript": "^5.9.3"
66
66
  }
67
67
  }