@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.
@@ -0,0 +1,207 @@
1
+ /**
2
+ * HTML report generator (the "expanded" mode) — a self-contained, presentable
3
+ * report rendered from the shared report model. Inline CSS, no external assets;
4
+ * opens in a browser and is shareable as a single file. Also the natural output
5
+ * for the future hosted service.
6
+ *
7
+ * Customization: a default template with `{{placeholder}}` slots, overridable
8
+ * via `template`, plus theme knobs (title, logo, accent, footer).
9
+ */
10
+
11
+ import type { AuditFinding } from "./core";
12
+ import { ruleDocUrl } from "./catalog";
13
+ import { buildReportModel, buildReportJson, type AuditSnapshot, type BuildModelOptions, type EnrichedFinding, type GuidanceCluster, type QuickWinFile, type ReportCounts } from "./report-model";
14
+
15
+ export type { AuditSnapshot } from "./report-model";
16
+
17
+ /** Customizable theme knobs filled into the template. */
18
+ export interface ReportTheme {
19
+ title?: string;
20
+ /** Logo: a URL (rendered as <img>) or inline text/SVG. */
21
+ logo?: string;
22
+ /** CSS color for accents. */
23
+ accent?: string;
24
+ /** Footer HTML (defaults to attribution). */
25
+ footer?: string;
26
+ }
27
+
28
+ export interface RenderHtmlOptions extends BuildModelOptions {
29
+ snapshot?: AuditSnapshot;
30
+ theme?: ReportTheme;
31
+ /** Full template override; `{{title}} {{accent}} {{logo}} {{meta}} {{body}} {{footer}}` slots. */
32
+ template?: string;
33
+ notes?: string[];
34
+ }
35
+
36
+ const DEFAULT_ACCENT = "#7c3aed";
37
+ const DEFAULT_TITLE = "CI Security Audit";
38
+ const DEFAULT_FOOTER = 'Generated by <a href="https://intentius.io/chant/cli/audit/">chant audit</a>.';
39
+
40
+ function esc(s: string): string {
41
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
42
+ }
43
+
44
+ /** A rule id as a link to its reference entry. */
45
+ function ruleLink(id: string): string {
46
+ return `<a class="rule-id" href="${esc(ruleDocUrl(id))}">${esc(id)}</a>`;
47
+ }
48
+
49
+ function renderDiff(diff: string): string {
50
+ const rows = diff
51
+ .split("\n")
52
+ .map((l) => {
53
+ const cls = l.startsWith("@@") ? "hunk" : l.startsWith("+") ? "add" : l.startsWith("-") ? "del" : "ctx";
54
+ return `<span class="${cls}">${esc(l) || " "}</span>`;
55
+ })
56
+ .join("\n");
57
+ return `<pre class="diff">${rows}</pre>`;
58
+ }
59
+
60
+ function renderQuickWins(files: QuickWinFile[]): string {
61
+ const cards = files
62
+ .map((qw) => {
63
+ const chips = qw.addressed.map((m) => `<span class="chip">${ruleLink(m.id)} ${esc(m.title)}</span>`).join(" ");
64
+ const diff = qw.diff ? renderDiff(qw.diff) : "";
65
+ const needs = qw.needsInput.length
66
+ ? `<div class="needs"><strong>Needs a value to auto-patch:</strong><ul>${qw.needsInput
67
+ .map((f) => `<li>${ruleLink(f.checkId)}${f.entity ? ` (<code>${esc(f.entity)}</code>)` : ""} — ${esc(f.meta.remediation)}</li>`)
68
+ .join("")}</ul></div>`
69
+ : "";
70
+ return `<div class="card"><div class="card-head"><code class="file">${esc(qw.file)}</code> ${chips}</div>${diff}${needs}</div>`;
71
+ })
72
+ .join("\n");
73
+ return `<section><h2>Quick wins <span class="muted">deterministic</span></h2><p class="muted">Safe mechanical fixes — the diff changes only the flagged lines.</p>${cards}</section>`;
74
+ }
75
+
76
+ function renderNeedsReview(clusters: GuidanceCluster[], n: number): string {
77
+ const body = clusters
78
+ .map((c) => {
79
+ const head = c.url ? `<a href="${esc(c.url)}">${esc(c.name)}</a>` : esc(c.name);
80
+ const rules = c.rules
81
+ .map(({ meta, findings }) => {
82
+ const locs = findings
83
+ .map((f) => `<li><code>${esc(f.file)}</code>${f.entity ? ` (<code>${esc(f.entity)}</code>)` : ""} — ${esc(f.message)}</li>`)
84
+ .join("");
85
+ return `<div class="rule"><div><span class="sev ${findings[0].severity}"></span><strong>${ruleLink(meta.id)}</strong> — ${esc(meta.title)}. <span class="muted">${esc(meta.remediation)}</span></div><ul>${locs}</ul></div>`;
86
+ })
87
+ .join("");
88
+ return `<div class="cluster"><h3>${head}</h3>${rules}</div>`;
89
+ })
90
+ .join("\n");
91
+ return `<details open><summary>Needs review <span class="muted">guidance — ${n}</span></summary><p class="muted">These need a judgement call — remediation guidance, not an auto-fix.</p>${body}</details>`;
92
+ }
93
+
94
+ function renderReportOnly(findings: EnrichedFinding[], n: number): string {
95
+ const rows = findings
96
+ .map((f) => `<tr><td>${ruleLink(f.checkId)}</td><td>${esc(f.meta.title)}</td><td><code>${esc(f.file)}</code></td><td>${esc(f.message)}</td></tr>`)
97
+ .join("");
98
+ return `<details><summary>Report-only <span class="muted">hygiene — ${n}</span></summary><table><thead><tr><th>Rule</th><th>Title</th><th>File</th><th>Detail</th></tr></thead><tbody>${rows}</tbody></table></details>`;
99
+ }
100
+
101
+ function renderHeader(counts: ReportCounts, snapshot: AuditSnapshot | undefined, notes: string[]): string {
102
+ const sev = `<span class="sev error"></span>${counts.errors} error <span class="sev warning"></span>${counts.warnings} warning <span class="sev info"></span>${counts.infos} info`;
103
+ const tiers = `<span class="chip">${counts.quickWin} quick-win</span> <span class="chip">${counts.needsReview} needs-review</span> <span class="chip">${counts.reportOnly} hygiene</span>`;
104
+ const meta: string[] = [];
105
+ if (snapshot) {
106
+ if (snapshot.host) meta.push(esc(snapshot.host));
107
+ if (snapshot.repo) meta.push(esc(snapshot.repo));
108
+ else meta.push(esc(snapshot.target));
109
+ if (snapshot.ref) meta.push(`ref <code>${esc(snapshot.ref)}</code>`);
110
+ if (snapshot.commit) meta.push(`commit <code>${esc(snapshot.commit.slice(0, 10))}</code>`);
111
+ meta.push(esc(snapshot.generatedAt.slice(0, 10)));
112
+ meta.push(`${snapshot.files.length} file${snapshot.files.length === 1 ? "" : "s"}`);
113
+ meta.push(`chant ${esc(snapshot.toolVersion)}`);
114
+ }
115
+ const noteHtml = notes.map((n) => `<div class="note">${esc(n)}</div>`).join("");
116
+ return `<div class="summary"><div class="meta">${meta.join(" · ")}</div><div class="counts">${sev}</div><div class="tiers">${tiers}</div>${noteHtml}</div>`;
117
+ }
118
+
119
+ const STYLES = `
120
+ :root { --accent: {{accent}}; }
121
+ * { box-sizing: border-box; }
122
+ body { font: 15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; color: #1f2328; margin: 0; background: #f6f8fa; }
123
+ .wrap { max-width: 920px; margin: 0 auto; padding: 32px 20px 64px; }
124
+ header { display: flex; align-items: center; gap: 12px; border-bottom: 3px solid var(--accent); padding-bottom: 16px; margin-bottom: 20px; }
125
+ header h1 { font-size: 22px; margin: 0; }
126
+ .logo { height: 32px; }
127
+ .summary { background: #fff; border: 1px solid #d0d7de; border-radius: 10px; padding: 16px 18px; margin-bottom: 24px; }
128
+ .summary .meta { color: #57606a; font-size: 13px; margin-bottom: 10px; }
129
+ .counts { font-weight: 600; margin-bottom: 8px; }
130
+ .sev { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin: 0 4px 0 10px; vertical-align: middle; }
131
+ .sev.error { background: #cf222e; } .sev.warning { background: #d4a72c; } .sev.info { background: #8c959f; }
132
+ .sev:first-child { margin-left: 0; }
133
+ .chip { display: inline-block; background: #eef1f4; border: 1px solid #d0d7de; border-radius: 999px; padding: 1px 10px; font-size: 12px; color: #424a53; }
134
+ .tiers .chip { margin-right: 4px; }
135
+ .note { margin-top: 10px; background: #fff8c5; border: 1px solid #d4a72c66; border-radius: 6px; padding: 8px 10px; font-size: 13px; }
136
+ h2 { font-size: 18px; margin: 28px 0 4px; } h3 { font-size: 15px; margin: 16px 0 6px; }
137
+ .muted { color: #57606a; font-weight: 400; font-size: 13px; }
138
+ .card { background: #fff; border: 1px solid #d0d7de; border-radius: 10px; padding: 14px 16px; margin: 12px 0; }
139
+ .card-head { margin-bottom: 8px; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
140
+ .file { background: #eef1f4; padding: 2px 7px; border-radius: 6px; font-size: 13px; }
141
+ .card-head .chip { background: var(--accent); border-color: var(--accent); color: #fff; }
142
+ pre.diff { background: #0d1117; color: #c9d1d9; border-radius: 8px; padding: 12px 14px; overflow-x: auto; font: 12.5px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; margin: 0; }
143
+ pre.diff span { display: block; white-space: pre; }
144
+ pre.diff .add { color: #3fb950; } pre.diff .del { color: #f85149; } pre.diff .hunk { color: #a371f7; } pre.diff .ctx { color: #8b949e; }
145
+ .needs { margin-top: 10px; font-size: 13px; }
146
+ details { background: #fff; border: 1px solid #d0d7de; border-radius: 10px; padding: 8px 16px; margin: 16px 0; }
147
+ summary { cursor: pointer; font-size: 16px; font-weight: 600; }
148
+ .cluster { margin: 10px 0; } .rule { margin: 8px 0; } .rule ul, .needs ul { margin: 4px 0 4px 0; }
149
+ table { border-collapse: collapse; width: 100%; font-size: 13px; margin-top: 8px; }
150
+ th, td { text-align: left; border-bottom: 1px solid #d0d7de; padding: 6px 8px; vertical-align: top; }
151
+ th { color: #57606a; }
152
+ code { font: 12.5px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
153
+ footer { margin-top: 40px; padding-top: 16px; border-top: 1px solid #d0d7de; color: #57606a; font-size: 13px; }
154
+ a { color: var(--accent); }
155
+ .rule-id { color: var(--accent); text-decoration: none; font-weight: 600; }
156
+ .rule-id:hover { text-decoration: underline; }
157
+ .card-head .chip .rule-id { color: #fff; }
158
+ `;
159
+
160
+ const DEFAULT_TEMPLATE = `<!doctype html>
161
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
162
+ <title>{{title}}</title>
163
+ <style>{{styles}}</style>
164
+ </head><body><div class="wrap">
165
+ <header>{{logo}}<h1>{{title}}</h1></header>
166
+ {{body}}
167
+ <footer>{{footer}}</footer>
168
+ </div></body></html>`;
169
+
170
+ /** Render an audit report as a self-contained HTML document. */
171
+ export function renderHtml(findings: AuditFinding[], opts: RenderHtmlOptions = {}): string {
172
+ const model = buildReportModel(findings, opts);
173
+ const theme = opts.theme ?? {};
174
+ const accent = theme.accent ?? DEFAULT_ACCENT;
175
+ const title = theme.title ?? DEFAULT_TITLE;
176
+ const logo = theme.logo ? (/^https?:\/\//.test(theme.logo) ? `<img class="logo" src="${esc(theme.logo)}" alt="">` : theme.logo) : "";
177
+ const footer = theme.footer ?? DEFAULT_FOOTER;
178
+
179
+ // Machine-readable data embedded so the HTML report is also parseable.
180
+ // `<` escaped so the JSON can't break out of the <script> element.
181
+ const dataJson = JSON.stringify(buildReportJson(findings, { snapshot: opts.snapshot }), null, 0).replace(/</g, "\\u003c");
182
+ const dataScript = `<script type="application/json" id="chant-audit-report">${dataJson}</script>`;
183
+
184
+ let body: string;
185
+ if (model.counts.total === 0) {
186
+ body = renderHeader(model.counts, opts.snapshot, opts.notes ?? []) + `<section><p>No issues found.</p></section>` + dataScript;
187
+ } else {
188
+ const parts = [renderHeader(model.counts, opts.snapshot, opts.notes ?? [])];
189
+ if (model.quickWins.length > 0) parts.push(renderQuickWins(model.quickWins));
190
+ if (model.needsReview.length > 0) parts.push(renderNeedsReview(model.needsReview, model.counts.needsReview));
191
+ if (model.reportOnly.length > 0) parts.push(renderReportOnly(model.reportOnly, model.counts.reportOnly));
192
+ parts.push(dataScript);
193
+ body = parts.join("\n");
194
+ }
195
+
196
+ const slots: Record<string, string> = {
197
+ title: esc(title),
198
+ accent,
199
+ styles: STYLES.replace("{{accent}}", accent),
200
+ logo,
201
+ body,
202
+ footer,
203
+ };
204
+ const template = opts.template ?? DEFAULT_TEMPLATE;
205
+ // Single pass over the template only, so injected body content isn't re-scanned.
206
+ return template.replace(/\{\{(\w+)\}\}/g, (_, k: string) => slots[k] ?? "");
207
+ }
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Shared report model — turns raw findings into the structured shape both the
3
+ * Markdown and HTML renderers consume, so they can never drift. Computes the
4
+ * tier split, de-noising, per-file quick-win patches (combined unified diff via
5
+ * proof.ts), guidance clusters, and severity counts.
6
+ */
7
+
8
+ import type { AuditFinding } from "./core";
9
+ import { RULE_CATALOG, ruleDocUrl, type Authority, type FixKind, type RuleMeta, type Tier } from "./catalog";
10
+ import { proveFix, unifiedDiff, type ProveOptions } from "./proof";
11
+ import type { Severity } from "../lint/rule";
12
+
13
+ /**
14
+ * Version of the machine-readable JSON report. Stability contract: additive
15
+ * changes (new fields) keep the same major; renamed/removed fields bump the
16
+ * major. Consumers should check the major and ignore unknown fields. See
17
+ * `docs/cli/audit`.
18
+ */
19
+ export const REPORT_SCHEMA_VERSION = "1.0";
20
+
21
+ export const SEVERITY_WEIGHT: Record<Severity, number> = { error: 0, warning: 1, info: 2 };
22
+
23
+ /** Report-only rules made redundant by a specific merge-worthy rule on the same entity. */
24
+ const SUPERSEDED_BY: Record<string, string[]> = {
25
+ WGL021: ["WGL016"], // "unused variable" is noise when the var is a flagged hardcoded secret
26
+ };
27
+
28
+ export interface EnrichedFinding extends AuditFinding {
29
+ meta: RuleMeta;
30
+ }
31
+
32
+ /** A per-file quick-win: a combined patch plus any findings still needing input. */
33
+ export interface QuickWinFile {
34
+ file: string;
35
+ /** Combined unified diff of the deterministic fixes applied to this file. */
36
+ diff?: string;
37
+ /** Rules the diff addresses. */
38
+ addressed: RuleMeta[];
39
+ /** Deduped findings that are deterministic but blocked on a value (e.g. a SHA). */
40
+ needsInput: EnrichedFinding[];
41
+ }
42
+
43
+ /** Guidance findings grouped by their primary authority. */
44
+ export interface GuidanceCluster {
45
+ name: string;
46
+ url?: string;
47
+ rules: Array<{ meta: RuleMeta; findings: EnrichedFinding[] }>;
48
+ }
49
+
50
+ export interface ReportCounts {
51
+ total: number;
52
+ quickWin: number;
53
+ needsReview: number;
54
+ reportOnly: number;
55
+ errors: number;
56
+ warnings: number;
57
+ infos: number;
58
+ }
59
+
60
+ export interface ReportModel {
61
+ counts: ReportCounts;
62
+ quickWins: QuickWinFile[];
63
+ needsReview: GuidanceCluster[];
64
+ reportOnly: EnrichedFinding[];
65
+ /** All shown findings (after de-noise), flat and sorted — for serialization. */
66
+ findings: EnrichedFinding[];
67
+ }
68
+
69
+ /** Provenance snapshot of what was audited (anchors findings to a commit). */
70
+ export interface AuditSnapshot {
71
+ target: string;
72
+ host?: string;
73
+ repo?: string;
74
+ ref?: string;
75
+ commit?: string;
76
+ files: string[];
77
+ generatedAt: string;
78
+ toolVersion: string;
79
+ }
80
+
81
+ /** A finding flattened for the machine-readable JSON report. */
82
+ export interface SerializedFinding {
83
+ checkId: string;
84
+ severity: Severity;
85
+ message: string;
86
+ file: string;
87
+ entity?: string;
88
+ lexicon: string;
89
+ tier: Tier;
90
+ fixKind: FixKind;
91
+ title: string;
92
+ remediation: string;
93
+ authority: Authority[];
94
+ /** Link to this rule's entry in the audit rules reference. */
95
+ docUrl: string;
96
+ }
97
+
98
+ /** The versioned machine-readable audit report. */
99
+ export interface AuditReportJson {
100
+ schemaVersion: string;
101
+ tool: { name: string; version: string };
102
+ snapshot?: AuditSnapshot;
103
+ summary: ReportCounts;
104
+ findings: SerializedFinding[];
105
+ }
106
+
107
+ export function metaFor(id: string): RuleMeta {
108
+ return (
109
+ RULE_CATALOG[id] ?? {
110
+ id,
111
+ tier: "report-only" as Tier,
112
+ fixKind: "guidance",
113
+ title: id,
114
+ remediation: "",
115
+ yamlBased: true,
116
+ }
117
+ );
118
+ }
119
+
120
+ export function sortFindings(a: EnrichedFinding, b: EnrichedFinding): number {
121
+ const sev = SEVERITY_WEIGHT[a.severity] - SEVERITY_WEIGHT[b.severity];
122
+ if (sev !== 0) return sev;
123
+ if (a.checkId !== b.checkId) return a.checkId < b.checkId ? -1 : 1;
124
+ return a.file < b.file ? -1 : a.file > b.file ? 1 : 0;
125
+ }
126
+
127
+ function byFile(items: EnrichedFinding[]): Map<string, EnrichedFinding[]> {
128
+ const map = new Map<string, EnrichedFinding[]>();
129
+ for (const it of [...items].sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0))) {
130
+ const list = map.get(it.file) ?? [];
131
+ list.push(it);
132
+ map.set(it.file, list);
133
+ }
134
+ return map;
135
+ }
136
+
137
+ function clusterName(m: RuleMeta): string {
138
+ return m.authority?.[0]?.name ?? "General hardening";
139
+ }
140
+
141
+ function buildQuickWins(findings: EnrichedFinding[], contents: Map<string, string>, proveOpts: ProveOptions): QuickWinFile[] {
142
+ const out: QuickWinFile[] = [];
143
+ for (const [file, group] of byFile(findings)) {
144
+ const ids = [...new Set(group.map((f) => f.checkId))].sort();
145
+ const original = contents.get(file);
146
+ const addressed: RuleMeta[] = [];
147
+ const needsInput: EnrichedFinding[] = [];
148
+ let patched = original;
149
+
150
+ if (original !== undefined) {
151
+ for (const id of ids) {
152
+ const res = proveFix(id, patched ?? original, proveOpts);
153
+ if (res.applied && res.patched !== undefined) {
154
+ patched = res.patched;
155
+ addressed.push(metaFor(id));
156
+ } else if (res.reason === "needs-input") {
157
+ needsInput.push(...group.filter((f) => f.checkId === id));
158
+ }
159
+ // "noop" (already resolved by a prior fix in the combined patch) is dropped.
160
+ }
161
+ } else {
162
+ for (const f of group) needsInput.push(f);
163
+ }
164
+
165
+ const diff = original !== undefined && patched !== undefined && patched !== original ? unifiedDiff(original, patched) : undefined;
166
+
167
+ // Dedupe needs-input by check + entity.
168
+ const seen = new Set<string>();
169
+ const deduped = needsInput.filter((f) => {
170
+ const k = `${f.checkId}:${f.entity ?? ""}`;
171
+ if (seen.has(k)) return false;
172
+ seen.add(k);
173
+ return true;
174
+ });
175
+
176
+ out.push({ file, diff, addressed, needsInput: deduped });
177
+ }
178
+ return out;
179
+ }
180
+
181
+ function buildClusters(findings: EnrichedFinding[]): GuidanceCluster[] {
182
+ const clusters = new Map<string, EnrichedFinding[]>();
183
+ for (const f of [...findings].sort(sortFindings)) {
184
+ const key = clusterName(f.meta);
185
+ const list = clusters.get(key) ?? [];
186
+ list.push(f);
187
+ clusters.set(key, list);
188
+ }
189
+ return [...clusters.entries()]
190
+ .sort((a, b) => (a[0] < b[0] ? -1 : 1))
191
+ .map(([name, group]) => {
192
+ const byRule = new Map<string, EnrichedFinding[]>();
193
+ for (const f of group) {
194
+ const list = byRule.get(f.checkId) ?? [];
195
+ list.push(f);
196
+ byRule.set(f.checkId, list);
197
+ }
198
+ return {
199
+ name,
200
+ url: group[0].meta.authority?.[0]?.url,
201
+ rules: [...byRule.values()].map((findings) => ({ meta: findings[0].meta, findings })),
202
+ };
203
+ });
204
+ }
205
+
206
+ export interface BuildModelOptions {
207
+ files?: Array<{ path: string; content: string }>;
208
+ resolveSha?: ProveOptions["resolveSha"];
209
+ resolveDigest?: ProveOptions["resolveDigest"];
210
+ }
211
+
212
+ /** Build the structured report model from raw findings. */
213
+ export function buildReportModel(findings: AuditFinding[], opts: BuildModelOptions = {}): ReportModel {
214
+ const enriched: EnrichedFinding[] = findings.map((f) => ({ ...f, meta: metaFor(f.checkId) }));
215
+ const contents = new Map((opts.files ?? []).map((f) => [f.path, f.content]));
216
+
217
+ const mergeWorthy = enriched.filter((f) => f.meta.tier === "merge-worthy");
218
+ const quickWinFindings = mergeWorthy.filter((f) => f.meta.fixKind === "deterministic");
219
+ const needsReviewFindings = mergeWorthy.filter((f) => f.meta.fixKind === "guidance");
220
+
221
+ // De-noise: drop a report-only finding only when a specific merge-worthy
222
+ // finding supersedes it on the same entity; keep unrelated hygiene.
223
+ const mwOnEntity = new Set(mergeWorthy.filter((f) => f.entity).map((f) => `${f.file}:${f.entity}:${f.checkId}`));
224
+ const reportOnly = enriched.filter((f) => {
225
+ if (f.meta.tier !== "report-only") return false;
226
+ const supers = SUPERSEDED_BY[f.checkId];
227
+ if (supers && f.entity && supers.some((id) => mwOnEntity.has(`${f.file}:${f.entity}:${id}`))) return false;
228
+ return true;
229
+ });
230
+
231
+ const shown = [...quickWinFindings, ...needsReviewFindings, ...reportOnly];
232
+ const counts: ReportCounts = {
233
+ total: shown.length,
234
+ quickWin: quickWinFindings.length,
235
+ needsReview: needsReviewFindings.length,
236
+ reportOnly: reportOnly.length,
237
+ errors: shown.filter((f) => f.severity === "error").length,
238
+ warnings: shown.filter((f) => f.severity === "warning").length,
239
+ infos: shown.filter((f) => f.severity === "info").length,
240
+ };
241
+
242
+ return {
243
+ counts,
244
+ quickWins: buildQuickWins(quickWinFindings, contents, { resolveSha: opts.resolveSha, resolveDigest: opts.resolveDigest }),
245
+ needsReview: buildClusters(needsReviewFindings),
246
+ reportOnly: [...reportOnly].sort(sortFindings),
247
+ findings: [...shown].sort(sortFindings),
248
+ };
249
+ }
250
+
251
+ /** Build the versioned, machine-readable JSON report (stable contract). */
252
+ export function buildReportJson(findings: AuditFinding[], opts: { snapshot?: AuditSnapshot; toolVersion?: string } = {}): AuditReportJson {
253
+ const model = buildReportModel(findings);
254
+ const version = opts.toolVersion ?? opts.snapshot?.toolVersion ?? "0.0.0";
255
+ return {
256
+ schemaVersion: REPORT_SCHEMA_VERSION,
257
+ tool: { name: "chant-audit", version },
258
+ snapshot: opts.snapshot,
259
+ summary: model.counts,
260
+ findings: model.findings.map((f) => ({
261
+ checkId: f.checkId,
262
+ severity: f.severity,
263
+ message: f.message,
264
+ file: f.file,
265
+ entity: f.entity,
266
+ lexicon: f.lexicon,
267
+ tier: f.meta.tier,
268
+ fixKind: f.meta.fixKind,
269
+ title: f.meta.title,
270
+ remediation: f.meta.remediation,
271
+ authority: f.meta.authority ?? [],
272
+ docUrl: ruleDocUrl(f.checkId),
273
+ })),
274
+ };
275
+ }
@@ -0,0 +1,119 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { renderMarkdown } from "./report";
3
+ import type { AuditFinding } from "./core";
4
+
5
+ const CI = `name: CI
6
+ on:
7
+ pull_request_target:
8
+ permissions: write-all
9
+ jobs:
10
+ build:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ `;
15
+
16
+ const FINDINGS: AuditFinding[] = [
17
+ { checkId: "GHA033", severity: "warning", message: "write-all permissions.", file: ".github/workflows/ci.yml", lexicon: "github" },
18
+ { checkId: "GHA021", severity: "warning", message: "unpinned checkout.", file: ".github/workflows/ci.yml", lexicon: "github", entity: "build" },
19
+ { checkId: "GHA035", severity: "error", message: "elevated token on pull_request_target.", file: ".github/workflows/ci.yml", lexicon: "github" },
20
+ { checkId: "GHA018", severity: "warning", message: "pull_request_target checks out PR code.", file: ".github/workflows/ci.yml", lexicon: "github", entity: "build" },
21
+ { checkId: "GHA022", severity: "info", message: "no timeout.", file: ".github/workflows/ci.yml", lexicon: "github" },
22
+ ];
23
+
24
+ describe("renderMarkdown — reworked structure", () => {
25
+ test("summary counts by tier and severity", () => {
26
+ const out = renderMarkdown(FINDINGS, { target: "owner/repo" });
27
+ expect(out).toContain("Target: owner/repo");
28
+ expect(out).toContain("5 findings — 2 quick-win, 2 needs-review, 1 report-only (1 error, 3 warning, 1 info).");
29
+ });
30
+
31
+ test("quick wins show a real combined diff when file content is provided", () => {
32
+ const out = renderMarkdown(FINDINGS, { files: [{ path: ".github/workflows/ci.yml", content: CI }] });
33
+ expect(out).toContain("## Quick wins (deterministic)");
34
+ expect(out).toContain("Addresses [GHA033](https://intentius.io/chant/lint-rules/audit-rules/#gha033) (Blanket write-all permissions):");
35
+ expect(out).toContain("```diff");
36
+ expect(out).toContain("-permissions: write-all");
37
+ expect(out).toContain("+permissions:");
38
+ expect(out).toContain("+ contents: read");
39
+ });
40
+
41
+ test("pin findings without a SHA resolver are listed, not diffed", () => {
42
+ const out = renderMarkdown(FINDINGS, { files: [{ path: ".github/workflows/ci.yml", content: CI }] });
43
+ expect(out).toContain("Needs a value before it can be auto-patched:");
44
+ expect(out).toContain("**[GHA021](https://intentius.io/chant/lint-rules/audit-rules/#gha021)**");
45
+ });
46
+
47
+ test("pin findings are diffed when a SHA resolver is supplied", () => {
48
+ const sha = "11bd71901bbe5b1630ceea73d27597364c9af683";
49
+ const out = renderMarkdown(FINDINGS, {
50
+ files: [{ path: ".github/workflows/ci.yml", content: CI }],
51
+ resolveSha: () => sha,
52
+ });
53
+ expect(out).toContain(`actions/checkout@${sha}`);
54
+ });
55
+
56
+ test("does not list a finding already resolved by the combined patch", () => {
57
+ const sha = "11bd71901bbe5b1630ceea73d27597364c9af683";
58
+ const content = "name: CI\non:\n push:\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - uses: acme/deploy-action@v1\n";
59
+ const findings: AuditFinding[] = [
60
+ { checkId: "GHA021", severity: "warning", message: "unpinned checkout.", file: ".github/workflows/ci.yml", lexicon: "github", entity: "build" },
61
+ { checkId: "GHA029", severity: "warning", message: "unpinned acme.", file: ".github/workflows/ci.yml", lexicon: "github", entity: "build" },
62
+ ];
63
+ const out = renderMarkdown(findings, { files: [{ path: ".github/workflows/ci.yml", content }], resolveSha: () => sha });
64
+ // Both actions pinned in one combined diff; neither listed as "needs a value".
65
+ expect(out).toContain("```diff");
66
+ expect(out).not.toContain("Needs a value before it can be auto-patched");
67
+ });
68
+
69
+ test("guidance findings cluster by authority", () => {
70
+ const out = renderMarkdown(FINDINGS);
71
+ expect(out).toContain("<summary>Needs review (guidance)");
72
+ // GHA035 and GHA018 both share the pwn-request authority → one cluster.
73
+ expect(out).toContain("Preventing pwn requests");
74
+ const clusterIdx = out.indexOf("Preventing pwn requests");
75
+ const after = out.slice(clusterIdx);
76
+ expect(after).toContain("**[GHA035](https://intentius.io/chant/lint-rules/audit-rules/#gha035)**");
77
+ expect(after).toContain("**[GHA018](https://intentius.io/chant/lint-rules/audit-rules/#gha018)**");
78
+ });
79
+
80
+ test("report-only hygiene goes in a collapsible table", () => {
81
+ const out = renderMarkdown(FINDINGS);
82
+ expect(out).toContain("<details>");
83
+ expect(out).toContain("<summary>Report-only (hygiene)");
84
+ expect(out).toContain("| [GHA022](https://intentius.io/chant/lint-rules/audit-rules/#gha022) |");
85
+ });
86
+
87
+ test("suppresses a report-only finding on an entity already flagged merge-worthy", () => {
88
+ const findings: AuditFinding[] = [
89
+ { checkId: "WGL016", severity: "error", message: "hardcoded secret.", file: ".gitlab-ci.yml", lexicon: "gitlab", entity: "DB_PASSWORD" },
90
+ { checkId: "WGL021", severity: "warning", message: "unused variable.", file: ".gitlab-ci.yml", lexicon: "gitlab", entity: "DB_PASSWORD" },
91
+ ];
92
+ const out = renderMarkdown(findings);
93
+ expect(out).toContain("WGL016");
94
+ expect(out).not.toContain("WGL021"); // suppressed — same entity already flagged
95
+ expect(out).toContain("1 finding — ");
96
+ });
97
+
98
+ test("does not suppress unrelated hygiene sharing a job entity", () => {
99
+ const findings: AuditFinding[] = [
100
+ { checkId: "GHA021", severity: "warning", message: "unpinned.", file: ".github/workflows/ci.yml", lexicon: "github", entity: "build" },
101
+ { checkId: "GHA022", severity: "info", message: "no timeout.", file: ".github/workflows/ci.yml", lexicon: "github", entity: "build" },
102
+ ];
103
+ const out = renderMarkdown(findings);
104
+ expect(out).toContain("GHA022"); // unrelated hygiene on the same job is kept
105
+ });
106
+
107
+ test("clean report when there are no findings", () => {
108
+ const out = renderMarkdown([]);
109
+ expect(out).toContain("No issues found.");
110
+ expect(out).not.toContain("Quick wins");
111
+ });
112
+
113
+ test("omits empty sections", () => {
114
+ const onlyGuidance = renderMarkdown([FINDINGS[2]]); // GHA035 only
115
+ expect(onlyGuidance).toContain("<summary>Needs review (guidance)");
116
+ expect(onlyGuidance).not.toContain("## Quick wins");
117
+ expect(onlyGuidance).not.toContain("Report-only");
118
+ });
119
+ });