@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.
@@ -0,0 +1,352 @@
1
+ /**
2
+ * `chant audit` — run chant's CI security checks against an existing repo's
3
+ * pipeline YAML and emit a tiered report. Does not require a chant project;
4
+ * it reads `.github/workflows`, `.gitlab-ci.yml`, and `.forgejo/workflows`
5
+ * directly and runs the real post-synth checks via the audit core.
6
+ */
7
+
8
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
9
+ import { join, relative } from "path";
10
+ import { auditFiles, type AuditInput, type AuditFinding, type AuditLexicon, type ChecksProvider } from "../../audit/core";
11
+ import { RULE_CATALOG } from "../../audit/catalog";
12
+ import { renderMarkdown } from "../../audit/report";
13
+ import { renderHtml, type ReportTheme } from "../../audit/report-html";
14
+ import { buildReportJson, type AuditSnapshot } from "../../audit/report-model";
15
+ import { fetchCiFiles, resolveActionSha, resolveImageDigest, resolveRepoCommit, parseRepoUrl, FetchError } from "../../audit/fetch";
16
+ import { extractUnpinnedActions, extractUnpinnedImages } from "../../audit/proof";
17
+ import type { ProveOptions } from "../../audit/proof";
18
+ import type { Severity } from "../../lint/rule";
19
+
20
+ export type AuditFormat = "stylish" | "json" | "sarif" | "markdown" | "html";
21
+ export type AuditTier = "merge-worthy" | "all";
22
+ export type AuditFailOn = "merge-worthy" | "warning" | "none";
23
+
24
+ export interface AuditCommandOptions {
25
+ /** Repo root/dir to scan, or an https:// repo URL to fetch and audit. */
26
+ path: string;
27
+ format?: AuditFormat;
28
+ /** Restrict findings to a tier (default "all"). */
29
+ tier?: AuditTier;
30
+ /** Exit-code policy (default "none" — read-only friendly). */
31
+ failOn?: AuditFailOn;
32
+ /** Server-side token for remote fetch (defaults to env). */
33
+ token?: string;
34
+ /** Injectable fetch for testing remote audits. */
35
+ fetchImpl?: typeof fetch;
36
+ /** Write the rendered report to this file instead of returning it for stdout. */
37
+ output?: string;
38
+ /** Injectable post-synth checks provider (testing). */
39
+ checksProvider?: ChecksProvider;
40
+ /** HTML report: theme knobs (title, logo, accent, footer). */
41
+ theme?: ReportTheme;
42
+ /** HTML report: full template override. */
43
+ template?: string;
44
+ /** Snapshot timestamp (ISO); defaults to now. Injectable for deterministic output. */
45
+ now?: string;
46
+ /** Tool version recorded in the HTML snapshot. */
47
+ toolVersion?: string;
48
+ }
49
+
50
+ export interface AuditCommandResult {
51
+ success: boolean;
52
+ /** Rendered report in the requested format. */
53
+ output: string;
54
+ findings: AuditFinding[];
55
+ /** Files that were scanned (relative to the root). */
56
+ scanned: string[];
57
+ exitCode: number;
58
+ error?: string;
59
+ /** Set when the report was written to a file (via `output`). */
60
+ wroteTo?: string;
61
+ }
62
+
63
+ /**
64
+ * Select the fetch token for a repo host. Tokens are host-specific — a GitHub
65
+ * PAT sent to GitLab/Codeberg is rejected (401) — so we never cross hosts.
66
+ */
67
+ export function tokenForHost(url: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
68
+ let host: string;
69
+ try {
70
+ host = new URL(url).hostname;
71
+ } catch {
72
+ return undefined;
73
+ }
74
+ switch (host) {
75
+ case "gitlab.com":
76
+ return env.GITLAB_TOKEN ?? env.CHANT_AUDIT_GITLAB_TOKEN;
77
+ case "codeberg.org":
78
+ return env.CODEBERG_TOKEN ?? env.CHANT_AUDIT_CODEBERG_TOKEN;
79
+ case "github.com":
80
+ return env.GITHUB_TOKEN ?? env.CHANT_AUDIT_GITHUB_TOKEN;
81
+ default:
82
+ return undefined;
83
+ }
84
+ }
85
+
86
+ /** GitHub token used for action-SHA resolution (always queries api.github.com). */
87
+ function githubToken(env: NodeJS.ProcessEnv = process.env): string | undefined {
88
+ return env.GITHUB_TOKEN ?? env.CHANT_AUDIT_GITHUB_TOKEN;
89
+ }
90
+
91
+ function isYaml(name: string): boolean {
92
+ return name.endsWith(".yml") || name.endsWith(".yaml");
93
+ }
94
+
95
+ function collectDir(root: string, dir: string, lexicon: AuditLexicon, out: AuditInput[]): void {
96
+ const abs = join(root, dir);
97
+ if (!existsSync(abs) || !statSync(abs).isDirectory()) return;
98
+ for (const name of readdirSync(abs).sort()) {
99
+ if (!isYaml(name)) continue;
100
+ const full = join(abs, name);
101
+ if (!statSync(full).isFile()) continue;
102
+ out.push({ path: relative(root, full), content: readFileSync(full, "utf-8"), lexicon });
103
+ }
104
+ }
105
+
106
+ /** Discover CI files under a repo root. */
107
+ export function discoverCiFiles(root: string): AuditInput[] {
108
+ const inputs: AuditInput[] = [];
109
+ collectDir(root, ".github/workflows", "github", inputs);
110
+ collectDir(root, ".forgejo/workflows", "forgejo", inputs);
111
+ const gitlab = join(root, ".gitlab-ci.yml");
112
+ if (existsSync(gitlab) && statSync(gitlab).isFile()) {
113
+ inputs.push({ path: ".gitlab-ci.yml", content: readFileSync(gitlab, "utf-8"), lexicon: "gitlab" });
114
+ }
115
+ return inputs;
116
+ }
117
+
118
+ function isMergeWorthy(f: AuditFinding): boolean {
119
+ return RULE_CATALOG[f.checkId]?.tier === "merge-worthy";
120
+ }
121
+
122
+ function exitCodeFor(findings: AuditFinding[], failOn: AuditFailOn): number {
123
+ if (failOn === "merge-worthy") return findings.some(isMergeWorthy) ? 1 : 0;
124
+ if (failOn === "warning") {
125
+ return findings.some((f) => f.severity === "error" || f.severity === "warning") ? 1 : 0;
126
+ }
127
+ return 0;
128
+ }
129
+
130
+ function sarifLevel(sev: Severity): string {
131
+ return sev === "error" ? "error" : sev === "warning" ? "warning" : "note";
132
+ }
133
+
134
+ /** Coverage caveats about what the audit could and couldn't see. */
135
+ export function coverageNotes(inputs: AuditInput[]): string[] {
136
+ const notes: string[] = [];
137
+ const withIncludes = inputs.filter((i) => i.lexicon === "gitlab" && /^include:/m.test(i.content)).length;
138
+ if (withIncludes > 0) {
139
+ notes.push(
140
+ `${withIncludes} GitLab pipeline${withIncludes === 1 ? " uses" : "s use"} \`include:\` — included files are not fetched, so findings cover the root file only.`,
141
+ );
142
+ }
143
+ return notes;
144
+ }
145
+
146
+ function renderStylish(findings: AuditFinding[], scanned: string[], notes: string[]): string {
147
+ const lines: string[] = [];
148
+ for (const note of notes) lines.push(`Note: ${note}`);
149
+ if (notes.length > 0) lines.push("");
150
+ const mw = findings.filter(isMergeWorthy);
151
+ const ro = findings.filter((f) => !isMergeWorthy(f));
152
+ lines.push(
153
+ `Audited ${scanned.length} CI file${scanned.length === 1 ? "" : "s"} — ` +
154
+ `${findings.length} finding${findings.length === 1 ? "" : "s"} ` +
155
+ `(${mw.length} merge-worthy, ${ro.length} report-only).`,
156
+ );
157
+ const section = (title: string, list: AuditFinding[]) => {
158
+ if (list.length === 0) return;
159
+ lines.push("", title);
160
+ for (const f of list) {
161
+ const where = f.entity ? `${f.file} (${f.entity})` : f.file;
162
+ const title = RULE_CATALOG[f.checkId]?.title ?? f.checkId;
163
+ lines.push(` [${f.checkId}] ${f.severity} ${where} — ${title}`);
164
+ }
165
+ };
166
+ section("Merge-worthy:", mw);
167
+ section("Report-only:", ro);
168
+ return lines.join("\n");
169
+ }
170
+
171
+ function renderSarif(findings: AuditFinding[]): string {
172
+ const ruleIds = [...new Set(findings.map((f) => f.checkId))].sort();
173
+ const rules = ruleIds.map((id) => {
174
+ const m = RULE_CATALOG[id];
175
+ return {
176
+ id,
177
+ name: m?.title ?? id,
178
+ shortDescription: { text: m?.title ?? id },
179
+ helpUri: m?.authority?.[0]?.url,
180
+ };
181
+ });
182
+ const results = findings.map((f) => ({
183
+ ruleId: f.checkId,
184
+ level: sarifLevel(f.severity),
185
+ message: { text: f.message },
186
+ locations: [{ physicalLocation: { artifactLocation: { uri: f.file } } }],
187
+ }));
188
+ return JSON.stringify(
189
+ {
190
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
191
+ version: "2.1.0",
192
+ runs: [{ tool: { driver: { name: "chant-audit", informationUri: "https://intentius.io/chant/", rules } }, results }],
193
+ },
194
+ null,
195
+ 2,
196
+ );
197
+ }
198
+
199
+ /** Build a provenance snapshot of what was audited (for the HTML report). */
200
+ async function buildSnapshot(options: AuditCommandOptions, files: string[], isUrl: boolean): Promise<AuditSnapshot> {
201
+ let host: string | undefined;
202
+ let repo: string | undefined;
203
+ let commit: string | undefined;
204
+ if (isUrl) {
205
+ try {
206
+ host = new URL(options.path).hostname;
207
+ const parsed = parseRepoUrl(options.path);
208
+ repo = `${parsed.owner}/${parsed.repo}`;
209
+ } catch {
210
+ // leave host/repo undefined
211
+ }
212
+ commit = await resolveRepoCommit(options.path, { token: options.token ?? tokenForHost(options.path), fetchImpl: options.fetchImpl });
213
+ } else {
214
+ host = "local";
215
+ }
216
+ return {
217
+ target: options.path,
218
+ host,
219
+ repo,
220
+ commit,
221
+ files,
222
+ generatedAt: options.now ?? new Date().toISOString(),
223
+ toolVersion: options.toolVersion ?? "0.0.0",
224
+ };
225
+ }
226
+
227
+ /** Run the audit and produce a rendered result. */
228
+ export async function auditCommand(options: AuditCommandOptions): Promise<AuditCommandResult> {
229
+ const format = options.format ?? "stylish";
230
+ const tier = options.tier ?? "all";
231
+ const failOn = options.failOn ?? "none";
232
+
233
+ const isUrl = /^https?:\/\//.test(options.path);
234
+
235
+ let inputs: AuditInput[];
236
+ if (isUrl) {
237
+ try {
238
+ inputs = await fetchCiFiles(options.path, {
239
+ token: options.token ?? tokenForHost(options.path),
240
+ fetchImpl: options.fetchImpl,
241
+ });
242
+ } catch (err) {
243
+ const msg = err instanceof FetchError ? err.message : err instanceof Error ? err.message : String(err);
244
+ return { success: false, output: "", findings: [], scanned: [], exitCode: 1, error: msg };
245
+ }
246
+ } else {
247
+ if (!existsSync(options.path)) {
248
+ return { success: false, output: "", findings: [], scanned: [], exitCode: 1, error: `Path not found: ${options.path}` };
249
+ }
250
+ inputs = discoverCiFiles(options.path);
251
+ }
252
+
253
+ const scanned = inputs.map((i) => i.path);
254
+
255
+ if (inputs.length === 0) {
256
+ return { success: true, output: `No CI files found under ${options.path}.`, findings: [], scanned: [], exitCode: 0 };
257
+ }
258
+
259
+ let findings: AuditFinding[];
260
+ try {
261
+ findings = await auditFiles(inputs, { checksProvider: options.checksProvider });
262
+ } catch (err) {
263
+ const msg = err instanceof Error ? err.message : String(err);
264
+ return { success: false, output: "", findings: [], scanned, exitCode: 1, error: msg };
265
+ }
266
+ if (tier === "merge-worthy") findings = findings.filter(isMergeWorthy);
267
+ const notes = coverageNotes(inputs);
268
+
269
+ // Diff-bearing renderers (markdown, html) need action SHAs / image digests
270
+ // resolved up front (sync maps so rendering stays synchronous).
271
+ let resolveSha: ProveOptions["resolveSha"];
272
+ let resolveDigest: ProveOptions["resolveDigest"];
273
+ if (isUrl && (format === "markdown" || format === "html")) {
274
+ // Action SHAs always resolve against api.github.com, so use the GitHub
275
+ // token regardless of which host the repo lives on.
276
+ const token = githubToken();
277
+ const refs = new Map<string, { action: string; ref: string }>();
278
+ const images = new Set<string>();
279
+ for (const inp of inputs) {
280
+ for (const a of extractUnpinnedActions(inp.content)) refs.set(`${a.action}@${a.ref}`, a);
281
+ for (const img of extractUnpinnedImages(inp.content)) images.add(img);
282
+ }
283
+ const [resolvedShas, resolvedDigests] = await Promise.all([
284
+ Promise.all(
285
+ [...refs.values()].map(async (a) => {
286
+ const sha = await resolveActionSha(a.action, a.ref, { token, fetchImpl: options.fetchImpl });
287
+ return [`${a.action}@${a.ref}`, sha] as [string, string | undefined];
288
+ }),
289
+ ),
290
+ Promise.all(
291
+ [...images].map(async (img) => {
292
+ const digest = await resolveImageDigest(img, { fetchImpl: options.fetchImpl });
293
+ return [img, digest] as [string, string | undefined];
294
+ }),
295
+ ),
296
+ ]);
297
+ const shaMap = new Map<string, string>();
298
+ for (const [key, sha] of resolvedShas) if (sha) shaMap.set(key, sha);
299
+ if (shaMap.size > 0) resolveSha = (action, ref) => shaMap.get(`${action}@${ref}`);
300
+ const digestMap = new Map<string, string>();
301
+ for (const [img, digest] of resolvedDigests) if (digest) digestMap.set(img, digest);
302
+ if (digestMap.size > 0) resolveDigest = (img) => digestMap.get(img);
303
+ }
304
+
305
+ const files = inputs.map((i) => ({ path: i.path, content: i.content }));
306
+ let output: string;
307
+ switch (format) {
308
+ case "json": {
309
+ const snapshot = await buildSnapshot(options, scanned, isUrl);
310
+ output = JSON.stringify(buildReportJson(findings, { snapshot, toolVersion: options.toolVersion }), null, 2);
311
+ break;
312
+ }
313
+ case "sarif":
314
+ output = renderSarif(findings);
315
+ break;
316
+ case "markdown":
317
+ output = renderMarkdown(findings, { target: options.path, files, resolveSha, resolveDigest, notes });
318
+ break;
319
+ case "html": {
320
+ const snapshot = await buildSnapshot(options, scanned, isUrl);
321
+ output = renderHtml(findings, { files, resolveSha, resolveDigest, notes, snapshot, theme: options.theme, template: options.template });
322
+ break;
323
+ }
324
+ default:
325
+ output = renderStylish(findings, scanned, notes);
326
+ }
327
+
328
+ const exitCode = exitCodeFor(findings, failOn);
329
+ if (options.output) {
330
+ try {
331
+ writeFileSync(options.output, output);
332
+ } catch (err) {
333
+ return { success: false, output, findings, scanned, exitCode: 1, error: `Failed to write ${options.output}: ${err instanceof Error ? err.message : String(err)}` };
334
+ }
335
+ return { success: true, output, findings, scanned, exitCode, wroteTo: options.output };
336
+ }
337
+
338
+ return { success: true, output, findings, scanned, exitCode };
339
+ }
340
+
341
+ /** Print an audit result to stdout. */
342
+ export function printAuditResult(result: AuditCommandResult): void {
343
+ if (!result.success) {
344
+ console.error(result.error ?? "Audit failed");
345
+ return;
346
+ }
347
+ if (result.wroteTo) {
348
+ console.error(`Wrote report to ${result.wroteTo}`);
349
+ return;
350
+ }
351
+ console.log(result.output);
352
+ }
@@ -1,9 +1,80 @@
1
1
  import { listCommand, printListResult } from "../commands/list";
2
2
  import { describeCommand, printDescribeResult } from "../commands/describe";
3
3
  import { importCommand, importFromLive, printImportResult } from "../commands/import";
4
+ import { auditCommand, printAuditResult, type AuditFormat, type AuditTier, type AuditFailOn } from "../commands/audit";
5
+ import type { ReportTheme } from "../../audit/report-html";
4
6
  import type { ResourceSelector } from "../../lexicon";
5
7
  import { formatError, formatSuccess, formatWarning } from "../format";
6
8
  import type { CommandContext } from "../registry";
9
+ import { createRequire } from "module";
10
+
11
+ const CHANT_VERSION: string = (() => {
12
+ try {
13
+ return createRequire(import.meta.url)("../../../package.json").version ?? "0.0.0";
14
+ } catch {
15
+ return "0.0.0";
16
+ }
17
+ })();
18
+
19
+ const AUDIT_FORMATS: AuditFormat[] = ["stylish", "json", "sarif", "markdown", "html"];
20
+ const AUDIT_TIERS: AuditTier[] = ["merge-worthy", "all"];
21
+ const AUDIT_FAIL_ON: AuditFailOn[] = ["merge-worthy", "warning", "none"];
22
+
23
+ export async function runAudit(ctx: CommandContext): Promise<number> {
24
+ const { args } = ctx;
25
+
26
+ const format: AuditFormat = args.json ? "json" : ((args.format || "stylish") as AuditFormat);
27
+ if (!AUDIT_FORMATS.includes(format)) {
28
+ console.error(formatError({ message: `Invalid --format: ${format}. Expected one of ${AUDIT_FORMATS.join(", ")}.` }));
29
+ return 1;
30
+ }
31
+ const tier = (args.tier ?? "all") as AuditTier;
32
+ if (!AUDIT_TIERS.includes(tier)) {
33
+ console.error(formatError({ message: `Invalid --tier: ${tier}. Expected one of ${AUDIT_TIERS.join(", ")}.` }));
34
+ return 1;
35
+ }
36
+ const failOn = (args.failOn ?? "none") as AuditFailOn;
37
+ if (!AUDIT_FAIL_ON.includes(failOn)) {
38
+ console.error(formatError({ message: `Invalid --fail-on: ${failOn}. Expected one of ${AUDIT_FAIL_ON.join(", ")}.` }));
39
+ return 1;
40
+ }
41
+
42
+ // HTML report customization: --template <file> (full override) + --theme <file> (JSON knobs).
43
+ let template: string | undefined;
44
+ let theme: ReportTheme | undefined;
45
+ if (format === "html") {
46
+ const { readFileSync } = await import("fs");
47
+ if (args.template) {
48
+ try {
49
+ template = readFileSync(args.template, "utf-8");
50
+ } catch (err) {
51
+ console.error(formatError({ message: `Failed to read --template ${args.template}: ${err instanceof Error ? err.message : String(err)}` }));
52
+ return 1;
53
+ }
54
+ }
55
+ if (args.theme) {
56
+ try {
57
+ theme = JSON.parse(readFileSync(args.theme, "utf-8")) as ReportTheme;
58
+ } catch (err) {
59
+ console.error(formatError({ message: `Failed to read --theme ${args.theme}: ${err instanceof Error ? err.message : String(err)}` }));
60
+ return 1;
61
+ }
62
+ }
63
+ }
64
+
65
+ const result = await auditCommand({
66
+ path: args.path,
67
+ format,
68
+ tier,
69
+ failOn,
70
+ output: args.output,
71
+ template,
72
+ theme,
73
+ toolVersion: CHANT_VERSION,
74
+ });
75
+ printAuditResult(result);
76
+ return result.exitCode;
77
+ }
7
78
 
8
79
  export async function runList(ctx: CommandContext): Promise<number> {
9
80
  const { args } = ctx;
package/src/cli/main.ts CHANGED
@@ -11,7 +11,7 @@ import { runLint } from "./handlers/lint";
11
11
  import { runDevGenerate, runDevPublish, runDevOnboard, runDevCheckLexicon, runDevUnknown } from "./handlers/dev";
12
12
  import { runServeLsp, runServeMcp, runServeUnknown } from "./handlers/serve";
13
13
  import { runInit, runInitLexicon } from "./handlers/init";
14
- import { runList, runDescribe, runImport, runUpdate, runDoctor } from "./handlers/misc";
14
+ import { runList, runDescribe, runImport, runAudit, runUpdate, runDoctor } from "./handlers/misc";
15
15
  import { runVendor } from "./handlers/vendor";
16
16
  import { runMigrate } from "./handlers/migrate";
17
17
  import { runLifecycleSnapshot, runLifecycleShow, runLifecycleDiff, runLifecyclePlan, runLifecycleAffected, runLifecycleLog, runLifecycleUnknown } from "./handlers/lifecycle";
@@ -118,6 +118,12 @@ export function parseArgs(args: string[]): ParsedArgs {
118
118
  result.src = args[++i];
119
119
  } else if (arg === "--env") {
120
120
  result.env = args[++i];
121
+ } else if (arg === "--tier") {
122
+ result.tier = args[++i];
123
+ } else if (arg === "--fail-on") {
124
+ result.failOn = args[++i];
125
+ } else if (arg === "--theme") {
126
+ result.theme = args[++i];
121
127
  } else if (arg === "--stacks") {
122
128
  result.stacks = true;
123
129
  } else if (arg === "--base") {
@@ -169,6 +175,10 @@ Commands:
169
175
  describe Show the effective config for one component
170
176
  vendor Pull pinned, checksummed patterns into your repo
171
177
  import Import external template into TypeScript
178
+ audit [path|url] Audit a repo's CI YAML for security issues
179
+ (--format stylish|json|sarif|markdown|html, -o <file>,
180
+ --tier merge-worthy|all, --fail-on merge-worthy|warning|none,
181
+ --template <file> / --theme <file> for the html report)
172
182
  migrate <file> Translate a workflow between lexicons
173
183
  (default: --from github --to gitlab)
174
184
 
@@ -287,6 +297,7 @@ const registry: CommandDef[] = [
287
297
  { name: "list", handler: runList },
288
298
  { name: "describe", handler: runDescribe },
289
299
  { name: "import", handler: runImport },
300
+ { name: "audit", handler: runAudit },
290
301
  { name: "migrate", handler: runMigrate },
291
302
  { name: "init", handler: runInit },
292
303
  { name: "init lexicon", handler: runInitLexicon },
@@ -63,6 +63,12 @@ export interface ParsedArgs {
63
63
  head?: string;
64
64
  /** `chant lifecycle affected --include-dependents` — add downstream consumers */
65
65
  includeDependents?: boolean;
66
+ /** `chant audit --tier merge-worthy|all` */
67
+ tier?: string;
68
+ /** `chant audit --fail-on merge-worthy|warning|none` */
69
+ failOn?: string;
70
+ /** `chant audit --theme <file>` — JSON theme knobs for the HTML report */
71
+ theme?: string;
66
72
  }
67
73
 
68
74
  /**