@intentius/chant-lexicon-github 0.1.23 → 0.3.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/integrity.json +34 -4
- package/dist/manifest.json +1 -1
- package/dist/rules/{gha020.ts → gha013.ts} +4 -4
- package/dist/rules/gha029.ts +68 -0
- package/dist/rules/gha030.ts +41 -0
- package/dist/rules/gha031.ts +77 -0
- package/dist/rules/gha032.ts +39 -0
- package/dist/rules/gha033.ts +47 -0
- package/dist/rules/gha034.ts +41 -0
- package/dist/rules/gha035.ts +66 -0
- package/dist/rules/gha036.ts +42 -0
- package/dist/rules/gha037.ts +46 -0
- package/dist/rules/gha038.ts +41 -0
- package/dist/rules/gha039.ts +41 -0
- package/dist/rules/gha040.ts +43 -0
- package/dist/rules/gha041.ts +38 -0
- package/dist/rules/gha042.ts +40 -0
- package/dist/rules/gha043.ts +41 -0
- package/dist/rules/gha044.ts +41 -0
- package/dist/rules/gha045.ts +38 -0
- package/dist/rules/gha046.ts +56 -0
- package/dist/rules/gha047.ts +51 -0
- package/dist/rules/gha048.ts +41 -0
- package/dist/rules/gha049.ts +42 -0
- package/dist/rules/gha050.ts +44 -0
- package/dist/rules/gha051.ts +45 -0
- package/dist/rules/gha052.ts +42 -0
- package/dist/rules/gha053.ts +41 -0
- package/dist/rules/gha054.ts +41 -0
- package/dist/rules/gha055.ts +50 -0
- package/dist/rules/gha056.ts +37 -0
- package/dist/rules/gha057.ts +40 -0
- package/dist/rules/gha058.ts +48 -0
- package/dist/rules/yaml-helpers.ts +335 -0
- package/package.json +1 -1
- package/src/codegen/docs.ts +273 -4
- package/src/composites/dependabot.test.ts +48 -0
- package/src/composites/dependabot.ts +91 -0
- package/src/composites/index.ts +2 -0
- package/src/index.ts +2 -0
- package/src/lint/post-synth/{gha020.test.ts → gha013.test.ts} +7 -7
- package/src/lint/post-synth/{gha020.ts → gha013.ts} +4 -4
- package/src/lint/post-synth/gha029.test.ts +108 -0
- package/src/lint/post-synth/gha029.ts +68 -0
- package/src/lint/post-synth/gha030.test.ts +87 -0
- package/src/lint/post-synth/gha030.ts +41 -0
- package/src/lint/post-synth/gha031.test.ts +75 -0
- package/src/lint/post-synth/gha031.ts +77 -0
- package/src/lint/post-synth/gha032.test.ts +65 -0
- package/src/lint/post-synth/gha032.ts +39 -0
- package/src/lint/post-synth/gha033.test.ts +68 -0
- package/src/lint/post-synth/gha033.ts +47 -0
- package/src/lint/post-synth/gha034.test.ts +86 -0
- package/src/lint/post-synth/gha034.ts +41 -0
- package/src/lint/post-synth/gha035.test.ts +85 -0
- package/src/lint/post-synth/gha035.ts +66 -0
- package/src/lint/post-synth/gha036.test.ts +68 -0
- package/src/lint/post-synth/gha036.ts +42 -0
- package/src/lint/post-synth/gha037.test.ts +66 -0
- package/src/lint/post-synth/gha037.ts +46 -0
- package/src/lint/post-synth/gha038.test.ts +64 -0
- package/src/lint/post-synth/gha038.ts +41 -0
- package/src/lint/post-synth/gha039.test.ts +66 -0
- package/src/lint/post-synth/gha039.ts +41 -0
- package/src/lint/post-synth/gha040.test.ts +77 -0
- package/src/lint/post-synth/gha040.ts +43 -0
- package/src/lint/post-synth/gha041.test.ts +48 -0
- package/src/lint/post-synth/gha041.ts +38 -0
- package/src/lint/post-synth/gha042.test.ts +53 -0
- package/src/lint/post-synth/gha042.ts +40 -0
- package/src/lint/post-synth/gha043.test.ts +69 -0
- package/src/lint/post-synth/gha043.ts +41 -0
- package/src/lint/post-synth/gha044.test.ts +56 -0
- package/src/lint/post-synth/gha044.ts +41 -0
- package/src/lint/post-synth/gha045.test.ts +51 -0
- package/src/lint/post-synth/gha045.ts +38 -0
- package/src/lint/post-synth/gha046.test.ts +59 -0
- package/src/lint/post-synth/gha046.ts +56 -0
- package/src/lint/post-synth/gha047.test.ts +66 -0
- package/src/lint/post-synth/gha047.ts +51 -0
- package/src/lint/post-synth/gha048.test.ts +51 -0
- package/src/lint/post-synth/gha048.ts +41 -0
- package/src/lint/post-synth/gha049.test.ts +72 -0
- package/src/lint/post-synth/gha049.ts +42 -0
- package/src/lint/post-synth/gha050.test.ts +55 -0
- package/src/lint/post-synth/gha050.ts +44 -0
- package/src/lint/post-synth/gha051.test.ts +72 -0
- package/src/lint/post-synth/gha051.ts +45 -0
- package/src/lint/post-synth/gha052.test.ts +52 -0
- package/src/lint/post-synth/gha052.ts +42 -0
- package/src/lint/post-synth/gha053.test.ts +65 -0
- package/src/lint/post-synth/gha053.ts +41 -0
- package/src/lint/post-synth/gha054.test.ts +49 -0
- package/src/lint/post-synth/gha054.ts +41 -0
- package/src/lint/post-synth/gha055.test.ts +49 -0
- package/src/lint/post-synth/gha055.ts +50 -0
- package/src/lint/post-synth/gha056.test.ts +47 -0
- package/src/lint/post-synth/gha056.ts +37 -0
- package/src/lint/post-synth/gha057.test.ts +61 -0
- package/src/lint/post-synth/gha057.ts +40 -0
- package/src/lint/post-synth/gha058.test.ts +67 -0
- package/src/lint/post-synth/gha058.ts +48 -0
- package/src/lint/post-synth/yaml-helpers.ts +335 -0
- package/src/lint/rules/data/flagged-actions.ts +36 -0
- package/src/lint/rules/data/known-action-slugs.ts +38 -0
- package/src/lint/rules/data/preinstalled-tools.ts +36 -0
- package/src/lint/rules/data/risky-features.ts +39 -0
- package/src/lint/rules/data/trusted-action-owners.ts +12 -0
- package/src/lint/rules/data/untrusted-contexts.ts +44 -0
- package/src/mcp/context-tools.test.ts +108 -0
- package/src/mcp/context-tools.ts +172 -0
- package/src/plugin.test.ts +58 -3
- package/src/plugin.ts +5 -1
- package/src/serializer.ts +38 -5
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA040: Self-Hosted Runner on a Trigger That Can Run Untrusted Code
|
|
3
|
+
*
|
|
4
|
+
* Flags a job that runs on a self-hosted runner under a trigger reachable by a
|
|
5
|
+
* fork or outside contributor (`pull_request`, `pull_request_target`,
|
|
6
|
+
* `workflow_run`). Self-hosted runners are non-ephemeral by default and share a
|
|
7
|
+
* host, so untrusted code can persist on the runner and compromise later jobs.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
11
|
+
import { getPrimaryOutput, extractTriggers, extractRunsOnByJob } from "./yaml-helpers";
|
|
12
|
+
|
|
13
|
+
const UNTRUSTED_TRIGGERS = ["pull_request", "pull_request_target", "workflow_run"];
|
|
14
|
+
|
|
15
|
+
export const gha040: PostSynthCheck = {
|
|
16
|
+
id: "GHA040",
|
|
17
|
+
description: "Self-hosted runner on a trigger that can run untrusted code",
|
|
18
|
+
|
|
19
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
20
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
21
|
+
|
|
22
|
+
for (const [, output] of ctx.outputs) {
|
|
23
|
+
const yaml = getPrimaryOutput(output);
|
|
24
|
+
const triggers = extractTriggers(yaml);
|
|
25
|
+
const untrusted = UNTRUSTED_TRIGGERS.filter((t) => triggers[t]);
|
|
26
|
+
if (untrusted.length === 0) continue;
|
|
27
|
+
|
|
28
|
+
for (const [job, labels] of extractRunsOnByJob(yaml)) {
|
|
29
|
+
if (labels.some((l) => l === "self-hosted")) {
|
|
30
|
+
diagnostics.push({
|
|
31
|
+
checkId: "GHA040",
|
|
32
|
+
severity: "warning",
|
|
33
|
+
message: `Job "${job}" runs on a self-hosted runner under the ${untrusted.join("/")} trigger, which a fork can reach. Use ephemeral GitHub-hosted runners for untrusted code, or require approval before the job runs.`,
|
|
34
|
+
entity: job,
|
|
35
|
+
lexicon: "github",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return diagnostics;
|
|
42
|
+
},
|
|
43
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA041: Blanket `secrets: inherit` into a Reusable Workflow
|
|
3
|
+
*
|
|
4
|
+
* Flags a reusable-workflow call that passes `secrets: inherit`. Inheritance
|
|
5
|
+
* hands the called workflow every secret the caller can see, regardless of what
|
|
6
|
+
* it needs. Pass through only the specific secrets the reusable workflow uses.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
10
|
+
import { getPrimaryOutput, jobLines } from "./yaml-helpers";
|
|
11
|
+
|
|
12
|
+
export const gha041: PostSynthCheck = {
|
|
13
|
+
id: "GHA041",
|
|
14
|
+
description: "Blanket secrets: inherit into a reusable workflow",
|
|
15
|
+
|
|
16
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
17
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
18
|
+
|
|
19
|
+
for (const [, output] of ctx.outputs) {
|
|
20
|
+
const yaml = getPrimaryOutput(output);
|
|
21
|
+
const seen = new Set<string>();
|
|
22
|
+
for (const { job, line } of jobLines(yaml)) {
|
|
23
|
+
if (/^\s+secrets:\s*inherit\s*$/.test(line) && !seen.has(job)) {
|
|
24
|
+
seen.add(job);
|
|
25
|
+
diagnostics.push({
|
|
26
|
+
checkId: "GHA041",
|
|
27
|
+
severity: "warning",
|
|
28
|
+
message: `Job "${job}" calls a reusable workflow with secrets: inherit, passing every caller secret. Pass through only the specific secrets it needs.`,
|
|
29
|
+
entity: job,
|
|
30
|
+
lexicon: "github",
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return diagnostics;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA042: Whole Secrets Context Passed Where Specific Secrets Would Do
|
|
3
|
+
*
|
|
4
|
+
* Flags `toJSON(secrets)` (serializing the entire secrets context) passed into
|
|
5
|
+
* a step's `with:`/`env:` or to a reusable workflow. It hands the consumer every
|
|
6
|
+
* secret instead of the one or two it needs. Reference specific secrets.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
10
|
+
import { getPrimaryOutput, jobLines } from "./yaml-helpers";
|
|
11
|
+
|
|
12
|
+
const TO_JSON_SECRETS = /to_?json\s*\(\s*secrets\s*\)/i;
|
|
13
|
+
|
|
14
|
+
export const gha042: PostSynthCheck = {
|
|
15
|
+
id: "GHA042",
|
|
16
|
+
description: "Entire secrets context passed where specific secrets would do",
|
|
17
|
+
|
|
18
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
19
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
20
|
+
|
|
21
|
+
for (const [, output] of ctx.outputs) {
|
|
22
|
+
const yaml = getPrimaryOutput(output);
|
|
23
|
+
const seen = new Set<string>();
|
|
24
|
+
for (const { job, line } of jobLines(yaml)) {
|
|
25
|
+
if (TO_JSON_SECRETS.test(line) && !seen.has(job)) {
|
|
26
|
+
seen.add(job);
|
|
27
|
+
diagnostics.push({
|
|
28
|
+
checkId: "GHA042",
|
|
29
|
+
severity: "warning",
|
|
30
|
+
message: `Job "${job}" passes the entire secrets context via toJSON(secrets). Reference only the specific secrets the consumer needs.`,
|
|
31
|
+
entity: job,
|
|
32
|
+
lexicon: "github",
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return diagnostics;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA043: Secret Consumed in a Job Without an Environment Gate
|
|
3
|
+
*
|
|
4
|
+
* Extends GHA026 (which fires only when a workflow uses secrets and defines no
|
|
5
|
+
* environment at all). When a workflow *does* gate some jobs with an
|
|
6
|
+
* `environment:`, this flags the specific jobs that consume secrets without one
|
|
7
|
+
* — the inconsistent-gating case, where a secret-using job skips the approval
|
|
8
|
+
* and scoping the author applied elsewhere.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
12
|
+
import { getPrimaryOutput, jobsReferencingSecrets, extractJobEnvironments } from "./yaml-helpers";
|
|
13
|
+
|
|
14
|
+
export const gha043: PostSynthCheck = {
|
|
15
|
+
id: "GHA043",
|
|
16
|
+
description: "Secret consumed in a job without an environment gate",
|
|
17
|
+
|
|
18
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
19
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
20
|
+
|
|
21
|
+
for (const [, output] of ctx.outputs) {
|
|
22
|
+
const yaml = getPrimaryOutput(output);
|
|
23
|
+
const gated = extractJobEnvironments(yaml);
|
|
24
|
+
if (gated.size === 0) continue; // no environments used anywhere — GHA026 territory
|
|
25
|
+
|
|
26
|
+
for (const job of jobsReferencingSecrets(yaml)) {
|
|
27
|
+
if (!gated.has(job)) {
|
|
28
|
+
diagnostics.push({
|
|
29
|
+
checkId: "GHA043",
|
|
30
|
+
severity: "warning",
|
|
31
|
+
message: `Job "${job}" consumes a secret but declares no environment:, while other jobs in this workflow are environment-gated. Gate this job too, or confirm it should bypass the approval/scoping the others use.`,
|
|
32
|
+
entity: job,
|
|
33
|
+
lexicon: "github",
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return diagnostics;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA044: Hardcoded Registry / Container Credential
|
|
3
|
+
*
|
|
4
|
+
* Flags a `password:` / `token:` / `registry-password:` value that is a literal
|
|
5
|
+
* rather than a `${{ secrets.* }}` reference — e.g. a registry login or service
|
|
6
|
+
* credential inlined in plain text. Move it into a secret.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
10
|
+
import { getPrimaryOutput, jobLines } from "./yaml-helpers";
|
|
11
|
+
|
|
12
|
+
const CRED_KEY_RE = /^\s+(?:- )?(password|token|registry-password):\s*(.+)$/;
|
|
13
|
+
|
|
14
|
+
export const gha044: PostSynthCheck = {
|
|
15
|
+
id: "GHA044",
|
|
16
|
+
description: "Hardcoded registry/container credential",
|
|
17
|
+
|
|
18
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
19
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
20
|
+
|
|
21
|
+
for (const [, output] of ctx.outputs) {
|
|
22
|
+
const yaml = getPrimaryOutput(output);
|
|
23
|
+
for (const { job, line } of jobLines(yaml)) {
|
|
24
|
+
const m = line.match(CRED_KEY_RE);
|
|
25
|
+
if (!m) continue;
|
|
26
|
+
const key = m[1];
|
|
27
|
+
const value = m[2].trim().replace(/^['"]|['"]$/g, "");
|
|
28
|
+
if (value === "" || value.includes("${{")) continue; // empty or a proper expression
|
|
29
|
+
diagnostics.push({
|
|
30
|
+
checkId: "GHA044",
|
|
31
|
+
severity: "error",
|
|
32
|
+
message: `Job "${job}" sets ${key}: to a hardcoded literal. Reference a secret (\${{ secrets.NAME }}) instead of inlining the credential.`,
|
|
33
|
+
entity: job,
|
|
34
|
+
lexicon: "github",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return diagnostics;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA045: Secret Interpolated Directly into a Shell Command
|
|
3
|
+
*
|
|
4
|
+
* Flags `${{ secrets.* }}` expanded straight into a `run:` script. The value is
|
|
5
|
+
* substituted into the command line before the shell runs, where a transform
|
|
6
|
+
* (base64, reversing, splitting) can defeat GitHub's log masking and leak it,
|
|
7
|
+
* and the raw value is exposed to argument-injection. Pass secrets through an
|
|
8
|
+
* `env:` variable and reference "$VAR" quoted instead.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
12
|
+
import { getPrimaryOutput, extractRunBlocks, extractExpressions } from "./yaml-helpers";
|
|
13
|
+
|
|
14
|
+
export const gha045: PostSynthCheck = {
|
|
15
|
+
id: "GHA045",
|
|
16
|
+
description: "Secret interpolated directly into a run: shell command",
|
|
17
|
+
|
|
18
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
19
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
20
|
+
|
|
21
|
+
for (const [, output] of ctx.outputs) {
|
|
22
|
+
const yaml = getPrimaryOutput(output);
|
|
23
|
+
for (const { job, run } of extractRunBlocks(yaml)) {
|
|
24
|
+
const leaked = extractExpressions(run).some((e) => /\bsecrets\./.test(e));
|
|
25
|
+
if (!leaked) continue;
|
|
26
|
+
diagnostics.push({
|
|
27
|
+
checkId: "GHA045",
|
|
28
|
+
severity: "warning",
|
|
29
|
+
message: `Job "${job}" interpolates a secret directly into a run: script, where a transform can defeat log masking. Pass it through an env: variable and reference "$VAR" quoted instead.`,
|
|
30
|
+
entity: job,
|
|
31
|
+
lexicon: "github",
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return diagnostics;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA046: Logically Unsound Guard Condition
|
|
3
|
+
*
|
|
4
|
+
* Flags an `if:` condition that reads like a security/flow gate but evaluates to
|
|
5
|
+
* a constant — `true`/`false` literals, an `X == X` tautology, or an expression
|
|
6
|
+
* that collapses via `|| true` / `&& false`. These pass review by looking like a
|
|
7
|
+
* control while gating nothing (or everything).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
11
|
+
import { getPrimaryOutput, extractIfConditions } from "./yaml-helpers";
|
|
12
|
+
|
|
13
|
+
/** Strip a leading `${{` / trailing `}}` wrapper from an expression. */
|
|
14
|
+
function inner(expr: string): string {
|
|
15
|
+
return expr.replace(/^\$\{\{/, "").replace(/\}\}$/, "").trim();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Classify a condition as a constant guard, returning a reason or undefined. */
|
|
19
|
+
export function unsoundReason(expr: string): string | undefined {
|
|
20
|
+
const e = inner(expr);
|
|
21
|
+
if (e === "true") return "always true (true literal)";
|
|
22
|
+
if (e === "false") return "always false (false literal)";
|
|
23
|
+
if (/\|\|\s*true\b/.test(e)) return "always true (|| true collapses the condition)";
|
|
24
|
+
if (/&&\s*false\b/.test(e)) return "always false (&& false collapses the condition)";
|
|
25
|
+
const eq = e.match(/^([\w.$'"[\]-]+)\s*==\s*([\w.$'"[\]-]+)$/);
|
|
26
|
+
if (eq && eq[1] === eq[2]) return "always true (both sides of == are identical)";
|
|
27
|
+
const ne = e.match(/^([\w.$'"[\]-]+)\s*!=\s*([\w.$'"[\]-]+)$/);
|
|
28
|
+
if (ne && ne[1] === ne[2]) return "always false (both sides of != are identical)";
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const gha046: PostSynthCheck = {
|
|
33
|
+
id: "GHA046",
|
|
34
|
+
description: "Logically unsound (constant) guard condition",
|
|
35
|
+
|
|
36
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
37
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
38
|
+
|
|
39
|
+
for (const [, output] of ctx.outputs) {
|
|
40
|
+
const yaml = getPrimaryOutput(output);
|
|
41
|
+
for (const { job, expr } of extractIfConditions(yaml)) {
|
|
42
|
+
const reason = unsoundReason(expr);
|
|
43
|
+
if (!reason) continue;
|
|
44
|
+
diagnostics.push({
|
|
45
|
+
checkId: "GHA046",
|
|
46
|
+
severity: "warning",
|
|
47
|
+
message: `Job "${job}" has an if: condition that is ${reason}: "${expr}". A gate that does not constrain anything is misleading — remove it or write the real condition.`,
|
|
48
|
+
entity: job,
|
|
49
|
+
lexicon: "github",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return diagnostics;
|
|
55
|
+
},
|
|
56
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA047: Ineffective `contains()` Guard (Reversed Arguments)
|
|
3
|
+
*
|
|
4
|
+
* Flags `contains('literal', <dynamic>)` — a string literal as the haystack and
|
|
5
|
+
* a dynamic value as the needle. `contains(search, item)` checks whether `item`
|
|
6
|
+
* is in `search`, so a constant first argument makes the result depend on a
|
|
7
|
+
* fixed string rather than the runtime value the author intended to test. The
|
|
8
|
+
* usual fix is to swap the arguments.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
12
|
+
import { getPrimaryOutput, extractIfConditions } from "./yaml-helpers";
|
|
13
|
+
|
|
14
|
+
// contains( 'literal' | "literal" , <something that is not a literal> )
|
|
15
|
+
const REVERSED_CONTAINS = /contains\(\s*(['"][^'"]*['"])\s*,\s*([^)]+)\)/g;
|
|
16
|
+
|
|
17
|
+
function isLiteral(arg: string): boolean {
|
|
18
|
+
const t = arg.trim();
|
|
19
|
+
return (t.startsWith("'") && t.endsWith("'")) || (t.startsWith('"') && t.endsWith('"'));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const gha047: PostSynthCheck = {
|
|
23
|
+
id: "GHA047",
|
|
24
|
+
description: "Ineffective contains() guard with reversed arguments",
|
|
25
|
+
|
|
26
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
27
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
28
|
+
|
|
29
|
+
for (const [, output] of ctx.outputs) {
|
|
30
|
+
const yaml = getPrimaryOutput(output);
|
|
31
|
+
for (const { job, expr } of extractIfConditions(yaml)) {
|
|
32
|
+
let m: RegExpExecArray | null;
|
|
33
|
+
REVERSED_CONTAINS.lastIndex = 0;
|
|
34
|
+
while ((m = REVERSED_CONTAINS.exec(expr)) !== null) {
|
|
35
|
+
// First arg is a literal; flag only when the second arg is dynamic.
|
|
36
|
+
if (isLiteral(m[2])) continue;
|
|
37
|
+
diagnostics.push({
|
|
38
|
+
checkId: "GHA047",
|
|
39
|
+
severity: "warning",
|
|
40
|
+
message: `Job "${job}" calls contains() with a string-literal haystack and a dynamic needle: "${m[0]}". contains(search, item) tests whether item is in search — swap the arguments so the dynamic value is searched.`,
|
|
41
|
+
entity: job,
|
|
42
|
+
lexicon: "github",
|
|
43
|
+
});
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return diagnostics;
|
|
50
|
+
},
|
|
51
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA048: Obfuscated Guard Condition
|
|
3
|
+
*
|
|
4
|
+
* Flags an `if:` condition that builds its compared value through string
|
|
5
|
+
* indirection — `format()`, `join()`, or `fromJSON()` feeding a comparison.
|
|
6
|
+
* Constructing the operand at evaluation time hides what the gate actually
|
|
7
|
+
* checks, which is how an unsound condition (GHA046/GHA047) is disguised to pass
|
|
8
|
+
* review. Write the comparison against the value directly.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
12
|
+
import { getPrimaryOutput, extractIfConditions } from "./yaml-helpers";
|
|
13
|
+
|
|
14
|
+
const INDIRECTION = /\b(format|join|fromJSON)\s*\(/i;
|
|
15
|
+
const COMPARISON = /==|!=|&&|\|\||contains\(|startsWith\(|endsWith\(/;
|
|
16
|
+
|
|
17
|
+
export const gha048: PostSynthCheck = {
|
|
18
|
+
id: "GHA048",
|
|
19
|
+
description: "Obfuscated guard condition (operand built by indirection)",
|
|
20
|
+
|
|
21
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
22
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
23
|
+
|
|
24
|
+
for (const [, output] of ctx.outputs) {
|
|
25
|
+
const yaml = getPrimaryOutput(output);
|
|
26
|
+
for (const { job, expr } of extractIfConditions(yaml)) {
|
|
27
|
+
if (INDIRECTION.test(expr) && COMPARISON.test(expr)) {
|
|
28
|
+
diagnostics.push({
|
|
29
|
+
checkId: "GHA048",
|
|
30
|
+
severity: "warning",
|
|
31
|
+
message: `Job "${job}" builds its if: gate through string indirection (format/join/fromJSON) feeding a comparison: "${expr}". Compare against the value directly so the condition is auditable.`,
|
|
32
|
+
entity: job,
|
|
33
|
+
lexicon: "github",
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return diagnostics;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA049: Persisted Checkout Credentials Reachable by an Uploaded Artifact
|
|
3
|
+
*
|
|
4
|
+
* `actions/checkout` writes the job token into `.git/config` by default
|
|
5
|
+
* (`persist-credentials: true`). A job that also uploads an artifact can sweep
|
|
6
|
+
* that `.git` directory into the artifact, leaking the token to anyone who can
|
|
7
|
+
* download it. Set `persist-credentials: false` on the checkout when the job
|
|
8
|
+
* uploads artifacts.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
12
|
+
import { getPrimaryOutput, linesByJob } from "./yaml-helpers";
|
|
13
|
+
|
|
14
|
+
export const gha049: PostSynthCheck = {
|
|
15
|
+
id: "GHA049",
|
|
16
|
+
description: "Persisted checkout credentials reachable by an uploaded artifact",
|
|
17
|
+
|
|
18
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
19
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
20
|
+
|
|
21
|
+
for (const [, output] of ctx.outputs) {
|
|
22
|
+
const yaml = getPrimaryOutput(output);
|
|
23
|
+
for (const [job, lines] of linesByJob(yaml)) {
|
|
24
|
+
const text = lines.join("\n");
|
|
25
|
+
const hasCheckout = /uses:\s*actions\/checkout/.test(text);
|
|
26
|
+
const persistDisabled = /persist-credentials:\s*false/.test(text);
|
|
27
|
+
const uploadsArtifact = /uses:\s*actions\/upload-artifact/.test(text);
|
|
28
|
+
if (hasCheckout && uploadsArtifact && !persistDisabled) {
|
|
29
|
+
diagnostics.push({
|
|
30
|
+
checkId: "GHA049",
|
|
31
|
+
severity: "warning",
|
|
32
|
+
message: `Job "${job}" checks out with persisted credentials (default) and uploads an artifact — the token in .git/config can leak into the artifact. Set persist-credentials: false on the checkout.`,
|
|
33
|
+
entity: job,
|
|
34
|
+
lexicon: "github",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return diagnostics;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA050: Cache Populated in a Privileged Context (Poisoning Risk)
|
|
3
|
+
*
|
|
4
|
+
* Flags `actions/cache` use under a privileged trigger (`pull_request_target`,
|
|
5
|
+
* `workflow_run`) that runs in the base-repo context. Cache entries written from
|
|
6
|
+
* code influenced by a fork can later be restored by a trusted run and executed,
|
|
7
|
+
* a cache-poisoning path. Restrict caching to trusted triggers or scope keys so
|
|
8
|
+
* untrusted runs cannot write entries a privileged run will restore.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
12
|
+
import { getPrimaryOutput, extractTriggers, linesByJob } from "./yaml-helpers";
|
|
13
|
+
|
|
14
|
+
const PRIVILEGED_TRIGGERS = ["pull_request_target", "workflow_run"];
|
|
15
|
+
|
|
16
|
+
export const gha050: PostSynthCheck = {
|
|
17
|
+
id: "GHA050",
|
|
18
|
+
description: "Cache populated in a privileged context (poisoning risk)",
|
|
19
|
+
|
|
20
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
21
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
22
|
+
|
|
23
|
+
for (const [, output] of ctx.outputs) {
|
|
24
|
+
const yaml = getPrimaryOutput(output);
|
|
25
|
+
const triggers = extractTriggers(yaml);
|
|
26
|
+
const privileged = PRIVILEGED_TRIGGERS.filter((t) => triggers[t]);
|
|
27
|
+
if (privileged.length === 0) continue;
|
|
28
|
+
|
|
29
|
+
for (const [job, lines] of linesByJob(yaml)) {
|
|
30
|
+
if (lines.some((l) => /uses:\s*actions\/cache/.test(l))) {
|
|
31
|
+
diagnostics.push({
|
|
32
|
+
checkId: "GHA050",
|
|
33
|
+
severity: "warning",
|
|
34
|
+
message: `Job "${job}" populates a cache under the ${privileged.join("/")} trigger, which runs in the privileged base context. A poisoned cache entry could be restored and executed by a trusted run — restrict caching to trusted triggers.`,
|
|
35
|
+
entity: job,
|
|
36
|
+
lexicon: "github",
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return diagnostics;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA051: Publish/Release Step Using a Long-Lived Token Instead of OIDC
|
|
3
|
+
*
|
|
4
|
+
* Flags a job that publishes a release/package using a long-lived token secret
|
|
5
|
+
* (`*_TOKEN`, `*_PASSWORD`, `*_API_KEY`) while the workflow requests no
|
|
6
|
+
* `id-token: write` permission. Registries that support OIDC let a workflow mint
|
|
7
|
+
* a short-lived, audience-scoped credential per run instead of holding a
|
|
8
|
+
* standing secret. Advisory — not every registry supports OIDC.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
12
|
+
import { getPrimaryOutput, linesByJob } from "./yaml-helpers";
|
|
13
|
+
|
|
14
|
+
const PUBLISH_RE = /npm publish|yarn publish|pnpm publish|twine upload|poetry publish|cargo publish|gh release create|docker push|gh-action-pypi-publish|npm-publish/;
|
|
15
|
+
const LONG_LIVED_SECRET_RE = /secrets\.(?!GITHUB_TOKEN\b)\w*(TOKEN|PASSWORD|API_KEY|APIKEY)/i;
|
|
16
|
+
|
|
17
|
+
export const gha051: PostSynthCheck = {
|
|
18
|
+
id: "GHA051",
|
|
19
|
+
description: "Publish/release step using a long-lived token instead of OIDC",
|
|
20
|
+
|
|
21
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
22
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
23
|
+
|
|
24
|
+
for (const [, output] of ctx.outputs) {
|
|
25
|
+
const yaml = getPrimaryOutput(output);
|
|
26
|
+
const requestsOidc = /id-token:\s*write/.test(yaml);
|
|
27
|
+
if (requestsOidc) continue;
|
|
28
|
+
|
|
29
|
+
for (const [job, lines] of linesByJob(yaml)) {
|
|
30
|
+
const text = lines.join("\n");
|
|
31
|
+
if (PUBLISH_RE.test(text) && LONG_LIVED_SECRET_RE.test(text)) {
|
|
32
|
+
diagnostics.push({
|
|
33
|
+
checkId: "GHA051",
|
|
34
|
+
severity: "info",
|
|
35
|
+
message: `Job "${job}" publishes using a long-lived token secret and requests no id-token: write. If the registry supports OIDC, mint a short-lived federated credential (permissions: id-token: write) instead of holding a standing token.`,
|
|
36
|
+
entity: job,
|
|
37
|
+
lexicon: "github",
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return diagnostics;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA052: Software Fetched and Executed at Runtime Without Verification
|
|
3
|
+
*
|
|
4
|
+
* Flags a `run:` step that pipes a network download straight into a shell
|
|
5
|
+
* (`curl ... | bash`, `wget ... | sh`, `iwr ... | iex`). The fetched script is
|
|
6
|
+
* unpinned and unverified, so whoever controls the URL controls what executes
|
|
7
|
+
* in the job. Download to a file, verify a checksum/signature, then run it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
11
|
+
import { getPrimaryOutput, extractRunBlocks } from "./yaml-helpers";
|
|
12
|
+
|
|
13
|
+
const PIPE_TO_SHELL = [
|
|
14
|
+
/\b(curl|wget)\b[^\n|]*\|\s*(sudo\s+)?(ba)?sh\b/i,
|
|
15
|
+
/\b(iwr|invoke-webrequest|irm|invoke-restmethod)\b[^\n|]*\|\s*(iex|invoke-expression)\b/i,
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export const gha052: PostSynthCheck = {
|
|
19
|
+
id: "GHA052",
|
|
20
|
+
description: "Software fetched and piped to a shell without verification",
|
|
21
|
+
|
|
22
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
23
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
24
|
+
|
|
25
|
+
for (const [, output] of ctx.outputs) {
|
|
26
|
+
const yaml = getPrimaryOutput(output);
|
|
27
|
+
for (const { job, run } of extractRunBlocks(yaml)) {
|
|
28
|
+
if (PIPE_TO_SHELL.some((re) => re.test(run))) {
|
|
29
|
+
diagnostics.push({
|
|
30
|
+
checkId: "GHA052",
|
|
31
|
+
severity: "warning",
|
|
32
|
+
message: `Job "${job}" pipes a network download directly into a shell — the fetched code is unpinned and unverified. Download to a file, verify a checksum or signature, then execute it.`,
|
|
33
|
+
entity: job,
|
|
34
|
+
lexicon: "github",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return diagnostics;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA053: Re-enabling Unsafe Workflow-Command Processing
|
|
3
|
+
*
|
|
4
|
+
* Flags opt-ins that reintroduce the `set-env` / `add-path` workflow commands
|
|
5
|
+
* removed for security (CVE-2020-15228) — `ACTIONS_ALLOW_UNSECURE_COMMANDS` and
|
|
6
|
+
* direct `::set-env::` / `::add-path::` emission. These let a logged string set
|
|
7
|
+
* arbitrary environment/PATH state for later steps, a code-execution path.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
11
|
+
import { getPrimaryOutput, linesByJob } from "./yaml-helpers";
|
|
12
|
+
|
|
13
|
+
const UNSAFE_COMMANDS = /ACTIONS_ALLOW_UNSECURE_COMMANDS|::set-env\s|::add-path\s/;
|
|
14
|
+
|
|
15
|
+
export const gha053: PostSynthCheck = {
|
|
16
|
+
id: "GHA053",
|
|
17
|
+
description: "Re-enables unsafe set-env / add-path workflow commands",
|
|
18
|
+
|
|
19
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
20
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
21
|
+
|
|
22
|
+
for (const [, output] of ctx.outputs) {
|
|
23
|
+
const yaml = getPrimaryOutput(output);
|
|
24
|
+
const seen = new Set<string>();
|
|
25
|
+
for (const [job, lines] of linesByJob(yaml)) {
|
|
26
|
+
if (lines.some((l) => UNSAFE_COMMANDS.test(l)) && !seen.has(job)) {
|
|
27
|
+
seen.add(job);
|
|
28
|
+
diagnostics.push({
|
|
29
|
+
checkId: "GHA053",
|
|
30
|
+
severity: "error",
|
|
31
|
+
message: `Job "${job}" re-enables the unsafe set-env/add-path workflow commands (ACTIONS_ALLOW_UNSECURE_COMMANDS or ::set-env::/::add-path::). Use $GITHUB_ENV / $GITHUB_PATH files instead — the legacy commands were removed for security.`,
|
|
32
|
+
entity: job,
|
|
33
|
+
lexicon: "github",
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return diagnostics;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA054: Use of a Feature with a Known Security Footgun
|
|
3
|
+
*
|
|
4
|
+
* Catch-all, data-driven check: flags emitted workflow content matching a
|
|
5
|
+
* vendored snapshot of known-risky GitHub Actions features (deprecated workflow
|
|
6
|
+
* commands, unsafe runtime opt-ins, masking footguns). Advisory; the dataset is
|
|
7
|
+
* necessarily incomplete.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
11
|
+
import { getPrimaryOutput, linesByJob } from "./yaml-helpers";
|
|
12
|
+
import { RISKY_FEATURES } from "../rules/data/risky-features";
|
|
13
|
+
|
|
14
|
+
export const gha054: PostSynthCheck = {
|
|
15
|
+
id: "GHA054",
|
|
16
|
+
description: "Use of a feature with a known security footgun",
|
|
17
|
+
|
|
18
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
19
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
20
|
+
|
|
21
|
+
for (const [, output] of ctx.outputs) {
|
|
22
|
+
const yaml = getPrimaryOutput(output);
|
|
23
|
+
for (const [job, lines] of linesByJob(yaml)) {
|
|
24
|
+
const text = lines.join("\n");
|
|
25
|
+
for (const feature of RISKY_FEATURES) {
|
|
26
|
+
if (feature.pattern.test(text)) {
|
|
27
|
+
diagnostics.push({
|
|
28
|
+
checkId: "GHA054",
|
|
29
|
+
severity: "warning",
|
|
30
|
+
message: `Job "${job}" uses ${feature.label} — ${feature.advice}.`,
|
|
31
|
+
entity: job,
|
|
32
|
+
lexicon: "github",
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return diagnostics;
|
|
40
|
+
},
|
|
41
|
+
};
|