@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,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA029: Unpinned Action or Reusable-Workflow Reference
|
|
3
|
+
*
|
|
4
|
+
* Flags any `uses:` (step action or job-level reusable workflow) that is pinned
|
|
5
|
+
* to a mutable tag or branch instead of a full 40-character commit SHA. A tag
|
|
6
|
+
* can be repointed to malicious code after review, so every external reference
|
|
7
|
+
* is a supply-chain trust decision.
|
|
8
|
+
*
|
|
9
|
+
* `actions/checkout` is intentionally left to the more specific GHA021. Local
|
|
10
|
+
* references (`./`, `../`) and `docker://` images (GHA030) are out of scope.
|
|
11
|
+
* Owners in the trusted allowlist are exempt.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
15
|
+
import { getPrimaryOutput, extractActionRefs, parseActionUses } from "./yaml-helpers";
|
|
16
|
+
import { TRUSTED_ACTION_OWNERS } from "../rules/data/trusted-action-owners";
|
|
17
|
+
|
|
18
|
+
const SHA_RE = /^[0-9a-f]{40}$/;
|
|
19
|
+
|
|
20
|
+
export interface UnpinnedRef {
|
|
21
|
+
job: string;
|
|
22
|
+
ref: string;
|
|
23
|
+
slug: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Find every action/reusable-workflow `uses:` that is not pinned to a commit
|
|
28
|
+
* SHA. Exposed as a pure function so the allowlist behaviour is testable.
|
|
29
|
+
*/
|
|
30
|
+
export function findUnpinnedActions(
|
|
31
|
+
yaml: string,
|
|
32
|
+
trustedOwners: ReadonlySet<string> = TRUSTED_ACTION_OWNERS,
|
|
33
|
+
): UnpinnedRef[] {
|
|
34
|
+
const result: UnpinnedRef[] = [];
|
|
35
|
+
for (const { job, ref } of extractActionRefs(yaml)) {
|
|
36
|
+
const parsed = parseActionUses(ref);
|
|
37
|
+
if (!parsed) continue; // local or docker:// reference
|
|
38
|
+
if (parsed.slug === "actions/checkout") continue; // owned by GHA021
|
|
39
|
+
if (trustedOwners.has(parsed.owner)) continue;
|
|
40
|
+
if (SHA_RE.test(parsed.gitRef)) continue; // already pinned
|
|
41
|
+
result.push({ job, ref, slug: parsed.slug });
|
|
42
|
+
}
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const gha029: PostSynthCheck = {
|
|
47
|
+
id: "GHA029",
|
|
48
|
+
description: "Action or reusable workflow not pinned to a commit SHA",
|
|
49
|
+
|
|
50
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
51
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
52
|
+
|
|
53
|
+
for (const [, output] of ctx.outputs) {
|
|
54
|
+
const yaml = getPrimaryOutput(output);
|
|
55
|
+
for (const { job, ref } of findUnpinnedActions(yaml)) {
|
|
56
|
+
diagnostics.push({
|
|
57
|
+
checkId: "GHA029",
|
|
58
|
+
severity: "warning",
|
|
59
|
+
message: `Job "${job}" uses ${ref} pinned to a tag or branch — pin to a full commit SHA for supply-chain security.`,
|
|
60
|
+
entity: job,
|
|
61
|
+
lexicon: "github",
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return diagnostics;
|
|
67
|
+
},
|
|
68
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { gha030 } from "./gha030";
|
|
4
|
+
|
|
5
|
+
function makeCtx(yaml: string): PostSynthContext {
|
|
6
|
+
return {
|
|
7
|
+
outputs: new Map([["github", yaml]]),
|
|
8
|
+
entities: new Map(),
|
|
9
|
+
buildResult: {
|
|
10
|
+
outputs: new Map([["github", yaml]]),
|
|
11
|
+
entities: new Map(),
|
|
12
|
+
warnings: [],
|
|
13
|
+
errors: [],
|
|
14
|
+
sourceFileCount: 1,
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("GHA030: container image without digest", () => {
|
|
20
|
+
test("flags a container image pinned to a tag", () => {
|
|
21
|
+
const yaml = `name: CI
|
|
22
|
+
on:
|
|
23
|
+
push:
|
|
24
|
+
jobs:
|
|
25
|
+
build:
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
container:
|
|
28
|
+
image: node:20
|
|
29
|
+
steps:
|
|
30
|
+
- run: echo build
|
|
31
|
+
`;
|
|
32
|
+
const diags = gha030.check(makeCtx(yaml));
|
|
33
|
+
expect(diags).toHaveLength(1);
|
|
34
|
+
expect(diags[0].checkId).toBe("GHA030");
|
|
35
|
+
expect(diags[0].entity).toBe("build");
|
|
36
|
+
expect(diags[0].message).toContain("node:20");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("flags a service image pinned to a tag", () => {
|
|
40
|
+
const yaml = `name: CI
|
|
41
|
+
on:
|
|
42
|
+
push:
|
|
43
|
+
jobs:
|
|
44
|
+
build:
|
|
45
|
+
runs-on: ubuntu-latest
|
|
46
|
+
services:
|
|
47
|
+
redis:
|
|
48
|
+
image: redis:7
|
|
49
|
+
steps:
|
|
50
|
+
- run: echo build
|
|
51
|
+
`;
|
|
52
|
+
const diags = gha030.check(makeCtx(yaml));
|
|
53
|
+
expect(diags).toHaveLength(1);
|
|
54
|
+
expect(diags[0].message).toContain("redis:7");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("flags a docker:// step reference without digest", () => {
|
|
58
|
+
const yaml = `name: CI
|
|
59
|
+
on:
|
|
60
|
+
push:
|
|
61
|
+
jobs:
|
|
62
|
+
build:
|
|
63
|
+
runs-on: ubuntu-latest
|
|
64
|
+
steps:
|
|
65
|
+
- uses: docker://alpine:3.19
|
|
66
|
+
`;
|
|
67
|
+
const diags = gha030.check(makeCtx(yaml));
|
|
68
|
+
expect(diags).toHaveLength(1);
|
|
69
|
+
expect(diags[0].message).toContain("alpine:3.19");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("does not flag a container image pinned to a digest", () => {
|
|
73
|
+
const yaml = `name: CI
|
|
74
|
+
on:
|
|
75
|
+
push:
|
|
76
|
+
jobs:
|
|
77
|
+
build:
|
|
78
|
+
runs-on: ubuntu-latest
|
|
79
|
+
container:
|
|
80
|
+
image: node@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
|
81
|
+
steps:
|
|
82
|
+
- run: echo build
|
|
83
|
+
`;
|
|
84
|
+
const diags = gha030.check(makeCtx(yaml));
|
|
85
|
+
expect(diags).toHaveLength(0);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA030: Container Image Without an Immutable Digest
|
|
3
|
+
*
|
|
4
|
+
* Flags job `container:` images, `services:` images, and `docker://` step
|
|
5
|
+
* references that are not pinned to an immutable `@sha256:` digest. A mutable
|
|
6
|
+
* tag (`:latest`, `:20`) can be repointed to a different image after review.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
10
|
+
import { getPrimaryOutput, extractImageRefs } from "./yaml-helpers";
|
|
11
|
+
|
|
12
|
+
const SOURCE_LABEL: Record<string, string> = {
|
|
13
|
+
container: "container image",
|
|
14
|
+
service: "service image",
|
|
15
|
+
step: "docker:// image",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const gha030: PostSynthCheck = {
|
|
19
|
+
id: "GHA030",
|
|
20
|
+
description: "Container image not pinned to an immutable digest",
|
|
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, image, source } of extractImageRefs(yaml)) {
|
|
28
|
+
if (image.includes("@sha256:")) continue;
|
|
29
|
+
diagnostics.push({
|
|
30
|
+
checkId: "GHA030",
|
|
31
|
+
severity: "warning",
|
|
32
|
+
message: `Job "${job}" ${SOURCE_LABEL[source]} "${image}" is not pinned to a digest — reference it by @sha256:... so the image cannot change after review.`,
|
|
33
|
+
entity: job,
|
|
34
|
+
lexicon: "github",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return diagnostics;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { gha031, editDistance, nearestLookAlike } from "./gha031";
|
|
4
|
+
|
|
5
|
+
function makeCtx(yaml: string): PostSynthContext {
|
|
6
|
+
return {
|
|
7
|
+
outputs: new Map([["github", yaml]]),
|
|
8
|
+
entities: new Map(),
|
|
9
|
+
buildResult: {
|
|
10
|
+
outputs: new Map([["github", yaml]]),
|
|
11
|
+
entities: new Map(),
|
|
12
|
+
warnings: [],
|
|
13
|
+
errors: [],
|
|
14
|
+
sourceFileCount: 1,
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("GHA031: look-alike action reference", () => {
|
|
20
|
+
test("editDistance basics", () => {
|
|
21
|
+
expect(editDistance("abc", "abc", 2)).toBe(0);
|
|
22
|
+
expect(editDistance("actions/chekout", "actions/checkout", 2)).toBe(1);
|
|
23
|
+
expect(editDistance("totally/different-name-xyz", "actions/checkout", 2)).toBe(3);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("nearestLookAlike returns the close known slug", () => {
|
|
27
|
+
expect(nearestLookAlike("actions/checkout")).toBeUndefined(); // exact match
|
|
28
|
+
expect(nearestLookAlike("actions/chekout")).toBe("actions/checkout");
|
|
29
|
+
expect(nearestLookAlike("totally/unrelated")).toBeUndefined();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("flags a typo-squatted action", () => {
|
|
33
|
+
const yaml = `name: CI
|
|
34
|
+
on:
|
|
35
|
+
push:
|
|
36
|
+
jobs:
|
|
37
|
+
build:
|
|
38
|
+
runs-on: ubuntu-latest
|
|
39
|
+
steps:
|
|
40
|
+
- uses: actions/checkoutt@v4
|
|
41
|
+
`;
|
|
42
|
+
const diags = gha031.check(makeCtx(yaml));
|
|
43
|
+
expect(diags).toHaveLength(1);
|
|
44
|
+
expect(diags[0].checkId).toBe("GHA031");
|
|
45
|
+
expect(diags[0].message).toContain("actions/checkout");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("does not flag a legitimate exact known action", () => {
|
|
49
|
+
const yaml = `name: CI
|
|
50
|
+
on:
|
|
51
|
+
push:
|
|
52
|
+
jobs:
|
|
53
|
+
build:
|
|
54
|
+
runs-on: ubuntu-latest
|
|
55
|
+
steps:
|
|
56
|
+
- uses: actions/checkout@v4
|
|
57
|
+
`;
|
|
58
|
+
const diags = gha031.check(makeCtx(yaml));
|
|
59
|
+
expect(diags).toHaveLength(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("does not flag an unrelated third-party action", () => {
|
|
63
|
+
const yaml = `name: CI
|
|
64
|
+
on:
|
|
65
|
+
push:
|
|
66
|
+
jobs:
|
|
67
|
+
build:
|
|
68
|
+
runs-on: ubuntu-latest
|
|
69
|
+
steps:
|
|
70
|
+
- uses: my-org/my-custom-thing@v1
|
|
71
|
+
`;
|
|
72
|
+
const diags = gha031.check(makeCtx(yaml));
|
|
73
|
+
expect(diags).toHaveLength(0);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA031: Action Reference Resembling a Well-Known Action
|
|
3
|
+
*
|
|
4
|
+
* Flags a `uses:` slug that is a near-miss (edit distance 1–2) of a popular
|
|
5
|
+
* action but not an exact match — a likely typo or a deliberate impersonation
|
|
6
|
+
* of the well-known action under different ownership. Advisory only; backed by
|
|
7
|
+
* a vendored reference list.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
11
|
+
import { getPrimaryOutput, extractActionRefs, parseActionUses } from "./yaml-helpers";
|
|
12
|
+
import { KNOWN_ACTION_SLUGS } from "../rules/data/known-action-slugs";
|
|
13
|
+
|
|
14
|
+
const KNOWN = new Set(KNOWN_ACTION_SLUGS);
|
|
15
|
+
|
|
16
|
+
/** Levenshtein edit distance, capped: returns early once it exceeds `max`. */
|
|
17
|
+
export function editDistance(a: string, b: string, max: number): number {
|
|
18
|
+
if (a === b) return 0;
|
|
19
|
+
if (Math.abs(a.length - b.length) > max) return max + 1;
|
|
20
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
21
|
+
for (let i = 1; i <= a.length; i++) {
|
|
22
|
+
const curr = [i];
|
|
23
|
+
let rowMin = i;
|
|
24
|
+
for (let j = 1; j <= b.length; j++) {
|
|
25
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
26
|
+
const val = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
|
|
27
|
+
curr.push(val);
|
|
28
|
+
if (val < rowMin) rowMin = val;
|
|
29
|
+
}
|
|
30
|
+
if (rowMin > max) return max + 1;
|
|
31
|
+
prev = curr;
|
|
32
|
+
}
|
|
33
|
+
return prev[b.length];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Find the nearest known slug within edit distance 1–2 of `slug`, if any. */
|
|
37
|
+
export function nearestLookAlike(slug: string): string | undefined {
|
|
38
|
+
if (KNOWN.has(slug)) return undefined; // exact match is legitimate
|
|
39
|
+
let best: string | undefined;
|
|
40
|
+
let bestDist = 3;
|
|
41
|
+
for (const known of KNOWN) {
|
|
42
|
+
const d = editDistance(slug, known, 2);
|
|
43
|
+
if (d >= 1 && d <= 2 && d < bestDist) {
|
|
44
|
+
best = known;
|
|
45
|
+
bestDist = d;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return best;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const gha031: PostSynthCheck = {
|
|
52
|
+
id: "GHA031",
|
|
53
|
+
description: "Action reference resembles a well-known action (possible impersonation)",
|
|
54
|
+
|
|
55
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
56
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
57
|
+
|
|
58
|
+
for (const [, output] of ctx.outputs) {
|
|
59
|
+
const yaml = getPrimaryOutput(output);
|
|
60
|
+
for (const { job, ref } of extractActionRefs(yaml)) {
|
|
61
|
+
const parsed = parseActionUses(ref);
|
|
62
|
+
if (!parsed) continue;
|
|
63
|
+
const lookAlike = nearestLookAlike(parsed.slug);
|
|
64
|
+
if (!lookAlike) continue;
|
|
65
|
+
diagnostics.push({
|
|
66
|
+
checkId: "GHA031",
|
|
67
|
+
severity: "warning",
|
|
68
|
+
message: `Job "${job}" uses "${parsed.slug}", which closely resembles the well-known action "${lookAlike}". Confirm the owner is who you intend — this may be a typo or impersonation.`,
|
|
69
|
+
entity: job,
|
|
70
|
+
lexicon: "github",
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return diagnostics;
|
|
76
|
+
},
|
|
77
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { gha032 } from "./gha032";
|
|
4
|
+
|
|
5
|
+
function makeCtx(yaml: string): PostSynthContext {
|
|
6
|
+
return {
|
|
7
|
+
outputs: new Map([["github", yaml]]),
|
|
8
|
+
entities: new Map(),
|
|
9
|
+
buildResult: {
|
|
10
|
+
outputs: new Map([["github", yaml]]),
|
|
11
|
+
entities: new Map(),
|
|
12
|
+
warnings: [],
|
|
13
|
+
errors: [],
|
|
14
|
+
sourceFileCount: 1,
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("GHA032: archived or compromised action", () => {
|
|
20
|
+
test("flags a known compromised action", () => {
|
|
21
|
+
const yaml = `name: CI
|
|
22
|
+
on:
|
|
23
|
+
push:
|
|
24
|
+
jobs:
|
|
25
|
+
build:
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
steps:
|
|
28
|
+
- uses: tj-actions/changed-files@v44
|
|
29
|
+
`;
|
|
30
|
+
const diags = gha032.check(makeCtx(yaml));
|
|
31
|
+
expect(diags).toHaveLength(1);
|
|
32
|
+
expect(diags[0].checkId).toBe("GHA032");
|
|
33
|
+
expect(diags[0].entity).toBe("build");
|
|
34
|
+
expect(diags[0].message).toContain("tj-actions/changed-files");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("flags a known archived action", () => {
|
|
38
|
+
const yaml = `name: CI
|
|
39
|
+
on:
|
|
40
|
+
push:
|
|
41
|
+
jobs:
|
|
42
|
+
build:
|
|
43
|
+
runs-on: ubuntu-latest
|
|
44
|
+
steps:
|
|
45
|
+
- uses: actions/setup-ruby@v1
|
|
46
|
+
`;
|
|
47
|
+
const diags = gha032.check(makeCtx(yaml));
|
|
48
|
+
expect(diags).toHaveLength(1);
|
|
49
|
+
expect(diags[0].message).toContain("ruby/setup-ruby");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("does not flag a maintained action", () => {
|
|
53
|
+
const yaml = `name: CI
|
|
54
|
+
on:
|
|
55
|
+
push:
|
|
56
|
+
jobs:
|
|
57
|
+
build:
|
|
58
|
+
runs-on: ubuntu-latest
|
|
59
|
+
steps:
|
|
60
|
+
- uses: actions/setup-node@v4
|
|
61
|
+
`;
|
|
62
|
+
const diags = gha032.check(makeCtx(yaml));
|
|
63
|
+
expect(diags).toHaveLength(0);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA032: Reference to an Archived or Compromised Action
|
|
3
|
+
*
|
|
4
|
+
* Flags a `uses:` slug that a vendored snapshot marks as archived/abandoned or
|
|
5
|
+
* carrying a disclosed security issue. Advisory only — the dataset is
|
|
6
|
+
* necessarily incomplete and may lag upstream changes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
10
|
+
import { getPrimaryOutput, extractActionRefs, parseActionUses } from "./yaml-helpers";
|
|
11
|
+
import { FLAGGED_ACTIONS } from "../rules/data/flagged-actions";
|
|
12
|
+
|
|
13
|
+
export const gha032: PostSynthCheck = {
|
|
14
|
+
id: "GHA032",
|
|
15
|
+
description: "Action is archived/abandoned or has a disclosed security issue",
|
|
16
|
+
|
|
17
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
18
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
19
|
+
|
|
20
|
+
for (const [, output] of ctx.outputs) {
|
|
21
|
+
const yaml = getPrimaryOutput(output);
|
|
22
|
+
for (const { job, ref } of extractActionRefs(yaml)) {
|
|
23
|
+
const parsed = parseActionUses(ref);
|
|
24
|
+
if (!parsed) continue;
|
|
25
|
+
const flagged = FLAGGED_ACTIONS[parsed.slug];
|
|
26
|
+
if (!flagged) continue;
|
|
27
|
+
diagnostics.push({
|
|
28
|
+
checkId: "GHA032",
|
|
29
|
+
severity: "warning",
|
|
30
|
+
message: `Job "${job}" uses "${parsed.slug}" — ${flagged.reason}. ${flagged.remediation}.`,
|
|
31
|
+
entity: job,
|
|
32
|
+
lexicon: "github",
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return diagnostics;
|
|
38
|
+
},
|
|
39
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { gha033 } from "./gha033";
|
|
4
|
+
|
|
5
|
+
function makeCtx(yaml: string): PostSynthContext {
|
|
6
|
+
return {
|
|
7
|
+
outputs: new Map([["github", yaml]]),
|
|
8
|
+
entities: new Map(),
|
|
9
|
+
buildResult: {
|
|
10
|
+
outputs: new Map([["github", yaml]]),
|
|
11
|
+
entities: new Map(),
|
|
12
|
+
warnings: [],
|
|
13
|
+
errors: [],
|
|
14
|
+
sourceFileCount: 1,
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("GHA033: blanket write-all permissions", () => {
|
|
20
|
+
test("flags workflow-level write-all", () => {
|
|
21
|
+
const yaml = `name: CI
|
|
22
|
+
on:
|
|
23
|
+
push:
|
|
24
|
+
permissions: write-all
|
|
25
|
+
jobs:
|
|
26
|
+
build:
|
|
27
|
+
runs-on: ubuntu-latest
|
|
28
|
+
steps:
|
|
29
|
+
- run: echo build
|
|
30
|
+
`;
|
|
31
|
+
const diags = gha033.check(makeCtx(yaml));
|
|
32
|
+
expect(diags).toHaveLength(1);
|
|
33
|
+
expect(diags[0].checkId).toBe("GHA033");
|
|
34
|
+
expect(diags[0].message).toContain("write-all");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("flags job-level write-all", () => {
|
|
38
|
+
const yaml = `name: CI
|
|
39
|
+
on:
|
|
40
|
+
push:
|
|
41
|
+
jobs:
|
|
42
|
+
build:
|
|
43
|
+
runs-on: ubuntu-latest
|
|
44
|
+
permissions: write-all
|
|
45
|
+
steps:
|
|
46
|
+
- run: echo build
|
|
47
|
+
`;
|
|
48
|
+
const diags = gha033.check(makeCtx(yaml));
|
|
49
|
+
expect(diags).toHaveLength(1);
|
|
50
|
+
expect(diags[0].entity).toBe("build");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("does not flag scoped permissions", () => {
|
|
54
|
+
const yaml = `name: CI
|
|
55
|
+
on:
|
|
56
|
+
push:
|
|
57
|
+
permissions:
|
|
58
|
+
contents: read
|
|
59
|
+
jobs:
|
|
60
|
+
build:
|
|
61
|
+
runs-on: ubuntu-latest
|
|
62
|
+
steps:
|
|
63
|
+
- run: echo build
|
|
64
|
+
`;
|
|
65
|
+
const diags = gha033.check(makeCtx(yaml));
|
|
66
|
+
expect(diags).toHaveLength(0);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA033: Blanket `write-all` Token Permissions
|
|
3
|
+
*
|
|
4
|
+
* Flags a `permissions: write-all` grant at the workflow or job level. It hands
|
|
5
|
+
* the `GITHUB_TOKEN` every write scope regardless of what the job uses, so any
|
|
6
|
+
* compromised or injected step becomes a write-capable actor across the repo.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
10
|
+
import { getPrimaryOutput, extractWorkflowPermissions, extractJobPermissions, writeSurface } from "./yaml-helpers";
|
|
11
|
+
|
|
12
|
+
export const gha033: PostSynthCheck = {
|
|
13
|
+
id: "GHA033",
|
|
14
|
+
description: "Blanket write-all token permissions",
|
|
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 wf = extractWorkflowPermissions(yaml);
|
|
23
|
+
if (wf && writeSurface(wf).writeAll) {
|
|
24
|
+
diagnostics.push({
|
|
25
|
+
checkId: "GHA033",
|
|
26
|
+
severity: "warning",
|
|
27
|
+
message: `Workflow declares permissions: write-all — replace it with the specific scopes the jobs actually need (least privilege).`,
|
|
28
|
+
lexicon: "github",
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
for (const [job, perms] of extractJobPermissions(yaml)) {
|
|
33
|
+
if (writeSurface(perms).writeAll) {
|
|
34
|
+
diagnostics.push({
|
|
35
|
+
checkId: "GHA033",
|
|
36
|
+
severity: "warning",
|
|
37
|
+
message: `Job "${job}" declares permissions: write-all — replace it with the specific scopes this job needs (least privilege).`,
|
|
38
|
+
entity: job,
|
|
39
|
+
lexicon: "github",
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return diagnostics;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { gha034 } from "./gha034";
|
|
4
|
+
|
|
5
|
+
function makeCtx(yaml: string): PostSynthContext {
|
|
6
|
+
return {
|
|
7
|
+
outputs: new Map([["github", yaml]]),
|
|
8
|
+
entities: new Map(),
|
|
9
|
+
buildResult: {
|
|
10
|
+
outputs: new Map([["github", yaml]]),
|
|
11
|
+
entities: new Map(),
|
|
12
|
+
warnings: [],
|
|
13
|
+
errors: [],
|
|
14
|
+
sourceFileCount: 1,
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("GHA034: workflow-wide write scope", () => {
|
|
20
|
+
test("flags a workflow-level write scope", () => {
|
|
21
|
+
const yaml = `name: CI
|
|
22
|
+
on:
|
|
23
|
+
push:
|
|
24
|
+
permissions:
|
|
25
|
+
contents: write
|
|
26
|
+
packages: write
|
|
27
|
+
jobs:
|
|
28
|
+
build:
|
|
29
|
+
runs-on: ubuntu-latest
|
|
30
|
+
steps:
|
|
31
|
+
- run: echo build
|
|
32
|
+
`;
|
|
33
|
+
const diags = gha034.check(makeCtx(yaml));
|
|
34
|
+
expect(diags).toHaveLength(1);
|
|
35
|
+
expect(diags[0].checkId).toBe("GHA034");
|
|
36
|
+
expect(diags[0].message).toContain("contents");
|
|
37
|
+
expect(diags[0].message).toContain("packages");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("does not flag workflow-level read-only scopes", () => {
|
|
41
|
+
const yaml = `name: CI
|
|
42
|
+
on:
|
|
43
|
+
push:
|
|
44
|
+
permissions:
|
|
45
|
+
contents: read
|
|
46
|
+
jobs:
|
|
47
|
+
build:
|
|
48
|
+
runs-on: ubuntu-latest
|
|
49
|
+
steps:
|
|
50
|
+
- run: echo build
|
|
51
|
+
`;
|
|
52
|
+
const diags = gha034.check(makeCtx(yaml));
|
|
53
|
+
expect(diags).toHaveLength(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("does not flag write-all (owned by GHA033)", () => {
|
|
57
|
+
const yaml = `name: CI
|
|
58
|
+
on:
|
|
59
|
+
push:
|
|
60
|
+
permissions: write-all
|
|
61
|
+
jobs:
|
|
62
|
+
build:
|
|
63
|
+
runs-on: ubuntu-latest
|
|
64
|
+
steps:
|
|
65
|
+
- run: echo build
|
|
66
|
+
`;
|
|
67
|
+
const diags = gha034.check(makeCtx(yaml));
|
|
68
|
+
expect(diags).toHaveLength(0);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("does not flag job-scoped write (the recommended form)", () => {
|
|
72
|
+
const yaml = `name: CI
|
|
73
|
+
on:
|
|
74
|
+
push:
|
|
75
|
+
jobs:
|
|
76
|
+
build:
|
|
77
|
+
runs-on: ubuntu-latest
|
|
78
|
+
permissions:
|
|
79
|
+
contents: write
|
|
80
|
+
steps:
|
|
81
|
+
- run: echo build
|
|
82
|
+
`;
|
|
83
|
+
const diags = gha034.check(makeCtx(yaml));
|
|
84
|
+
expect(diags).toHaveLength(0);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA034: Workflow-Wide Write Scope (Prefer Job-Scoped Permissions)
|
|
3
|
+
*
|
|
4
|
+
* Flags individual write scopes granted at the workflow level. A workflow-wide
|
|
5
|
+
* grant gives every job that scope even when only one needs it. Least privilege
|
|
6
|
+
* prefers declaring the write scope on the single job that uses it.
|
|
7
|
+
*
|
|
8
|
+
* The `write-all` blanket preset is handled by GHA033 and skipped here.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
12
|
+
import { getPrimaryOutput, extractWorkflowPermissions, writeSurface } from "./yaml-helpers";
|
|
13
|
+
|
|
14
|
+
export const gha034: PostSynthCheck = {
|
|
15
|
+
id: "GHA034",
|
|
16
|
+
description: "Write permissions granted workflow-wide instead of per-job",
|
|
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
|
+
const wf = extractWorkflowPermissions(yaml);
|
|
25
|
+
if (!wf) continue;
|
|
26
|
+
|
|
27
|
+
const { writeAll, scopes } = writeSurface(wf);
|
|
28
|
+
if (writeAll) continue; // GHA033 owns write-all
|
|
29
|
+
if (scopes.length === 0) continue;
|
|
30
|
+
|
|
31
|
+
diagnostics.push({
|
|
32
|
+
checkId: "GHA034",
|
|
33
|
+
severity: "warning",
|
|
34
|
+
message: `Workflow grants write scope (${scopes.join(", ")}) for all jobs — move each write scope onto the specific job that needs it for least privilege.`,
|
|
35
|
+
lexicon: "github",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return diagnostics;
|
|
40
|
+
},
|
|
41
|
+
};
|