@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.
- 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 +15 -5
- package/dist/lint/audit-catalog.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/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/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,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
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { gha067 } from "./gha067";
|
|
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("GHA067: unconditional docker build with no path filter or guard", () => {
|
|
14
|
+
test("flags a docker build on push with no paths filter and no if guard", () => {
|
|
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
|
+
- run: docker build -t app .
|
|
24
|
+
`;
|
|
25
|
+
const diags = gha067.check(makeCtx(yaml));
|
|
26
|
+
expect(diags).toHaveLength(1);
|
|
27
|
+
expect(diags[0].checkId).toBe("GHA067");
|
|
28
|
+
expect(diags[0].entity).toBe("build");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("does not flag when the trigger has a paths filter", () => {
|
|
32
|
+
const yaml = `name: CI
|
|
33
|
+
on:
|
|
34
|
+
push:
|
|
35
|
+
paths:
|
|
36
|
+
- "src/**"
|
|
37
|
+
jobs:
|
|
38
|
+
build:
|
|
39
|
+
runs-on: ubuntu-latest
|
|
40
|
+
steps:
|
|
41
|
+
- run: docker build -t app .
|
|
42
|
+
`;
|
|
43
|
+
expect(gha067.check(makeCtx(yaml))).toHaveLength(0);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("does not flag when the step has an if guard", () => {
|
|
47
|
+
const yaml = `name: CI
|
|
48
|
+
on:
|
|
49
|
+
push: {}
|
|
50
|
+
jobs:
|
|
51
|
+
build:
|
|
52
|
+
runs-on: ubuntu-latest
|
|
53
|
+
steps:
|
|
54
|
+
- run: docker build -t app .
|
|
55
|
+
if: github.event.head_commit.modified contains 'Dockerfile'
|
|
56
|
+
`;
|
|
57
|
+
expect(gha067.check(makeCtx(yaml))).toHaveLength(0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("does not flag a workflow with no push/pull_request trigger", () => {
|
|
61
|
+
const yaml = `name: CI
|
|
62
|
+
on:
|
|
63
|
+
workflow_dispatch: {}
|
|
64
|
+
jobs:
|
|
65
|
+
build:
|
|
66
|
+
runs-on: ubuntu-latest
|
|
67
|
+
steps:
|
|
68
|
+
- run: docker build -t app .
|
|
69
|
+
`;
|
|
70
|
+
expect(gha067.check(makeCtx(yaml))).toHaveLength(0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("does not flag a step that isn't a docker build", () => {
|
|
74
|
+
const yaml = `name: CI
|
|
75
|
+
on:
|
|
76
|
+
push: {}
|
|
77
|
+
jobs:
|
|
78
|
+
build:
|
|
79
|
+
runs-on: ubuntu-latest
|
|
80
|
+
steps:
|
|
81
|
+
- run: npm test
|
|
82
|
+
`;
|
|
83
|
+
expect(gha067.check(makeCtx(yaml))).toHaveLength(0);
|
|
84
|
+
});
|
|
85
|
+
});
|