@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.
- package/dist/components/generate-op-pipeline.d.ts +81 -0
- package/dist/components/generate-op-pipeline.d.ts.map +1 -0
- package/dist/integrity.json +16 -6
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/gha013.d.ts.map +1 -1
- package/dist/lint/post-synth/gha059.d.ts +40 -0
- package/dist/lint/post-synth/gha059.d.ts.map +1 -0
- package/dist/lint/post-synth/gha060.d.ts +34 -0
- package/dist/lint/post-synth/gha060.d.ts.map +1 -0
- package/dist/lint/post-synth/gha061.d.ts +27 -0
- package/dist/lint/post-synth/gha061.d.ts.map +1 -0
- package/dist/lint/post-synth/gha062.d.ts +24 -0
- package/dist/lint/post-synth/gha062.d.ts.map +1 -0
- package/dist/lint/post-synth/gha063.d.ts +12 -0
- package/dist/lint/post-synth/gha063.d.ts.map +1 -0
- package/dist/lint/post-synth/gha064.d.ts +12 -0
- package/dist/lint/post-synth/gha064.d.ts.map +1 -0
- package/dist/lint/post-synth/gha065.d.ts +19 -0
- package/dist/lint/post-synth/gha065.d.ts.map +1 -0
- package/dist/lint/post-synth/gha066.d.ts +12 -0
- package/dist/lint/post-synth/gha066.d.ts.map +1 -0
- package/dist/lint/post-synth/gha067.d.ts +12 -0
- package/dist/lint/post-synth/gha067.d.ts.map +1 -0
- package/dist/lint/post-synth/gha068.d.ts +13 -0
- package/dist/lint/post-synth/gha068.d.ts.map +1 -0
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/yaml-helpers.d.ts +20 -0
- package/dist/lint/post-synth/yaml-helpers.d.ts.map +1 -1
- package/dist/lint/rules/data/action-usage-policy.d.ts +30 -0
- package/dist/lint/rules/data/action-usage-policy.d.ts.map +1 -0
- package/dist/lint/rules/data/advisory-feed.d.ts +36 -0
- package/dist/lint/rules/data/advisory-feed.d.ts.map +1 -0
- package/dist/manifest.json +109 -1
- package/dist/meta.json +5 -0
- package/dist/okf/index.md +10 -0
- package/dist/okf/rules/GHA059.md +15 -0
- package/dist/okf/rules/GHA060.md +15 -0
- package/dist/okf/rules/GHA061.md +15 -0
- package/dist/okf/rules/GHA062.md +15 -0
- package/dist/okf/rules/GHA063.md +15 -0
- package/dist/okf/rules/GHA064.md +15 -0
- package/dist/okf/rules/GHA065.md +15 -0
- package/dist/okf/rules/GHA066.md +15 -0
- package/dist/okf/rules/GHA067.md +15 -0
- package/dist/okf/rules/GHA068.md +15 -0
- package/dist/okf/types/Job.md +9 -0
- package/dist/okf/types/Workflow.md +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/rules/gha013.ts +10 -3
- package/dist/rules/gha059.ts +132 -0
- package/dist/rules/gha060.ts +146 -0
- package/dist/rules/gha061.ts +85 -0
- package/dist/rules/gha062.ts +68 -0
- package/dist/rules/gha063.ts +60 -0
- package/dist/rules/gha064.ts +55 -0
- package/dist/rules/gha065.ts +129 -0
- package/dist/rules/gha066.ts +47 -0
- package/dist/rules/gha067.ts +76 -0
- package/dist/rules/gha068.ts +51 -0
- package/dist/rules/yaml-helpers.ts +37 -2
- package/dist/types/index.d.ts +1 -0
- package/package.json +2 -2
- package/src/codegen/docs.ts +1 -1
- package/src/components/generate-op-pipeline.test.ts +146 -0
- package/src/components/generate-op-pipeline.ts +183 -0
- package/src/generated/index.d.ts +1 -0
- package/src/generated/lexicon-github.json +5 -0
- package/src/lint/audit-catalog.ts +16 -1
- package/src/lint/post-synth/gha013.test.ts +17 -0
- package/src/lint/post-synth/gha013.ts +10 -3
- package/src/lint/post-synth/gha059.test.ts +147 -0
- package/src/lint/post-synth/gha059.ts +132 -0
- package/src/lint/post-synth/gha060.test.ts +144 -0
- package/src/lint/post-synth/gha060.ts +146 -0
- package/src/lint/post-synth/gha061.test.ts +85 -0
- package/src/lint/post-synth/gha061.ts +85 -0
- package/src/lint/post-synth/gha062.test.ts +88 -0
- package/src/lint/post-synth/gha062.ts +68 -0
- package/src/lint/post-synth/gha063.test.ts +86 -0
- package/src/lint/post-synth/gha063.ts +60 -0
- package/src/lint/post-synth/gha064.test.ts +73 -0
- package/src/lint/post-synth/gha064.ts +55 -0
- package/src/lint/post-synth/gha065.test.ts +85 -0
- package/src/lint/post-synth/gha065.ts +129 -0
- package/src/lint/post-synth/gha066.test.ts +66 -0
- package/src/lint/post-synth/gha066.ts +47 -0
- package/src/lint/post-synth/gha067.test.ts +85 -0
- package/src/lint/post-synth/gha067.ts +76 -0
- package/src/lint/post-synth/gha068.test.ts +71 -0
- package/src/lint/post-synth/gha068.ts +51 -0
- package/src/lint/post-synth/index.ts +20 -0
- package/src/lint/post-synth/yaml-helpers.ts +37 -2
- package/src/lint/rules/data/action-usage-policy.ts +31 -0
- package/src/lint/rules/data/advisory-feed.ts +38 -0
- package/src/plugin.test.ts +24 -3
- package/src/plugin.ts +28 -0
|
@@ -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,86 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { gha063 } from "./gha063";
|
|
4
|
+
|
|
5
|
+
function makeCtx(yaml: string): PostSynthContext {
|
|
6
|
+
return {
|
|
7
|
+
outputs: new Map([["github", yaml]]),
|
|
8
|
+
entities: new Map(),
|
|
9
|
+
buildResult: { outputs: new Map([["github", yaml]]), entities: new Map(), warnings: [], errors: [], sourceFileCount: 1 },
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("GHA063: dependency setup without caching", () => {
|
|
14
|
+
test("flags setup-node with no cache option and no cache step", () => {
|
|
15
|
+
const yaml = `name: CI
|
|
16
|
+
on:
|
|
17
|
+
push:
|
|
18
|
+
jobs:
|
|
19
|
+
build:
|
|
20
|
+
runs-on: ubuntu-latest
|
|
21
|
+
steps:
|
|
22
|
+
- uses: actions/checkout@v4
|
|
23
|
+
- uses: actions/setup-node@v4
|
|
24
|
+
with:
|
|
25
|
+
node-version: "20"
|
|
26
|
+
- run: npm ci
|
|
27
|
+
`;
|
|
28
|
+
const diags = gha063.check(makeCtx(yaml));
|
|
29
|
+
expect(diags).toHaveLength(1);
|
|
30
|
+
expect(diags[0].checkId).toBe("GHA063");
|
|
31
|
+
expect(diags[0].entity).toBe("build");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("does not flag setup-node with cache enabled", () => {
|
|
35
|
+
const yaml = `name: CI
|
|
36
|
+
on:
|
|
37
|
+
push:
|
|
38
|
+
jobs:
|
|
39
|
+
build:
|
|
40
|
+
runs-on: ubuntu-latest
|
|
41
|
+
steps:
|
|
42
|
+
- uses: actions/checkout@v4
|
|
43
|
+
- uses: actions/setup-node@v4
|
|
44
|
+
with:
|
|
45
|
+
node-version: "20"
|
|
46
|
+
cache: npm
|
|
47
|
+
- run: npm ci
|
|
48
|
+
`;
|
|
49
|
+
expect(gha063.check(makeCtx(yaml))).toHaveLength(0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("does not flag setup-node when a separate actions/cache step covers it", () => {
|
|
53
|
+
const yaml = `name: CI
|
|
54
|
+
on:
|
|
55
|
+
push:
|
|
56
|
+
jobs:
|
|
57
|
+
build:
|
|
58
|
+
runs-on: ubuntu-latest
|
|
59
|
+
steps:
|
|
60
|
+
- uses: actions/checkout@v4
|
|
61
|
+
- uses: actions/setup-node@v4
|
|
62
|
+
with:
|
|
63
|
+
node-version: "20"
|
|
64
|
+
- uses: actions/cache@v4
|
|
65
|
+
with:
|
|
66
|
+
path: ~/.npm
|
|
67
|
+
key: npm-\${{ hashFiles('package-lock.json') }}
|
|
68
|
+
- run: npm ci
|
|
69
|
+
`;
|
|
70
|
+
expect(gha063.check(makeCtx(yaml))).toHaveLength(0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("does not flag a job with no setup action at all", () => {
|
|
74
|
+
const yaml = `name: CI
|
|
75
|
+
on:
|
|
76
|
+
push:
|
|
77
|
+
jobs:
|
|
78
|
+
build:
|
|
79
|
+
runs-on: ubuntu-latest
|
|
80
|
+
steps:
|
|
81
|
+
- uses: actions/checkout@v4
|
|
82
|
+
- run: make build
|
|
83
|
+
`;
|
|
84
|
+
expect(gha063.check(makeCtx(yaml))).toHaveLength(0);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -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,73 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { gha064 } from "./gha064";
|
|
4
|
+
|
|
5
|
+
function makeCtx(yaml: string): PostSynthContext {
|
|
6
|
+
return {
|
|
7
|
+
outputs: new Map([["github", yaml]]),
|
|
8
|
+
entities: new Map(),
|
|
9
|
+
buildResult: { outputs: new Map([["github", yaml]]), entities: new Map(), warnings: [], errors: [], sourceFileCount: 1 },
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("GHA064: expensive runner without justification", () => {
|
|
14
|
+
test("flags a macOS runner with no macOS-specific step", () => {
|
|
15
|
+
const yaml = `name: CI
|
|
16
|
+
on:
|
|
17
|
+
push:
|
|
18
|
+
jobs:
|
|
19
|
+
build:
|
|
20
|
+
runs-on: macos-latest
|
|
21
|
+
steps:
|
|
22
|
+
- uses: actions/checkout@v4
|
|
23
|
+
- run: npm test
|
|
24
|
+
`;
|
|
25
|
+
const diags = gha064.check(makeCtx(yaml));
|
|
26
|
+
expect(diags).toHaveLength(1);
|
|
27
|
+
expect(diags[0].checkId).toBe("GHA064");
|
|
28
|
+
expect(diags[0].entity).toBe("build");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("does not flag macOS when a step is Xcode-specific", () => {
|
|
32
|
+
const yaml = `name: CI
|
|
33
|
+
on:
|
|
34
|
+
push:
|
|
35
|
+
jobs:
|
|
36
|
+
build:
|
|
37
|
+
runs-on: macos-latest
|
|
38
|
+
steps:
|
|
39
|
+
- uses: actions/checkout@v4
|
|
40
|
+
- run: xcodebuild -scheme App build
|
|
41
|
+
`;
|
|
42
|
+
expect(gha064.check(makeCtx(yaml))).toHaveLength(0);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("does not flag runs-on driven by a matrix expression", () => {
|
|
46
|
+
const yaml = `name: CI
|
|
47
|
+
on:
|
|
48
|
+
push:
|
|
49
|
+
jobs:
|
|
50
|
+
build:
|
|
51
|
+
strategy:
|
|
52
|
+
matrix:
|
|
53
|
+
os: [ubuntu-latest, macos-latest, windows-latest]
|
|
54
|
+
runs-on: \${{ matrix.os }}
|
|
55
|
+
steps:
|
|
56
|
+
- run: npm test
|
|
57
|
+
`;
|
|
58
|
+
expect(gha064.check(makeCtx(yaml))).toHaveLength(0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("does not flag a Linux runner", () => {
|
|
62
|
+
const yaml = `name: CI
|
|
63
|
+
on:
|
|
64
|
+
push:
|
|
65
|
+
jobs:
|
|
66
|
+
build:
|
|
67
|
+
runs-on: ubuntu-latest
|
|
68
|
+
steps:
|
|
69
|
+
- run: npm test
|
|
70
|
+
`;
|
|
71
|
+
expect(gha064.check(makeCtx(yaml))).toHaveLength(0);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -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,85 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { gha065 } from "./gha065";
|
|
4
|
+
|
|
5
|
+
function makeCtx(yaml: string): PostSynthContext {
|
|
6
|
+
return {
|
|
7
|
+
outputs: new Map([["github", yaml]]),
|
|
8
|
+
entities: new Map(),
|
|
9
|
+
buildResult: { outputs: new Map([["github", yaml]]), entities: new Map(), warnings: [], errors: [], sourceFileCount: 1 },
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("GHA065: unbounded matrix fan-out", () => {
|
|
14
|
+
test("flags a large multi-dimension matrix with no max-parallel", () => {
|
|
15
|
+
const yaml = `name: CI
|
|
16
|
+
on:
|
|
17
|
+
push:
|
|
18
|
+
jobs:
|
|
19
|
+
build:
|
|
20
|
+
strategy:
|
|
21
|
+
matrix:
|
|
22
|
+
os: [ubuntu-latest, macos-latest, windows-latest]
|
|
23
|
+
node: [14, 16, 18, 20, 22]
|
|
24
|
+
runs-on: \${{ matrix.os }}
|
|
25
|
+
steps:
|
|
26
|
+
- run: npm test
|
|
27
|
+
`;
|
|
28
|
+
const diags = gha065.check(makeCtx(yaml));
|
|
29
|
+
expect(diags).toHaveLength(1);
|
|
30
|
+
expect(diags[0].checkId).toBe("GHA065");
|
|
31
|
+
expect(diags[0].entity).toBe("build");
|
|
32
|
+
expect(diags[0].message).toContain("15 jobs");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("does not flag when max-parallel caps the fan-out", () => {
|
|
36
|
+
const yaml = `name: CI
|
|
37
|
+
on:
|
|
38
|
+
push:
|
|
39
|
+
jobs:
|
|
40
|
+
build:
|
|
41
|
+
strategy:
|
|
42
|
+
max-parallel: 4
|
|
43
|
+
matrix:
|
|
44
|
+
os: [ubuntu-latest, macos-latest, windows-latest]
|
|
45
|
+
node: [14, 16, 18, 20, 22]
|
|
46
|
+
runs-on: \${{ matrix.os }}
|
|
47
|
+
steps:
|
|
48
|
+
- run: npm test
|
|
49
|
+
`;
|
|
50
|
+
expect(gha065.check(makeCtx(yaml))).toHaveLength(0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("does not flag a small matrix under the threshold", () => {
|
|
54
|
+
const yaml = `name: CI
|
|
55
|
+
on:
|
|
56
|
+
push:
|
|
57
|
+
jobs:
|
|
58
|
+
build:
|
|
59
|
+
strategy:
|
|
60
|
+
matrix:
|
|
61
|
+
os: [ubuntu-latest, macos-latest]
|
|
62
|
+
node: [18, 20]
|
|
63
|
+
runs-on: \${{ matrix.os }}
|
|
64
|
+
steps:
|
|
65
|
+
- run: npm test
|
|
66
|
+
`;
|
|
67
|
+
expect(gha065.check(makeCtx(yaml))).toHaveLength(0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("does not flag a single-dimension matrix", () => {
|
|
71
|
+
const yaml = `name: CI
|
|
72
|
+
on:
|
|
73
|
+
push:
|
|
74
|
+
jobs:
|
|
75
|
+
build:
|
|
76
|
+
strategy:
|
|
77
|
+
matrix:
|
|
78
|
+
node: [16, 18, 20, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22]
|
|
79
|
+
runs-on: ubuntu-latest
|
|
80
|
+
steps:
|
|
81
|
+
- run: npm test
|
|
82
|
+
`;
|
|
83
|
+
expect(gha065.check(makeCtx(yaml))).toHaveLength(0);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -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,66 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { gha066 } from "./gha066";
|
|
4
|
+
|
|
5
|
+
function makeCtx(yaml: string): PostSynthContext {
|
|
6
|
+
return {
|
|
7
|
+
outputs: new Map([["github", yaml]]),
|
|
8
|
+
entities: new Map(),
|
|
9
|
+
buildResult: { outputs: new Map([["github", yaml]]), entities: new Map(), warnings: [], errors: [], sourceFileCount: 1 },
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("GHA066: unbounded artifact retention", () => {
|
|
14
|
+
test("flags upload-artifact with no retention-days", () => {
|
|
15
|
+
const yaml = `name: CI
|
|
16
|
+
on:
|
|
17
|
+
push:
|
|
18
|
+
jobs:
|
|
19
|
+
build:
|
|
20
|
+
runs-on: ubuntu-latest
|
|
21
|
+
steps:
|
|
22
|
+
- uses: actions/checkout@v4
|
|
23
|
+
- uses: actions/upload-artifact@v4
|
|
24
|
+
with:
|
|
25
|
+
name: build-output
|
|
26
|
+
path: dist/
|
|
27
|
+
`;
|
|
28
|
+
const diags = gha066.check(makeCtx(yaml));
|
|
29
|
+
expect(diags).toHaveLength(1);
|
|
30
|
+
expect(diags[0].checkId).toBe("GHA066");
|
|
31
|
+
expect(diags[0].entity).toBe("build");
|
|
32
|
+
expect(diags[0].message).toContain("build-output");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("does not flag upload-artifact with retention-days set", () => {
|
|
36
|
+
const yaml = `name: CI
|
|
37
|
+
on:
|
|
38
|
+
push:
|
|
39
|
+
jobs:
|
|
40
|
+
build:
|
|
41
|
+
runs-on: ubuntu-latest
|
|
42
|
+
steps:
|
|
43
|
+
- uses: actions/checkout@v4
|
|
44
|
+
- uses: actions/upload-artifact@v4
|
|
45
|
+
with:
|
|
46
|
+
name: build-output
|
|
47
|
+
path: dist/
|
|
48
|
+
retention-days: 7
|
|
49
|
+
`;
|
|
50
|
+
expect(gha066.check(makeCtx(yaml))).toHaveLength(0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("does not flag a job with no artifact upload", () => {
|
|
54
|
+
const yaml = `name: CI
|
|
55
|
+
on:
|
|
56
|
+
push:
|
|
57
|
+
jobs:
|
|
58
|
+
build:
|
|
59
|
+
runs-on: ubuntu-latest
|
|
60
|
+
steps:
|
|
61
|
+
- uses: actions/checkout@v4
|
|
62
|
+
- run: npm test
|
|
63
|
+
`;
|
|
64
|
+
expect(gha066.check(makeCtx(yaml))).toHaveLength(0);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -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
|
+
};
|