@intentius/chant-lexicon-github 0.1.22 → 0.2.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 +188 -0
- 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/plugin.test.ts +47 -1
- package/src/serializer.ts +38 -5
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { gha047 } from "./gha047";
|
|
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("GHA047: reversed contains() guard", () => {
|
|
20
|
+
test("flags contains('literal', dynamic)", () => {
|
|
21
|
+
const yaml = `name: CI
|
|
22
|
+
on:
|
|
23
|
+
push:
|
|
24
|
+
jobs:
|
|
25
|
+
build:
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
if: \${{ contains('refs/heads/main', github.ref) }}
|
|
28
|
+
steps:
|
|
29
|
+
- run: echo build
|
|
30
|
+
`;
|
|
31
|
+
const diags = gha047.check(makeCtx(yaml));
|
|
32
|
+
expect(diags).toHaveLength(1);
|
|
33
|
+
expect(diags[0].checkId).toBe("GHA047");
|
|
34
|
+
expect(diags[0].entity).toBe("build");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("does not flag normal contains(dynamic, 'literal')", () => {
|
|
38
|
+
const yaml = `name: CI
|
|
39
|
+
on:
|
|
40
|
+
push:
|
|
41
|
+
jobs:
|
|
42
|
+
build:
|
|
43
|
+
runs-on: ubuntu-latest
|
|
44
|
+
if: \${{ contains(github.event.pull_request.labels.*.name, 'bug') }}
|
|
45
|
+
steps:
|
|
46
|
+
- run: echo build
|
|
47
|
+
`;
|
|
48
|
+
const diags = gha047.check(makeCtx(yaml));
|
|
49
|
+
expect(diags).toHaveLength(0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("does not flag contains() of two literals", () => {
|
|
53
|
+
const yaml = `name: CI
|
|
54
|
+
on:
|
|
55
|
+
push:
|
|
56
|
+
jobs:
|
|
57
|
+
build:
|
|
58
|
+
runs-on: ubuntu-latest
|
|
59
|
+
if: \${{ contains('abc', 'b') }}
|
|
60
|
+
steps:
|
|
61
|
+
- run: echo build
|
|
62
|
+
`;
|
|
63
|
+
const diags = gha047.check(makeCtx(yaml));
|
|
64
|
+
expect(diags).toHaveLength(0);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA047: Ineffective `contains()` Guard (Reversed Arguments)
|
|
3
|
+
*
|
|
4
|
+
* Flags `contains('literal', <dynamic>)` — a string literal as the haystack and
|
|
5
|
+
* a dynamic value as the needle. `contains(search, item)` checks whether `item`
|
|
6
|
+
* is in `search`, so a constant first argument makes the result depend on a
|
|
7
|
+
* fixed string rather than the runtime value the author intended to test. The
|
|
8
|
+
* usual fix is to swap the arguments.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
12
|
+
import { getPrimaryOutput, extractIfConditions } from "./yaml-helpers";
|
|
13
|
+
|
|
14
|
+
// contains( 'literal' | "literal" , <something that is not a literal> )
|
|
15
|
+
const REVERSED_CONTAINS = /contains\(\s*(['"][^'"]*['"])\s*,\s*([^)]+)\)/g;
|
|
16
|
+
|
|
17
|
+
function isLiteral(arg: string): boolean {
|
|
18
|
+
const t = arg.trim();
|
|
19
|
+
return (t.startsWith("'") && t.endsWith("'")) || (t.startsWith('"') && t.endsWith('"'));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const gha047: PostSynthCheck = {
|
|
23
|
+
id: "GHA047",
|
|
24
|
+
description: "Ineffective contains() guard with reversed arguments",
|
|
25
|
+
|
|
26
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
27
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
28
|
+
|
|
29
|
+
for (const [, output] of ctx.outputs) {
|
|
30
|
+
const yaml = getPrimaryOutput(output);
|
|
31
|
+
for (const { job, expr } of extractIfConditions(yaml)) {
|
|
32
|
+
let m: RegExpExecArray | null;
|
|
33
|
+
REVERSED_CONTAINS.lastIndex = 0;
|
|
34
|
+
while ((m = REVERSED_CONTAINS.exec(expr)) !== null) {
|
|
35
|
+
// First arg is a literal; flag only when the second arg is dynamic.
|
|
36
|
+
if (isLiteral(m[2])) continue;
|
|
37
|
+
diagnostics.push({
|
|
38
|
+
checkId: "GHA047",
|
|
39
|
+
severity: "warning",
|
|
40
|
+
message: `Job "${job}" calls contains() with a string-literal haystack and a dynamic needle: "${m[0]}". contains(search, item) tests whether item is in search — swap the arguments so the dynamic value is searched.`,
|
|
41
|
+
entity: job,
|
|
42
|
+
lexicon: "github",
|
|
43
|
+
});
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return diagnostics;
|
|
50
|
+
},
|
|
51
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { gha048 } from "./gha048";
|
|
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("GHA048: obfuscated guard condition", () => {
|
|
20
|
+
test("flags an operand built with format() in a comparison", () => {
|
|
21
|
+
const yaml = `name: CI
|
|
22
|
+
on:
|
|
23
|
+
push:
|
|
24
|
+
jobs:
|
|
25
|
+
build:
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
if: \${{ format('{0}', github.actor) == 'octocat' }}
|
|
28
|
+
steps:
|
|
29
|
+
- run: echo build
|
|
30
|
+
`;
|
|
31
|
+
const diags = gha048.check(makeCtx(yaml));
|
|
32
|
+
expect(diags).toHaveLength(1);
|
|
33
|
+
expect(diags[0].checkId).toBe("GHA048");
|
|
34
|
+
expect(diags[0].entity).toBe("build");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("does not flag a direct comparison", () => {
|
|
38
|
+
const yaml = `name: CI
|
|
39
|
+
on:
|
|
40
|
+
push:
|
|
41
|
+
jobs:
|
|
42
|
+
build:
|
|
43
|
+
runs-on: ubuntu-latest
|
|
44
|
+
if: \${{ github.actor == 'octocat' }}
|
|
45
|
+
steps:
|
|
46
|
+
- run: echo build
|
|
47
|
+
`;
|
|
48
|
+
const diags = gha048.check(makeCtx(yaml));
|
|
49
|
+
expect(diags).toHaveLength(0);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA048: Obfuscated Guard Condition
|
|
3
|
+
*
|
|
4
|
+
* Flags an `if:` condition that builds its compared value through string
|
|
5
|
+
* indirection — `format()`, `join()`, or `fromJSON()` feeding a comparison.
|
|
6
|
+
* Constructing the operand at evaluation time hides what the gate actually
|
|
7
|
+
* checks, which is how an unsound condition (GHA046/GHA047) is disguised to pass
|
|
8
|
+
* review. Write the comparison against the value directly.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
12
|
+
import { getPrimaryOutput, extractIfConditions } from "./yaml-helpers";
|
|
13
|
+
|
|
14
|
+
const INDIRECTION = /\b(format|join|fromJSON)\s*\(/i;
|
|
15
|
+
const COMPARISON = /==|!=|&&|\|\||contains\(|startsWith\(|endsWith\(/;
|
|
16
|
+
|
|
17
|
+
export const gha048: PostSynthCheck = {
|
|
18
|
+
id: "GHA048",
|
|
19
|
+
description: "Obfuscated guard condition (operand built by indirection)",
|
|
20
|
+
|
|
21
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
22
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
23
|
+
|
|
24
|
+
for (const [, output] of ctx.outputs) {
|
|
25
|
+
const yaml = getPrimaryOutput(output);
|
|
26
|
+
for (const { job, expr } of extractIfConditions(yaml)) {
|
|
27
|
+
if (INDIRECTION.test(expr) && COMPARISON.test(expr)) {
|
|
28
|
+
diagnostics.push({
|
|
29
|
+
checkId: "GHA048",
|
|
30
|
+
severity: "warning",
|
|
31
|
+
message: `Job "${job}" builds its if: gate through string indirection (format/join/fromJSON) feeding a comparison: "${expr}". Compare against the value directly so the condition is auditable.`,
|
|
32
|
+
entity: job,
|
|
33
|
+
lexicon: "github",
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return diagnostics;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { gha049 } from "./gha049";
|
|
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("GHA049: persisted checkout credentials in artifact", () => {
|
|
20
|
+
test("flags checkout + upload-artifact without persist-credentials: false", () => {
|
|
21
|
+
const yaml = `name: CI
|
|
22
|
+
on:
|
|
23
|
+
push:
|
|
24
|
+
jobs:
|
|
25
|
+
build:
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
steps:
|
|
28
|
+
- uses: actions/checkout@v4
|
|
29
|
+
- uses: actions/upload-artifact@v4
|
|
30
|
+
with:
|
|
31
|
+
path: .
|
|
32
|
+
`;
|
|
33
|
+
const diags = gha049.check(makeCtx(yaml));
|
|
34
|
+
expect(diags).toHaveLength(1);
|
|
35
|
+
expect(diags[0].checkId).toBe("GHA049");
|
|
36
|
+
expect(diags[0].entity).toBe("build");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("does not flag when persist-credentials is disabled", () => {
|
|
40
|
+
const yaml = `name: CI
|
|
41
|
+
on:
|
|
42
|
+
push:
|
|
43
|
+
jobs:
|
|
44
|
+
build:
|
|
45
|
+
runs-on: ubuntu-latest
|
|
46
|
+
steps:
|
|
47
|
+
- uses: actions/checkout@v4
|
|
48
|
+
with:
|
|
49
|
+
persist-credentials: false
|
|
50
|
+
- uses: actions/upload-artifact@v4
|
|
51
|
+
with:
|
|
52
|
+
path: .
|
|
53
|
+
`;
|
|
54
|
+
const diags = gha049.check(makeCtx(yaml));
|
|
55
|
+
expect(diags).toHaveLength(0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("does not flag checkout without artifact upload", () => {
|
|
59
|
+
const yaml = `name: CI
|
|
60
|
+
on:
|
|
61
|
+
push:
|
|
62
|
+
jobs:
|
|
63
|
+
build:
|
|
64
|
+
runs-on: ubuntu-latest
|
|
65
|
+
steps:
|
|
66
|
+
- uses: actions/checkout@v4
|
|
67
|
+
- run: echo build
|
|
68
|
+
`;
|
|
69
|
+
const diags = gha049.check(makeCtx(yaml));
|
|
70
|
+
expect(diags).toHaveLength(0);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA049: Persisted Checkout Credentials Reachable by an Uploaded Artifact
|
|
3
|
+
*
|
|
4
|
+
* `actions/checkout` writes the job token into `.git/config` by default
|
|
5
|
+
* (`persist-credentials: true`). A job that also uploads an artifact can sweep
|
|
6
|
+
* that `.git` directory into the artifact, leaking the token to anyone who can
|
|
7
|
+
* download it. Set `persist-credentials: false` on the checkout when the job
|
|
8
|
+
* uploads artifacts.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
12
|
+
import { getPrimaryOutput, linesByJob } from "./yaml-helpers";
|
|
13
|
+
|
|
14
|
+
export const gha049: PostSynthCheck = {
|
|
15
|
+
id: "GHA049",
|
|
16
|
+
description: "Persisted checkout credentials reachable by an uploaded artifact",
|
|
17
|
+
|
|
18
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
19
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
20
|
+
|
|
21
|
+
for (const [, output] of ctx.outputs) {
|
|
22
|
+
const yaml = getPrimaryOutput(output);
|
|
23
|
+
for (const [job, lines] of linesByJob(yaml)) {
|
|
24
|
+
const text = lines.join("\n");
|
|
25
|
+
const hasCheckout = /uses:\s*actions\/checkout/.test(text);
|
|
26
|
+
const persistDisabled = /persist-credentials:\s*false/.test(text);
|
|
27
|
+
const uploadsArtifact = /uses:\s*actions\/upload-artifact/.test(text);
|
|
28
|
+
if (hasCheckout && uploadsArtifact && !persistDisabled) {
|
|
29
|
+
diagnostics.push({
|
|
30
|
+
checkId: "GHA049",
|
|
31
|
+
severity: "warning",
|
|
32
|
+
message: `Job "${job}" checks out with persisted credentials (default) and uploads an artifact — the token in .git/config can leak into the artifact. Set persist-credentials: false on the checkout.`,
|
|
33
|
+
entity: job,
|
|
34
|
+
lexicon: "github",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return diagnostics;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { gha050 } from "./gha050";
|
|
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("GHA050: cache populated in privileged context", () => {
|
|
20
|
+
test("flags actions/cache under pull_request_target", () => {
|
|
21
|
+
const yaml = `name: CI
|
|
22
|
+
on:
|
|
23
|
+
pull_request_target:
|
|
24
|
+
jobs:
|
|
25
|
+
build:
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
steps:
|
|
28
|
+
- uses: actions/cache@v4
|
|
29
|
+
with:
|
|
30
|
+
path: ~/.npm
|
|
31
|
+
key: npm-cache
|
|
32
|
+
`;
|
|
33
|
+
const diags = gha050.check(makeCtx(yaml));
|
|
34
|
+
expect(diags).toHaveLength(1);
|
|
35
|
+
expect(diags[0].checkId).toBe("GHA050");
|
|
36
|
+
expect(diags[0].entity).toBe("build");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("does not flag cache on a safe trigger", () => {
|
|
40
|
+
const yaml = `name: CI
|
|
41
|
+
on:
|
|
42
|
+
push:
|
|
43
|
+
jobs:
|
|
44
|
+
build:
|
|
45
|
+
runs-on: ubuntu-latest
|
|
46
|
+
steps:
|
|
47
|
+
- uses: actions/cache@v4
|
|
48
|
+
with:
|
|
49
|
+
path: ~/.npm
|
|
50
|
+
key: npm-cache
|
|
51
|
+
`;
|
|
52
|
+
const diags = gha050.check(makeCtx(yaml));
|
|
53
|
+
expect(diags).toHaveLength(0);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA050: Cache Populated in a Privileged Context (Poisoning Risk)
|
|
3
|
+
*
|
|
4
|
+
* Flags `actions/cache` use under a privileged trigger (`pull_request_target`,
|
|
5
|
+
* `workflow_run`) that runs in the base-repo context. Cache entries written from
|
|
6
|
+
* code influenced by a fork can later be restored by a trusted run and executed,
|
|
7
|
+
* a cache-poisoning path. Restrict caching to trusted triggers or scope keys so
|
|
8
|
+
* untrusted runs cannot write entries a privileged run will restore.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
12
|
+
import { getPrimaryOutput, extractTriggers, linesByJob } from "./yaml-helpers";
|
|
13
|
+
|
|
14
|
+
const PRIVILEGED_TRIGGERS = ["pull_request_target", "workflow_run"];
|
|
15
|
+
|
|
16
|
+
export const gha050: PostSynthCheck = {
|
|
17
|
+
id: "GHA050",
|
|
18
|
+
description: "Cache populated in a privileged context (poisoning risk)",
|
|
19
|
+
|
|
20
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
21
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
22
|
+
|
|
23
|
+
for (const [, output] of ctx.outputs) {
|
|
24
|
+
const yaml = getPrimaryOutput(output);
|
|
25
|
+
const triggers = extractTriggers(yaml);
|
|
26
|
+
const privileged = PRIVILEGED_TRIGGERS.filter((t) => triggers[t]);
|
|
27
|
+
if (privileged.length === 0) continue;
|
|
28
|
+
|
|
29
|
+
for (const [job, lines] of linesByJob(yaml)) {
|
|
30
|
+
if (lines.some((l) => /uses:\s*actions\/cache/.test(l))) {
|
|
31
|
+
diagnostics.push({
|
|
32
|
+
checkId: "GHA050",
|
|
33
|
+
severity: "warning",
|
|
34
|
+
message: `Job "${job}" populates a cache under the ${privileged.join("/")} trigger, which runs in the privileged base context. A poisoned cache entry could be restored and executed by a trusted run — restrict caching to trusted triggers.`,
|
|
35
|
+
entity: job,
|
|
36
|
+
lexicon: "github",
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return diagnostics;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { gha051 } from "./gha051";
|
|
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("GHA051: publish with long-lived token instead of OIDC", () => {
|
|
20
|
+
test("flags npm publish with a token secret and no id-token", () => {
|
|
21
|
+
const yaml = `name: Release
|
|
22
|
+
on:
|
|
23
|
+
push:
|
|
24
|
+
jobs:
|
|
25
|
+
publish:
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
steps:
|
|
28
|
+
- env:
|
|
29
|
+
NODE_AUTH_TOKEN: \${{ secrets.NPM_TOKEN }}
|
|
30
|
+
run: npm publish
|
|
31
|
+
`;
|
|
32
|
+
const diags = gha051.check(makeCtx(yaml));
|
|
33
|
+
expect(diags).toHaveLength(1);
|
|
34
|
+
expect(diags[0].checkId).toBe("GHA051");
|
|
35
|
+
expect(diags[0].severity).toBe("info");
|
|
36
|
+
expect(diags[0].entity).toBe("publish");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("does not flag when id-token: write is requested", () => {
|
|
40
|
+
const yaml = `name: Release
|
|
41
|
+
on:
|
|
42
|
+
push:
|
|
43
|
+
permissions:
|
|
44
|
+
id-token: write
|
|
45
|
+
jobs:
|
|
46
|
+
publish:
|
|
47
|
+
runs-on: ubuntu-latest
|
|
48
|
+
steps:
|
|
49
|
+
- env:
|
|
50
|
+
NODE_AUTH_TOKEN: \${{ secrets.NPM_TOKEN }}
|
|
51
|
+
run: npm publish
|
|
52
|
+
`;
|
|
53
|
+
const diags = gha051.check(makeCtx(yaml));
|
|
54
|
+
expect(diags).toHaveLength(0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("does not flag a non-publish job", () => {
|
|
58
|
+
const yaml = `name: CI
|
|
59
|
+
on:
|
|
60
|
+
push:
|
|
61
|
+
jobs:
|
|
62
|
+
build:
|
|
63
|
+
runs-on: ubuntu-latest
|
|
64
|
+
steps:
|
|
65
|
+
- env:
|
|
66
|
+
API: \${{ secrets.API_TOKEN }}
|
|
67
|
+
run: npm run build
|
|
68
|
+
`;
|
|
69
|
+
const diags = gha051.check(makeCtx(yaml));
|
|
70
|
+
expect(diags).toHaveLength(0);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA051: Publish/Release Step Using a Long-Lived Token Instead of OIDC
|
|
3
|
+
*
|
|
4
|
+
* Flags a job that publishes a release/package using a long-lived token secret
|
|
5
|
+
* (`*_TOKEN`, `*_PASSWORD`, `*_API_KEY`) while the workflow requests no
|
|
6
|
+
* `id-token: write` permission. Registries that support OIDC let a workflow mint
|
|
7
|
+
* a short-lived, audience-scoped credential per run instead of holding a
|
|
8
|
+
* standing secret. Advisory — not every registry supports OIDC.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
12
|
+
import { getPrimaryOutput, linesByJob } from "./yaml-helpers";
|
|
13
|
+
|
|
14
|
+
const PUBLISH_RE = /npm publish|yarn publish|pnpm publish|twine upload|poetry publish|cargo publish|gh release create|docker push|gh-action-pypi-publish|npm-publish/;
|
|
15
|
+
const LONG_LIVED_SECRET_RE = /secrets\.(?!GITHUB_TOKEN\b)\w*(TOKEN|PASSWORD|API_KEY|APIKEY)/i;
|
|
16
|
+
|
|
17
|
+
export const gha051: PostSynthCheck = {
|
|
18
|
+
id: "GHA051",
|
|
19
|
+
description: "Publish/release step using a long-lived token instead of OIDC",
|
|
20
|
+
|
|
21
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
22
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
23
|
+
|
|
24
|
+
for (const [, output] of ctx.outputs) {
|
|
25
|
+
const yaml = getPrimaryOutput(output);
|
|
26
|
+
const requestsOidc = /id-token:\s*write/.test(yaml);
|
|
27
|
+
if (requestsOidc) continue;
|
|
28
|
+
|
|
29
|
+
for (const [job, lines] of linesByJob(yaml)) {
|
|
30
|
+
const text = lines.join("\n");
|
|
31
|
+
if (PUBLISH_RE.test(text) && LONG_LIVED_SECRET_RE.test(text)) {
|
|
32
|
+
diagnostics.push({
|
|
33
|
+
checkId: "GHA051",
|
|
34
|
+
severity: "info",
|
|
35
|
+
message: `Job "${job}" publishes using a long-lived token secret and requests no id-token: write. If the registry supports OIDC, mint a short-lived federated credential (permissions: id-token: write) instead of holding a standing token.`,
|
|
36
|
+
entity: job,
|
|
37
|
+
lexicon: "github",
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return diagnostics;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { gha052 } from "./gha052";
|
|
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("GHA052: curl | bash runtime execution", () => {
|
|
20
|
+
test("flags curl piped to bash", () => {
|
|
21
|
+
const yaml = `name: CI
|
|
22
|
+
on:
|
|
23
|
+
push:
|
|
24
|
+
jobs:
|
|
25
|
+
build:
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
steps:
|
|
28
|
+
- run: curl -sSL https://example.com/install.sh | bash
|
|
29
|
+
`;
|
|
30
|
+
const diags = gha052.check(makeCtx(yaml));
|
|
31
|
+
expect(diags).toHaveLength(1);
|
|
32
|
+
expect(diags[0].checkId).toBe("GHA052");
|
|
33
|
+
expect(diags[0].entity).toBe("build");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("does not flag download-then-verify", () => {
|
|
37
|
+
const yaml = `name: CI
|
|
38
|
+
on:
|
|
39
|
+
push:
|
|
40
|
+
jobs:
|
|
41
|
+
build:
|
|
42
|
+
runs-on: ubuntu-latest
|
|
43
|
+
steps:
|
|
44
|
+
- run: |
|
|
45
|
+
curl -sSLo install.sh https://example.com/install.sh
|
|
46
|
+
echo "abc123 install.sh" | sha256sum -c
|
|
47
|
+
bash install.sh
|
|
48
|
+
`;
|
|
49
|
+
const diags = gha052.check(makeCtx(yaml));
|
|
50
|
+
expect(diags).toHaveLength(0);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA052: Software Fetched and Executed at Runtime Without Verification
|
|
3
|
+
*
|
|
4
|
+
* Flags a `run:` step that pipes a network download straight into a shell
|
|
5
|
+
* (`curl ... | bash`, `wget ... | sh`, `iwr ... | iex`). The fetched script is
|
|
6
|
+
* unpinned and unverified, so whoever controls the URL controls what executes
|
|
7
|
+
* in the job. Download to a file, verify a checksum/signature, then run it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
11
|
+
import { getPrimaryOutput, extractRunBlocks } from "./yaml-helpers";
|
|
12
|
+
|
|
13
|
+
const PIPE_TO_SHELL = [
|
|
14
|
+
/\b(curl|wget)\b[^\n|]*\|\s*(sudo\s+)?(ba)?sh\b/i,
|
|
15
|
+
/\b(iwr|invoke-webrequest|irm|invoke-restmethod)\b[^\n|]*\|\s*(iex|invoke-expression)\b/i,
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export const gha052: PostSynthCheck = {
|
|
19
|
+
id: "GHA052",
|
|
20
|
+
description: "Software fetched and piped to a shell without verification",
|
|
21
|
+
|
|
22
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
23
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
24
|
+
|
|
25
|
+
for (const [, output] of ctx.outputs) {
|
|
26
|
+
const yaml = getPrimaryOutput(output);
|
|
27
|
+
for (const { job, run } of extractRunBlocks(yaml)) {
|
|
28
|
+
if (PIPE_TO_SHELL.some((re) => re.test(run))) {
|
|
29
|
+
diagnostics.push({
|
|
30
|
+
checkId: "GHA052",
|
|
31
|
+
severity: "warning",
|
|
32
|
+
message: `Job "${job}" pipes a network download directly into a shell — the fetched code is unpinned and unverified. Download to a file, verify a checksum or signature, then execute it.`,
|
|
33
|
+
entity: job,
|
|
34
|
+
lexicon: "github",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return diagnostics;
|
|
41
|
+
},
|
|
42
|
+
};
|