@intentius/chant-lexicon-github 0.0.18 → 0.0.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/integrity.json +14 -4
- package/dist/manifest.json +1 -1
- package/dist/rules/gha020.ts +40 -0
- package/dist/rules/gha021.ts +48 -0
- package/dist/rules/gha022.ts +50 -0
- package/dist/rules/gha023.ts +44 -0
- package/dist/rules/gha024.ts +42 -0
- package/dist/rules/gha025.ts +42 -0
- package/dist/rules/gha026.ts +40 -0
- package/dist/rules/gha027.ts +57 -0
- package/dist/rules/gha028.ts +37 -0
- package/dist/skills/chant-github.md +1 -1
- package/dist/skills/github-actions-security.md +87 -0
- package/package.json +20 -2
- package/src/codegen/docs.test.ts +19 -0
- package/src/codegen/generate.test.ts +12 -0
- package/src/codegen/package.test.ts +8 -0
- package/src/coverage.test.ts +23 -0
- package/src/import/roundtrip.test.ts +206 -0
- package/src/lint/post-synth/gha006.test.ts +56 -0
- package/src/lint/post-synth/gha009.test.ts +56 -0
- package/src/lint/post-synth/gha011.test.ts +61 -0
- package/src/lint/post-synth/gha017.test.ts +51 -0
- package/src/lint/post-synth/gha018.test.ts +66 -0
- package/src/lint/post-synth/gha019.test.ts +66 -0
- package/src/lint/post-synth/gha020.test.ts +66 -0
- package/src/lint/post-synth/gha020.ts +40 -0
- package/src/lint/post-synth/gha021.test.ts +67 -0
- package/src/lint/post-synth/gha021.ts +48 -0
- package/src/lint/post-synth/gha022.test.ts +45 -0
- package/src/lint/post-synth/gha022.ts +50 -0
- package/src/lint/post-synth/gha023.test.ts +52 -0
- package/src/lint/post-synth/gha023.ts +44 -0
- package/src/lint/post-synth/gha024.test.ts +67 -0
- package/src/lint/post-synth/gha024.ts +42 -0
- package/src/lint/post-synth/gha025.test.ts +65 -0
- package/src/lint/post-synth/gha025.ts +42 -0
- package/src/lint/post-synth/gha026.test.ts +65 -0
- package/src/lint/post-synth/gha026.ts +40 -0
- package/src/lint/post-synth/gha027.test.ts +48 -0
- package/src/lint/post-synth/gha027.ts +57 -0
- package/src/lint/post-synth/gha028.test.ts +48 -0
- package/src/lint/post-synth/gha028.ts +37 -0
- package/src/lint/rules/deprecated-action-version.test.ts +26 -0
- package/src/lint/rules/detect-secrets.test.ts +25 -0
- package/src/lint/rules/extract-inline-structs.test.ts +25 -0
- package/src/lint/rules/file-job-limit.test.ts +28 -0
- package/src/lint/rules/job-timeout.test.ts +31 -0
- package/src/lint/rules/missing-recommended-inputs.test.ts +26 -0
- package/src/lint/rules/no-hardcoded-secrets.test.ts +31 -0
- package/src/lint/rules/no-raw-expressions.test.ts +31 -0
- package/src/lint/rules/suggest-cache.test.ts +25 -0
- package/src/lint/rules/use-condition-builders.test.ts +31 -0
- package/src/lint/rules/use-matrix-builder.test.ts +25 -0
- package/src/lint/rules/use-typed-actions.test.ts +39 -0
- package/src/lint/rules/validate-concurrency.test.ts +31 -0
- package/src/plugin.test.ts +1 -1
- package/src/plugin.ts +30 -2
- package/src/skills/github-actions-security.md +87 -0
- package/src/validate.ts +14 -1
- package/src/variables.test.ts +48 -0
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "xxhash64",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "10f628cf1d0e9bdc",
|
|
5
5
|
"meta.json": "317499a992c9c274",
|
|
6
6
|
"types/index.d.ts": "93ce391baebf2afb",
|
|
7
7
|
"rules/missing-recommended-inputs.ts": "42f2f3b0b6c7b52c",
|
|
@@ -17,15 +17,25 @@
|
|
|
17
17
|
"rules/suggest-cache.ts": "c45f7659afde2f15",
|
|
18
18
|
"rules/validate-concurrency.ts": "c12a1aa4ee8badb5",
|
|
19
19
|
"rules/use-matrix-builder.ts": "6b1c0ebf43378805",
|
|
20
|
+
"rules/gha020.ts": "36ef5e141524bab0",
|
|
20
21
|
"rules/gha017.ts": "ff1c08fdedf83afa",
|
|
22
|
+
"rules/gha027.ts": "6071aedb178c90a8",
|
|
21
23
|
"rules/gha019.ts": "d9184093f36ac167",
|
|
24
|
+
"rules/gha024.ts": "ed75a2900c8bf12d",
|
|
22
25
|
"rules/gha009.ts": "df140c0cac573bc4",
|
|
26
|
+
"rules/gha026.ts": "5ace32df6cb850af",
|
|
27
|
+
"rules/gha028.ts": "9c1ba1eb9a93d8b6",
|
|
28
|
+
"rules/gha022.ts": "41038ee697a497d1",
|
|
23
29
|
"rules/gha018.ts": "46acbe27d4c0c817",
|
|
24
30
|
"rules/yaml-helpers.ts": "df426df288c175c9",
|
|
25
31
|
"rules/gha011.ts": "105e2d4faeaa9977",
|
|
32
|
+
"rules/gha025.ts": "d196899f490521ba",
|
|
26
33
|
"rules/gha006.ts": "baca27402ba18d",
|
|
27
|
-
"
|
|
28
|
-
"
|
|
34
|
+
"rules/gha023.ts": "2d00140d63591c9",
|
|
35
|
+
"rules/gha021.ts": "da7e2491926d1817",
|
|
36
|
+
"skills/chant-github.md": "620bdfa7c7c6c3bc",
|
|
37
|
+
"skills/github-actions-patterns.md": "887ff05cbb3af292",
|
|
38
|
+
"skills/github-actions-security.md": "1804b95909c199ae"
|
|
29
39
|
},
|
|
30
|
-
"composite": "
|
|
40
|
+
"composite": "2bd4a8603c193c0f"
|
|
31
41
|
}
|
package/dist/manifest.json
CHANGED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA020: Missing Job-Level Permissions for Sensitive Triggers
|
|
3
|
+
*
|
|
4
|
+
* Flags jobs without explicit `permissions:` when the workflow uses
|
|
5
|
+
* `pull_request_target` or `workflow_dispatch` triggers.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
9
|
+
import { getPrimaryOutput, extractJobs, extractTriggers } from "./yaml-helpers";
|
|
10
|
+
|
|
11
|
+
export const gha020: PostSynthCheck = {
|
|
12
|
+
id: "GHA020",
|
|
13
|
+
description: "Missing job-level permissions for sensitive triggers",
|
|
14
|
+
|
|
15
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
16
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
17
|
+
|
|
18
|
+
for (const [, output] of ctx.outputs) {
|
|
19
|
+
const yaml = getPrimaryOutput(output);
|
|
20
|
+
const triggers = extractTriggers(yaml);
|
|
21
|
+
|
|
22
|
+
if (!triggers["pull_request_target"] && !triggers["workflow_dispatch"]) continue;
|
|
23
|
+
|
|
24
|
+
const jobs = extractJobs(yaml);
|
|
25
|
+
for (const [jobName, job] of jobs) {
|
|
26
|
+
if (!job.permissions) {
|
|
27
|
+
diagnostics.push({
|
|
28
|
+
checkId: "GHA020",
|
|
29
|
+
severity: "warning",
|
|
30
|
+
message: `Job "${jobName}" lacks explicit permissions but workflow uses a sensitive trigger. Add job-level permissions for least-privilege security.`,
|
|
31
|
+
entity: jobName,
|
|
32
|
+
lexicon: "github",
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return diagnostics;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA021: Checkout Action Without Pinned SHA
|
|
3
|
+
*
|
|
4
|
+
* Flags `actions/checkout` usage that references a tag (e.g. v4) instead of
|
|
5
|
+
* a pinned commit SHA.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
9
|
+
import { getPrimaryOutput, extractJobs } from "./yaml-helpers";
|
|
10
|
+
|
|
11
|
+
export const gha021: PostSynthCheck = {
|
|
12
|
+
id: "GHA021",
|
|
13
|
+
description: "actions/checkout used without pinned SHA",
|
|
14
|
+
|
|
15
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
16
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
17
|
+
|
|
18
|
+
for (const [, output] of ctx.outputs) {
|
|
19
|
+
const yaml = getPrimaryOutput(output);
|
|
20
|
+
const jobs = extractJobs(yaml);
|
|
21
|
+
|
|
22
|
+
for (const [jobName, job] of jobs) {
|
|
23
|
+
if (!job.steps) continue;
|
|
24
|
+
|
|
25
|
+
for (const step of job.steps) {
|
|
26
|
+
if (!step.uses) continue;
|
|
27
|
+
|
|
28
|
+
const match = step.uses.match(/^actions\/checkout@(.+)$/);
|
|
29
|
+
if (!match) continue;
|
|
30
|
+
|
|
31
|
+
const ref = match[1];
|
|
32
|
+
// A pinned SHA is 40 hex characters
|
|
33
|
+
if (/^[0-9a-f]{40}$/.test(ref)) continue;
|
|
34
|
+
|
|
35
|
+
diagnostics.push({
|
|
36
|
+
checkId: "GHA021",
|
|
37
|
+
severity: "warning",
|
|
38
|
+
message: `Job "${jobName}" uses actions/checkout@${ref} — pin to a full commit SHA for supply-chain security.`,
|
|
39
|
+
entity: jobName,
|
|
40
|
+
lexicon: "github",
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return diagnostics;
|
|
47
|
+
},
|
|
48
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA022: Job Without timeout-minutes
|
|
3
|
+
*
|
|
4
|
+
* Flags jobs that do not specify `timeout-minutes`, which can lead to
|
|
5
|
+
* hung workflows consuming runner minutes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
9
|
+
import { getPrimaryOutput, extractJobs } from "./yaml-helpers";
|
|
10
|
+
|
|
11
|
+
export const gha022: PostSynthCheck = {
|
|
12
|
+
id: "GHA022",
|
|
13
|
+
description: "Job without timeout-minutes",
|
|
14
|
+
|
|
15
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
16
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
17
|
+
|
|
18
|
+
for (const [, output] of ctx.outputs) {
|
|
19
|
+
const yaml = getPrimaryOutput(output);
|
|
20
|
+
const jobs = extractJobs(yaml);
|
|
21
|
+
|
|
22
|
+
// Find job sections in the raw YAML to check for timeout-minutes
|
|
23
|
+
const jobsIdx = yaml.search(/^jobs:\s*$/m);
|
|
24
|
+
if (jobsIdx === -1) continue;
|
|
25
|
+
|
|
26
|
+
const afterJobs = yaml.slice(jobsIdx + yaml.slice(jobsIdx).indexOf("\n") + 1);
|
|
27
|
+
const endMatch = afterJobs.search(/^[a-z]/m);
|
|
28
|
+
const jobsContent = endMatch === -1 ? afterJobs : afterJobs.slice(0, endMatch);
|
|
29
|
+
|
|
30
|
+
for (const [jobName] of jobs) {
|
|
31
|
+
// Find this job's section in the raw YAML
|
|
32
|
+
const jobPattern = new RegExp(`^ ${jobName}:\\n([\\s\\S]*?)(?=\\n [a-z]|$)`, "m");
|
|
33
|
+
const jobSection = jobsContent.match(jobPattern);
|
|
34
|
+
const section = jobSection ? jobSection[0] : "";
|
|
35
|
+
|
|
36
|
+
if (!/timeout-minutes:/m.test(section)) {
|
|
37
|
+
diagnostics.push({
|
|
38
|
+
checkId: "GHA022",
|
|
39
|
+
severity: "info",
|
|
40
|
+
message: `Job "${jobName}" does not specify timeout-minutes. Consider adding a timeout to prevent hung workflows.`,
|
|
41
|
+
entity: jobName,
|
|
42
|
+
lexicon: "github",
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return diagnostics;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA023: Deprecated set-output Command
|
|
3
|
+
*
|
|
4
|
+
* Flags usage of `::set-output` in run steps, which has been deprecated
|
|
5
|
+
* in favor of `$GITHUB_OUTPUT`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
9
|
+
import { getPrimaryOutput, extractJobs } from "./yaml-helpers";
|
|
10
|
+
|
|
11
|
+
export const gha023: PostSynthCheck = {
|
|
12
|
+
id: "GHA023",
|
|
13
|
+
description: "Deprecated ::set-output command usage",
|
|
14
|
+
|
|
15
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
16
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
17
|
+
|
|
18
|
+
for (const [, output] of ctx.outputs) {
|
|
19
|
+
const yaml = getPrimaryOutput(output);
|
|
20
|
+
const jobs = extractJobs(yaml);
|
|
21
|
+
|
|
22
|
+
for (const [jobName, job] of jobs) {
|
|
23
|
+
if (!job.steps) continue;
|
|
24
|
+
|
|
25
|
+
for (const step of job.steps) {
|
|
26
|
+
if (!step.run) continue;
|
|
27
|
+
|
|
28
|
+
if (step.run.includes("::set-output")) {
|
|
29
|
+
const stepLabel = step.name ?? "unnamed step";
|
|
30
|
+
diagnostics.push({
|
|
31
|
+
checkId: "GHA023",
|
|
32
|
+
severity: "warning",
|
|
33
|
+
message: `Job "${jobName}" step "${stepLabel}" uses deprecated ::set-output. Use $GITHUB_OUTPUT instead.`,
|
|
34
|
+
entity: jobName,
|
|
35
|
+
lexicon: "github",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return diagnostics;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA024: Missing Concurrency for Deploy Workflows
|
|
3
|
+
*
|
|
4
|
+
* Flags deploy workflows that lack a `concurrency:` block, which risks
|
|
5
|
+
* overlapping deployments.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
9
|
+
import { getPrimaryOutput, extractJobs, extractWorkflowName } from "./yaml-helpers";
|
|
10
|
+
|
|
11
|
+
export const gha024: PostSynthCheck = {
|
|
12
|
+
id: "GHA024",
|
|
13
|
+
description: "Missing concurrency block for deploy workflow",
|
|
14
|
+
|
|
15
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
16
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
17
|
+
|
|
18
|
+
for (const [, output] of ctx.outputs) {
|
|
19
|
+
const yaml = getPrimaryOutput(output);
|
|
20
|
+
|
|
21
|
+
const workflowName = extractWorkflowName(yaml) ?? "";
|
|
22
|
+
const jobs = extractJobs(yaml);
|
|
23
|
+
const jobNames = [...jobs.keys()];
|
|
24
|
+
|
|
25
|
+
const isDeployWorkflow =
|
|
26
|
+
/deploy/i.test(workflowName) || jobNames.some((name) => /deploy/i.test(name));
|
|
27
|
+
|
|
28
|
+
if (!isDeployWorkflow) continue;
|
|
29
|
+
|
|
30
|
+
if (!/^concurrency:/m.test(yaml)) {
|
|
31
|
+
diagnostics.push({
|
|
32
|
+
checkId: "GHA024",
|
|
33
|
+
severity: "info",
|
|
34
|
+
message: "Deploy workflow does not specify concurrency. Add a concurrency block to prevent overlapping deployments.",
|
|
35
|
+
lexicon: "github",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return diagnostics;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA025: Using `pull_request_target` Without Restrictions
|
|
3
|
+
*
|
|
4
|
+
* Flags workflows that use `pull_request_target` without a `types:` filter,
|
|
5
|
+
* which can expose secrets to untrusted fork PRs.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
9
|
+
import { getPrimaryOutput, extractTriggers } from "./yaml-helpers";
|
|
10
|
+
|
|
11
|
+
export const gha025: PostSynthCheck = {
|
|
12
|
+
id: "GHA025",
|
|
13
|
+
description: "Using pull_request_target without restrictions",
|
|
14
|
+
|
|
15
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
16
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
17
|
+
|
|
18
|
+
for (const [, output] of ctx.outputs) {
|
|
19
|
+
const yaml = getPrimaryOutput(output);
|
|
20
|
+
const triggers = extractTriggers(yaml);
|
|
21
|
+
|
|
22
|
+
if (!triggers["pull_request_target"]) continue;
|
|
23
|
+
|
|
24
|
+
// Check if pull_request_target section has a types: filter
|
|
25
|
+
const prtSection = yaml.match(/^\s{2}pull_request_target:\s*\n((?:\s{4,}.+\n)*)/m);
|
|
26
|
+
const hasTypes = prtSection?.[1]?.match(/^\s+types:/m);
|
|
27
|
+
|
|
28
|
+
if (!hasTypes) {
|
|
29
|
+
diagnostics.push({
|
|
30
|
+
checkId: "GHA025",
|
|
31
|
+
severity: "warning",
|
|
32
|
+
message:
|
|
33
|
+
"Workflow uses `pull_request_target` without a `types:` filter. This exposes secrets to all fork PRs. Add a `types:` restriction (e.g., [labeled, opened]) to limit exposure.",
|
|
34
|
+
entity: "pull_request_target",
|
|
35
|
+
lexicon: "github",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return diagnostics;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA026: Secret Passed to Action Without `environment` Protection
|
|
3
|
+
*
|
|
4
|
+
* Flags workflows that reference `secrets.` in steps but have no
|
|
5
|
+
* `environment:` key in any job, meaning secrets lack deployment
|
|
6
|
+
* protection rules.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
10
|
+
import { getPrimaryOutput } from "./yaml-helpers";
|
|
11
|
+
|
|
12
|
+
export const gha026: PostSynthCheck = {
|
|
13
|
+
id: "GHA026",
|
|
14
|
+
description: "Secret passed to action without environment protection",
|
|
15
|
+
|
|
16
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
17
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
18
|
+
|
|
19
|
+
for (const [, output] of ctx.outputs) {
|
|
20
|
+
const yaml = getPrimaryOutput(output);
|
|
21
|
+
|
|
22
|
+
const usesSecrets = /secrets\./m.test(yaml);
|
|
23
|
+
if (!usesSecrets) continue;
|
|
24
|
+
|
|
25
|
+
const hasEnvironment = /^\s+environment:/m.test(yaml);
|
|
26
|
+
if (hasEnvironment) continue;
|
|
27
|
+
|
|
28
|
+
diagnostics.push({
|
|
29
|
+
checkId: "GHA026",
|
|
30
|
+
severity: "info",
|
|
31
|
+
message:
|
|
32
|
+
"Workflow references secrets but no job defines an `environment:`. Consider using environment protection rules to gate secret access with required reviewers or wait timers.",
|
|
33
|
+
entity: "secrets",
|
|
34
|
+
lexicon: "github",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return diagnostics;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA027: Missing `if: always()` on Cleanup Steps
|
|
3
|
+
*
|
|
4
|
+
* Flags steps whose name contains "cleanup", "teardown", or "clean up"
|
|
5
|
+
* (case-insensitive) that lack an `if:` condition. Cleanup steps should
|
|
6
|
+
* typically run with `if: always()` so they execute even when prior steps fail.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
10
|
+
import { getPrimaryOutput, extractJobs } from "./yaml-helpers";
|
|
11
|
+
|
|
12
|
+
const CLEANUP_PATTERN = /cleanup|teardown|clean\s+up/i;
|
|
13
|
+
|
|
14
|
+
export const gha027: PostSynthCheck = {
|
|
15
|
+
id: "GHA027",
|
|
16
|
+
description: "Missing `if: always()` on cleanup steps",
|
|
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
|
+
// Scan raw YAML for step blocks with cleanup-like names
|
|
25
|
+
const stepPattern = /-\s+name:\s+(.+)/g;
|
|
26
|
+
let match: RegExpExecArray | null;
|
|
27
|
+
|
|
28
|
+
while ((match = stepPattern.exec(yaml)) !== null) {
|
|
29
|
+
const stepName = match[1].trim().replace(/^['"]|['"]$/g, "");
|
|
30
|
+
if (!CLEANUP_PATTERN.test(stepName)) continue;
|
|
31
|
+
|
|
32
|
+
// Get the block after this step name line
|
|
33
|
+
const afterName = yaml.slice(match.index + match[0].length);
|
|
34
|
+
// Capture lines until the next step entry or job
|
|
35
|
+
const blockEnd = afterName.search(/\n\s{6}-\s|\n\s{2}[a-z]/);
|
|
36
|
+
const block = blockEnd === -1 ? afterName : afterName.slice(0, blockEnd);
|
|
37
|
+
|
|
38
|
+
if (!/^\s+if:/m.test(block)) {
|
|
39
|
+
// Find which job this step belongs to
|
|
40
|
+
const beforeStep = yaml.slice(0, match.index);
|
|
41
|
+
const jobMatch = [...beforeStep.matchAll(/^\s{2}([a-z][a-z0-9-]*):/gm)];
|
|
42
|
+
const jobName = jobMatch.length > 0 ? jobMatch[jobMatch.length - 1][1] : "unknown";
|
|
43
|
+
|
|
44
|
+
diagnostics.push({
|
|
45
|
+
checkId: "GHA027",
|
|
46
|
+
severity: "info",
|
|
47
|
+
message: `Step "${stepName}" in job "${jobName}" looks like a cleanup step but has no \`if:\` condition. Add \`if: always()\` so it runs even when prior steps fail.`,
|
|
48
|
+
entity: `${jobName}.${stepName}`,
|
|
49
|
+
lexicon: "github",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return diagnostics;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA028: Workflow With No `on` Triggers
|
|
3
|
+
*
|
|
4
|
+
* Flags workflow files that lack a top-level `on:` key, which means
|
|
5
|
+
* the workflow will never be triggered.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
9
|
+
import { getPrimaryOutput } from "./yaml-helpers";
|
|
10
|
+
|
|
11
|
+
export const gha028: PostSynthCheck = {
|
|
12
|
+
id: "GHA028",
|
|
13
|
+
description: "Workflow with no `on` triggers",
|
|
14
|
+
|
|
15
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
16
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
17
|
+
|
|
18
|
+
for (const [, output] of ctx.outputs) {
|
|
19
|
+
const yaml = getPrimaryOutput(output);
|
|
20
|
+
|
|
21
|
+
const hasOn = /^on:/m.test(yaml);
|
|
22
|
+
|
|
23
|
+
if (!hasOn) {
|
|
24
|
+
diagnostics.push({
|
|
25
|
+
checkId: "GHA028",
|
|
26
|
+
severity: "error",
|
|
27
|
+
message:
|
|
28
|
+
"Workflow has no `on:` trigger block. Without triggers the workflow will never run. Add an `on:` section with at least one event.",
|
|
29
|
+
entity: "on",
|
|
30
|
+
lexicon: "github",
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return diagnostics;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
@@ -8,7 +8,7 @@ user-invocable: true
|
|
|
8
8
|
|
|
9
9
|
## How chant and GitHub Actions relate
|
|
10
10
|
|
|
11
|
-
chant is a **synthesis
|
|
11
|
+
chant is a **synthesis compiler** — it compiles TypeScript source files into `.github/workflows/*.yml` (YAML). `chant build` does not call GitHub APIs; synthesis is pure and deterministic.
|
|
12
12
|
|
|
13
13
|
- Use **chant** for: build, lint, diff (local YAML comparison)
|
|
14
14
|
- Use **git + GitHub** for: push, pull requests, workflow monitoring
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
---
|
|
2
|
+
skill: github-actions-security
|
|
3
|
+
description: GitHub Actions security best practices — secret scanning, OIDC, permissions hardening, supply chain security
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# GitHub Actions Security Playbook
|
|
7
|
+
|
|
8
|
+
## Permissions Hardening
|
|
9
|
+
|
|
10
|
+
Always set the minimum required permissions at the workflow level:
|
|
11
|
+
|
|
12
|
+
```typescript
|
|
13
|
+
new Workflow({
|
|
14
|
+
name: "CI",
|
|
15
|
+
on: { push: { branches: ["main"] } },
|
|
16
|
+
permissions: { contents: "read" },
|
|
17
|
+
});
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
For deployments that need write access, scope it to the job:
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
new Job({
|
|
24
|
+
"runs-on": "ubuntu-latest",
|
|
25
|
+
permissions: { contents: "read", "id-token": "write" },
|
|
26
|
+
steps: [...],
|
|
27
|
+
});
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Pin Actions by SHA
|
|
31
|
+
|
|
32
|
+
Never use mutable tags like `@v4`. Pin to a full commit SHA:
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
new Step({
|
|
36
|
+
uses: "actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11", // v4.1.1
|
|
37
|
+
})
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## OIDC for Cloud Providers
|
|
41
|
+
|
|
42
|
+
Use OpenID Connect instead of long-lived secrets:
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
// AWS
|
|
46
|
+
new Step({
|
|
47
|
+
uses: "aws-actions/configure-aws-credentials@v4",
|
|
48
|
+
with: {
|
|
49
|
+
"role-to-assume": "arn:aws:iam::123456789012:role/deploy",
|
|
50
|
+
"aws-region": "us-east-1",
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Secret Scanning
|
|
56
|
+
|
|
57
|
+
- Never echo secrets in `run:` steps
|
|
58
|
+
- Use `environment` protection rules for production secrets
|
|
59
|
+
- Rotate secrets regularly and audit access logs
|
|
60
|
+
|
|
61
|
+
## Supply Chain Security
|
|
62
|
+
|
|
63
|
+
- Use `permissions: {}` (empty) as a baseline, then grant only what each job needs
|
|
64
|
+
- Avoid `pull_request_target` with `actions/checkout` (code injection risk)
|
|
65
|
+
- Use Dependabot or Renovate to keep action versions current
|
|
66
|
+
- Add `concurrency` blocks to prevent parallel deploys
|
|
67
|
+
|
|
68
|
+
## Concurrency Control
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
new Concurrency({
|
|
72
|
+
group: "${{ github.workflow }}-${{ github.ref }}",
|
|
73
|
+
"cancel-in-progress": true,
|
|
74
|
+
})
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Job Timeouts
|
|
78
|
+
|
|
79
|
+
Always set timeouts to prevent runaway jobs:
|
|
80
|
+
|
|
81
|
+
```typescript
|
|
82
|
+
new Job({
|
|
83
|
+
"runs-on": "ubuntu-latest",
|
|
84
|
+
"timeout-minutes": 30,
|
|
85
|
+
steps: [...],
|
|
86
|
+
});
|
|
87
|
+
```
|
package/package.json
CHANGED
|
@@ -1,7 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-github",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.22",
|
|
4
|
+
"description": "GitHub Actions lexicon for chant — declarative IaC in TypeScript",
|
|
4
5
|
"license": "Apache-2.0",
|
|
6
|
+
"homepage": "https://intentius.io/chant",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/intentius/chant.git",
|
|
10
|
+
"directory": "lexicons/github"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/intentius/chant/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"infrastructure-as-code",
|
|
17
|
+
"iac",
|
|
18
|
+
"typescript",
|
|
19
|
+
"github",
|
|
20
|
+
"github-actions",
|
|
21
|
+
"chant"
|
|
22
|
+
],
|
|
5
23
|
"type": "module",
|
|
6
24
|
"files": [
|
|
7
25
|
"src/",
|
|
@@ -25,7 +43,7 @@
|
|
|
25
43
|
"prepack": "bun run generate && bun run bundle && bun run validate"
|
|
26
44
|
},
|
|
27
45
|
"dependencies": {
|
|
28
|
-
"@intentius/chant": "0.0.
|
|
46
|
+
"@intentius/chant": "0.0.22"
|
|
29
47
|
},
|
|
30
48
|
"devDependencies": {
|
|
31
49
|
"typescript": "^5.9.3"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import { join, dirname } from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
|
|
6
|
+
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
|
|
8
|
+
describe("docs pipeline", () => {
|
|
9
|
+
test("docs.ts source file exists", () => {
|
|
10
|
+
expect(existsSync(join(thisDir, "docs.ts"))).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("docs.ts exports generateDocs function", () => {
|
|
14
|
+
// docs.ts has template literal parse issues in Bun due to ${{ }} expressions
|
|
15
|
+
// in inline strings. Verify the file exists and contains the export.
|
|
16
|
+
const content = Bun.file(join(thisDir, "docs.ts")).text();
|
|
17
|
+
expect(content).resolves.toContain("export async function generateDocs");
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { generate, writeGeneratedFiles } from "./generate";
|
|
3
|
+
|
|
4
|
+
describe("generate pipeline", () => {
|
|
5
|
+
test("generate function is exported and callable", () => {
|
|
6
|
+
expect(typeof generate).toBe("function");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("writeGeneratedFiles function is exported", () => {
|
|
10
|
+
expect(typeof writeGeneratedFiles).toBe("function");
|
|
11
|
+
});
|
|
12
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { packageLexicon } from "./package";
|
|
3
|
+
|
|
4
|
+
describe("package pipeline", () => {
|
|
5
|
+
test("packageLexicon function is exported and callable", () => {
|
|
6
|
+
expect(typeof packageLexicon).toBe("function");
|
|
7
|
+
});
|
|
8
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
analyzeGitHubCoverage,
|
|
4
|
+
computeCoverage,
|
|
5
|
+
overallPct,
|
|
6
|
+
formatSummary,
|
|
7
|
+
formatVerbose,
|
|
8
|
+
checkThresholds,
|
|
9
|
+
} from "./coverage";
|
|
10
|
+
|
|
11
|
+
describe("coverage module", () => {
|
|
12
|
+
test("analyzeGitHubCoverage is exported and callable", () => {
|
|
13
|
+
expect(typeof analyzeGitHubCoverage).toBe("function");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("re-exports from core are functions", () => {
|
|
17
|
+
expect(typeof computeCoverage).toBe("function");
|
|
18
|
+
expect(typeof overallPct).toBe("function");
|
|
19
|
+
expect(typeof formatSummary).toBe("function");
|
|
20
|
+
expect(typeof formatVerbose).toBe("function");
|
|
21
|
+
expect(typeof checkThresholds).toBe("function");
|
|
22
|
+
});
|
|
23
|
+
});
|