@intentius/chant-lexicon-github 0.46.0 → 0.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/generate-op-pipeline.d.ts +81 -0
- package/dist/components/generate-op-pipeline.d.ts.map +1 -0
- package/dist/integrity.json +16 -6
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/gha013.d.ts.map +1 -1
- package/dist/lint/post-synth/gha059.d.ts +40 -0
- package/dist/lint/post-synth/gha059.d.ts.map +1 -0
- package/dist/lint/post-synth/gha060.d.ts +34 -0
- package/dist/lint/post-synth/gha060.d.ts.map +1 -0
- package/dist/lint/post-synth/gha061.d.ts +27 -0
- package/dist/lint/post-synth/gha061.d.ts.map +1 -0
- package/dist/lint/post-synth/gha062.d.ts +24 -0
- package/dist/lint/post-synth/gha062.d.ts.map +1 -0
- package/dist/lint/post-synth/gha063.d.ts +12 -0
- package/dist/lint/post-synth/gha063.d.ts.map +1 -0
- package/dist/lint/post-synth/gha064.d.ts +12 -0
- package/dist/lint/post-synth/gha064.d.ts.map +1 -0
- package/dist/lint/post-synth/gha065.d.ts +19 -0
- package/dist/lint/post-synth/gha065.d.ts.map +1 -0
- package/dist/lint/post-synth/gha066.d.ts +12 -0
- package/dist/lint/post-synth/gha066.d.ts.map +1 -0
- package/dist/lint/post-synth/gha067.d.ts +12 -0
- package/dist/lint/post-synth/gha067.d.ts.map +1 -0
- package/dist/lint/post-synth/gha068.d.ts +13 -0
- package/dist/lint/post-synth/gha068.d.ts.map +1 -0
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/yaml-helpers.d.ts +20 -0
- package/dist/lint/post-synth/yaml-helpers.d.ts.map +1 -1
- package/dist/lint/rules/data/action-usage-policy.d.ts +30 -0
- package/dist/lint/rules/data/action-usage-policy.d.ts.map +1 -0
- package/dist/lint/rules/data/advisory-feed.d.ts +36 -0
- package/dist/lint/rules/data/advisory-feed.d.ts.map +1 -0
- package/dist/manifest.json +109 -1
- package/dist/meta.json +5 -0
- package/dist/okf/index.md +10 -0
- package/dist/okf/rules/GHA059.md +15 -0
- package/dist/okf/rules/GHA060.md +15 -0
- package/dist/okf/rules/GHA061.md +15 -0
- package/dist/okf/rules/GHA062.md +15 -0
- package/dist/okf/rules/GHA063.md +15 -0
- package/dist/okf/rules/GHA064.md +15 -0
- package/dist/okf/rules/GHA065.md +15 -0
- package/dist/okf/rules/GHA066.md +15 -0
- package/dist/okf/rules/GHA067.md +15 -0
- package/dist/okf/rules/GHA068.md +15 -0
- package/dist/okf/types/Job.md +9 -0
- package/dist/okf/types/Workflow.md +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/rules/gha013.ts +10 -3
- package/dist/rules/gha059.ts +132 -0
- package/dist/rules/gha060.ts +146 -0
- package/dist/rules/gha061.ts +85 -0
- package/dist/rules/gha062.ts +68 -0
- package/dist/rules/gha063.ts +60 -0
- package/dist/rules/gha064.ts +55 -0
- package/dist/rules/gha065.ts +129 -0
- package/dist/rules/gha066.ts +47 -0
- package/dist/rules/gha067.ts +76 -0
- package/dist/rules/gha068.ts +51 -0
- package/dist/rules/yaml-helpers.ts +37 -2
- package/dist/types/index.d.ts +1 -0
- package/package.json +2 -2
- package/src/codegen/docs.ts +1 -1
- package/src/components/generate-op-pipeline.test.ts +146 -0
- package/src/components/generate-op-pipeline.ts +183 -0
- package/src/generated/index.d.ts +1 -0
- package/src/generated/lexicon-github.json +5 -0
- package/src/lint/audit-catalog.ts +16 -1
- package/src/lint/post-synth/gha013.test.ts +17 -0
- package/src/lint/post-synth/gha013.ts +10 -3
- package/src/lint/post-synth/gha059.test.ts +147 -0
- package/src/lint/post-synth/gha059.ts +132 -0
- package/src/lint/post-synth/gha060.test.ts +144 -0
- package/src/lint/post-synth/gha060.ts +146 -0
- package/src/lint/post-synth/gha061.test.ts +85 -0
- package/src/lint/post-synth/gha061.ts +85 -0
- package/src/lint/post-synth/gha062.test.ts +88 -0
- package/src/lint/post-synth/gha062.ts +68 -0
- package/src/lint/post-synth/gha063.test.ts +86 -0
- package/src/lint/post-synth/gha063.ts +60 -0
- package/src/lint/post-synth/gha064.test.ts +73 -0
- package/src/lint/post-synth/gha064.ts +55 -0
- package/src/lint/post-synth/gha065.test.ts +85 -0
- package/src/lint/post-synth/gha065.ts +129 -0
- package/src/lint/post-synth/gha066.test.ts +66 -0
- package/src/lint/post-synth/gha066.ts +47 -0
- package/src/lint/post-synth/gha067.test.ts +85 -0
- package/src/lint/post-synth/gha067.ts +76 -0
- package/src/lint/post-synth/gha068.test.ts +71 -0
- package/src/lint/post-synth/gha068.ts +51 -0
- package/src/lint/post-synth/index.ts +20 -0
- package/src/lint/post-synth/yaml-helpers.ts +37 -2
- package/src/lint/rules/data/action-usage-policy.ts +31 -0
- package/src/lint/rules/data/advisory-feed.ts +38 -0
- package/src/plugin.test.ts +24 -3
- package/src/plugin.ts +28 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA059: Stale or Missing Pin Annotation
|
|
3
|
+
*
|
|
4
|
+
* A ref pinned to a commit SHA is only as trustworthy as the label a reviewer
|
|
5
|
+
* reads next to it — `uses: actions/setup-node@1a2b…9a0b # v4.0.2` lets a
|
|
6
|
+
* reviewer sanity-check the digest without resolving it themselves. This check
|
|
7
|
+
* flags two ways that label goes stale:
|
|
8
|
+
*
|
|
9
|
+
* 1. Missing — a SHA-pinned ref with no trailing version comment at all.
|
|
10
|
+
* 2. Mismatched — inferred without any network call, from internal
|
|
11
|
+
* inconsistency in the workflow itself: the same action is pinned to the
|
|
12
|
+
* same commit SHA in one place and a *different* label in another, or the
|
|
13
|
+
* same label is attached to two *different* commit SHAs. Either shape
|
|
14
|
+
* proves at least one of the labels is wrong — a real digest has exactly
|
|
15
|
+
* one correct human-readable name.
|
|
16
|
+
*
|
|
17
|
+
* Cross-referencing a label against the actual git history (to catch a lone,
|
|
18
|
+
* internally-consistent but still-wrong annotation) needs a network call and
|
|
19
|
+
* is out of scope here — see GHA062 for the feed-based advisory check, which
|
|
20
|
+
* takes the same "no network in the rule itself" stance via an injected feed.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
24
|
+
import { getPrimaryOutput, extractActionRefs, extractUsesComment, parseActionUses } from "./yaml-helpers";
|
|
25
|
+
|
|
26
|
+
const SHA_RE = /^[0-9a-f]{40}$/;
|
|
27
|
+
|
|
28
|
+
export interface StalePinFinding {
|
|
29
|
+
job: string;
|
|
30
|
+
ref: string;
|
|
31
|
+
slug: string;
|
|
32
|
+
sha: string;
|
|
33
|
+
kind: "missing" | "mismatched";
|
|
34
|
+
/** For "mismatched": the other label/SHA this one conflicts with. */
|
|
35
|
+
conflict?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Find every SHA-pinned `uses:` with a missing or internally-inconsistent
|
|
40
|
+
* version annotation. Scoped to one workflow document — cross-file
|
|
41
|
+
* consistency isn't attempted since two independent workflows pinning the
|
|
42
|
+
* same action differently isn't evidence of drift.
|
|
43
|
+
*/
|
|
44
|
+
export function findStalePinAnnotations(yaml: string): StalePinFinding[] {
|
|
45
|
+
const refs = extractActionRefs(yaml)
|
|
46
|
+
.map(({ job, ref }) => ({ job, ref, parsed: parseActionUses(ref) }))
|
|
47
|
+
.filter((r): r is { job: string; ref: string; parsed: NonNullable<ReturnType<typeof parseActionUses>> } => !!r.parsed && SHA_RE.test(r.parsed.gitRef));
|
|
48
|
+
|
|
49
|
+
const findings: StalePinFinding[] = [];
|
|
50
|
+
|
|
51
|
+
// First pass: missing annotations.
|
|
52
|
+
const withComment: Array<{ job: string; ref: string; slug: string; sha: string; comment: string }> = [];
|
|
53
|
+
for (const { job, ref, parsed } of refs) {
|
|
54
|
+
const comment = extractUsesComment(ref);
|
|
55
|
+
if (!comment) {
|
|
56
|
+
findings.push({ job, ref, slug: parsed.slug, sha: parsed.gitRef, kind: "missing" });
|
|
57
|
+
} else {
|
|
58
|
+
withComment.push({ job, ref, slug: parsed.slug, sha: parsed.gitRef, comment });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Second pass: internal mismatch. Group by slug — the same action's
|
|
63
|
+
// commit-SHA↔label mapping must be one-to-one across the whole file.
|
|
64
|
+
const bySlug = new Map<string, Array<{ job: string; ref: string; slug: string; sha: string; comment: string }>>();
|
|
65
|
+
for (const entry of withComment) {
|
|
66
|
+
const list = bySlug.get(entry.slug) ?? [];
|
|
67
|
+
list.push(entry);
|
|
68
|
+
bySlug.set(entry.slug, list);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const [, entries] of bySlug) {
|
|
72
|
+
const shaToComments = new Map<string, Set<string>>();
|
|
73
|
+
const commentToShas = new Map<string, Set<string>>();
|
|
74
|
+
for (const e of entries) {
|
|
75
|
+
(shaToComments.get(e.sha) ?? shaToComments.set(e.sha, new Set()).get(e.sha)!).add(e.comment);
|
|
76
|
+
(commentToShas.get(e.comment) ?? commentToShas.set(e.comment, new Set()).get(e.comment)!).add(e.sha);
|
|
77
|
+
}
|
|
78
|
+
for (const e of entries) {
|
|
79
|
+
const otherComments = [...(shaToComments.get(e.sha) ?? [])].filter((c) => c !== e.comment);
|
|
80
|
+
const otherShas = [...(commentToShas.get(e.comment) ?? [])].filter((s) => s !== e.sha);
|
|
81
|
+
if (otherComments.length > 0) {
|
|
82
|
+
findings.push({
|
|
83
|
+
job: e.job,
|
|
84
|
+
ref: e.ref,
|
|
85
|
+
slug: e.slug,
|
|
86
|
+
sha: e.sha,
|
|
87
|
+
kind: "mismatched",
|
|
88
|
+
conflict: `commit ${e.sha} is also annotated "# ${otherComments[0]}" elsewhere`,
|
|
89
|
+
});
|
|
90
|
+
} else if (otherShas.length > 0) {
|
|
91
|
+
findings.push({
|
|
92
|
+
job: e.job,
|
|
93
|
+
ref: e.ref,
|
|
94
|
+
slug: e.slug,
|
|
95
|
+
sha: e.sha,
|
|
96
|
+
kind: "mismatched",
|
|
97
|
+
conflict: `label "# ${e.comment}" is also attached to commit ${otherShas[0]} elsewhere`,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return findings;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const gha059: PostSynthCheck = {
|
|
107
|
+
id: "GHA059",
|
|
108
|
+
description: "SHA-pinned action reference has a missing or internally-inconsistent version annotation",
|
|
109
|
+
|
|
110
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
111
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
112
|
+
|
|
113
|
+
for (const [, output] of ctx.outputs) {
|
|
114
|
+
const yaml = getPrimaryOutput(output);
|
|
115
|
+
for (const finding of findStalePinAnnotations(yaml)) {
|
|
116
|
+
const message =
|
|
117
|
+
finding.kind === "missing"
|
|
118
|
+
? `Job "${finding.job}" pins ${finding.ref} to a commit SHA with no trailing version comment (e.g. "# v4.0.2") — add one so reviewers can sanity-check the digest.`
|
|
119
|
+
: `Job "${finding.job}" pins ${finding.ref} whose annotation is internally inconsistent (${finding.conflict}) — one of these labels no longer matches the digest it's attached to.`;
|
|
120
|
+
diagnostics.push({
|
|
121
|
+
checkId: "GHA059",
|
|
122
|
+
severity: "warning",
|
|
123
|
+
message,
|
|
124
|
+
entity: finding.job,
|
|
125
|
+
lexicon: "github",
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return diagnostics;
|
|
131
|
+
},
|
|
132
|
+
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { gha060, findOverScopedTokens } from "./gha060";
|
|
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("GHA060: over-scoped generated token", () => {
|
|
20
|
+
test("flags a minted token whose output is never referenced (unused)", () => {
|
|
21
|
+
const yaml = `name: CI
|
|
22
|
+
on:
|
|
23
|
+
push:
|
|
24
|
+
jobs:
|
|
25
|
+
build:
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
steps:
|
|
28
|
+
- id: app-token
|
|
29
|
+
uses: actions/create-github-app-token@v1
|
|
30
|
+
with:
|
|
31
|
+
app-id: \${{ vars.APP_ID }}
|
|
32
|
+
private-key: \${{ secrets.APP_KEY }}
|
|
33
|
+
permission-contents: write
|
|
34
|
+
- run: echo build
|
|
35
|
+
`;
|
|
36
|
+
const diags = gha060.check(makeCtx(yaml));
|
|
37
|
+
expect(diags).toHaveLength(1);
|
|
38
|
+
expect(diags[0].checkId).toBe("GHA060");
|
|
39
|
+
expect(diags[0].severity).toBe("warning");
|
|
40
|
+
expect(diags[0].message).toContain("no other step references its output");
|
|
41
|
+
expect(diags[0].message).toContain("permission-contents");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("flags a consumed token with no evidence of the granted write scope (no-signal)", () => {
|
|
45
|
+
const yaml = `name: CI
|
|
46
|
+
on:
|
|
47
|
+
push:
|
|
48
|
+
jobs:
|
|
49
|
+
build:
|
|
50
|
+
runs-on: ubuntu-latest
|
|
51
|
+
steps:
|
|
52
|
+
- id: app-token
|
|
53
|
+
uses: actions/create-github-app-token@v1
|
|
54
|
+
with:
|
|
55
|
+
app-id: \${{ vars.APP_ID }}
|
|
56
|
+
private-key: \${{ secrets.APP_KEY }}
|
|
57
|
+
permission-contents: write
|
|
58
|
+
- uses: actions/checkout@v4
|
|
59
|
+
with:
|
|
60
|
+
token: \${{ steps.app-token.outputs.token }}
|
|
61
|
+
`;
|
|
62
|
+
const diags = gha060.check(makeCtx(yaml));
|
|
63
|
+
expect(diags).toHaveLength(1);
|
|
64
|
+
expect(diags[0].message).toContain("no consuming step shows evidence");
|
|
65
|
+
expect(diags[0].message).toContain("permission-contents");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("does not flag when a consuming step evidences the granted scope", () => {
|
|
69
|
+
const yaml = `name: CI
|
|
70
|
+
on:
|
|
71
|
+
push:
|
|
72
|
+
jobs:
|
|
73
|
+
build:
|
|
74
|
+
runs-on: ubuntu-latest
|
|
75
|
+
steps:
|
|
76
|
+
- id: app-token
|
|
77
|
+
uses: actions/create-github-app-token@v1
|
|
78
|
+
with:
|
|
79
|
+
app-id: \${{ vars.APP_ID }}
|
|
80
|
+
private-key: \${{ secrets.APP_KEY }}
|
|
81
|
+
permission-contents: write
|
|
82
|
+
- uses: actions/checkout@v4
|
|
83
|
+
with:
|
|
84
|
+
token: \${{ steps.app-token.outputs.token }}
|
|
85
|
+
- run: |
|
|
86
|
+
git push origin main
|
|
87
|
+
`;
|
|
88
|
+
expect(findOverScopedTokens(yaml)).toHaveLength(0);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("does not flag read-only permission scopes", () => {
|
|
92
|
+
const yaml = `name: CI
|
|
93
|
+
on:
|
|
94
|
+
push:
|
|
95
|
+
jobs:
|
|
96
|
+
build:
|
|
97
|
+
runs-on: ubuntu-latest
|
|
98
|
+
steps:
|
|
99
|
+
- id: app-token
|
|
100
|
+
uses: actions/create-github-app-token@v1
|
|
101
|
+
with:
|
|
102
|
+
app-id: \${{ vars.APP_ID }}
|
|
103
|
+
private-key: \${{ secrets.APP_KEY }}
|
|
104
|
+
permission-contents: read
|
|
105
|
+
- run: echo build
|
|
106
|
+
`;
|
|
107
|
+
expect(findOverScopedTokens(yaml)).toHaveLength(0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("does not flag steps unrelated to a token-minting action", () => {
|
|
111
|
+
const yaml = `name: CI
|
|
112
|
+
on:
|
|
113
|
+
push:
|
|
114
|
+
jobs:
|
|
115
|
+
build:
|
|
116
|
+
runs-on: ubuntu-latest
|
|
117
|
+
steps:
|
|
118
|
+
- uses: actions/checkout@v4
|
|
119
|
+
- run: npm test
|
|
120
|
+
`;
|
|
121
|
+
expect(findOverScopedTokens(yaml)).toHaveLength(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("recognizes the alternate app-token action slugs", () => {
|
|
125
|
+
const yaml = `name: CI
|
|
126
|
+
on:
|
|
127
|
+
push:
|
|
128
|
+
jobs:
|
|
129
|
+
build:
|
|
130
|
+
runs-on: ubuntu-latest
|
|
131
|
+
steps:
|
|
132
|
+
- id: app-token
|
|
133
|
+
uses: tibdex/github-app-token@v2
|
|
134
|
+
with:
|
|
135
|
+
app_id: \${{ vars.APP_ID }}
|
|
136
|
+
permission-issues: write
|
|
137
|
+
- run: echo build
|
|
138
|
+
`;
|
|
139
|
+
const found = findOverScopedTokens(yaml);
|
|
140
|
+
expect(found).toHaveLength(1);
|
|
141
|
+
expect(found[0].scopes).toEqual(["issues"]);
|
|
142
|
+
expect(found[0].reason).toBe("unused");
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA060: Over-Scoped Generated Token
|
|
3
|
+
*
|
|
4
|
+
* Flags a GitHub App installation token minted with a `permission-<scope>:
|
|
5
|
+
* write`/`admin` input broader than what the job's consuming steps evidence.
|
|
6
|
+
* Targets the well-known GitHub App token actions (`actions/create-github-app-
|
|
7
|
+
* token`, `tibdex/github-app-token`, `getsentry/action-github-app-token`),
|
|
8
|
+
* which take `permission-<resource>: read|write|admin` inputs and hand back
|
|
9
|
+
* the minted token as a step output.
|
|
10
|
+
*
|
|
11
|
+
* Two ways a generated token is over-scoped:
|
|
12
|
+
* 1. Unused — the token's output (`steps.<id>.outputs.token`) is never
|
|
13
|
+
* referenced by any other step in the job, so every write/admin scope
|
|
14
|
+
* granted is pure waste.
|
|
15
|
+
* 2. No signal — the token IS consumed, but no other step in the job shows
|
|
16
|
+
* any evidence (a `gh`/`git` invocation, an API path, a keyword) of
|
|
17
|
+
* exercising a scope granted `write`/`admin`.
|
|
18
|
+
*
|
|
19
|
+
* "No signal" is a heuristic, not a proof — a step could exercise a scope
|
|
20
|
+
* through machinery this doesn't recognize (a custom script calling `octokit`
|
|
21
|
+
* directly, say). It reports `guidance`, not a `deterministic` fix, for
|
|
22
|
+
* exactly that reason: a human confirms before narrowing the grant.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
26
|
+
import { getPrimaryOutput, extractStepsByJob, parseActionUses } from "./yaml-helpers";
|
|
27
|
+
|
|
28
|
+
const APP_TOKEN_SLUGS = new Set([
|
|
29
|
+
"actions/create-github-app-token",
|
|
30
|
+
"tibdex/github-app-token",
|
|
31
|
+
"getsentry/action-github-app-token",
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
const WRITE_LEVELS = new Set(["write", "admin"]);
|
|
35
|
+
|
|
36
|
+
/** Textual evidence that a step exercises a given permission scope. Not exhaustive — a fallback covers unlisted scopes. */
|
|
37
|
+
const SCOPE_SIGNALS: Record<string, RegExp> = {
|
|
38
|
+
contents: /git\s+push|gh\s+release|\/(?:git\/)?contents\b/i,
|
|
39
|
+
issues: /gh\s+issue|\/issues\b/i,
|
|
40
|
+
"pull-requests": /gh\s+pr\b|\/pulls\b/i,
|
|
41
|
+
packages: /npm\s+publish|docker\s+push|\/packages\b/i,
|
|
42
|
+
administration: /\/admin\b|administration/i,
|
|
43
|
+
actions: /\/actions\/runs|gh\s+run\b/i,
|
|
44
|
+
checks: /check-runs|\bchecks\b/i,
|
|
45
|
+
deployments: /\/deployments\b/i,
|
|
46
|
+
statuses: /\/statuses\b/i,
|
|
47
|
+
workflows: /\.github\/workflows/i,
|
|
48
|
+
environments: /\/environments\b/i,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function scopeSignalMatches(scope: string, text: string): boolean {
|
|
52
|
+
const signal = SCOPE_SIGNALS[scope];
|
|
53
|
+
if (signal) return signal.test(text);
|
|
54
|
+
return new RegExp(scope.replace(/-/g, "[-_ ]?"), "i").test(text);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function stepText(step: Record<string, unknown>): string {
|
|
58
|
+
const parts: string[] = [];
|
|
59
|
+
if (typeof step.run === "string") parts.push(step.run);
|
|
60
|
+
if (step.with && typeof step.with === "object") parts.push(JSON.stringify(step.with));
|
|
61
|
+
if (step.env && typeof step.env === "object") parts.push(JSON.stringify(step.env));
|
|
62
|
+
if (typeof step.uses === "string") parts.push(step.uses);
|
|
63
|
+
return parts.join("\n");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface OverScopedTokenFinding {
|
|
67
|
+
job: string;
|
|
68
|
+
stepId: string;
|
|
69
|
+
scopes: string[];
|
|
70
|
+
reason: "unused" | "no-signal";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Find every generated GitHub App token whose granted write/admin scopes exceed what the job's other steps evidence. */
|
|
74
|
+
export function findOverScopedTokens(yaml: string): OverScopedTokenFinding[] {
|
|
75
|
+
const findings: OverScopedTokenFinding[] = [];
|
|
76
|
+
|
|
77
|
+
for (const [job, steps] of extractStepsByJob(yaml)) {
|
|
78
|
+
steps.forEach((step, idx) => {
|
|
79
|
+
const uses = typeof step.uses === "string" ? step.uses : undefined;
|
|
80
|
+
if (!uses) return;
|
|
81
|
+
const parsed = parseActionUses(uses);
|
|
82
|
+
if (!parsed || !APP_TOKEN_SLUGS.has(parsed.slug)) return;
|
|
83
|
+
|
|
84
|
+
const withBlock = step.with && typeof step.with === "object" ? (step.with as Record<string, unknown>) : {};
|
|
85
|
+
const stepId = typeof step.id === "string" ? step.id : `#${idx}`;
|
|
86
|
+
|
|
87
|
+
const writeScopes: string[] = [];
|
|
88
|
+
for (const [key, value] of Object.entries(withBlock)) {
|
|
89
|
+
const m = /^permission-([a-z-]+)$/i.exec(key);
|
|
90
|
+
if (!m) continue;
|
|
91
|
+
if (WRITE_LEVELS.has(String(value).toLowerCase())) writeScopes.push(m[1]);
|
|
92
|
+
}
|
|
93
|
+
if (writeScopes.length === 0) return;
|
|
94
|
+
|
|
95
|
+
// Evidence comes only from OTHER steps — the minting step's own `with:`
|
|
96
|
+
// block names the scopes being granted, so it can't also count as
|
|
97
|
+
// evidence they're used.
|
|
98
|
+
const otherStepsText = steps
|
|
99
|
+
.filter((_, i) => i !== idx)
|
|
100
|
+
.map(stepText)
|
|
101
|
+
.join("\n");
|
|
102
|
+
|
|
103
|
+
const outputRef = `steps.${stepId}.outputs.token`;
|
|
104
|
+
if (!otherStepsText.includes(outputRef)) {
|
|
105
|
+
findings.push({ job, stepId, scopes: writeScopes, reason: "unused" });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const unsignaled = writeScopes.filter((scope) => !scopeSignalMatches(scope, otherStepsText));
|
|
110
|
+
if (unsignaled.length > 0) {
|
|
111
|
+
findings.push({ job, stepId, scopes: unsignaled, reason: "no-signal" });
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return findings;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const gha060: PostSynthCheck = {
|
|
120
|
+
id: "GHA060",
|
|
121
|
+
description: "Generated GitHub App token granted broader scope than its consuming steps evidence",
|
|
122
|
+
|
|
123
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
124
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
125
|
+
|
|
126
|
+
for (const [, output] of ctx.outputs) {
|
|
127
|
+
const yaml = getPrimaryOutput(output);
|
|
128
|
+
for (const finding of findOverScopedTokens(yaml)) {
|
|
129
|
+
const scopeList = finding.scopes.map((s) => `permission-${s}`).join(", ");
|
|
130
|
+
const message =
|
|
131
|
+
finding.reason === "unused"
|
|
132
|
+
? `Job "${finding.job}" step "${finding.stepId}" mints a GitHub App token with ${scopeList} but no other step references its output — remove the unused scope(s) or the token step entirely.`
|
|
133
|
+
: `Job "${finding.job}" step "${finding.stepId}" mints a GitHub App token with ${scopeList}, but no consuming step shows evidence of exercising that scope — narrow the token to the scopes actually used.`;
|
|
134
|
+
diagnostics.push({
|
|
135
|
+
checkId: "GHA060",
|
|
136
|
+
severity: "warning",
|
|
137
|
+
message,
|
|
138
|
+
entity: finding.job,
|
|
139
|
+
lexicon: "github",
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return diagnostics;
|
|
145
|
+
},
|
|
146
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { gha061, evaluateUsagePolicy } from "./gha061";
|
|
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
|
+
const YAML = `name: CI
|
|
20
|
+
on:
|
|
21
|
+
push:
|
|
22
|
+
jobs:
|
|
23
|
+
build:
|
|
24
|
+
runs-on: ubuntu-latest
|
|
25
|
+
steps:
|
|
26
|
+
- uses: actions/checkout@v4
|
|
27
|
+
- uses: some-rando/sketchy-action@v1
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
describe("GHA061: usage policy enforcement (opt-in)", () => {
|
|
31
|
+
test("is silent when the wrapped check runs with no policy configured", () => {
|
|
32
|
+
expect(gha061.check(makeCtx(YAML))).toHaveLength(0);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("evaluateUsagePolicy returns nothing for an empty/unset policy", () => {
|
|
36
|
+
expect(evaluateUsagePolicy(YAML, {})).toHaveLength(0);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("flags a denied slug", () => {
|
|
40
|
+
const diags = evaluateUsagePolicy(YAML, { deny: ["some-rando/sketchy-action"] });
|
|
41
|
+
expect(diags).toHaveLength(1);
|
|
42
|
+
expect(diags[0].checkId).toBe("GHA061");
|
|
43
|
+
expect(diags[0].severity).toBe("error");
|
|
44
|
+
expect(diags[0].message).toContain("some-rando/sketchy-action");
|
|
45
|
+
expect(diags[0].message).toContain("denied");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("flags a denied owner (bare owner entry)", () => {
|
|
49
|
+
const diags = evaluateUsagePolicy(YAML, { deny: ["some-rando"] });
|
|
50
|
+
expect(diags).toHaveLength(1);
|
|
51
|
+
expect(diags[0].message).toContain("some-rando/sketchy-action");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("flags a slug outside the allowlist", () => {
|
|
55
|
+
const diags = evaluateUsagePolicy(YAML, { allow: ["actions/*"] });
|
|
56
|
+
expect(diags).toHaveLength(1);
|
|
57
|
+
expect(diags[0].severity).toBe("warning");
|
|
58
|
+
expect(diags[0].message).toContain("not in the configured action-usage allowlist");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("does not flag a slug inside the allowlist", () => {
|
|
62
|
+
const diags = evaluateUsagePolicy(YAML, { allow: ["actions/*", "some-rando/*"] });
|
|
63
|
+
expect(diags).toHaveLength(0);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("deny wins over allow for the same entry", () => {
|
|
67
|
+
const diags = evaluateUsagePolicy(YAML, { allow: ["actions/*", "some-rando/*"], deny: ["some-rando/sketchy-action"] });
|
|
68
|
+
expect(diags).toHaveLength(1);
|
|
69
|
+
expect(diags[0].severity).toBe("error");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("skips local and docker references regardless of policy", () => {
|
|
73
|
+
const yaml = `name: CI
|
|
74
|
+
on:
|
|
75
|
+
push:
|
|
76
|
+
jobs:
|
|
77
|
+
build:
|
|
78
|
+
runs-on: ubuntu-latest
|
|
79
|
+
steps:
|
|
80
|
+
- uses: ./.github/actions/local
|
|
81
|
+
- uses: docker://alpine@sha256:abc
|
|
82
|
+
`;
|
|
83
|
+
expect(evaluateUsagePolicy(yaml, { allow: ["actions/*"] })).toHaveLength(0);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA061: Action Reference Outside the Configured Usage Policy
|
|
3
|
+
*
|
|
4
|
+
* An opt-in allow/deny check over third-party `uses:` references, for
|
|
5
|
+
* environments that constrain which external components may run at all —
|
|
6
|
+
* distinct from GHA029/031/032, which judge a reference on its own pinning/
|
|
7
|
+
* naming/health, not against an organizational decision about who is trusted.
|
|
8
|
+
*
|
|
9
|
+
* Ships silent: `DEFAULT_ACTION_USAGE_POLICY` (../rules/data/action-usage-
|
|
10
|
+
* policy.ts) is empty, and {@link evaluateUsagePolicy} returns no findings
|
|
11
|
+
* for an empty policy — see that module's doc for why (`PostSynthContext`
|
|
12
|
+
* carries no per-check runtime config channel) and how a project actually
|
|
13
|
+
* opts in: author a `lint.policies` entry that calls `evaluateUsagePolicy`
|
|
14
|
+
* with its own policy, wrapped in a `PostSynthCheck`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
18
|
+
import { getPrimaryOutput, extractActionRefs, parseActionUses } from "./yaml-helpers";
|
|
19
|
+
import { DEFAULT_ACTION_USAGE_POLICY, type ActionUsagePolicy } from "../rules/data/action-usage-policy";
|
|
20
|
+
|
|
21
|
+
export type { ActionUsagePolicy } from "../rules/data/action-usage-policy";
|
|
22
|
+
|
|
23
|
+
function matches(entry: string, owner: string, slug: string): boolean {
|
|
24
|
+
if (entry === slug) return true;
|
|
25
|
+
if (entry === owner) return true;
|
|
26
|
+
if (entry.endsWith("/*") && entry.slice(0, -2) === owner) return true;
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function matchesAny(entries: string[] | undefined, owner: string, slug: string): boolean {
|
|
31
|
+
return (entries ?? []).some((e) => matches(e, owner, slug));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Evaluate a workflow's `uses:` references against a usage policy. Pure: no
|
|
36
|
+
* fs, no network, no default policy baked in — an empty/unset policy (no
|
|
37
|
+
* `allow` and no `deny`) always yields no findings, which is what makes this
|
|
38
|
+
* genuinely opt-in rather than "opt-in by omission of one flag."
|
|
39
|
+
*/
|
|
40
|
+
export function evaluateUsagePolicy(yaml: string, policy: ActionUsagePolicy): PostSynthDiagnostic[] {
|
|
41
|
+
const { allow, deny } = policy;
|
|
42
|
+
if ((!allow || allow.length === 0) && (!deny || deny.length === 0)) return [];
|
|
43
|
+
|
|
44
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
45
|
+
for (const { job, ref } of extractActionRefs(yaml)) {
|
|
46
|
+
const parsed = parseActionUses(ref);
|
|
47
|
+
if (!parsed) continue; // local or docker:// reference — not a registry policy carries slugs for
|
|
48
|
+
|
|
49
|
+
if (matchesAny(deny, parsed.owner, parsed.slug)) {
|
|
50
|
+
diagnostics.push({
|
|
51
|
+
checkId: "GHA061",
|
|
52
|
+
severity: "error",
|
|
53
|
+
message: `Job "${job}" uses "${parsed.slug}", which is denied by the configured action-usage policy.`,
|
|
54
|
+
entity: job,
|
|
55
|
+
lexicon: "github",
|
|
56
|
+
});
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (allow && allow.length > 0 && !matchesAny(allow, parsed.owner, parsed.slug)) {
|
|
61
|
+
diagnostics.push({
|
|
62
|
+
checkId: "GHA061",
|
|
63
|
+
severity: "warning",
|
|
64
|
+
message: `Job "${job}" uses "${parsed.slug}", which is not in the configured action-usage allowlist.`,
|
|
65
|
+
entity: job,
|
|
66
|
+
lexicon: "github",
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return diagnostics;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const gha061: PostSynthCheck = {
|
|
74
|
+
id: "GHA061",
|
|
75
|
+
description: "Action reference outside the configured usage policy (opt-in)",
|
|
76
|
+
|
|
77
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
78
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
79
|
+
for (const [, output] of ctx.outputs) {
|
|
80
|
+
const yaml = getPrimaryOutput(output);
|
|
81
|
+
diagnostics.push(...evaluateUsagePolicy(yaml, DEFAULT_ACTION_USAGE_POLICY));
|
|
82
|
+
}
|
|
83
|
+
return diagnostics;
|
|
84
|
+
},
|
|
85
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { gha062, checkAdvisories } from "./gha062";
|
|
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
|
+
const SHA = "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b";
|
|
20
|
+
|
|
21
|
+
const YAML = `name: CI
|
|
22
|
+
on:
|
|
23
|
+
push:
|
|
24
|
+
jobs:
|
|
25
|
+
build:
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
steps:
|
|
28
|
+
- uses: actions/setup-node@${SHA}
|
|
29
|
+
- uses: actions/checkout@v4
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
describe("GHA062: advisory cross-reference (feed-driven, graceful degrade)", () => {
|
|
33
|
+
test("is silent when the wrapped check runs with no feed present", () => {
|
|
34
|
+
expect(gha062.check(makeCtx(YAML))).toHaveLength(0);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("checkAdvisories degrades to no findings when the feed argument is omitted", () => {
|
|
38
|
+
expect(checkAdvisories(YAML)).toHaveLength(0);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("checkAdvisories degrades to no findings for an explicitly empty feed", () => {
|
|
42
|
+
expect(checkAdvisories(YAML, { entries: [] })).toHaveLength(0);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("does not throw for an undefined feed", () => {
|
|
46
|
+
expect(() => checkAdvisories(YAML, undefined)).not.toThrow();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("flags a SHA-pinned ref matching a feed entry", () => {
|
|
50
|
+
const diags = checkAdvisories(YAML, {
|
|
51
|
+
entries: [{ slug: "actions/setup-node", shas: [SHA], id: "GHSA-xxxx-yyyy-zzzz", summary: "Arbitrary code execution", url: "https://example.com/advisory" }],
|
|
52
|
+
});
|
|
53
|
+
expect(diags).toHaveLength(1);
|
|
54
|
+
expect(diags[0].checkId).toBe("GHA062");
|
|
55
|
+
expect(diags[0].severity).toBe("error");
|
|
56
|
+
expect(diags[0].message).toContain("GHSA-xxxx-yyyy-zzzz");
|
|
57
|
+
expect(diags[0].message).toContain("actions/setup-node");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("flags a tag-pinned ref matching a feed entry by ref", () => {
|
|
61
|
+
const diags = checkAdvisories(YAML, {
|
|
62
|
+
entries: [{ slug: "actions/checkout", refs: ["v4"], id: "GHSA-aaaa-bbbb-cccc", summary: "Path traversal" }],
|
|
63
|
+
});
|
|
64
|
+
expect(diags).toHaveLength(1);
|
|
65
|
+
expect(diags[0].message).toContain("actions/checkout@v4");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("includes the patched-ref hint when the feed carries one", () => {
|
|
69
|
+
const diags = checkAdvisories(YAML, {
|
|
70
|
+
entries: [{ slug: "actions/setup-node", shas: [SHA], id: "GHSA-xxxx", summary: "Issue", patchedRef: "v4.1.0" }],
|
|
71
|
+
});
|
|
72
|
+
expect(diags[0].message).toContain("v4.1.0");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("does not flag a reference the feed doesn't mention", () => {
|
|
76
|
+
const diags = checkAdvisories(YAML, {
|
|
77
|
+
entries: [{ slug: "some/other-action", shas: [SHA], id: "GHSA-xxxx", summary: "Unrelated" }],
|
|
78
|
+
});
|
|
79
|
+
expect(diags).toHaveLength(0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("does not flag when the SHA doesn't match the entry's affected list", () => {
|
|
83
|
+
const diags = checkAdvisories(YAML, {
|
|
84
|
+
entries: [{ slug: "actions/setup-node", shas: ["bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], id: "GHSA-xxxx", summary: "Issue" }],
|
|
85
|
+
});
|
|
86
|
+
expect(diags).toHaveLength(0);
|
|
87
|
+
});
|
|
88
|
+
});
|