@intentius/chant-lexicon-github 0.49.0 → 0.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/generate-op-pipeline.d.ts +81 -0
- package/dist/components/generate-op-pipeline.d.ts.map +1 -0
- package/dist/integrity.json +15 -5
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/gha059.d.ts +40 -0
- package/dist/lint/post-synth/gha059.d.ts.map +1 -0
- package/dist/lint/post-synth/gha060.d.ts +34 -0
- package/dist/lint/post-synth/gha060.d.ts.map +1 -0
- package/dist/lint/post-synth/gha061.d.ts +27 -0
- package/dist/lint/post-synth/gha061.d.ts.map +1 -0
- package/dist/lint/post-synth/gha062.d.ts +24 -0
- package/dist/lint/post-synth/gha062.d.ts.map +1 -0
- package/dist/lint/post-synth/gha063.d.ts +12 -0
- package/dist/lint/post-synth/gha063.d.ts.map +1 -0
- package/dist/lint/post-synth/gha064.d.ts +12 -0
- package/dist/lint/post-synth/gha064.d.ts.map +1 -0
- package/dist/lint/post-synth/gha065.d.ts +19 -0
- package/dist/lint/post-synth/gha065.d.ts.map +1 -0
- package/dist/lint/post-synth/gha066.d.ts +12 -0
- package/dist/lint/post-synth/gha066.d.ts.map +1 -0
- package/dist/lint/post-synth/gha067.d.ts +12 -0
- package/dist/lint/post-synth/gha067.d.ts.map +1 -0
- package/dist/lint/post-synth/gha068.d.ts +13 -0
- package/dist/lint/post-synth/gha068.d.ts.map +1 -0
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/yaml-helpers.d.ts +20 -0
- package/dist/lint/post-synth/yaml-helpers.d.ts.map +1 -1
- package/dist/lint/rules/data/action-usage-policy.d.ts +30 -0
- package/dist/lint/rules/data/action-usage-policy.d.ts.map +1 -0
- package/dist/lint/rules/data/advisory-feed.d.ts +36 -0
- package/dist/lint/rules/data/advisory-feed.d.ts.map +1 -0
- package/dist/manifest.json +109 -1
- package/dist/meta.json +5 -0
- package/dist/okf/index.md +10 -0
- package/dist/okf/rules/GHA059.md +15 -0
- package/dist/okf/rules/GHA060.md +15 -0
- package/dist/okf/rules/GHA061.md +15 -0
- package/dist/okf/rules/GHA062.md +15 -0
- package/dist/okf/rules/GHA063.md +15 -0
- package/dist/okf/rules/GHA064.md +15 -0
- package/dist/okf/rules/GHA065.md +15 -0
- package/dist/okf/rules/GHA066.md +15 -0
- package/dist/okf/rules/GHA067.md +15 -0
- package/dist/okf/rules/GHA068.md +15 -0
- package/dist/okf/types/Job.md +9 -0
- package/dist/okf/types/Workflow.md +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/rules/gha059.ts +132 -0
- package/dist/rules/gha060.ts +146 -0
- package/dist/rules/gha061.ts +85 -0
- package/dist/rules/gha062.ts +68 -0
- package/dist/rules/gha063.ts +60 -0
- package/dist/rules/gha064.ts +55 -0
- package/dist/rules/gha065.ts +129 -0
- package/dist/rules/gha066.ts +47 -0
- package/dist/rules/gha067.ts +76 -0
- package/dist/rules/gha068.ts +51 -0
- package/dist/rules/yaml-helpers.ts +37 -2
- package/dist/types/index.d.ts +1 -0
- package/package.json +2 -2
- package/src/codegen/docs.ts +1 -1
- package/src/components/generate-op-pipeline.test.ts +146 -0
- package/src/components/generate-op-pipeline.ts +183 -0
- package/src/generated/index.d.ts +1 -0
- package/src/generated/lexicon-github.json +5 -0
- package/src/lint/audit-catalog.ts +16 -1
- package/src/lint/post-synth/gha059.test.ts +147 -0
- package/src/lint/post-synth/gha059.ts +132 -0
- package/src/lint/post-synth/gha060.test.ts +144 -0
- package/src/lint/post-synth/gha060.ts +146 -0
- package/src/lint/post-synth/gha061.test.ts +85 -0
- package/src/lint/post-synth/gha061.ts +85 -0
- package/src/lint/post-synth/gha062.test.ts +88 -0
- package/src/lint/post-synth/gha062.ts +68 -0
- package/src/lint/post-synth/gha063.test.ts +86 -0
- package/src/lint/post-synth/gha063.ts +60 -0
- package/src/lint/post-synth/gha064.test.ts +73 -0
- package/src/lint/post-synth/gha064.ts +55 -0
- package/src/lint/post-synth/gha065.test.ts +85 -0
- package/src/lint/post-synth/gha065.ts +129 -0
- package/src/lint/post-synth/gha066.test.ts +66 -0
- package/src/lint/post-synth/gha066.ts +47 -0
- package/src/lint/post-synth/gha067.test.ts +85 -0
- package/src/lint/post-synth/gha067.ts +76 -0
- package/src/lint/post-synth/gha068.test.ts +71 -0
- package/src/lint/post-synth/gha068.ts +51 -0
- package/src/lint/post-synth/index.ts +20 -0
- package/src/lint/post-synth/yaml-helpers.ts +37 -2
- package/src/lint/rules/data/action-usage-policy.ts +31 -0
- package/src/lint/rules/data/advisory-feed.ts +38 -0
- package/src/plugin.test.ts +24 -3
- package/src/plugin.ts +28 -0
|
@@ -0,0 +1,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
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA062: Pinned Reference Matches a Known-Vulnerability Advisory
|
|
3
|
+
*
|
|
4
|
+
* Cross-references every pinned `uses:` (commit SHA or tag/branch) against a
|
|
5
|
+
* caller-supplied advisory feed and flags a match. Unlike GHA031/032's
|
|
6
|
+
* vendored, committed reference lists, a vulnerability feed is meant to be
|
|
7
|
+
* refreshed continuously — so this rule takes it as data, not code: the pure
|
|
8
|
+
* core never fetches (epic #350's design spine), and an absent/empty feed
|
|
9
|
+
* degrades to "no findings," never an error or a thrown exception. That is
|
|
10
|
+
* what "unreachable" means for a rule that makes no network call of its own:
|
|
11
|
+
* whatever couldn't reach the feed already failed upstream of this function,
|
|
12
|
+
* and this function's job is to not compound that with a crash.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
16
|
+
import { getPrimaryOutput, extractActionRefs, parseActionUses } from "./yaml-helpers";
|
|
17
|
+
import { DEFAULT_ADVISORY_FEED, type AdvisoryFeed, type AdvisoryEntry } from "../rules/data/advisory-feed";
|
|
18
|
+
|
|
19
|
+
export type { AdvisoryFeed, AdvisoryEntry } from "../rules/data/advisory-feed";
|
|
20
|
+
|
|
21
|
+
function matchesEntry(entry: AdvisoryEntry, slug: string, gitRef: string): boolean {
|
|
22
|
+
if (entry.slug !== slug) return false;
|
|
23
|
+
if (entry.shas?.includes(gitRef)) return true;
|
|
24
|
+
if (entry.refs?.includes(gitRef)) return true;
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check a workflow's pinned action references against an advisory feed. Pure
|
|
30
|
+
* and total: a missing/empty feed (`entries.length === 0`, including the
|
|
31
|
+
* `undefined` default) always yields `[]`, never a throw.
|
|
32
|
+
*/
|
|
33
|
+
export function checkAdvisories(yaml: string, feed: AdvisoryFeed | undefined = DEFAULT_ADVISORY_FEED): PostSynthDiagnostic[] {
|
|
34
|
+
if (!feed || !feed.entries || feed.entries.length === 0) return [];
|
|
35
|
+
|
|
36
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
37
|
+
for (const { job, ref } of extractActionRefs(yaml)) {
|
|
38
|
+
const parsed = parseActionUses(ref);
|
|
39
|
+
if (!parsed) continue;
|
|
40
|
+
for (const entry of feed.entries) {
|
|
41
|
+
if (!matchesEntry(entry, parsed.slug, parsed.gitRef)) continue;
|
|
42
|
+
const patch = entry.patchedRef ? ` A patched ref is available: ${entry.patchedRef}.` : "";
|
|
43
|
+
const link = entry.url ? ` (${entry.url})` : "";
|
|
44
|
+
diagnostics.push({
|
|
45
|
+
checkId: "GHA062",
|
|
46
|
+
severity: "error",
|
|
47
|
+
message: `Job "${job}" uses "${parsed.slug}@${parsed.gitRef}", which matches disclosed advisory ${entry.id}${link}: ${entry.summary}.${patch}`,
|
|
48
|
+
entity: job,
|
|
49
|
+
lexicon: "github",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return diagnostics;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const gha062: PostSynthCheck = {
|
|
57
|
+
id: "GHA062",
|
|
58
|
+
description: "Pinned action reference matches a known-vulnerability advisory (feed-driven)",
|
|
59
|
+
|
|
60
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
61
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
62
|
+
for (const [, output] of ctx.outputs) {
|
|
63
|
+
const yaml = getPrimaryOutput(output);
|
|
64
|
+
diagnostics.push(...checkAdvisories(yaml, DEFAULT_ADVISORY_FEED));
|
|
65
|
+
}
|
|
66
|
+
return diagnostics;
|
|
67
|
+
},
|
|
68
|
+
};
|