@intentius/chant 0.4.0 → 0.6.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 +251 -0
- package/src/audit/core.test.ts +175 -0
- package/src/audit/core.ts +0 -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 +48 -0
- package/src/cli/commands/__fixtures__/audit-docker/app/Dockerfile +4 -0
- package/src/cli/commands/__fixtures__/audit-docker/docker-compose.yml +5 -0
- package/src/cli/commands/__fixtures__/audit-k8s/manifests/deploy.yaml +18 -0
- package/src/cli/commands/__fixtures__/audit-repo/.github/workflows/ci.yml +11 -0
- package/src/cli/commands/audit.test.ts +201 -0
- package/src/cli/commands/audit.ts +442 -0
- package/src/cli/handlers/misc.ts +71 -0
- package/src/cli/main.ts +12 -1
- package/src/cli/registry.ts +6 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown report generator (the "minimized" mode) — renders the shared
|
|
3
|
+
* report model as Markdown that pastes cleanly into a PR or issue.
|
|
4
|
+
*
|
|
5
|
+
* - **Quick wins (deterministic)** lead, per file, with one combined unified
|
|
6
|
+
* diff so the report doubles as a ready-to-apply patch.
|
|
7
|
+
* - **Needs review (guidance)** and **Report-only (hygiene)** collapse into
|
|
8
|
+
* `<details>` so a pasted report opens on the actionable fix.
|
|
9
|
+
*
|
|
10
|
+
* For a presentable/branded report, see `renderHtml` (report-html.ts).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { AuditFinding } from "./core";
|
|
14
|
+
import { ruleDocUrl } from "./catalog";
|
|
15
|
+
import type { ProveOptions } from "./proof";
|
|
16
|
+
import { buildReportModel, type EnrichedFinding, type GuidanceCluster, type QuickWinFile } from "./report-model";
|
|
17
|
+
|
|
18
|
+
/** A rule id as a Markdown link to its reference entry. */
|
|
19
|
+
function ruleLink(id: string): string {
|
|
20
|
+
return `[${id}](${ruleDocUrl(id)})`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface RenderOptions {
|
|
24
|
+
/** Repo URL or path shown in the header. */
|
|
25
|
+
target?: string;
|
|
26
|
+
/** Audited file contents (path → YAML), enabling inline fix diffs. */
|
|
27
|
+
files?: Array<{ path: string; content: string }>;
|
|
28
|
+
/** Resolve an action ref to a SHA so pin fixes can be diffed. */
|
|
29
|
+
resolveSha?: ProveOptions["resolveSha"];
|
|
30
|
+
/** Resolve a container image to a digest so image-pin fixes can be diffed. */
|
|
31
|
+
resolveDigest?: ProveOptions["resolveDigest"];
|
|
32
|
+
/** Coverage caveats shown near the top (e.g. unresolved GitLab includes). */
|
|
33
|
+
notes?: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function escapeCell(s: string): string {
|
|
37
|
+
return s.replace(/\|/g, "\\|").replace(/\n/g, " ").trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function renderQuickWins(files: QuickWinFile[]): string[] {
|
|
41
|
+
const lines: string[] = ["## Quick wins (deterministic)", "", "Safe mechanical fixes — the diff changes only the flagged lines.", ""];
|
|
42
|
+
for (const qw of files) {
|
|
43
|
+
lines.push(`### \`${qw.file}\``);
|
|
44
|
+
if (qw.diff) {
|
|
45
|
+
const labels = qw.addressed.map((m) => `${ruleLink(m.id)} (${m.title})`).join(", ");
|
|
46
|
+
lines.push("", `Addresses ${labels}:`, "", "```diff", qw.diff, "```");
|
|
47
|
+
}
|
|
48
|
+
if (qw.needsInput.length > 0) {
|
|
49
|
+
lines.push("", "Needs a value before it can be auto-patched:");
|
|
50
|
+
for (const f of qw.needsInput) {
|
|
51
|
+
const where = f.entity ? ` (\`${f.entity}\`)` : "";
|
|
52
|
+
lines.push(`- **${ruleLink(f.checkId)}**${where} — ${f.meta.remediation}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
lines.push("");
|
|
56
|
+
}
|
|
57
|
+
return lines;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function renderNeedsReview(clusters: GuidanceCluster[]): string[] {
|
|
61
|
+
const lines: string[] = ["These need a judgement call — remediation guidance, not an auto-fix.", ""];
|
|
62
|
+
for (const cluster of clusters) {
|
|
63
|
+
lines.push(`### ${cluster.url ? `[${cluster.name}](${cluster.url})` : cluster.name}`, "");
|
|
64
|
+
for (const { meta, findings } of cluster.rules) {
|
|
65
|
+
lines.push(`- **${ruleLink(meta.id)}** — ${meta.title} (${findings[0].severity}). ${meta.remediation}`);
|
|
66
|
+
for (const f of findings) {
|
|
67
|
+
const where = f.entity ? `\`${f.file}\` (\`${f.entity}\`)` : `\`${f.file}\``;
|
|
68
|
+
lines.push(` - ${where} — ${escapeCell(f.message)}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
lines.push("");
|
|
72
|
+
}
|
|
73
|
+
return lines;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function renderReportOnly(findings: EnrichedFinding[]): string[] {
|
|
77
|
+
const lines: string[] = ["", "| Rule | Title | File | Detail |", "| --- | --- | --- | --- |"];
|
|
78
|
+
for (const f of findings) {
|
|
79
|
+
lines.push(`| ${ruleLink(f.checkId)} | ${escapeCell(f.meta.title)} | \`${f.file}\` | ${escapeCell(f.message)} |`);
|
|
80
|
+
}
|
|
81
|
+
lines.push("");
|
|
82
|
+
return lines;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Render an audit report as Markdown. */
|
|
86
|
+
export function renderMarkdown(findings: AuditFinding[], opts: RenderOptions = {}): string {
|
|
87
|
+
const model = buildReportModel(findings, opts);
|
|
88
|
+
const { counts } = model;
|
|
89
|
+
|
|
90
|
+
const lines: string[] = ["# CI security audit"];
|
|
91
|
+
if (opts.target) lines.push("", `Target: ${opts.target}`);
|
|
92
|
+
lines.push("");
|
|
93
|
+
for (const note of opts.notes ?? []) lines.push(`> Note: ${note}`, "");
|
|
94
|
+
|
|
95
|
+
if (counts.total === 0) {
|
|
96
|
+
lines.push("No issues found.", "", "---", "Generated by [chant audit](https://intentius.io/chant/cli/audit/).", "");
|
|
97
|
+
return lines.join("\n");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
lines.push(
|
|
101
|
+
`${counts.total} finding${counts.total === 1 ? "" : "s"} — ` +
|
|
102
|
+
`${counts.quickWin} quick-win, ${counts.needsReview} needs-review, ${counts.reportOnly} report-only ` +
|
|
103
|
+
`(${counts.errors} error, ${counts.warnings} warning, ${counts.infos} info).`,
|
|
104
|
+
"",
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
if (model.quickWins.length > 0) lines.push(...renderQuickWins(model.quickWins));
|
|
108
|
+
if (model.needsReview.length > 0) {
|
|
109
|
+
lines.push("<details>", `<summary>Needs review (guidance) — ${counts.needsReview}</summary>`, "");
|
|
110
|
+
lines.push(...renderNeedsReview(model.needsReview));
|
|
111
|
+
lines.push("</details>", "");
|
|
112
|
+
}
|
|
113
|
+
if (model.reportOnly.length > 0) {
|
|
114
|
+
lines.push("<details>", `<summary>Report-only (hygiene) — ${counts.reportOnly}</summary>`, "");
|
|
115
|
+
lines.push(...renderReportOnly(model.reportOnly));
|
|
116
|
+
lines.push("</details>", "");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
lines.push("---", "Generated by [chant audit](https://intentius.io/chant/cli/audit/).", "");
|
|
120
|
+
return lines.join("\n");
|
|
121
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { readFileSync } from "fs";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
import { renderRulesReference } from "./rules-doc";
|
|
5
|
+
import { RULE_CATALOG, ruleDocUrl } from "./catalog";
|
|
6
|
+
|
|
7
|
+
const PAGE = fileURLToPath(new URL("../../../../docs/src/content/docs/lint-rules/audit-rules.mdx", import.meta.url));
|
|
8
|
+
|
|
9
|
+
describe("audit rules reference", () => {
|
|
10
|
+
test("committed page is in sync with the catalog (regenerate if this fails)", () => {
|
|
11
|
+
const committed = readFileSync(PAGE, "utf-8");
|
|
12
|
+
expect(committed).toBe(renderRulesReference());
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("every rule has an anchor reachable from ruleDocUrl", () => {
|
|
16
|
+
const page = renderRulesReference();
|
|
17
|
+
for (const id of Object.keys(RULE_CATALOG)) {
|
|
18
|
+
// `### GHA033` → Starlight slug `#gha033`, which ruleDocUrl targets.
|
|
19
|
+
expect(page).toContain(`### ${id}`);
|
|
20
|
+
expect(ruleDocUrl(id)).toBe(`https://intentius.io/chant/lint-rules/audit-rules/#${id.toLowerCase()}`);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generator for the audit rules reference docs page. The page is derived from
|
|
3
|
+
* RULE_CATALOG so report rule-id links always have a target, and a sync test
|
|
4
|
+
* (rules-doc.test.ts) keeps the committed page in step with the catalog.
|
|
5
|
+
*
|
|
6
|
+
* Each rule gets an `### <ID>` heading, which Starlight slugifies to `#<id>`
|
|
7
|
+
* (lowercased) — the anchor `ruleDocUrl()` links to.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { RULE_CATALOG, type RuleMeta } from "./catalog";
|
|
11
|
+
|
|
12
|
+
const GROUPS: Array<{ heading: string; prefixes: string[]; blurb: string }> = [
|
|
13
|
+
{ heading: "GitHub Actions (GHA)", prefixes: ["GHA"], blurb: "Also applied to Forgejo workflows, which are GitHub-dialect." },
|
|
14
|
+
{ heading: "GitLab CI (WGL)", prefixes: ["WGL"], blurb: "" },
|
|
15
|
+
{ heading: "Forgejo (WFJ)", prefixes: ["WFJ"], blurb: "" },
|
|
16
|
+
{ heading: "Kubernetes (WK8 / ARGO)", prefixes: ["WK8", "ARGO"], blurb: "Run against Kubernetes manifests." },
|
|
17
|
+
{ heading: "Docker (DKRD)", prefixes: ["DKRD"], blurb: "Run against Dockerfiles and Compose files." },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
function ruleBlock(m: RuleMeta): string {
|
|
21
|
+
const tags = `${m.tier} · ${m.fixKind}`;
|
|
22
|
+
const authority = m.authority?.length
|
|
23
|
+
? `\n\nAuthority: ${m.authority.map((a) => `[${a.name}](${a.url})`).join(" · ")}`
|
|
24
|
+
: "";
|
|
25
|
+
return `### ${m.id}\n\n**${m.title}** — ${tags}\n\n${m.remediation}${authority}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Render the full audit rules reference page (frontmatter + body). */
|
|
29
|
+
export function renderRulesReference(): string {
|
|
30
|
+
const ids = Object.keys(RULE_CATALOG).sort();
|
|
31
|
+
const sections = GROUPS.map(({ heading, prefixes, blurb }) => {
|
|
32
|
+
const blocks = ids.filter((id) => prefixes.some((p) => id.startsWith(p))).map((id) => ruleBlock(RULE_CATALOG[id]));
|
|
33
|
+
if (blocks.length === 0) return "";
|
|
34
|
+
return `## ${heading}\n${blurb ? `\n${blurb}\n` : ""}\n${blocks.join("\n\n")}`;
|
|
35
|
+
}).filter(Boolean);
|
|
36
|
+
|
|
37
|
+
return `---
|
|
38
|
+
title: Audit rules reference
|
|
39
|
+
description: Every rule chant audit can report, with its tier, fix kind, and remediation.
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
This is the reference for every rule [\`chant audit\`](/chant/cli/audit/) can report. Each finding in a report links to its rule here.
|
|
43
|
+
|
|
44
|
+
Each rule is tagged with its **tier** — \`merge-worthy\` (a security or correctness issue worth a PR) or \`report-only\` (hygiene) — and its **fix kind** — \`deterministic\` (a safe mechanical fix the report can apply as a diff) or \`guidance\` (needs a judgement call).
|
|
45
|
+
|
|
46
|
+
${sections.join("\n\n")}
|
|
47
|
+
`;
|
|
48
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
apiVersion: apps/v1
|
|
2
|
+
kind: Deployment
|
|
3
|
+
metadata:
|
|
4
|
+
name: web
|
|
5
|
+
spec:
|
|
6
|
+
selector:
|
|
7
|
+
matchLabels:
|
|
8
|
+
app: web
|
|
9
|
+
template:
|
|
10
|
+
metadata:
|
|
11
|
+
labels:
|
|
12
|
+
app: web
|
|
13
|
+
spec:
|
|
14
|
+
containers:
|
|
15
|
+
- name: web
|
|
16
|
+
image: nginx:latest
|
|
17
|
+
securityContext:
|
|
18
|
+
privileged: true
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { fileURLToPath } from "url";
|
|
3
|
+
import { auditCommand, discoverCiFiles, discoverManifests, discoverDocker, tokenForHost, coverageNotes } from "./audit";
|
|
4
|
+
import { MissingLexiconError, type AuditInput } from "../../audit/core";
|
|
5
|
+
import { readFileSync, existsSync, rmSync } from "fs";
|
|
6
|
+
import { tmpdir } from "os";
|
|
7
|
+
import { join } from "path";
|
|
8
|
+
|
|
9
|
+
const REPO = fileURLToPath(new URL("./__fixtures__/audit-repo", import.meta.url));
|
|
10
|
+
|
|
11
|
+
describe("auditCommand", () => {
|
|
12
|
+
test("selects a host-specific token (no cross-host leakage)", () => {
|
|
13
|
+
const env = { GITHUB_TOKEN: "gh", GITLAB_TOKEN: "gl", CODEBERG_TOKEN: "cb" } as unknown as NodeJS.ProcessEnv;
|
|
14
|
+
expect(tokenForHost("https://github.com/o/r", env)).toBe("gh");
|
|
15
|
+
expect(tokenForHost("https://gitlab.com/o/r", env)).toBe("gl");
|
|
16
|
+
expect(tokenForHost("https://codeberg.org/o/r", env)).toBe("cb");
|
|
17
|
+
// A GitHub token is never offered to other hosts.
|
|
18
|
+
const onlyGh = { GITHUB_TOKEN: "gh" } as unknown as NodeJS.ProcessEnv;
|
|
19
|
+
expect(tokenForHost("https://gitlab.com/o/r", onlyGh)).toBeUndefined();
|
|
20
|
+
expect(tokenForHost("https://codeberg.org/o/r", onlyGh)).toBeUndefined();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("coverageNotes flags unresolved GitLab includes", () => {
|
|
24
|
+
const withInc: AuditInput[] = [{ path: ".gitlab-ci.yml", content: "include:\n - local: a.yml\nbuild:\n script: [echo]\n", lexicon: "gitlab" }];
|
|
25
|
+
expect(coverageNotes(withInc)[0]).toMatch(/include:/);
|
|
26
|
+
const without: AuditInput[] = [{ path: ".gitlab-ci.yml", content: "build:\n script: [echo]\n", lexicon: "gitlab" }];
|
|
27
|
+
expect(coverageNotes(without)).toEqual([]);
|
|
28
|
+
// github files never produce the gitlab include note
|
|
29
|
+
const gh: AuditInput[] = [{ path: ".github/workflows/ci.yml", content: "on: push\n", lexicon: "github" }];
|
|
30
|
+
expect(coverageNotes(gh)).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("discovers and audits Kubernetes manifests", async () => {
|
|
34
|
+
const repo = fileURLToPath(new URL("./__fixtures__/audit-k8s", import.meta.url));
|
|
35
|
+
const files = discoverManifests(repo);
|
|
36
|
+
expect(files.map((f) => f.path)).toContain("manifests/deploy.yaml");
|
|
37
|
+
expect(files.every((f) => f.lexicon === "k8s")).toBe(true);
|
|
38
|
+
|
|
39
|
+
const result = await auditCommand({ path: repo, format: "stylish" });
|
|
40
|
+
expect(result.success).toBe(true);
|
|
41
|
+
const ids = new Set(result.findings.map((f) => f.checkId));
|
|
42
|
+
expect(ids).toContain("WK8202"); // privileged container
|
|
43
|
+
expect(ids).toContain("WK8006"); // :latest image
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("discovers and audits Docker artifacts (nested Dockerfile + compose)", async () => {
|
|
47
|
+
const repo = fileURLToPath(new URL("./__fixtures__/audit-docker", import.meta.url));
|
|
48
|
+
const files = discoverDocker(repo);
|
|
49
|
+
const paths = files.map((f) => f.path).sort();
|
|
50
|
+
expect(paths).toContain("app/Dockerfile");
|
|
51
|
+
expect(paths).toContain("docker-compose.yml");
|
|
52
|
+
|
|
53
|
+
const result = await auditCommand({ path: repo, format: "stylish" });
|
|
54
|
+
expect(result.success).toBe(true);
|
|
55
|
+
const ids = new Set(result.findings.map((f) => f.checkId));
|
|
56
|
+
expect(ids).toContain("DKRD012"); // Dockerfile has no USER (nested — basename-key fix)
|
|
57
|
+
expect(ids).toContain("DKRD010"); // apt-get without --no-install-recommends
|
|
58
|
+
expect(ids).toContain("DKRD003"); // compose exposes SSH port 22
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("discovers CI files under a repo root", () => {
|
|
62
|
+
const files = discoverCiFiles(REPO);
|
|
63
|
+
expect(files.map((f) => f.path)).toContain(".github/workflows/ci.yml");
|
|
64
|
+
expect(files.every((f) => f.lexicon === "github")).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("reports merge-worthy findings on the fixture repo", async () => {
|
|
68
|
+
const result = await auditCommand({ path: REPO, format: "stylish" });
|
|
69
|
+
expect(result.success).toBe(true);
|
|
70
|
+
const ids = new Set(result.findings.map((f) => f.checkId));
|
|
71
|
+
expect(ids).toContain("GHA033");
|
|
72
|
+
expect(ids).toContain("GHA021");
|
|
73
|
+
expect(result.output).toContain("Merge-worthy:");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("--json emits the versioned envelope with snapshot, summary, findings", async () => {
|
|
77
|
+
const result = await auditCommand({ path: REPO, format: "json", toolVersion: "0.4.0" });
|
|
78
|
+
const parsed = JSON.parse(result.output);
|
|
79
|
+
expect(parsed.schemaVersion).toBe("1.0");
|
|
80
|
+
expect(parsed.tool).toEqual({ name: "chant-audit", version: "0.4.0" });
|
|
81
|
+
expect(parsed.snapshot.files).toContain(".github/workflows/ci.yml");
|
|
82
|
+
expect(parsed.snapshot.toolVersion).toBe("0.4.0");
|
|
83
|
+
expect(parsed.summary.total).toBeGreaterThan(0);
|
|
84
|
+
expect(Array.isArray(parsed.findings)).toBe(true);
|
|
85
|
+
// Each finding carries its classification, so consumers can filter.
|
|
86
|
+
const f = parsed.findings.find((x: { checkId: string }) => x.checkId === "GHA033");
|
|
87
|
+
expect(f.tier).toBe("merge-worthy");
|
|
88
|
+
expect(f.fixKind).toBe("deterministic");
|
|
89
|
+
expect(Array.isArray(f.authority)).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("--fail-on merge-worthy exits nonzero when merge-worthy findings exist", async () => {
|
|
93
|
+
const fail = await auditCommand({ path: REPO, failOn: "merge-worthy" });
|
|
94
|
+
expect(fail.exitCode).toBe(1);
|
|
95
|
+
const none = await auditCommand({ path: REPO, failOn: "none" });
|
|
96
|
+
expect(none.exitCode).toBe(0);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("--tier merge-worthy filters out report-only findings", async () => {
|
|
100
|
+
const all = await auditCommand({ path: REPO, tier: "all" });
|
|
101
|
+
const mw = await auditCommand({ path: REPO, tier: "merge-worthy" });
|
|
102
|
+
expect(mw.findings.length).toBeLessThanOrEqual(all.findings.length);
|
|
103
|
+
expect(mw.findings.some((f) => f.checkId === "GHA022")).toBe(false); // report-only
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("writes the report to --output instead of returning it for stdout", async () => {
|
|
107
|
+
const out = join(tmpdir(), `chant-audit-test-${process.pid}.md`);
|
|
108
|
+
if (existsSync(out)) rmSync(out);
|
|
109
|
+
const result = await auditCommand({ path: REPO, format: "markdown", output: out });
|
|
110
|
+
expect(result.success).toBe(true);
|
|
111
|
+
expect(result.wroteTo).toBe(out);
|
|
112
|
+
expect(existsSync(out)).toBe(true);
|
|
113
|
+
expect(readFileSync(out, "utf-8")).toContain("# CI security audit");
|
|
114
|
+
rmSync(out);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("surfaces a friendly error when a lexicon package is missing", async () => {
|
|
118
|
+
const result = await auditCommand({
|
|
119
|
+
path: REPO,
|
|
120
|
+
checksProvider: async () => {
|
|
121
|
+
throw new MissingLexiconError("Missing lexicon package needed to audit github workflows. Install it with: npm i @intentius/chant-lexicon-github");
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
expect(result.success).toBe(false);
|
|
125
|
+
expect(result.exitCode).toBe(1);
|
|
126
|
+
expect(result.error).toMatch(/npm i @intentius\/chant-lexicon-github/);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("a path with no CI files succeeds with a clear message", async () => {
|
|
130
|
+
const tmp = join(tmpdir(), `chant-audit-empty-${process.pid}`);
|
|
131
|
+
const { mkdirSync } = await import("fs");
|
|
132
|
+
mkdirSync(tmp, { recursive: true });
|
|
133
|
+
const result = await auditCommand({ path: tmp });
|
|
134
|
+
expect(result.success).toBe(true);
|
|
135
|
+
expect(result.exitCode).toBe(0);
|
|
136
|
+
expect(result.output).toContain("No CI files found");
|
|
137
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("audits a remote repo URL via injected fetch", async () => {
|
|
141
|
+
const b64 = (s: string) => Buffer.from(s, "utf-8").toString("base64");
|
|
142
|
+
const yaml = "name: CI\non:\n push:\npermissions: write-all\njobs:\n build:\n runs-on: ubuntu-latest\n";
|
|
143
|
+
const impl = (async (url: string | URL | Request) => {
|
|
144
|
+
const u = String(url);
|
|
145
|
+
if (u.includes("/contents/.github/workflows/ci.yml")) {
|
|
146
|
+
return new Response(JSON.stringify({ name: "ci.yml", path: ".github/workflows/ci.yml", type: "file", content: b64(yaml), encoding: "base64" }), { status: 200 });
|
|
147
|
+
}
|
|
148
|
+
if (u.includes("/contents/.github/workflows")) {
|
|
149
|
+
return new Response(JSON.stringify([{ name: "ci.yml", path: ".github/workflows/ci.yml", type: "file", size: 100 }]), { status: 200 });
|
|
150
|
+
}
|
|
151
|
+
return new Response("not found", { status: 404 });
|
|
152
|
+
}) as unknown as typeof fetch;
|
|
153
|
+
|
|
154
|
+
const result = await auditCommand({ path: "https://github.com/acme/widgets", fetchImpl: impl });
|
|
155
|
+
expect(result.success).toBe(true);
|
|
156
|
+
expect(result.scanned).toContain(".github/workflows/ci.yml");
|
|
157
|
+
expect(result.findings.some((f) => f.checkId === "GHA033")).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("remote markdown audit inlines a pin diff using resolved SHAs", async () => {
|
|
161
|
+
const b64 = (s: string) => Buffer.from(s, "utf-8").toString("base64");
|
|
162
|
+
const sha = "11bd71901bbe5b1630ceea73d27597364c9af683";
|
|
163
|
+
const yaml = "name: CI\non:\n push:\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n";
|
|
164
|
+
const impl = (async (url: string | URL | Request) => {
|
|
165
|
+
const u = String(url);
|
|
166
|
+
if (u.includes("/commits/v4")) return new Response(JSON.stringify({ sha }), { status: 200 });
|
|
167
|
+
if (u.includes("/contents/.github/workflows/ci.yml")) {
|
|
168
|
+
return new Response(JSON.stringify({ name: "ci.yml", path: ".github/workflows/ci.yml", type: "file", content: b64(yaml), encoding: "base64" }), { status: 200 });
|
|
169
|
+
}
|
|
170
|
+
if (u.includes("/contents/.github/workflows")) {
|
|
171
|
+
return new Response(JSON.stringify([{ name: "ci.yml", path: ".github/workflows/ci.yml", type: "file", size: 100 }]), { status: 200 });
|
|
172
|
+
}
|
|
173
|
+
return new Response("not found", { status: 404 });
|
|
174
|
+
}) as unknown as typeof fetch;
|
|
175
|
+
|
|
176
|
+
const result = await auditCommand({ path: "https://github.com/acme/widgets", format: "markdown", fetchImpl: impl });
|
|
177
|
+
expect(result.output).toContain(`actions/checkout@${sha}`);
|
|
178
|
+
expect(result.output).toContain("```diff");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("html format renders a self-contained document with a snapshot", async () => {
|
|
182
|
+
const result = await auditCommand({ path: REPO, format: "html", now: "2026-06-16T00:00:00.000Z", toolVersion: "0.4.0" });
|
|
183
|
+
expect(result.success).toBe(true);
|
|
184
|
+
expect(result.output.startsWith("<!doctype html>")).toBe(true);
|
|
185
|
+
expect(result.output).toContain("chant 0.4.0");
|
|
186
|
+
expect(result.output).toContain("local"); // host for a local audit
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("a non-allowlisted URL fails cleanly", async () => {
|
|
190
|
+
const result = await auditCommand({ path: "https://evil.example.com/o/r" });
|
|
191
|
+
expect(result.success).toBe(false);
|
|
192
|
+
expect(result.error).toMatch(/Host not allowed/);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("sarif output is valid JSON with results", async () => {
|
|
196
|
+
const result = await auditCommand({ path: REPO, format: "sarif" });
|
|
197
|
+
const sarif = JSON.parse(result.output);
|
|
198
|
+
expect(sarif.version).toBe("2.1.0");
|
|
199
|
+
expect(sarif.runs[0].results.length).toBeGreaterThan(0);
|
|
200
|
+
});
|
|
201
|
+
});
|