@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,141 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { proveFix, unifiedDiff, extractUnpinnedImages } from "./proof";
3
+
4
+ const WF = `name: CI
5
+ on:
6
+ push:
7
+ jobs:
8
+ build:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - run: npm ci
13
+ `;
14
+
15
+ const WF_WRITE_ALL = `name: CI
16
+ on:
17
+ push:
18
+ permissions: write-all
19
+ jobs:
20
+ build:
21
+ runs-on: ubuntu-latest
22
+ `;
23
+
24
+ describe("proveFix — pin action (GHA021/GHA029)", () => {
25
+ test("pins an unpinned action and the diff shows only that line", () => {
26
+ const sha = "11bd71901bbe5b1630ceea73d27597364c9af683";
27
+ const res = proveFix("GHA021", WF, { resolveSha: () => sha });
28
+ expect(res.applied).toBe(true);
29
+ expect(res.patched).toContain(`actions/checkout@${sha} # v4`);
30
+ // Only the uses line changes: exactly one - and one + in the diff.
31
+ const removed = res.diff!.split("\n").filter((l) => l.startsWith("-") && !l.startsWith("---"));
32
+ const added = res.diff!.split("\n").filter((l) => l.startsWith("+") && !l.startsWith("+++"));
33
+ expect(removed).toEqual(["- - uses: actions/checkout@v4"]);
34
+ expect(added).toEqual([`+ - uses: actions/checkout@${sha} # v4`]);
35
+ });
36
+
37
+ test("needs a sha when none can be resolved", () => {
38
+ const res = proveFix("GHA021", WF, { resolveSha: () => undefined });
39
+ expect(res.applied).toBe(false);
40
+ expect(res.note).toMatch(/SHA is required/i);
41
+ });
42
+
43
+ test("no-op when the action is already pinned", () => {
44
+ const pinned = WF.replace("@v4", "@11bd71901bbe5b1630ceea73d27597364c9af683");
45
+ const res = proveFix("GHA021", pinned, { resolveSha: () => "x" });
46
+ expect(res.applied).toBe(false);
47
+ expect(res.note).toMatch(/no-op/i);
48
+ });
49
+ });
50
+
51
+ describe("proveFix — permissions", () => {
52
+ test("adds a least-privilege block additively (GHA017)", () => {
53
+ const res = proveFix("GHA017", WF);
54
+ expect(res.applied).toBe(true);
55
+ expect(res.patched).toContain("permissions:\n contents: read");
56
+ // Purely additive: no removed lines.
57
+ const removed = res.diff!.split("\n").filter((l) => l.startsWith("-") && !l.startsWith("---"));
58
+ expect(removed).toEqual([]);
59
+ const added = res.diff!.split("\n").filter((l) => l.startsWith("+") && !l.startsWith("+++"));
60
+ expect(added).toEqual(["+permissions:", "+ contents: read"]);
61
+ });
62
+
63
+ test("no-op when a permissions block already exists", () => {
64
+ const withPerms = WF.replace("jobs:", "permissions:\n contents: read\njobs:");
65
+ const res = proveFix("GHA017", withPerms);
66
+ expect(res.applied).toBe(false);
67
+ });
68
+
69
+ test("narrows write-all (GHA033)", () => {
70
+ const res = proveFix("GHA033", WF_WRITE_ALL);
71
+ expect(res.applied).toBe(true);
72
+ expect(res.patched).toContain("permissions:\n contents: read");
73
+ expect(res.patched).not.toContain("write-all");
74
+ });
75
+ });
76
+
77
+ describe("proveFix — pin image digest (GHA030/WGL031)", () => {
78
+ const WF_IMG = `name: CI
79
+ on:
80
+ push:
81
+ jobs:
82
+ build:
83
+ runs-on: ubuntu-latest
84
+ container:
85
+ image: node:20
86
+ `;
87
+ const digest = "sha256:" + "a".repeat(64);
88
+
89
+ test("pins an image to a digest and the diff shows only that line", () => {
90
+ const res = proveFix("GHA030", WF_IMG, { resolveDigest: () => digest });
91
+ expect(res.applied).toBe(true);
92
+ expect(res.patched).toContain(`image: node:20@${digest}`);
93
+ const removed = res.diff!.split("\n").filter((l) => l.startsWith("-") && !l.startsWith("---"));
94
+ const added = res.diff!.split("\n").filter((l) => l.startsWith("+") && !l.startsWith("+++"));
95
+ expect(removed).toEqual(["- image: node:20"]);
96
+ expect(added).toEqual([`+ image: node:20@${digest}`]);
97
+ });
98
+
99
+ test("needs a value when no digest can be resolved", () => {
100
+ const res = proveFix("GHA030", WF_IMG, { resolveDigest: () => undefined });
101
+ expect(res.applied).toBe(false);
102
+ expect(res.reason).toBe("needs-input");
103
+ });
104
+
105
+ test("WGL031 uses the same image-pin fix", () => {
106
+ const gl = "build:\n image: python:3.12\n script:\n - echo hi\n";
107
+ const res = proveFix("WGL031", gl, { resolveDigest: () => digest });
108
+ expect(res.applied).toBe(true);
109
+ expect(res.patched).toContain(`image: python:3.12@${digest}`);
110
+ });
111
+
112
+ test("extractUnpinnedImages finds pinnable images, skips digested/variable", () => {
113
+ const content = "image: node:20\nimage: foo@sha256:" + "b".repeat(64) + "\nimage: $REG/x:1\n";
114
+ expect(extractUnpinnedImages(content)).toEqual(["node:20"]);
115
+ });
116
+ });
117
+
118
+ describe("proveFix — guidance findings are not auto-fixed", () => {
119
+ test("a guidance rule returns remediation, not a patch", () => {
120
+ const res = proveFix("GHA036", WF); // script injection — guidance
121
+ expect(res.applied).toBe(false);
122
+ expect(res.patched).toBeUndefined();
123
+ expect(res.note && res.note.length).toBeGreaterThan(0);
124
+ });
125
+ });
126
+
127
+ describe("unifiedDiff", () => {
128
+ test("identical input produces an empty diff", () => {
129
+ expect(unifiedDiff(WF, WF)).toBe("");
130
+ });
131
+
132
+ test("emits a hunk header and the changed lines", () => {
133
+ const a = "a\nb\nc\n";
134
+ const b = "a\nB\nc\n";
135
+ const diff = unifiedDiff(a, b);
136
+ expect(diff).toContain("@@");
137
+ expect(diff).toContain("-b");
138
+ expect(diff).toContain("+B");
139
+ expect(diff).toContain(" a");
140
+ });
141
+ });
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Proof of minimal change — for a deterministic (fixKind: "deterministic")
3
+ * finding, produce a minimal patched YAML + unified diff so a PR can show it
4
+ * changes exactly the flagged line and nothing else.
5
+ *
6
+ * No LLM, no API key — purely mechanical text edits. Findings that need
7
+ * judgment (fixKind: "guidance") are NOT auto-fixed here; they return
8
+ * `applied: false` with the catalog remediation as guidance. Any LLM-assisted
9
+ * apply of a guidance fix is a separate, optional, local concern.
10
+ *
11
+ * Edits are applied directly to the YAML text (not via a model round-trip), so
12
+ * the patched output differs only where intended — the diff proves it.
13
+ */
14
+
15
+ import { RULE_CATALOG } from "./catalog";
16
+
17
+ export interface ProveOptions {
18
+ /** Resolve an action ref (e.g. "actions/checkout@v4") to a 40-char SHA. */
19
+ resolveSha?: (action: string, ref: string) => string | undefined;
20
+ /** Resolve a container image (e.g. "node:20") to a "sha256:..." digest. */
21
+ resolveDigest?: (image: string) => string | undefined;
22
+ }
23
+
24
+ export interface ProofResult {
25
+ checkId: string;
26
+ /** True when a deterministic fix was produced. */
27
+ applied: boolean;
28
+ /** Full patched content (only when applied). */
29
+ patched?: string;
30
+ /** Unified diff of the change (only when applied). */
31
+ diff?: string;
32
+ /** Guidance/explanation when not applied (guidance fix, no-op, or needs-sha). */
33
+ note?: string;
34
+ /**
35
+ * Why the result is what it is:
36
+ * - applied: a fix was produced
37
+ * - noop: nothing to fix (issue absent, or already resolved by a prior fix)
38
+ * - needs-input: deterministic but blocked on external input (e.g. a SHA)
39
+ * - guidance: not auto-fixable; needs human judgement
40
+ */
41
+ reason: "applied" | "noop" | "needs-input" | "guidance";
42
+ }
43
+
44
+ const SHA_RE = /^[0-9a-f]{40}$/;
45
+ const USES_RE = /^(\s*-?\s*uses:\s*)([^@\s'"]+)@([^\s'"#]+)(.*)$/;
46
+ const IMAGE_RE = /^(\s*image:\s*)(["']?)([^\s"'#]+)\2(.*)$/;
47
+
48
+ function notApplied(checkId: string, reason: "noop" | "needs-input" | "guidance", note: string): ProofResult {
49
+ return { checkId, applied: false, reason, note };
50
+ }
51
+
52
+ /** Extract unpinned `uses: action@ref` references (deduped) from workflow YAML. */
53
+ export function extractUnpinnedActions(content: string): Array<{ action: string; ref: string }> {
54
+ const seen = new Set<string>();
55
+ const out: Array<{ action: string; ref: string }> = [];
56
+ for (const line of content.split("\n")) {
57
+ const m = line.match(USES_RE);
58
+ if (!m) continue;
59
+ const [, , action, ref] = m;
60
+ if (SHA_RE.test(ref)) continue;
61
+ const key = `${action}@${ref}`;
62
+ if (seen.has(key)) continue;
63
+ seen.add(key);
64
+ out.push({ action, ref });
65
+ }
66
+ return out;
67
+ }
68
+
69
+ /** Pin unpinned `uses: action@ref` lines to a SHA. */
70
+ function pinActions(content: string, opts: ProveOptions): { patched: string; changed: boolean; needsSha: boolean } {
71
+ const lines = content.split("\n");
72
+ let changed = false;
73
+ let needsSha = false;
74
+ for (let i = 0; i < lines.length; i++) {
75
+ const m = lines[i].match(USES_RE);
76
+ if (!m) continue;
77
+ const [, prefix, action, ref, rest] = m;
78
+ if (SHA_RE.test(ref)) continue; // already pinned
79
+ const sha = opts.resolveSha?.(action, ref);
80
+ if (!sha) {
81
+ needsSha = true;
82
+ continue;
83
+ }
84
+ lines[i] = `${prefix}${action}@${sha} # ${ref}${rest.replace(/\s*#.*$/, "")}`;
85
+ changed = true;
86
+ }
87
+ return { patched: lines.join("\n"), changed, needsSha };
88
+ }
89
+
90
+ /** A container image is unpinnable here if it lacks a digest and isn't a variable. */
91
+ function isPinnableImage(ref: string): boolean {
92
+ return !ref.includes("@sha256:") && !ref.includes("$");
93
+ }
94
+
95
+ /** Extract unpinned `image:` references (deduped) from CI YAML. */
96
+ export function extractUnpinnedImages(content: string): string[] {
97
+ const seen = new Set<string>();
98
+ const out: string[] = [];
99
+ for (const line of content.split("\n")) {
100
+ const m = line.match(IMAGE_RE);
101
+ if (!m) continue;
102
+ const ref = m[3];
103
+ if (!isPinnableImage(ref) || seen.has(ref)) continue;
104
+ seen.add(ref);
105
+ out.push(ref);
106
+ }
107
+ return out;
108
+ }
109
+
110
+ /** Pin unpinned `image:` references to a digest via the resolver. */
111
+ function pinImages(content: string, opts: ProveOptions): { patched: string; changed: boolean; needsValue: boolean } {
112
+ const lines = content.split("\n");
113
+ let changed = false;
114
+ let needsValue = false;
115
+ for (let i = 0; i < lines.length; i++) {
116
+ const m = lines[i].match(IMAGE_RE);
117
+ if (!m) continue;
118
+ const [, prefix, , ref, rest] = m;
119
+ if (!isPinnableImage(ref)) continue;
120
+ const digest = opts.resolveDigest?.(ref);
121
+ if (!digest) {
122
+ needsValue = true;
123
+ continue;
124
+ }
125
+ lines[i] = `${prefix}${ref}@${digest}${rest.replace(/\s*#.*$/, "")}`;
126
+ changed = true;
127
+ }
128
+ return { patched: lines.join("\n"), changed, needsValue };
129
+ }
130
+
131
+ /** Insert a least-privilege top-level permissions block if absent. */
132
+ function addPermissions(content: string): { patched: string; changed: boolean } {
133
+ if (/^permissions:/m.test(content)) return { patched: content, changed: false };
134
+ const lines = content.split("\n");
135
+ const jobsIdx = lines.findIndex((l) => /^jobs:\s*$/.test(l));
136
+ if (jobsIdx === -1) return { patched: content, changed: false };
137
+ lines.splice(jobsIdx, 0, "permissions:", " contents: read");
138
+ return { patched: lines.join("\n"), changed: true };
139
+ }
140
+
141
+ /** Replace a top-level `permissions: write-all` with a least-privilege block. */
142
+ function narrowWriteAll(content: string): { patched: string; changed: boolean } {
143
+ const re = /^permissions:[ \t]+write-all[ \t]*$/m;
144
+ if (!re.test(content)) return { patched: content, changed: false };
145
+ return { patched: content.replace(re, "permissions:\n contents: read"), changed: true };
146
+ }
147
+
148
+ /**
149
+ * Produce a deterministic fix + diff for a finding, if one is mechanical.
150
+ * Returns `applied: false` with guidance for non-deterministic findings.
151
+ */
152
+ export function proveFix(checkId: string, content: string, opts: ProveOptions = {}): ProofResult {
153
+ const cat = RULE_CATALOG[checkId];
154
+ if (cat && cat.fixKind !== "deterministic") {
155
+ return notApplied(checkId, "guidance", cat.remediation || "Manual fix required.");
156
+ }
157
+
158
+ let result: { patched: string; changed: boolean; needsSha?: boolean };
159
+ switch (checkId) {
160
+ case "GHA021":
161
+ case "GHA029":
162
+ result = pinActions(content, opts);
163
+ if (!result.changed && (result as { needsSha?: boolean }).needsSha) {
164
+ return notApplied(checkId, "needs-input", "A commit SHA is required to pin; resolve it (e.g. via the fetch layer) and re-run.");
165
+ }
166
+ break;
167
+ case "GHA030":
168
+ case "WGL031": {
169
+ const r = pinImages(content, opts);
170
+ if (!r.changed && r.needsValue) {
171
+ return notApplied(checkId, "needs-input", "A registry digest is required to pin the image; resolve it (e.g. via the fetch layer) and re-run.");
172
+ }
173
+ result = r;
174
+ break;
175
+ }
176
+ case "GHA017":
177
+ result = addPermissions(content);
178
+ break;
179
+ case "GHA033":
180
+ result = narrowWriteAll(content);
181
+ break;
182
+ default:
183
+ return notApplied(checkId, "needs-input", cat?.remediation || "No deterministic fix implemented for this rule yet.");
184
+ }
185
+
186
+ if (!result.changed) {
187
+ return notApplied(checkId, "noop", "Nothing to fix — the issue is not present (no-op).");
188
+ }
189
+ return {
190
+ checkId,
191
+ applied: true,
192
+ reason: "applied",
193
+ patched: result.patched,
194
+ diff: unifiedDiff(content, result.patched),
195
+ };
196
+ }
197
+
198
+ // ── Minimal line-based unified diff ──────────────────────────────────
199
+
200
+ type Op = { type: "eq" | "del" | "add"; line: string };
201
+
202
+ /** LCS-based line diff. */
203
+ function diffOps(a: string[], b: string[]): Op[] {
204
+ const n = a.length;
205
+ const m = b.length;
206
+ const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
207
+ for (let i = n - 1; i >= 0; i--) {
208
+ for (let j = m - 1; j >= 0; j--) {
209
+ lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
210
+ }
211
+ }
212
+ const ops: Op[] = [];
213
+ let i = 0;
214
+ let j = 0;
215
+ while (i < n && j < m) {
216
+ if (a[i] === b[j]) {
217
+ ops.push({ type: "eq", line: a[i] });
218
+ i++;
219
+ j++;
220
+ } else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
221
+ ops.push({ type: "del", line: a[i] });
222
+ i++;
223
+ } else {
224
+ ops.push({ type: "add", line: b[j] });
225
+ j++;
226
+ }
227
+ }
228
+ while (i < n) ops.push({ type: "del", line: a[i++] });
229
+ while (j < m) ops.push({ type: "add", line: b[j++] });
230
+ return ops;
231
+ }
232
+
233
+ /** Render a unified diff with up to `context` equal lines around changes. */
234
+ export function unifiedDiff(oldStr: string, newStr: string, context = 3): string {
235
+ const a = oldStr.split("\n");
236
+ const b = newStr.split("\n");
237
+ const ops = diffOps(a, b);
238
+
239
+ // Mark which op indexes are within `context` of a change.
240
+ const keep = new Array(ops.length).fill(false);
241
+ for (let k = 0; k < ops.length; k++) {
242
+ if (ops[k].type !== "eq") {
243
+ for (let d = -context; d <= context; d++) {
244
+ const idx = k + d;
245
+ if (idx >= 0 && idx < ops.length) keep[idx] = true;
246
+ }
247
+ }
248
+ }
249
+
250
+ const lines: string[] = [];
251
+ let oldLine = 1;
252
+ let newLine = 1;
253
+ let k = 0;
254
+ while (k < ops.length) {
255
+ if (!keep[k]) {
256
+ if (ops[k].type !== "add") oldLine++;
257
+ if (ops[k].type !== "del") newLine++;
258
+ k++;
259
+ continue;
260
+ }
261
+ // Start of a hunk.
262
+ const hunk: string[] = [];
263
+ const oldStart = oldLine;
264
+ const newStart = newLine;
265
+ let oldCount = 0;
266
+ let newCount = 0;
267
+ while (k < ops.length && keep[k]) {
268
+ const op = ops[k];
269
+ if (op.type === "eq") {
270
+ hunk.push(` ${op.line}`);
271
+ oldCount++;
272
+ newCount++;
273
+ oldLine++;
274
+ newLine++;
275
+ } else if (op.type === "del") {
276
+ hunk.push(`-${op.line}`);
277
+ oldCount++;
278
+ oldLine++;
279
+ } else {
280
+ hunk.push(`+${op.line}`);
281
+ newCount++;
282
+ newLine++;
283
+ }
284
+ k++;
285
+ }
286
+ lines.push(`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`);
287
+ lines.push(...hunk);
288
+ }
289
+ return lines.join("\n");
290
+ }
@@ -0,0 +1,83 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { renderHtml, type AuditSnapshot } from "./report-html";
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: "GHA035", severity: "error", message: "elevated token on pull_request_target.", file: ".github/workflows/ci.yml", lexicon: "github" },
19
+ { checkId: "GHA022", severity: "info", message: "no timeout.", file: ".github/workflows/ci.yml", lexicon: "github" },
20
+ ];
21
+
22
+ const SNAPSHOT: AuditSnapshot = {
23
+ target: "https://github.com/owner/repo",
24
+ host: "github.com",
25
+ repo: "owner/repo",
26
+ commit: "11bd71901bbe5b1630ceea73d27597364c9af683",
27
+ files: [".github/workflows/ci.yml"],
28
+ generatedAt: "2026-06-16T00:00:00.000Z",
29
+ toolVersion: "0.4.0",
30
+ };
31
+
32
+ describe("renderHtml", () => {
33
+ test("produces a self-contained HTML document with sections and snapshot", () => {
34
+ const html = renderHtml(FINDINGS, { files: [{ path: ".github/workflows/ci.yml", content: CI }], snapshot: SNAPSHOT });
35
+ expect(html.startsWith("<!doctype html>")).toBe(true);
36
+ expect(html).toContain("<style>"); // inline CSS, self-contained
37
+ expect(html).toContain("owner/repo");
38
+ expect(html).toContain("11bd71901b"); // short commit in snapshot meta
39
+ expect(html).toContain("Quick wins");
40
+ expect(html).toContain("Needs review");
41
+ expect(html).toContain('class="diff"'); // the permissions fix renders as a diff
42
+ expect(html).toContain("chant 0.4.0");
43
+ });
44
+
45
+ test("embeds the machine-readable JSON report (parseable, escaped)", () => {
46
+ const html = renderHtml(FINDINGS, { snapshot: SNAPSHOT });
47
+ const m = html.match(/<script type="application\/json" id="chant-audit-report">(.*?)<\/script>/s);
48
+ expect(m).not.toBeNull();
49
+ // No raw "<" can break out of the script element.
50
+ expect(m![1]).not.toContain("<");
51
+ const data = JSON.parse(m![1].replace(/\\u003c/g, "<"));
52
+ expect(data.schemaVersion).toBe("1.0");
53
+ expect(data.snapshot.commit).toBe(SNAPSHOT.commit);
54
+ expect(data.findings.length).toBe(3);
55
+ });
56
+
57
+ test("escapes HTML in finding content (no injection)", () => {
58
+ const evil: AuditFinding[] = [
59
+ { checkId: "GHA036", severity: "error", message: "<script>alert(1)</script>", file: "a.yml", lexicon: "github" },
60
+ ];
61
+ const html = renderHtml(evil);
62
+ expect(html).not.toContain("<script>alert(1)</script>");
63
+ expect(html).toContain("&lt;script&gt;");
64
+ });
65
+
66
+ test("applies theme knobs (title, accent)", () => {
67
+ const html = renderHtml(FINDINGS, { theme: { title: "Acme Security", accent: "#ff0000" } });
68
+ expect(html).toContain("<title>Acme Security</title>");
69
+ expect(html).toContain("#ff0000");
70
+ });
71
+
72
+ test("honours a full template override", () => {
73
+ const html = renderHtml(FINDINGS, { template: "<html>{{title}}::{{body}}</html>", theme: { title: "X" } });
74
+ expect(html.startsWith("<html>X::")).toBe(true);
75
+ expect(html).toContain("Quick wins"); // body still rendered
76
+ });
77
+
78
+ test("clean report when no findings", () => {
79
+ const html = renderHtml([]);
80
+ expect(html).toContain("No issues found.");
81
+ expect(html).not.toContain("Quick wins");
82
+ });
83
+ });