@intentius/chant 0.4.0 → 0.5.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/package.json +1 -1
- package/src/audit/catalog.test.ts +71 -0
- package/src/audit/catalog.ts +200 -0
- package/src/audit/core.test.ts +129 -0
- package/src/audit/core.ts +170 -0
- package/src/audit/fetch.test.ts +182 -0
- package/src/audit/fetch.ts +371 -0
- package/src/audit/proof.test.ts +141 -0
- package/src/audit/proof.ts +290 -0
- package/src/audit/report-html.test.ts +83 -0
- package/src/audit/report-html.ts +207 -0
- package/src/audit/report-model.ts +275 -0
- package/src/audit/report.test.ts +119 -0
- package/src/audit/report.ts +121 -0
- package/src/audit/rules-doc.test.ts +23 -0
- package/src/audit/rules-doc.ts +46 -0
- package/src/cli/commands/__fixtures__/audit-repo/.github/workflows/ci.yml +11 -0
- package/src/cli/commands/audit.test.ts +170 -0
- package/src/cli/commands/audit.ts +352 -0
- package/src/cli/handlers/misc.ts +71 -0
- package/src/cli/main.ts +12 -1
- package/src/cli/registry.ts +6 -0
package/package.json
CHANGED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { RULE_CATALOG, ruleMeta } from "./catalog";
|
|
3
|
+
import { loadPlugins } from "../cli/plugins";
|
|
4
|
+
|
|
5
|
+
/** All post-synth check ids the audit can actually surface, from the lexicons. */
|
|
6
|
+
async function realCheckIds(): Promise<Set<string>> {
|
|
7
|
+
const plugins = await loadPlugins(["github", "gitlab", "forgejo"]);
|
|
8
|
+
const ids = new Set<string>();
|
|
9
|
+
for (const plugin of plugins) {
|
|
10
|
+
for (const check of plugin.postSynthChecks?.() ?? []) {
|
|
11
|
+
ids.add(check.id);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return ids;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("RULE_CATALOG", () => {
|
|
18
|
+
test("covers every post-synth check the lexicons ship (no missing ids)", async () => {
|
|
19
|
+
const real = await realCheckIds();
|
|
20
|
+
const missing = [...real].filter((id) => !(id in RULE_CATALOG)).sort();
|
|
21
|
+
expect(missing).toEqual([]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("has no stale entries that aren't real checks", async () => {
|
|
25
|
+
const real = await realCheckIds();
|
|
26
|
+
const stale = Object.keys(RULE_CATALOG).filter((id) => !real.has(id)).sort();
|
|
27
|
+
expect(stale).toEqual([]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("every entry has a title, remediation, and valid tier/fixKind", () => {
|
|
31
|
+
for (const [id, m] of Object.entries(RULE_CATALOG)) {
|
|
32
|
+
expect(m.id, `${id} id matches key`).toBe(id);
|
|
33
|
+
expect(m.title.length, `${id} has a title`).toBeGreaterThan(0);
|
|
34
|
+
expect(m.remediation.length, `${id} has remediation`).toBeGreaterThan(0);
|
|
35
|
+
expect(["merge-worthy", "report-only"]).toContain(m.tier);
|
|
36
|
+
expect(["deterministic", "guidance"]).toContain(m.fixKind);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("authority citations only attach to merge-worthy entries", () => {
|
|
41
|
+
for (const [id, m] of Object.entries(RULE_CATALOG)) {
|
|
42
|
+
if (m.authority && m.authority.length > 0) {
|
|
43
|
+
expect(m.tier, `${id} with authority is merge-worthy`).toBe("merge-worthy");
|
|
44
|
+
for (const a of m.authority) {
|
|
45
|
+
expect(a.name.length).toBeGreaterThan(0);
|
|
46
|
+
expect(a.url.startsWith("https://")).toBe(true);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("flagship security rules carry an authority citation", () => {
|
|
53
|
+
const flagship = ["GHA017", "GHA021", "GHA029", "GHA033", "GHA034", "GHA036", "GHA037", "WGL016", "WGL029"];
|
|
54
|
+
for (const id of flagship) {
|
|
55
|
+
const m = ruleMeta(id);
|
|
56
|
+
expect(m, `${id} present`).toBeDefined();
|
|
57
|
+
expect(m!.tier).toBe("merge-worthy");
|
|
58
|
+
expect((m!.authority?.length ?? 0), `${id} has authority`).toBeGreaterThan(0);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("deterministic fixes are limited to the safe mechanical set", () => {
|
|
63
|
+
const deterministic = Object.values(RULE_CATALOG)
|
|
64
|
+
.filter((m) => m.fixKind === "deterministic")
|
|
65
|
+
.map((m) => m.id)
|
|
66
|
+
.sort();
|
|
67
|
+
expect(deterministic).toEqual(
|
|
68
|
+
["GHA017", "GHA021", "GHA029", "GHA030", "GHA033", "WGL031"].sort(),
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule catalog — classifies every CI post-synth check the auditor can surface.
|
|
3
|
+
*
|
|
4
|
+
* Two axes drive how a finding is presented:
|
|
5
|
+
* - `tier`: `merge-worthy` (a security vuln/supply-chain exposure or a hard
|
|
6
|
+
* correctness bug — worth opening a PR to the target) vs `report-only`
|
|
7
|
+
* (hygiene/style/perf/deprecation — shown in the report, never a PR title).
|
|
8
|
+
* - `fixKind`: `deterministic` (a safe mechanical fix can be auto-applied or
|
|
9
|
+
* diffed) vs `guidance` (needs human/LLM judgment — emit remediation text
|
|
10
|
+
* only; never auto-applied; never run by the hosted service).
|
|
11
|
+
*
|
|
12
|
+
* The catalog covers exactly the post-synth checks run by the audit (github
|
|
13
|
+
* GHA*, gitlab WGL*, forgejo WFJ*). A drift-guard test asserts it stays in
|
|
14
|
+
* sync with the lexicons.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type Tier = "merge-worthy" | "report-only";
|
|
18
|
+
|
|
19
|
+
/** deterministic = safe auto-fix/diff; guidance = report text only (needs judgment). */
|
|
20
|
+
export type FixKind = "deterministic" | "guidance";
|
|
21
|
+
|
|
22
|
+
export interface Authority {
|
|
23
|
+
name: string;
|
|
24
|
+
url: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RuleMeta {
|
|
28
|
+
id: string;
|
|
29
|
+
tier: Tier;
|
|
30
|
+
fixKind: FixKind;
|
|
31
|
+
title: string;
|
|
32
|
+
/** External backing so a finding isn't just chant's opinion. */
|
|
33
|
+
authority?: Authority[];
|
|
34
|
+
/** One-line fix guidance (always present). */
|
|
35
|
+
remediation: string;
|
|
36
|
+
/**
|
|
37
|
+
* False if the check reads the chant model (`ctx.entities`) rather than the
|
|
38
|
+
* emitted YAML (`ctx.outputs`) — such a check won't fire on audited YAML.
|
|
39
|
+
* All current post-synth checks are output-based, so this is true.
|
|
40
|
+
*/
|
|
41
|
+
yamlBased: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Authority references ─────────────────────────────────────────────
|
|
45
|
+
const SCORECARD_TOKEN: Authority = {
|
|
46
|
+
name: "OSSF Scorecard — Token-Permissions",
|
|
47
|
+
url: "https://github.com/ossf/scorecard/blob/main/docs/checks.md#token-permissions",
|
|
48
|
+
};
|
|
49
|
+
const GH_TOKEN: Authority = {
|
|
50
|
+
name: "GitHub — Automatic token authentication",
|
|
51
|
+
url: "https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication",
|
|
52
|
+
};
|
|
53
|
+
const SCORECARD_PINNED: Authority = {
|
|
54
|
+
name: "OSSF Scorecard — Pinned-Dependencies",
|
|
55
|
+
url: "https://github.com/ossf/scorecard/blob/main/docs/checks.md#pinned-dependencies",
|
|
56
|
+
};
|
|
57
|
+
const GH_THIRD_PARTY: Authority = {
|
|
58
|
+
name: "GitHub — Using third-party actions",
|
|
59
|
+
url: "https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#using-third-party-actions",
|
|
60
|
+
};
|
|
61
|
+
const GH_INJECTION: Authority = {
|
|
62
|
+
name: "GitHub — Understanding the risk of script injections",
|
|
63
|
+
url: "https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#understanding-the-risk-of-script-injections",
|
|
64
|
+
};
|
|
65
|
+
const GH_PWN: Authority = {
|
|
66
|
+
name: "GitHub Security Lab — Preventing pwn requests",
|
|
67
|
+
url: "https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/",
|
|
68
|
+
};
|
|
69
|
+
const GH_SECRETS: Authority = {
|
|
70
|
+
name: "GitHub — Using secrets in GitHub Actions",
|
|
71
|
+
url: "https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions",
|
|
72
|
+
};
|
|
73
|
+
const GH_OIDC: Authority = {
|
|
74
|
+
name: "GitHub — Security hardening with OpenID Connect",
|
|
75
|
+
url: "https://docs.github.com/en/actions/concepts/security/openid-connect",
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
function meta(
|
|
79
|
+
id: string,
|
|
80
|
+
tier: Tier,
|
|
81
|
+
fixKind: FixKind,
|
|
82
|
+
title: string,
|
|
83
|
+
remediation: string,
|
|
84
|
+
authority?: Authority[],
|
|
85
|
+
): RuleMeta {
|
|
86
|
+
return { id, tier, fixKind, title, remediation, authority, yamlBased: true };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const M = "merge-worthy" as const;
|
|
90
|
+
const R = "report-only" as const;
|
|
91
|
+
const D = "deterministic" as const;
|
|
92
|
+
const G = "guidance" as const;
|
|
93
|
+
|
|
94
|
+
/** Every audited post-synth check, keyed by id. */
|
|
95
|
+
export const RULE_CATALOG: Record<string, RuleMeta> = {
|
|
96
|
+
// ── GitHub Actions (GHA) ───────────────────────────────────────────
|
|
97
|
+
GHA006: meta("GHA006", R, G, "Duplicate workflow name", "Give each workflow a unique `name:`."),
|
|
98
|
+
GHA009: meta("GHA009", M, G, "Empty matrix dimension", "Remove the empty matrix axis or give it values; an empty axis produces zero jobs."),
|
|
99
|
+
GHA011: meta("GHA011", M, G, "needs references a non-existent job", "Fix the `needs:` target to name a real job."),
|
|
100
|
+
GHA013: meta("GHA013", M, G, "Missing job permissions on a sensitive trigger", "Add an explicit least-privilege `permissions:` block to jobs under `pull_request_target`/`workflow_dispatch`.", [SCORECARD_TOKEN, GH_TOKEN]),
|
|
101
|
+
GHA017: meta("GHA017", M, D, "No explicit permissions block", "Add a top-level `permissions: { contents: read }` and widen only where a job needs it.", [SCORECARD_TOKEN, GH_TOKEN]),
|
|
102
|
+
GHA018: meta("GHA018", M, G, "pull_request_target checks out untrusted code", "Don't check out / run PR head code under `pull_request_target`; split into a privileged + unprivileged workflow.", [GH_PWN]),
|
|
103
|
+
GHA019: meta("GHA019", M, G, "Circular needs chain", "Break the cycle in the job dependency graph."),
|
|
104
|
+
GHA021: meta("GHA021", M, D, "actions/checkout not pinned to a SHA", "Pin `actions/checkout` to a full 40-char commit SHA.", [SCORECARD_PINNED, GH_THIRD_PARTY]),
|
|
105
|
+
GHA022: meta("GHA022", R, G, "Job without timeout-minutes", "Add `timeout-minutes:` to bound runaway jobs."),
|
|
106
|
+
GHA023: meta("GHA023", R, G, "Deprecated ::set-output", "Replace `::set-output` with `$GITHUB_OUTPUT`."),
|
|
107
|
+
GHA024: meta("GHA024", R, G, "Missing concurrency block", "Add a `concurrency:` group to deploy workflows."),
|
|
108
|
+
GHA025: meta("GHA025", M, G, "Unrestricted pull_request_target", "Gate `pull_request_target` jobs and avoid running untrusted code with elevated scope.", [GH_PWN]),
|
|
109
|
+
GHA026: meta("GHA026", R, G, "Secret used without environment protection", "Move secret-consuming jobs behind a protected `environment:`."),
|
|
110
|
+
GHA027: meta("GHA027", R, G, "Cleanup step without if: always()", "Add `if: always()` to cleanup steps."),
|
|
111
|
+
GHA028: meta("GHA028", M, G, "Workflow with no triggers", "Add an `on:` trigger; the workflow never runs without one."),
|
|
112
|
+
GHA029: meta("GHA029", M, D, "Action not pinned to a commit SHA", "Pin the action to a full commit SHA instead of a tag/branch.", [SCORECARD_PINNED, GH_THIRD_PARTY]),
|
|
113
|
+
GHA030: meta("GHA030", M, D, "Container image not pinned to a digest", "Pin the image to an immutable `@sha256:` digest.", [SCORECARD_PINNED]),
|
|
114
|
+
GHA031: meta("GHA031", M, G, "Possible action impersonation", "Verify the action owner/slug; it resembles a well-known action.", [SCORECARD_PINNED]),
|
|
115
|
+
GHA032: meta("GHA032", M, G, "Archived/abandoned or vulnerable action", "Replace the archived action or one with a disclosed security issue."),
|
|
116
|
+
GHA033: meta("GHA033", M, D, "Blanket write-all permissions", "Replace `write-all` with the specific scopes the jobs need (default `contents: read`).", [SCORECARD_TOKEN, GH_TOKEN]),
|
|
117
|
+
GHA034: meta("GHA034", M, G, "Write permissions granted workflow-wide", "Move write scopes to the single job that needs them; keep the workflow least-privilege.", [SCORECARD_TOKEN, GH_TOKEN]),
|
|
118
|
+
GHA035: meta("GHA035", M, G, "Elevated token on an untrusted-code trigger", "Drop the elevated `permissions:` on triggers that can run untrusted code.", [GH_PWN, SCORECARD_TOKEN]),
|
|
119
|
+
GHA036: meta("GHA036", M, G, "Untrusted input interpolated into run:", "Pass untrusted `${{ }}` values via an `env:` var and reference `\"$VAR\"`, never inline in the script.", [GH_INJECTION]),
|
|
120
|
+
GHA037: meta("GHA037", M, G, "Untrusted input written to GITHUB_ENV/GITHUB_PATH", "Don't write untrusted input to `$GITHUB_ENV`/`$GITHUB_PATH`; sanitize or avoid.", [GH_INJECTION]),
|
|
121
|
+
GHA038: meta("GHA038", M, G, "workflow_run checks out untrusted code in a privileged context", "Avoid checking out untrusted code under `workflow_run`; treat it as privileged.", [GH_PWN]),
|
|
122
|
+
GHA039: meta("GHA039", M, G, "Auth gate on a spoofable author field", "Gate on a non-spoofable identity, not a commit-author field.", [GH_PWN]),
|
|
123
|
+
GHA040: meta("GHA040", M, G, "Self-hosted runner on an untrusted-code trigger", "Don't run untrusted-code triggers on self-hosted runners.", [GH_PWN]),
|
|
124
|
+
GHA041: meta("GHA041", M, G, "Blanket secrets: inherit", "Pass only the specific secrets the reusable workflow needs.", [GH_SECRETS]),
|
|
125
|
+
GHA042: meta("GHA042", M, G, "Entire secrets context passed", "Pass named secrets instead of the whole `secrets` context.", [GH_SECRETS]),
|
|
126
|
+
GHA043: meta("GHA043", M, G, "Secret consumed without an environment gate", "Put secret-consuming jobs behind a protected environment.", [GH_SECRETS]),
|
|
127
|
+
GHA044: meta("GHA044", M, G, "Hardcoded registry/container credential", "Remove the hardcoded credential, move it to a secret, and rotate it (responsible disclosure first).", [GH_SECRETS]),
|
|
128
|
+
GHA045: meta("GHA045", M, G, "Secret interpolated into run:", "Reference secrets via `env:`, not inline in the shell command.", [GH_INJECTION, GH_SECRETS]),
|
|
129
|
+
GHA046: meta("GHA046", M, G, "Constant/unsound guard condition", "Fix the always-true/false `if:` — it may neutralize a security gate."),
|
|
130
|
+
GHA047: meta("GHA047", M, G, "Ineffective contains() guard (reversed args)", "Swap the `contains()` arguments so the guard actually filters."),
|
|
131
|
+
GHA048: meta("GHA048", M, G, "Obfuscated guard condition", "Simplify the indirect `if:` so its effect is reviewable."),
|
|
132
|
+
GHA049: meta("GHA049", M, G, "Persisted checkout credentials reachable by an artifact", "Use `persist-credentials: false` or exclude `.git` from uploaded artifacts.", [GH_SECRETS]),
|
|
133
|
+
GHA050: meta("GHA050", M, G, "Cache populated in a privileged context", "Don't populate caches from untrusted code paths (poisoning risk).", [GH_PWN]),
|
|
134
|
+
GHA051: meta("GHA051", R, G, "Long-lived token instead of OIDC", "Migrate publish/release to OIDC short-lived credentials."),
|
|
135
|
+
GHA052: meta("GHA052", M, G, "Software piped to a shell without verification", "Verify a checksum/signature before executing fetched scripts.", [SCORECARD_PINNED]),
|
|
136
|
+
GHA053: meta("GHA053", M, G, "Re-enables unsafe set-env/add-path", "Remove `ACTIONS_ALLOW_UNSECURE_COMMANDS`; use `$GITHUB_ENV`/`$GITHUB_PATH`.", [GH_INJECTION]),
|
|
137
|
+
GHA054: meta("GHA054", M, G, "Feature with a known security footgun", "Replace the flagged feature with the safe alternative."),
|
|
138
|
+
GHA055: meta("GHA055", R, G, "Runtime install of a tool already on the runner", "Drop the redundant install to save time."),
|
|
139
|
+
GHA056: meta("GHA056", R, G, "Workflow without a name", "Add a `name:` to the workflow."),
|
|
140
|
+
GHA057: meta("GHA057", M, G, "Dependency update can execute untrusted code", "Disable the option that lets dependency updates run external code.", [GH_PWN]),
|
|
141
|
+
GHA058: meta("GHA058", R, G, "Dependency update has no cooldown window", "Add a cooldown so new releases aren't merged instantly."),
|
|
142
|
+
|
|
143
|
+
// ── GitLab CI (WGL) ────────────────────────────────────────────────
|
|
144
|
+
WGL010: meta("WGL010", M, G, "Job references an undefined stage", "Add the stage to `stages:` or fix the job's `stage:`."),
|
|
145
|
+
WGL011: meta("WGL011", M, G, "Job rules always evaluate to never", "Fix the `rules:` so the job can run; it is currently unreachable."),
|
|
146
|
+
WGL012: meta("WGL012", R, G, "Deprecated property", "Replace the deprecated GitLab CI property."),
|
|
147
|
+
WGL013: meta("WGL013", M, G, "Invalid needs target", "Fix the dangling/self `needs:` reference."),
|
|
148
|
+
WGL014: meta("WGL014", M, G, "Invalid extends target", "Point `extends:` at a template that exists in the pipeline."),
|
|
149
|
+
WGL015: meta("WGL015", M, G, "Circular needs chain", "Break the cycle in the job dependency graph."),
|
|
150
|
+
WGL016: meta("WGL016", M, G, "Hardcoded secret in variables", "Move the secret out of `variables:` into a masked/protected CI variable and rotate it.", [GH_SECRETS]),
|
|
151
|
+
WGL017: meta("WGL017", M, G, "Insecure (non-HTTPS) registry", "Use an HTTPS registry endpoint."),
|
|
152
|
+
WGL018: meta("WGL018", R, G, "Missing job timeout", "Add a `timeout:` to bound long-running jobs."),
|
|
153
|
+
WGL019: meta("WGL019", R, G, "Missing retry on deploy job", "Add a `retry:` strategy to deploy jobs."),
|
|
154
|
+
WGL020: meta("WGL020", M, G, "Duplicate job names", "Rename so each job resolves to a unique name."),
|
|
155
|
+
WGL021: meta("WGL021", R, G, "Unused global variable", "Remove the unused global `variables:` entry."),
|
|
156
|
+
WGL022: meta("WGL022", R, G, "Missing artifacts expiry", "Add `expire_in:` to artifacts to avoid disk bloat."),
|
|
157
|
+
WGL023: meta("WGL023", R, G, "Overly broad rules (when: always)", "Add real conditions to the job's `rules:`."),
|
|
158
|
+
WGL024: meta("WGL024", R, G, "Manual job without allow_failure", "Add `allow_failure: true` so a manual job doesn't block the pipeline."),
|
|
159
|
+
WGL025: meta("WGL025", R, G, "Cache without a key", "Add a `cache.key` to avoid cross-job cache collisions."),
|
|
160
|
+
WGL026: meta("WGL026", M, G, "Privileged DinD service without TLS", "Set `DOCKER_TLS_CERTDIR` for privileged Docker-in-Docker services."),
|
|
161
|
+
WGL027: meta("WGL027", M, G, "Empty script", "Give the job a non-empty `script:`; it currently does nothing."),
|
|
162
|
+
WGL028: meta("WGL028", R, G, "Redundant needs", "Drop `needs:` already implied by stage ordering."),
|
|
163
|
+
WGL029: meta("WGL029", M, G, "include/component resolved by a moving ref", "Pin `include:project`/component to a tag or commit SHA, not a branch.", [SCORECARD_PINNED]),
|
|
164
|
+
WGL030: meta("WGL030", M, G, "Insecure or mutable include:remote", "Use HTTPS and pin the remote include to an immutable ref.", [SCORECARD_PINNED]),
|
|
165
|
+
WGL031: meta("WGL031", M, D, "Container image not pinned to a digest", "Pin the image to an immutable `@sha256:` digest.", [SCORECARD_PINNED]),
|
|
166
|
+
WGL032: meta("WGL032", M, G, "Possible include/component impersonation", "Verify the include source; it resembles a well-known project.", [SCORECARD_PINNED]),
|
|
167
|
+
WGL033: meta("WGL033", M, G, "OIDC id_token without a scoped audience", "Set a specific `aud:` on the OIDC id_token.", [GH_OIDC]),
|
|
168
|
+
WGL034: meta("WGL034", M, G, "OIDC id_token mintable from a merge-request pipeline", "Restrict OIDC token minting to protected pipelines.", [GH_OIDC, GH_PWN]),
|
|
169
|
+
WGL035: meta("WGL035", M, G, "Untrusted CI variable interpolated into a script", "Pass untrusted variables via the environment and quote them; don't inline.", [GH_INJECTION]),
|
|
170
|
+
WGL036: meta("WGL036", M, G, "Privileged service reachable from merge-request pipelines", "Block privileged/DinD services on merge-request pipelines.", [GH_PWN]),
|
|
171
|
+
WGL037: meta("WGL037", M, G, "Security gate on an untrusted ref regex", "Don't gate security decisions on a regex over an untrusted ref variable.", [GH_PWN]),
|
|
172
|
+
WGL038: meta("WGL038", M, G, "Secret reachable from a merge-request pipeline", "Scope secret-like variables to protected branches/pipelines.", [GH_SECRETS, GH_PWN]),
|
|
173
|
+
WGL039: meta("WGL039", M, G, "Secret printed to job logs", "Stop echoing the secret-like variable; mask it.", [GH_SECRETS]),
|
|
174
|
+
WGL040: meta("WGL040", M, G, "Hardcoded credential in a registry login", "Move the credential to a masked CI variable and rotate it.", [GH_SECRETS]),
|
|
175
|
+
WGL041: meta("WGL041", M, G, "Tautological rules:if condition", "Fix the always-true `rules:if`; it may neutralize a gate."),
|
|
176
|
+
WGL042: meta("WGL042", R, G, "Unreachable rules after an unconditional match", "Remove the dead `rules:` entries after the catch-all."),
|
|
177
|
+
WGL043: meta("WGL043", M, G, "Match-anything regex gate in rules:if", "Tighten the regex; a match-anything gate is no gate.", [GH_PWN]),
|
|
178
|
+
WGL044: meta("WGL044", M, G, "Public artifacts expose build output", "Mark sensitive artifacts non-public (`public: false`)."),
|
|
179
|
+
WGL045: meta("WGL045", M, G, "Artifact path may capture a credential file", "Narrow the artifact path so it can't capture credential files.", [GH_SECRETS]),
|
|
180
|
+
WGL046: meta("WGL046", M, G, "Cache populated in a merge-request pipeline", "Don't populate caches from merge-request pipelines (poisoning risk).", [GH_PWN]),
|
|
181
|
+
WGL047: meta("WGL047", M, G, "Software piped to a shell without verification", "Verify a checksum/signature before executing fetched scripts.", [SCORECARD_PINNED]),
|
|
182
|
+
WGL048: meta("WGL048", R, G, "Pipeline without workflow:name", "Add a `workflow:name` for clearer pipeline naming."),
|
|
183
|
+
|
|
184
|
+
// ── Forgejo (WFJ) ──────────────────────────────────────────────────
|
|
185
|
+
WFJ010: meta("WFJ010", M, G, "Unresolved action reference on Forgejo", "Use an action reference Forgejo can resolve (full URL or a mirrored action)."),
|
|
186
|
+
WFJ011: meta("WFJ011", M, G, "GitHub-hosted runner label with no Forgejo equivalent", "Use a runner label your Forgejo instance provides."),
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/** Look up catalog metadata for a check id, if known. */
|
|
190
|
+
export function ruleMeta(id: string): RuleMeta | undefined {
|
|
191
|
+
return RULE_CATALOG[id];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Docs path for the audit rules reference (one anchor per rule id). */
|
|
195
|
+
export const RULES_DOC_PATH = "/chant/lint-rules/audit-rules/";
|
|
196
|
+
|
|
197
|
+
/** Absolute URL to a rule's entry in the audit rules reference. */
|
|
198
|
+
export function ruleDocUrl(id: string): string {
|
|
199
|
+
return `https://intentius.io${RULES_DOC_PATH}#${id.toLowerCase()}`;
|
|
200
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { auditFiles, type AuditInput } from "./core";
|
|
3
|
+
import type { PostSynthCheck } from "../lint/post-synth";
|
|
4
|
+
|
|
5
|
+
const DIRTY_GH = `name: CI
|
|
6
|
+
on:
|
|
7
|
+
push:
|
|
8
|
+
permissions: write-all
|
|
9
|
+
jobs:
|
|
10
|
+
build:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
- uses: acme/deploy-action@v1
|
|
15
|
+
- run: npm ci
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
const CLEAN_GH = `name: CI
|
|
19
|
+
on:
|
|
20
|
+
push:
|
|
21
|
+
permissions:
|
|
22
|
+
contents: read
|
|
23
|
+
jobs:
|
|
24
|
+
build:
|
|
25
|
+
runs-on: ubuntu-latest
|
|
26
|
+
timeout-minutes: 10
|
|
27
|
+
steps:
|
|
28
|
+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
|
29
|
+
- run: npm ci
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
const DIRTY_GL = `variables:
|
|
33
|
+
DB_PASSWORD: "s3cr3t-hunter2-password-value"
|
|
34
|
+
build:
|
|
35
|
+
stage: build
|
|
36
|
+
script:
|
|
37
|
+
- echo hi
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
const DIRTY_FJ = `name: CI
|
|
41
|
+
on:
|
|
42
|
+
push:
|
|
43
|
+
permissions: write-all
|
|
44
|
+
jobs:
|
|
45
|
+
build:
|
|
46
|
+
runs-on: ubuntu-latest
|
|
47
|
+
steps:
|
|
48
|
+
- uses: actions/checkout@v4
|
|
49
|
+
- run: echo hi
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
describe("auditFiles", () => {
|
|
53
|
+
test("flags unpinned action and write-all permissions in a github workflow", async () => {
|
|
54
|
+
const inputs: AuditInput[] = [
|
|
55
|
+
{ path: ".github/workflows/ci.yml", content: DIRTY_GH, lexicon: "github" },
|
|
56
|
+
];
|
|
57
|
+
const findings = await auditFiles(inputs);
|
|
58
|
+
const ids = new Set(findings.map((f) => f.checkId));
|
|
59
|
+
|
|
60
|
+
expect(ids).toContain("GHA033"); // write-all permissions
|
|
61
|
+
expect(ids).toContain("GHA021"); // unpinned actions/checkout
|
|
62
|
+
expect(ids).toContain("GHA029"); // unpinned action reference
|
|
63
|
+
// Findings are tagged to the source file.
|
|
64
|
+
expect(findings.every((f) => f.file === ".github/workflows/ci.yml")).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("a hardened github workflow has no permission/pinning security findings", async () => {
|
|
68
|
+
const findings = await auditFiles([
|
|
69
|
+
{ path: ".github/workflows/ci.yml", content: CLEAN_GH, lexicon: "github" },
|
|
70
|
+
]);
|
|
71
|
+
const ids = new Set(findings.map((f) => f.checkId));
|
|
72
|
+
expect(ids.has("GHA033")).toBe(false);
|
|
73
|
+
expect(ids.has("GHA021")).toBe(false);
|
|
74
|
+
expect(ids.has("GHA029")).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("flags a hardcoded secret in a gitlab pipeline", async () => {
|
|
78
|
+
const findings = await auditFiles([
|
|
79
|
+
{ path: ".gitlab-ci.yml", content: DIRTY_GL, lexicon: "gitlab" },
|
|
80
|
+
]);
|
|
81
|
+
const ids = new Set(findings.map((f) => f.checkId));
|
|
82
|
+
expect(ids).toContain("WGL016");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("runs github-tier security checks on forgejo (github-dialect) workflows", async () => {
|
|
86
|
+
const findings = await auditFiles([
|
|
87
|
+
{ path: ".forgejo/workflows/ci.yml", content: DIRTY_FJ, lexicon: "forgejo" },
|
|
88
|
+
]);
|
|
89
|
+
const ids = new Set(findings.map((f) => f.checkId));
|
|
90
|
+
// Forgejo workflows are GitHub-syntax, so the GHA security tier applies.
|
|
91
|
+
expect(ids).toContain("GHA033");
|
|
92
|
+
expect(ids).toContain("GHA021");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("uses an injected checks provider and tags findings per file", async () => {
|
|
96
|
+
const fake: PostSynthCheck = {
|
|
97
|
+
id: "FAKE001",
|
|
98
|
+
description: "fake",
|
|
99
|
+
check: () => [{ checkId: "FAKE001", severity: "warning", message: "hit" }],
|
|
100
|
+
};
|
|
101
|
+
const findings = await auditFiles(
|
|
102
|
+
[
|
|
103
|
+
{ path: "a.yml", content: "x", lexicon: "github" },
|
|
104
|
+
{ path: "b.yml", content: "y", lexicon: "github" },
|
|
105
|
+
],
|
|
106
|
+
{ checksProvider: async () => [fake] },
|
|
107
|
+
);
|
|
108
|
+
expect(findings.map((f) => f.file).sort()).toEqual(["a.yml", "b.yml"]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("a check that throws does not abort the audit", async () => {
|
|
112
|
+
const boom: PostSynthCheck = {
|
|
113
|
+
id: "BOOM",
|
|
114
|
+
description: "throws",
|
|
115
|
+
check: () => {
|
|
116
|
+
throw new Error("bad yaml");
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
const ok: PostSynthCheck = {
|
|
120
|
+
id: "OK001",
|
|
121
|
+
description: "ok",
|
|
122
|
+
check: () => [{ checkId: "OK001", severity: "info", message: "ok" }],
|
|
123
|
+
};
|
|
124
|
+
const findings = await auditFiles([{ path: "a.yml", content: "x", lexicon: "github" }], {
|
|
125
|
+
checksProvider: async () => [boom, ok],
|
|
126
|
+
});
|
|
127
|
+
expect(findings.map((f) => f.checkId)).toEqual(["OK001"]);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit core — run chant's CI security checks against arbitrary repo YAML.
|
|
3
|
+
*
|
|
4
|
+
* The post-synth security checks (`lexicons/<lex>/src/lint/post-synth/*.ts`)
|
|
5
|
+
* read the emitted workflow YAML from `ctx.outputs`, not the chant model
|
|
6
|
+
* (`ctx.entities`). So an auditor can feed *existing* repo YAML straight in as
|
|
7
|
+
* a synthetic output and run the real rules — no import-to-chant-model step.
|
|
8
|
+
*
|
|
9
|
+
* Each file is audited as its own `primary` output so single-document security
|
|
10
|
+
* checks (the merge-worthy tier: permissions, pinning, injection, secrets)
|
|
11
|
+
* fire on every workflow. Cross-file checks (e.g. duplicate workflow names)
|
|
12
|
+
* only see one file at a time here; that is acceptable because the security
|
|
13
|
+
* tier is per-document.
|
|
14
|
+
*
|
|
15
|
+
* Checks that read `ctx.entities` instead of `ctx.outputs` will not fire on
|
|
16
|
+
* audited YAML — the security tier is YAML-based, so this is by design.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { Severity } from "../lint/rule";
|
|
20
|
+
import type { PostSynthCheck, PostSynthContext } from "../lint/post-synth";
|
|
21
|
+
import type { SerializerResult } from "../serializer";
|
|
22
|
+
import { loadPlugins } from "../cli/plugins";
|
|
23
|
+
|
|
24
|
+
/** Lexicons whose post-synth checks the auditor knows how to run. */
|
|
25
|
+
export type AuditLexicon = "github" | "gitlab" | "forgejo";
|
|
26
|
+
|
|
27
|
+
/** A single CI file to audit. */
|
|
28
|
+
export interface AuditInput {
|
|
29
|
+
/** Path used to tag findings (e.g. ".github/workflows/ci.yml"). */
|
|
30
|
+
path: string;
|
|
31
|
+
/** Raw YAML content of the file. */
|
|
32
|
+
content: string;
|
|
33
|
+
/** Which lexicon's checks to run against it. */
|
|
34
|
+
lexicon: AuditLexicon;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A finding produced by a post-synth check against an audited file. */
|
|
38
|
+
export interface AuditFinding {
|
|
39
|
+
checkId: string;
|
|
40
|
+
severity: Severity;
|
|
41
|
+
message: string;
|
|
42
|
+
/** The audited file this finding came from. */
|
|
43
|
+
file: string;
|
|
44
|
+
/** The lexicon that produced the finding. */
|
|
45
|
+
lexicon: string;
|
|
46
|
+
/** Optional entity (e.g. job name) the check attached. */
|
|
47
|
+
entity?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the post-synth checks for a lexicon. Injectable so the core can be
|
|
52
|
+
* unit-tested without loading real lexicon packages.
|
|
53
|
+
*/
|
|
54
|
+
export type ChecksProvider = (lexicon: AuditLexicon) => Promise<PostSynthCheck[]>;
|
|
55
|
+
|
|
56
|
+
const checksCache = new Map<AuditLexicon, PostSynthCheck[]>();
|
|
57
|
+
|
|
58
|
+
function dedupeById(checks: PostSynthCheck[]): PostSynthCheck[] {
|
|
59
|
+
const byId = new Map<string, PostSynthCheck>();
|
|
60
|
+
for (const check of checks) {
|
|
61
|
+
if (!byId.has(check.id)) byId.set(check.id, check);
|
|
62
|
+
}
|
|
63
|
+
return [...byId.values()];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Default provider: load the lexicon plugin(s) and return their post-synth
|
|
68
|
+
* checks. Forgejo workflows are GitHub-dialect YAML, so the GitHub security
|
|
69
|
+
* tier is run against them in addition to Forgejo's own checks.
|
|
70
|
+
*/
|
|
71
|
+
/** Thrown when a lexicon package the audit needs isn't installed. */
|
|
72
|
+
export class MissingLexiconError extends Error {}
|
|
73
|
+
|
|
74
|
+
async function load(names: string[]): Promise<Awaited<ReturnType<typeof loadPlugins>>> {
|
|
75
|
+
try {
|
|
76
|
+
return await loadPlugins(names);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
const pkgs = names.map((n) => `@intentius/chant-lexicon-${n}`).join(" ");
|
|
79
|
+
throw new MissingLexiconError(
|
|
80
|
+
`Missing lexicon package needed to audit ${names.join("/")} workflows. Install it with: npm i ${pkgs}\n(${err instanceof Error ? err.message : String(err)})`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function defaultChecksProvider(lexicon: AuditLexicon): Promise<PostSynthCheck[]> {
|
|
86
|
+
const cached = checksCache.get(lexicon);
|
|
87
|
+
if (cached) return cached;
|
|
88
|
+
|
|
89
|
+
let checks: PostSynthCheck[];
|
|
90
|
+
if (lexicon === "forgejo") {
|
|
91
|
+
const [forgejo, github] = await load(["forgejo", "github"]);
|
|
92
|
+
checks = dedupeById([
|
|
93
|
+
...(forgejo?.postSynthChecks?.() ?? []),
|
|
94
|
+
...(github?.postSynthChecks?.() ?? []),
|
|
95
|
+
]);
|
|
96
|
+
} else {
|
|
97
|
+
const [plugin] = await load([lexicon]);
|
|
98
|
+
checks = plugin?.postSynthChecks?.() ?? [];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
checksCache.set(lexicon, checks);
|
|
102
|
+
return checks;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Audit a set of CI files and return all findings. Pure with respect to the
|
|
107
|
+
* filesystem and network — callers supply file contents.
|
|
108
|
+
*/
|
|
109
|
+
export async function auditFiles(
|
|
110
|
+
inputs: AuditInput[],
|
|
111
|
+
opts: { checksProvider?: ChecksProvider } = {},
|
|
112
|
+
): Promise<AuditFinding[]> {
|
|
113
|
+
const provider = opts.checksProvider ?? defaultChecksProvider;
|
|
114
|
+
const findings: AuditFinding[] = [];
|
|
115
|
+
|
|
116
|
+
// Group by lexicon so each plugin's checks are resolved once.
|
|
117
|
+
const byLexicon = new Map<AuditLexicon, AuditInput[]>();
|
|
118
|
+
for (const input of inputs) {
|
|
119
|
+
const list = byLexicon.get(input.lexicon) ?? [];
|
|
120
|
+
list.push(input);
|
|
121
|
+
byLexicon.set(input.lexicon, list);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const [lexicon, files] of byLexicon) {
|
|
125
|
+
const checks = await provider(lexicon);
|
|
126
|
+
if (checks.length === 0) continue;
|
|
127
|
+
|
|
128
|
+
for (const file of files) {
|
|
129
|
+
const output: SerializerResult = {
|
|
130
|
+
primary: file.content,
|
|
131
|
+
files: { [file.path]: file.content },
|
|
132
|
+
};
|
|
133
|
+
const buildResult: PostSynthContext["buildResult"] = {
|
|
134
|
+
outputs: new Map<string, string | SerializerResult>([[lexicon, output]]),
|
|
135
|
+
entities: new Map(),
|
|
136
|
+
warnings: [],
|
|
137
|
+
errors: [],
|
|
138
|
+
sourceFileCount: 1,
|
|
139
|
+
};
|
|
140
|
+
const ctx: PostSynthContext = {
|
|
141
|
+
outputs: buildResult.outputs,
|
|
142
|
+
entities: buildResult.entities,
|
|
143
|
+
buildResult,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
for (const check of checks) {
|
|
147
|
+
let diags;
|
|
148
|
+
try {
|
|
149
|
+
diags = check.check(ctx);
|
|
150
|
+
} catch {
|
|
151
|
+
// A check that throws on unusual external YAML must not abort the
|
|
152
|
+
// whole audit. Skip it and keep going.
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
for (const d of diags) {
|
|
156
|
+
findings.push({
|
|
157
|
+
checkId: d.checkId,
|
|
158
|
+
severity: d.severity,
|
|
159
|
+
message: d.message,
|
|
160
|
+
file: file.path,
|
|
161
|
+
lexicon: d.lexicon ?? lexicon,
|
|
162
|
+
entity: d.entity,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return findings;
|
|
170
|
+
}
|