@intentius/chant-lexicon-github 0.124.31 → 0.124.32
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/action-pins.js +53 -0
- package/dist/codegen/docs-cli.js +2 -0
- package/dist/codegen/docs.js +142 -0
- package/dist/codegen/generate-cli.js +26 -0
- package/dist/codegen/generate-lexicon.js +36 -0
- package/dist/codegen/generate-typescript.js +108 -0
- package/dist/codegen/generate.js +111 -0
- package/dist/codegen/naming.js +49 -0
- package/dist/codegen/package.js +40 -0
- package/dist/codegen/parse.js +595 -0
- package/dist/codegen/patches.js +36 -0
- package/dist/components/ci-jobs.js +84 -0
- package/dist/components/generate-op-pipeline.js +440 -0
- package/dist/components/generate-op-waves-pipeline.js +158 -0
- package/dist/components/generate-pipeline.d.ts.map +1 -1
- package/dist/components/generate-pipeline.js +476 -0
- package/dist/composites/cache.js +17 -0
- package/dist/composites/catalog.js +859 -0
- package/dist/composites/checkout.js +25 -0
- package/dist/composites/dependabot.js +43 -0
- package/dist/composites/deploy-environment.js +38 -0
- package/dist/composites/docker-build.js +70 -0
- package/dist/composites/download-artifact.js +18 -0
- package/dist/composites/go-ci.js +62 -0
- package/dist/composites/index.js +15 -0
- package/dist/composites/node-ci.js +44 -0
- package/dist/composites/node-pipeline.js +96 -0
- package/dist/composites/pr-plan-report.js +129 -0
- package/dist/composites/python-ci.js +60 -0
- package/dist/composites/setup-go.js +18 -0
- package/dist/composites/setup-node.js +21 -0
- package/dist/composites/setup-python.js +18 -0
- package/dist/composites/upload-artifact.js +19 -0
- package/dist/coverage.js +32 -0
- package/dist/expression.js +177 -0
- package/dist/generated/index.js +26 -0
- package/dist/generated/runtime.js +4 -0
- package/dist/graph-edges.js +174 -0
- package/dist/import/generator.js +108 -0
- package/dist/import/parser.js +69 -0
- package/dist/index.js +22 -0
- package/dist/integrity.json +2 -2
- package/dist/lint/audit-catalog.js +69 -0
- package/dist/lint/audit-lineage.js +168 -0
- package/dist/lint/post-synth/gha006.js +50 -0
- package/dist/lint/post-synth/gha009.js +35 -0
- package/dist/lint/post-synth/gha011.js +34 -0
- package/dist/lint/post-synth/gha013.js +40 -0
- package/dist/lint/post-synth/gha017.js +25 -0
- package/dist/lint/post-synth/gha018.js +33 -0
- package/dist/lint/post-synth/gha019.js +58 -0
- package/dist/lint/post-synth/gha021.js +42 -0
- package/dist/lint/post-synth/gha022.js +44 -0
- package/dist/lint/post-synth/gha023.js +37 -0
- package/dist/lint/post-synth/gha024.js +32 -0
- package/dist/lint/post-synth/gha025.js +33 -0
- package/dist/lint/post-synth/gha026.js +32 -0
- package/dist/lint/post-synth/gha027.js +46 -0
- package/dist/lint/post-synth/gha028.js +28 -0
- package/dist/lint/post-synth/gha029.js +56 -0
- package/dist/lint/post-synth/gha030.js +35 -0
- package/dist/lint/post-synth/gha031.js +75 -0
- package/dist/lint/post-synth/gha032.js +35 -0
- package/dist/lint/post-synth/gha033.js +39 -0
- package/dist/lint/post-synth/gha034.js +35 -0
- package/dist/lint/post-synth/gha035.js +50 -0
- package/dist/lint/post-synth/gha036.js +37 -0
- package/dist/lint/post-synth/gha037.js +41 -0
- package/dist/lint/post-synth/gha038.js +35 -0
- package/dist/lint/post-synth/gha039.js +34 -0
- package/dist/lint/post-synth/gha040.js +36 -0
- package/dist/lint/post-synth/gha041.js +32 -0
- package/dist/lint/post-synth/gha042.js +33 -0
- package/dist/lint/post-synth/gha043.js +35 -0
- package/dist/lint/post-synth/gha044.js +36 -0
- package/dist/lint/post-synth/gha045.js +33 -0
- package/dist/lint/post-synth/gha046.js +55 -0
- package/dist/lint/post-synth/gha047.js +44 -0
- package/dist/lint/post-synth/gha048.js +34 -0
- package/dist/lint/post-synth/gha049.js +36 -0
- package/dist/lint/post-synth/gha050.js +37 -0
- package/dist/lint/post-synth/gha051.js +38 -0
- package/dist/lint/post-synth/gha052.js +35 -0
- package/dist/lint/post-synth/gha053.js +34 -0
- package/dist/lint/post-synth/gha054.js +35 -0
- package/dist/lint/post-synth/gha055.js +44 -0
- package/dist/lint/post-synth/gha056.js +32 -0
- package/dist/lint/post-synth/gha057.js +35 -0
- package/dist/lint/post-synth/gha058.js +42 -0
- package/dist/lint/post-synth/gha059.js +119 -0
- package/dist/lint/post-synth/gha060.js +129 -0
- package/dist/lint/post-synth/gha061.js +78 -0
- package/dist/lint/post-synth/gha062.js +65 -0
- package/dist/lint/post-synth/gha063.js +56 -0
- package/dist/lint/post-synth/gha064.js +47 -0
- package/dist/lint/post-synth/gha065.js +125 -0
- package/dist/lint/post-synth/gha066.js +40 -0
- package/dist/lint/post-synth/gha067.js +75 -0
- package/dist/lint/post-synth/gha068.js +47 -0
- package/dist/lint/post-synth/gha069.js +99 -0
- package/dist/lint/post-synth/index.js +114 -0
- package/dist/lint/post-synth/yaml-helpers.js +465 -0
- package/dist/lint/rules/data/action-usage-policy.js +16 -0
- package/dist/lint/rules/data/advisory-feed.js +15 -0
- package/dist/lint/rules/data/deprecated-versions.js +14 -0
- package/dist/lint/rules/data/flagged-actions.js +18 -0
- package/dist/lint/rules/data/known-action-slugs.js +38 -0
- package/dist/lint/rules/data/known-actions.js +12 -0
- package/dist/lint/rules/data/preinstalled-tools.js +36 -0
- package/dist/lint/rules/data/recommended-inputs.js +9 -0
- package/dist/lint/rules/data/risky-features.js +22 -0
- package/dist/lint/rules/data/secret-patterns.js +30 -0
- package/dist/lint/rules/data/trusted-action-owners.js +12 -0
- package/dist/lint/rules/data/untrusted-contexts.js +44 -0
- package/dist/lint/rules/deprecated-action-version.js +44 -0
- package/dist/lint/rules/detect-secrets.js +45 -0
- package/dist/lint/rules/extract-inline-structs.js +55 -0
- package/dist/lint/rules/file-job-limit.js +43 -0
- package/dist/lint/rules/index.js +16 -0
- package/dist/lint/rules/job-timeout.js +53 -0
- package/dist/lint/rules/missing-recommended-inputs.js +51 -0
- package/dist/lint/rules/no-hardcoded-secrets.js +39 -0
- package/dist/lint/rules/no-raw-expressions.js +44 -0
- package/dist/lint/rules/suggest-cache.js +60 -0
- package/dist/lint/rules/use-condition-builders.js +37 -0
- package/dist/lint/rules/use-matrix-builder.js +36 -0
- package/dist/lint/rules/use-typed-actions.js +41 -0
- package/dist/lint/rules/validate-concurrency.js +59 -0
- package/dist/lsp/completions.d.ts.map +1 -1
- package/dist/lsp/completions.js +18 -0
- package/dist/lsp/hover.d.ts.map +1 -1
- package/dist/lsp/hover.js +33 -0
- package/dist/manifest.json +1 -1
- package/dist/mcp/context-tools.js +152 -0
- package/dist/package-cli.js +20 -0
- package/dist/plugin.js +344 -0
- package/dist/serializer.js +572 -0
- package/dist/spec/fetch.js +46 -0
- package/dist/validate-cli.js +16 -0
- package/dist/validate.js +38 -0
- package/dist/variables.js +41 -0
- package/package.json +6 -6
- package/src/codegen/package.ts +2 -2
- package/src/components/__fixtures__/pr-loop-build.github.golden.yml +6 -2
- package/src/components/__fixtures__/pr-loop-waves.github.golden.yml +178 -0
- package/src/components/__fixtures__/pr-loop.github.golden.yml +6 -2
- package/src/components/generate-pipeline.ts +66 -29
- package/src/components/generate-pr-pipeline.test.ts +44 -20
- package/src/components/generate-pr-waves-pipeline.test.ts +117 -0
- package/src/lsp/completions.ts +2 -1
- package/src/lsp/hover.ts +2 -1
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA056: Workflow Without a `name:`
|
|
3
|
+
*
|
|
4
|
+
* Flags an emitted workflow with no top-level `name:`. Without one, GitHub falls
|
|
5
|
+
* back to the file path in the Actions UI and audit logs, making runs harder to
|
|
6
|
+
* identify and review. A clear name improves observability and audit output.
|
|
7
|
+
*/
|
|
8
|
+
import { getPrimaryOutput, extractWorkflowName } from "./yaml-helpers.js";
|
|
9
|
+
export const gha056 = {
|
|
10
|
+
id: "GHA056",
|
|
11
|
+
description: "Workflow without a name",
|
|
12
|
+
check(ctx) {
|
|
13
|
+
const diagnostics = [];
|
|
14
|
+
for (const [lexicon, output] of ctx.outputs) {
|
|
15
|
+
const yaml = getPrimaryOutput(output);
|
|
16
|
+
// Only inspect things that look like workflows (have a jobs: block).
|
|
17
|
+
if (!/^jobs:\s*$/m.test(yaml))
|
|
18
|
+
continue;
|
|
19
|
+
const name = extractWorkflowName(yaml);
|
|
20
|
+
if (!name) {
|
|
21
|
+
diagnostics.push({
|
|
22
|
+
checkId: "GHA056",
|
|
23
|
+
severity: "info",
|
|
24
|
+
message: `Workflow "${lexicon}" has no top-level name: — add one so runs are identifiable in the Actions UI and audit logs.`,
|
|
25
|
+
entity: "workflow",
|
|
26
|
+
lexicon: "github",
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return diagnostics;
|
|
31
|
+
},
|
|
32
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA057: Dependency Update Executing Untrusted External Code
|
|
3
|
+
*
|
|
4
|
+
* Flags a dependabot `updates:` entry with
|
|
5
|
+
* `insecure-external-code-execution: allow`. That setting lets a freshly-pulled
|
|
6
|
+
* dependency run lifecycle scripts (e.g. npm `postinstall`) during the update
|
|
7
|
+
* itself — a compromised release executes in the update job before any review.
|
|
8
|
+
* Set it to `deny`, or isolate the ecosystem.
|
|
9
|
+
*/
|
|
10
|
+
import { getDependabotYaml, extractDependabotUpdates } from "./yaml-helpers.js";
|
|
11
|
+
export const gha057 = {
|
|
12
|
+
id: "GHA057",
|
|
13
|
+
description: "Dependency update allows executing untrusted external code",
|
|
14
|
+
check(ctx) {
|
|
15
|
+
const diagnostics = [];
|
|
16
|
+
for (const [, output] of ctx.outputs) {
|
|
17
|
+
const dependabot = getDependabotYaml(output);
|
|
18
|
+
if (!dependabot)
|
|
19
|
+
continue;
|
|
20
|
+
for (const update of extractDependabotUpdates(dependabot)) {
|
|
21
|
+
if (update["insecure-external-code-execution"] === "allow") {
|
|
22
|
+
const eco = String(update["package-ecosystem"] ?? "?");
|
|
23
|
+
diagnostics.push({
|
|
24
|
+
checkId: "GHA057",
|
|
25
|
+
severity: "error",
|
|
26
|
+
message: `Dependabot update for "${eco}" sets insecure-external-code-execution: allow, running dependency lifecycle scripts during the update. Set it to deny.`,
|
|
27
|
+
entity: eco,
|
|
28
|
+
lexicon: "github",
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return diagnostics;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA058: Dependency Update Without a Cooldown
|
|
3
|
+
*
|
|
4
|
+
* Flags a dependabot `updates:` entry with no cooldown (or an all-zero one). A
|
|
5
|
+
* version published seconds ago — including a compromised one — is adopted
|
|
6
|
+
* immediately, with no window for the ecosystem to yank it or for anyone to
|
|
7
|
+
* react. Configure a `cooldown:` so freshly-published versions wait before they
|
|
8
|
+
* are proposed.
|
|
9
|
+
*/
|
|
10
|
+
import { getDependabotYaml, extractDependabotUpdates } from "./yaml-helpers.js";
|
|
11
|
+
const COOLDOWN_DAY_KEYS = ["default-days", "semver-major-days", "semver-minor-days", "semver-patch-days"];
|
|
12
|
+
function hasEffectiveCooldown(cooldown) {
|
|
13
|
+
if (!cooldown || typeof cooldown !== "object")
|
|
14
|
+
return false;
|
|
15
|
+
const c = cooldown;
|
|
16
|
+
return COOLDOWN_DAY_KEYS.some((k) => typeof c[k] === "number" && c[k] > 0);
|
|
17
|
+
}
|
|
18
|
+
export const gha058 = {
|
|
19
|
+
id: "GHA058",
|
|
20
|
+
description: "Dependency update has no cooldown window",
|
|
21
|
+
check(ctx) {
|
|
22
|
+
const diagnostics = [];
|
|
23
|
+
for (const [, output] of ctx.outputs) {
|
|
24
|
+
const dependabot = getDependabotYaml(output);
|
|
25
|
+
if (!dependabot)
|
|
26
|
+
continue;
|
|
27
|
+
for (const update of extractDependabotUpdates(dependabot)) {
|
|
28
|
+
if (!hasEffectiveCooldown(update.cooldown)) {
|
|
29
|
+
const eco = String(update["package-ecosystem"] ?? "?");
|
|
30
|
+
diagnostics.push({
|
|
31
|
+
checkId: "GHA058",
|
|
32
|
+
severity: "warning",
|
|
33
|
+
message: `Dependabot update for "${eco}" has no cooldown — a version published moments ago is adopted immediately. Add a cooldown window so fresh (possibly compromised) releases wait before they are proposed.`,
|
|
34
|
+
entity: eco,
|
|
35
|
+
lexicon: "github",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return diagnostics;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
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
|
+
import { getPrimaryOutput, extractActionRefs, extractUsesComment, parseActionUses, usesLineComments } from "./yaml-helpers.js";
|
|
23
|
+
const SHA_RE = /^[0-9a-f]{40}$/;
|
|
24
|
+
/**
|
|
25
|
+
* Find every SHA-pinned `uses:` with a missing or internally-inconsistent
|
|
26
|
+
* version annotation. Scoped to one workflow document — cross-file
|
|
27
|
+
* consistency isn't attempted since two independent workflows pinning the
|
|
28
|
+
* same action differently isn't evidence of drift.
|
|
29
|
+
*/
|
|
30
|
+
export function findStalePinAnnotations(yaml) {
|
|
31
|
+
const refs = extractActionRefs(yaml)
|
|
32
|
+
.map(({ job, ref }) => ({ job, ref, parsed: parseActionUses(ref) }))
|
|
33
|
+
.filter((r) => !!r.parsed && SHA_RE.test(r.parsed.gitRef));
|
|
34
|
+
const findings = [];
|
|
35
|
+
// First pass: missing annotations.
|
|
36
|
+
// The parsed value carries no comment, so match each ref to its `uses:`
|
|
37
|
+
// line, in document order, to read the label.
|
|
38
|
+
const lines = usesLineComments(yaml);
|
|
39
|
+
const commentFor = (ref) => {
|
|
40
|
+
const k = lines.findIndex((l) => l.ref === ref);
|
|
41
|
+
if (k === -1)
|
|
42
|
+
return extractUsesComment(ref);
|
|
43
|
+
return lines.splice(k, 1)[0].comment;
|
|
44
|
+
};
|
|
45
|
+
const withComment = [];
|
|
46
|
+
for (const { job, ref, parsed } of refs) {
|
|
47
|
+
const comment = commentFor(ref);
|
|
48
|
+
if (!comment) {
|
|
49
|
+
findings.push({ job, ref, slug: parsed.slug, sha: parsed.gitRef, kind: "missing" });
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
withComment.push({ job, ref, slug: parsed.slug, sha: parsed.gitRef, comment });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Second pass: internal mismatch. Group by slug — the same action's
|
|
56
|
+
// commit-SHA↔label mapping must be one-to-one across the whole file.
|
|
57
|
+
const bySlug = new Map();
|
|
58
|
+
for (const entry of withComment) {
|
|
59
|
+
const list = bySlug.get(entry.slug) ?? [];
|
|
60
|
+
list.push(entry);
|
|
61
|
+
bySlug.set(entry.slug, list);
|
|
62
|
+
}
|
|
63
|
+
for (const [, entries] of bySlug) {
|
|
64
|
+
const shaToComments = new Map();
|
|
65
|
+
const commentToShas = new Map();
|
|
66
|
+
for (const e of entries) {
|
|
67
|
+
(shaToComments.get(e.sha) ?? shaToComments.set(e.sha, new Set()).get(e.sha)).add(e.comment);
|
|
68
|
+
(commentToShas.get(e.comment) ?? commentToShas.set(e.comment, new Set()).get(e.comment)).add(e.sha);
|
|
69
|
+
}
|
|
70
|
+
for (const e of entries) {
|
|
71
|
+
const otherComments = [...(shaToComments.get(e.sha) ?? [])].filter((c) => c !== e.comment);
|
|
72
|
+
const otherShas = [...(commentToShas.get(e.comment) ?? [])].filter((s) => s !== e.sha);
|
|
73
|
+
if (otherComments.length > 0) {
|
|
74
|
+
findings.push({
|
|
75
|
+
job: e.job,
|
|
76
|
+
ref: e.ref,
|
|
77
|
+
slug: e.slug,
|
|
78
|
+
sha: e.sha,
|
|
79
|
+
kind: "mismatched",
|
|
80
|
+
conflict: `commit ${e.sha} is also annotated "# ${otherComments[0]}" elsewhere`,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
else if (otherShas.length > 0) {
|
|
84
|
+
findings.push({
|
|
85
|
+
job: e.job,
|
|
86
|
+
ref: e.ref,
|
|
87
|
+
slug: e.slug,
|
|
88
|
+
sha: e.sha,
|
|
89
|
+
kind: "mismatched",
|
|
90
|
+
conflict: `label "# ${e.comment}" is also attached to commit ${otherShas[0]} elsewhere`,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return findings;
|
|
96
|
+
}
|
|
97
|
+
export const gha059 = {
|
|
98
|
+
id: "GHA059",
|
|
99
|
+
description: "SHA-pinned action reference has a missing or internally-inconsistent version annotation",
|
|
100
|
+
check(ctx) {
|
|
101
|
+
const diagnostics = [];
|
|
102
|
+
for (const [, output] of ctx.outputs) {
|
|
103
|
+
const yaml = getPrimaryOutput(output);
|
|
104
|
+
for (const finding of findStalePinAnnotations(yaml)) {
|
|
105
|
+
const message = finding.kind === "missing"
|
|
106
|
+
? `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.`
|
|
107
|
+
: `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.`;
|
|
108
|
+
diagnostics.push({
|
|
109
|
+
checkId: "GHA059",
|
|
110
|
+
severity: "warning",
|
|
111
|
+
message,
|
|
112
|
+
entity: finding.job,
|
|
113
|
+
lexicon: "github",
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return diagnostics;
|
|
118
|
+
},
|
|
119
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
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
|
+
import { getPrimaryOutput, extractStepsByJob, parseActionUses } from "./yaml-helpers.js";
|
|
25
|
+
const APP_TOKEN_SLUGS = new Set([
|
|
26
|
+
"actions/create-github-app-token",
|
|
27
|
+
"tibdex/github-app-token",
|
|
28
|
+
"getsentry/action-github-app-token",
|
|
29
|
+
]);
|
|
30
|
+
const WRITE_LEVELS = new Set(["write", "admin"]);
|
|
31
|
+
/** Textual evidence that a step exercises a given permission scope. Not exhaustive — a fallback covers unlisted scopes. */
|
|
32
|
+
const SCOPE_SIGNALS = {
|
|
33
|
+
contents: /git\s+push|gh\s+release|\/(?:git\/)?contents\b/i,
|
|
34
|
+
issues: /gh\s+issue|\/issues\b/i,
|
|
35
|
+
"pull-requests": /gh\s+pr\b|\/pulls\b/i,
|
|
36
|
+
packages: /npm\s+publish|docker\s+push|\/packages\b/i,
|
|
37
|
+
administration: /\/admin\b|administration/i,
|
|
38
|
+
actions: /\/actions\/runs|gh\s+run\b/i,
|
|
39
|
+
checks: /check-runs|\bchecks\b/i,
|
|
40
|
+
deployments: /\/deployments\b/i,
|
|
41
|
+
statuses: /\/statuses\b/i,
|
|
42
|
+
workflows: /\.github\/workflows/i,
|
|
43
|
+
environments: /\/environments\b/i,
|
|
44
|
+
};
|
|
45
|
+
function scopeSignalMatches(scope, text) {
|
|
46
|
+
const signal = SCOPE_SIGNALS[scope];
|
|
47
|
+
if (signal)
|
|
48
|
+
return signal.test(text);
|
|
49
|
+
return new RegExp(scope.replace(/-/g, "[-_ ]?"), "i").test(text);
|
|
50
|
+
}
|
|
51
|
+
function stepText(step) {
|
|
52
|
+
const parts = [];
|
|
53
|
+
if (typeof step.run === "string")
|
|
54
|
+
parts.push(step.run);
|
|
55
|
+
if (step.with && typeof step.with === "object")
|
|
56
|
+
parts.push(JSON.stringify(step.with));
|
|
57
|
+
if (step.env && typeof step.env === "object")
|
|
58
|
+
parts.push(JSON.stringify(step.env));
|
|
59
|
+
if (typeof step.uses === "string")
|
|
60
|
+
parts.push(step.uses);
|
|
61
|
+
return parts.join("\n");
|
|
62
|
+
}
|
|
63
|
+
/** Find every generated GitHub App token whose granted write/admin scopes exceed what the job's other steps evidence. */
|
|
64
|
+
export function findOverScopedTokens(yaml) {
|
|
65
|
+
const findings = [];
|
|
66
|
+
for (const [job, steps] of extractStepsByJob(yaml)) {
|
|
67
|
+
steps.forEach((step, idx) => {
|
|
68
|
+
const uses = typeof step.uses === "string" ? step.uses : undefined;
|
|
69
|
+
if (!uses)
|
|
70
|
+
return;
|
|
71
|
+
const parsed = parseActionUses(uses);
|
|
72
|
+
if (!parsed || !APP_TOKEN_SLUGS.has(parsed.slug))
|
|
73
|
+
return;
|
|
74
|
+
const withBlock = step.with && typeof step.with === "object" ? step.with : {};
|
|
75
|
+
const stepId = typeof step.id === "string" ? step.id : `#${idx}`;
|
|
76
|
+
const writeScopes = [];
|
|
77
|
+
for (const [key, value] of Object.entries(withBlock)) {
|
|
78
|
+
const m = /^permission-([a-z-]+)$/i.exec(key);
|
|
79
|
+
if (!m)
|
|
80
|
+
continue;
|
|
81
|
+
if (WRITE_LEVELS.has(String(value).toLowerCase()))
|
|
82
|
+
writeScopes.push(m[1]);
|
|
83
|
+
}
|
|
84
|
+
if (writeScopes.length === 0)
|
|
85
|
+
return;
|
|
86
|
+
// Evidence comes only from OTHER steps — the minting step's own `with:`
|
|
87
|
+
// block names the scopes being granted, so it can't also count as
|
|
88
|
+
// evidence they're used.
|
|
89
|
+
const otherStepsText = steps
|
|
90
|
+
.filter((_, i) => i !== idx)
|
|
91
|
+
.map(stepText)
|
|
92
|
+
.join("\n");
|
|
93
|
+
const outputRef = `steps.${stepId}.outputs.token`;
|
|
94
|
+
if (!otherStepsText.includes(outputRef)) {
|
|
95
|
+
findings.push({ job, stepId, scopes: writeScopes, reason: "unused" });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const unsignaled = writeScopes.filter((scope) => !scopeSignalMatches(scope, otherStepsText));
|
|
99
|
+
if (unsignaled.length > 0) {
|
|
100
|
+
findings.push({ job, stepId, scopes: unsignaled, reason: "no-signal" });
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return findings;
|
|
105
|
+
}
|
|
106
|
+
export const gha060 = {
|
|
107
|
+
id: "GHA060",
|
|
108
|
+
description: "Generated GitHub App token granted broader scope than its consuming steps evidence",
|
|
109
|
+
check(ctx) {
|
|
110
|
+
const diagnostics = [];
|
|
111
|
+
for (const [, output] of ctx.outputs) {
|
|
112
|
+
const yaml = getPrimaryOutput(output);
|
|
113
|
+
for (const finding of findOverScopedTokens(yaml)) {
|
|
114
|
+
const scopeList = finding.scopes.map((s) => `permission-${s}`).join(", ");
|
|
115
|
+
const message = finding.reason === "unused"
|
|
116
|
+
? `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.`
|
|
117
|
+
: `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.`;
|
|
118
|
+
diagnostics.push({
|
|
119
|
+
checkId: "GHA060",
|
|
120
|
+
severity: "warning",
|
|
121
|
+
message,
|
|
122
|
+
entity: finding.job,
|
|
123
|
+
lexicon: "github",
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return diagnostics;
|
|
128
|
+
},
|
|
129
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
import { getPrimaryOutput, extractActionRefs, parseActionUses } from "./yaml-helpers.js";
|
|
17
|
+
import { DEFAULT_ACTION_USAGE_POLICY } from "../rules/data/action-usage-policy.js";
|
|
18
|
+
function matches(entry, owner, slug) {
|
|
19
|
+
if (entry === slug)
|
|
20
|
+
return true;
|
|
21
|
+
if (entry === owner)
|
|
22
|
+
return true;
|
|
23
|
+
if (entry.endsWith("/*") && entry.slice(0, -2) === owner)
|
|
24
|
+
return true;
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
function matchesAny(entries, owner, slug) {
|
|
28
|
+
return (entries ?? []).some((e) => matches(e, owner, slug));
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Evaluate a workflow's `uses:` references against a usage policy. Pure: no
|
|
32
|
+
* fs, no network, no default policy baked in — an empty/unset policy (no
|
|
33
|
+
* `allow` and no `deny`) always yields no findings, which is what makes this
|
|
34
|
+
* genuinely opt-in rather than "opt-in by omission of one flag."
|
|
35
|
+
*/
|
|
36
|
+
export function evaluateUsagePolicy(yaml, policy) {
|
|
37
|
+
const { allow, deny } = policy;
|
|
38
|
+
if ((!allow || allow.length === 0) && (!deny || deny.length === 0))
|
|
39
|
+
return [];
|
|
40
|
+
const diagnostics = [];
|
|
41
|
+
for (const { job, ref } of extractActionRefs(yaml)) {
|
|
42
|
+
const parsed = parseActionUses(ref);
|
|
43
|
+
if (!parsed)
|
|
44
|
+
continue; // local or docker:// reference — not a registry policy carries slugs for
|
|
45
|
+
if (matchesAny(deny, parsed.owner, parsed.slug)) {
|
|
46
|
+
diagnostics.push({
|
|
47
|
+
checkId: "GHA061",
|
|
48
|
+
severity: "error",
|
|
49
|
+
message: `Job "${job}" uses "${parsed.slug}", which is denied by the configured action-usage policy.`,
|
|
50
|
+
entity: job,
|
|
51
|
+
lexicon: "github",
|
|
52
|
+
});
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (allow && allow.length > 0 && !matchesAny(allow, parsed.owner, parsed.slug)) {
|
|
56
|
+
diagnostics.push({
|
|
57
|
+
checkId: "GHA061",
|
|
58
|
+
severity: "warning",
|
|
59
|
+
message: `Job "${job}" uses "${parsed.slug}", which is not in the configured action-usage allowlist.`,
|
|
60
|
+
entity: job,
|
|
61
|
+
lexicon: "github",
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return diagnostics;
|
|
66
|
+
}
|
|
67
|
+
export const gha061 = {
|
|
68
|
+
id: "GHA061",
|
|
69
|
+
description: "Action reference outside the configured usage policy (opt-in)",
|
|
70
|
+
check(ctx) {
|
|
71
|
+
const diagnostics = [];
|
|
72
|
+
for (const [, output] of ctx.outputs) {
|
|
73
|
+
const yaml = getPrimaryOutput(output);
|
|
74
|
+
diagnostics.push(...evaluateUsagePolicy(yaml, DEFAULT_ACTION_USAGE_POLICY));
|
|
75
|
+
}
|
|
76
|
+
return diagnostics;
|
|
77
|
+
},
|
|
78
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
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
|
+
import { getPrimaryOutput, extractActionRefs, parseActionUses } from "./yaml-helpers.js";
|
|
15
|
+
import { DEFAULT_ADVISORY_FEED } from "../rules/data/advisory-feed.js";
|
|
16
|
+
function matchesEntry(entry, slug, gitRef) {
|
|
17
|
+
if (entry.slug !== slug)
|
|
18
|
+
return false;
|
|
19
|
+
if (entry.shas?.includes(gitRef))
|
|
20
|
+
return true;
|
|
21
|
+
if (entry.refs?.includes(gitRef))
|
|
22
|
+
return true;
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Check a workflow's pinned action references against an advisory feed. Pure
|
|
27
|
+
* and total: a missing/empty feed (`entries.length === 0`, including the
|
|
28
|
+
* `undefined` default) always yields `[]`, never a throw.
|
|
29
|
+
*/
|
|
30
|
+
export function checkAdvisories(yaml, feed = DEFAULT_ADVISORY_FEED) {
|
|
31
|
+
if (!feed || !feed.entries || feed.entries.length === 0)
|
|
32
|
+
return [];
|
|
33
|
+
const diagnostics = [];
|
|
34
|
+
for (const { job, ref } of extractActionRefs(yaml)) {
|
|
35
|
+
const parsed = parseActionUses(ref);
|
|
36
|
+
if (!parsed)
|
|
37
|
+
continue;
|
|
38
|
+
for (const entry of feed.entries) {
|
|
39
|
+
if (!matchesEntry(entry, parsed.slug, parsed.gitRef))
|
|
40
|
+
continue;
|
|
41
|
+
const patch = entry.patchedRef ? ` A patched ref is available: ${entry.patchedRef}.` : "";
|
|
42
|
+
const link = entry.url ? ` (${entry.url})` : "";
|
|
43
|
+
diagnostics.push({
|
|
44
|
+
checkId: "GHA062",
|
|
45
|
+
severity: "error",
|
|
46
|
+
message: `Job "${job}" uses "${parsed.slug}@${parsed.gitRef}", which matches disclosed advisory ${entry.id}${link}: ${entry.summary}.${patch}`,
|
|
47
|
+
entity: job,
|
|
48
|
+
lexicon: "github",
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return diagnostics;
|
|
53
|
+
}
|
|
54
|
+
export const gha062 = {
|
|
55
|
+
id: "GHA062",
|
|
56
|
+
description: "Pinned action reference matches a known-vulnerability advisory (feed-driven)",
|
|
57
|
+
check(ctx) {
|
|
58
|
+
const diagnostics = [];
|
|
59
|
+
for (const [, output] of ctx.outputs) {
|
|
60
|
+
const yaml = getPrimaryOutput(output);
|
|
61
|
+
diagnostics.push(...checkAdvisories(yaml, DEFAULT_ADVISORY_FEED));
|
|
62
|
+
}
|
|
63
|
+
return diagnostics;
|
|
64
|
+
},
|
|
65
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA063: Dependency Setup Without Caching
|
|
3
|
+
*
|
|
4
|
+
* Flags a `actions/setup-{node,python,java,ruby,dotnet}` step that leaves its
|
|
5
|
+
* built-in `cache:` option unset, with no adjacent `actions/cache` step to
|
|
6
|
+
* cover the gap. Every run re-downloads the same dependencies from the
|
|
7
|
+
* registry from a cold cache — pure wasted time and bandwidth, not a
|
|
8
|
+
* correctness or security issue (efficiency, #444).
|
|
9
|
+
*/
|
|
10
|
+
import { getPrimaryOutput, jobEntries, parseActionUses } from "./yaml-helpers.js";
|
|
11
|
+
/** Setup actions whose `with.cache` option enables built-in dependency caching. */
|
|
12
|
+
const CACHEABLE_SETUP_ACTIONS = new Set(["actions/setup-node", "actions/setup-python", "actions/setup-java", "actions/setup-ruby", "actions/setup-dotnet"]);
|
|
13
|
+
function cacheEnabled(withBlock) {
|
|
14
|
+
if (!withBlock || typeof withBlock !== "object")
|
|
15
|
+
return false;
|
|
16
|
+
const cache = withBlock.cache;
|
|
17
|
+
if (typeof cache === "boolean")
|
|
18
|
+
return cache;
|
|
19
|
+
if (typeof cache === "string")
|
|
20
|
+
return cache.trim().length > 0 && cache.trim() !== "false";
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
export const gha063 = {
|
|
24
|
+
id: "GHA063",
|
|
25
|
+
description: "Dependency setup action without caching enabled",
|
|
26
|
+
check(ctx) {
|
|
27
|
+
const diagnostics = [];
|
|
28
|
+
for (const [, output] of ctx.outputs) {
|
|
29
|
+
const yaml = getPrimaryOutput(output);
|
|
30
|
+
for (const [jobName, jobObj] of jobEntries(yaml)) {
|
|
31
|
+
const steps = Array.isArray(jobObj.steps) ? jobObj.steps : [];
|
|
32
|
+
const hasGenericCacheStep = steps.some((s) => parseActionUses(String(s.uses ?? ""))?.slug === "actions/cache");
|
|
33
|
+
if (hasGenericCacheStep)
|
|
34
|
+
continue;
|
|
35
|
+
for (const step of steps) {
|
|
36
|
+
const uses = typeof step.uses === "string" ? step.uses : undefined;
|
|
37
|
+
if (!uses)
|
|
38
|
+
continue;
|
|
39
|
+
const parsed = parseActionUses(uses);
|
|
40
|
+
if (!parsed || !CACHEABLE_SETUP_ACTIONS.has(parsed.slug))
|
|
41
|
+
continue;
|
|
42
|
+
if (cacheEnabled(step.with))
|
|
43
|
+
continue;
|
|
44
|
+
diagnostics.push({
|
|
45
|
+
checkId: "GHA063",
|
|
46
|
+
severity: "info",
|
|
47
|
+
message: `Job "${jobName}" uses ${parsed.slug} without enabling its \`cache:\` option, and no separate actions/cache step covers it — dependencies are re-fetched from a cold cache on every run. Set \`with.cache\` (e.g. \`cache: npm\`) or add an actions/cache step.`,
|
|
48
|
+
entity: jobName,
|
|
49
|
+
lexicon: "github",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return diagnostics;
|
|
55
|
+
},
|
|
56
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA064: Expensive Runner Without Justification
|
|
3
|
+
*
|
|
4
|
+
* Flags a job hardcoded onto a pricier macOS/Windows runner (billed at a
|
|
5
|
+
* multiple of Linux minutes) when none of its `run:` steps show any sign the
|
|
6
|
+
* job actually needs that OS. A matrix-driven `runs-on: ${{ matrix.os }}`
|
|
7
|
+
* (intentional cross-platform testing) is never flagged — only a literal,
|
|
8
|
+
* hardcoded label. Efficiency (#444), not a correctness/security issue.
|
|
9
|
+
*/
|
|
10
|
+
import { getPrimaryOutput, extractRunsOnByJob, extractRunBlocks } from "./yaml-helpers.js";
|
|
11
|
+
const OS_JUSTIFICATION = [
|
|
12
|
+
{ prefix: "macos-", label: "macOS", keywords: /xcodebuild|xcode-select|\.xcodeproj|\.xcworkspace|carthage|fastlane|cocoapods|pod install|swiftpm|codesign|notarize/i },
|
|
13
|
+
{ prefix: "windows-", label: "Windows", keywords: /msbuild|\.sln\b|\.ps1\b|vcvarsall|nuget\s|choco\s|Set-ItemProperty|Get-ChildItem/i },
|
|
14
|
+
];
|
|
15
|
+
export const gha064 = {
|
|
16
|
+
id: "GHA064",
|
|
17
|
+
description: "Job hardcoded onto an expensive runner with no sign it needs that OS",
|
|
18
|
+
check(ctx) {
|
|
19
|
+
const diagnostics = [];
|
|
20
|
+
for (const [, output] of ctx.outputs) {
|
|
21
|
+
const yaml = getPrimaryOutput(output);
|
|
22
|
+
const runsOnByJob = extractRunsOnByJob(yaml);
|
|
23
|
+
const runBlocks = extractRunBlocks(yaml);
|
|
24
|
+
for (const [jobName, labels] of runsOnByJob) {
|
|
25
|
+
if (labels.some((l) => l.includes("${{")))
|
|
26
|
+
continue; // matrix/expression-driven — intentional
|
|
27
|
+
for (const { prefix, label, keywords } of OS_JUSTIFICATION) {
|
|
28
|
+
const matched = labels.find((l) => l.toLowerCase().startsWith(prefix));
|
|
29
|
+
if (!matched)
|
|
30
|
+
continue;
|
|
31
|
+
const justified = runBlocks.some((r) => r.job === jobName && keywords.test(r.run));
|
|
32
|
+
if (justified)
|
|
33
|
+
break;
|
|
34
|
+
diagnostics.push({
|
|
35
|
+
checkId: "GHA064",
|
|
36
|
+
severity: "info",
|
|
37
|
+
message: `Job "${jobName}" runs on "${matched}" (a pricier ${label} runner) but no step looks ${label}-specific. Confirm it needs ${label}, or move it to a Linux runner.`,
|
|
38
|
+
entity: jobName,
|
|
39
|
+
lexicon: "github",
|
|
40
|
+
});
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return diagnostics;
|
|
46
|
+
},
|
|
47
|
+
};
|