/** * Audit core — run chant's CI security checks against arbitrary repo YAML. * * The post-synth security checks (`lexicons//src/lint/post-synth/*.ts`) * read the emitted workflow YAML from `ctx.outputs`, not the chant model * (`ctx.entities`). So an auditor can feed *existing* repo YAML straight in as * a synthetic output and run the real rules — no import-to-chant-model step. * * Each file is audited as its own `primary` output so single-document security * checks (the merge-worthy tier: permissions, pinning, injection, secrets) * fire on every workflow. Cross-file checks (e.g. duplicate workflow names) * only see one file at a time here; that is acceptable because the security * tier is per-document. * * Checks that read `ctx.entities` instead of `ctx.outputs` will not fire on * audited YAML — the security tier is YAML-based, so this is by design. */ import { basename } from "path"; import type { Severity } from "../lint/rule"; import type { PostSynthCheck, PostSynthContext } from "../lint/post-synth"; import type { SerializerResult } from "../serializer"; import type { LexiconPlugin } from "../lexicon"; /** * The lexicon whose post-synth checks run against an audited file. Any lexicon * name is accepted — `defaultChecksProvider` loads the plugin by name — so a * caller can audit a lexicon outside the built-in set. The listed names are the * ones with built-in content detection (see `discover.ts`), kept here only for * editor autocomplete; `(string & {})` keeps the union open. */ export type AuditLexicon = | "github" | "gitlab" | "forgejo" | "k8s" | "docker" | "aws" | "azure" | "gcp" | "helm" | (string & {}); /** A single CI file to audit. */ export interface AuditInput { /** Path used to tag findings (e.g. ".github/workflows/ci.yml"). */ path: string; /** Raw content of the file. */ content: string; /** Which lexicon's checks to run against it. */ lexicon: AuditLexicon; /** * Bundle inputs (e.g. a Helm chart) supply a files map keyed by relative path * — checks that read `output.files` (helm, docker) see the whole bundle. */ files?: Record; } /** A finding produced by a post-synth check against an audited file. */ export interface AuditFinding { checkId: string; severity: Severity; message: string; /** The audited file this finding came from. */ file: string; /** The lexicon that produced the finding. */ lexicon: string; /** Optional entity (e.g. job name) the check attached. */ entity?: string; } /** * Resolve the post-synth checks for a lexicon. Injectable so the core can be * unit-tested without loading real lexicon packages. */ export type ChecksProvider = (lexicon: AuditLexicon) => Promise; const checksCache = new Map(); function dedupeById(checks: PostSynthCheck[]): PostSynthCheck[] { const byId = new Map(); for (const check of checks) { if (!byId.has(check.id)) byId.set(check.id, check); } return [...byId.values()]; } /** * Default provider: load the lexicon plugin(s) and return their post-synth * checks. Forgejo workflows are GitHub-dialect YAML, so the GitHub security * tier is run against them in addition to Forgejo's own checks. */ /** Thrown when a lexicon package the audit needs isn't installed. */ export class MissingLexiconError extends Error {} async function load(names: string[]): Promise { try { // Lazy import so that merely importing `auditFiles` doesn't pull in // `cli/plugins` -> config loader -> the TypeScript compiler. A caller that // supplies its own `checksProvider` (e.g. an edge/bundled deployment) never // reaches this and never bundles that graph. See #408. const { loadPlugins } = await import("../cli/plugins"); return await loadPlugins(names); } catch (err) { const pkgs = names.map((n) => `@intentius/chant-lexicon-${n}`).join(" "); throw new MissingLexiconError( `Missing lexicon package needed to audit ${names.join("/")} workflows. Install it with: npm i ${pkgs}\n(${err instanceof Error ? err.message : String(err)})`, ); } } async function defaultChecksProvider(lexicon: AuditLexicon): Promise { const cached = checksCache.get(lexicon); if (cached) return cached; let checks: PostSynthCheck[]; if (lexicon === "forgejo") { const [forgejo, github] = await load(["forgejo", "github"]); checks = dedupeById([ ...(forgejo?.postSynthChecks?.() ?? []), ...(github?.postSynthChecks?.() ?? []), ]); } else { const [plugin] = await load([lexicon]); checks = plugin?.postSynthChecks?.() ?? []; } checksCache.set(lexicon, checks); return checks; } /** * Audit a set of CI files and return all findings. Pure with respect to the * filesystem and network — callers supply file contents. */ export async function auditFiles( inputs: AuditInput[], opts: { checksProvider?: ChecksProvider } = {}, ): Promise { const provider = opts.checksProvider ?? defaultChecksProvider; const findings: AuditFinding[] = []; // Group by lexicon so each plugin's checks are resolved once. const byLexicon = new Map(); for (const input of inputs) { const list = byLexicon.get(input.lexicon) ?? []; list.push(input); byLexicon.set(input.lexicon, list); } for (const [lexicon, files] of byLexicon) { const checks = await provider(lexicon); if (checks.length === 0) continue; findings.push(...auditLexicon(lexicon, files, checks)); } return findings; } /** Label for findings that span more than one file (e.g. duplicate names). */ export const CROSS_FILE = "(cross-file)"; /** * Each file as its own serialized output. Most lexicons get a SerializerResult * (primary + basename-keyed files; docker filters `files` by name). The gcp * checks require the output to be a raw string (`typeof output === "string"`), * so gcp gets the content directly. */ function toOutput(file: AuditInput): string | SerializerResult { if (file.files) return { primary: file.content, files: file.files }; if (file.lexicon === "gcp") return file.content; return { primary: file.content, files: { [basename(file.path)]: file.content } }; } function runChecks(checks: PostSynthCheck[], outputs: Map): ReturnType { const buildResult: PostSynthContext["buildResult"] = { outputs, entities: new Map(), warnings: [], errors: [], sourceFileCount: outputs.size }; const ctx: PostSynthContext = { outputs, entities: buildResult.entities, buildResult }; const diags = []; for (const check of checks) { try { diags.push(...check.check(ctx)); } catch { // A check that throws on unusual external YAML must not abort the audit. } } return diags; } function diagKey(d: { checkId: string; entity?: string; message: string }): string { return `${d.checkId}${d.entity ?? ""}${d.message}`; } /** * Audit one lexicon's files with cross-file awareness. * * Two passes: * - **all-files** (every file in one context) is the source of truth — it lets * relational checks resolve (an Application sees its AppProject elsewhere; a * duplicate name is seen across files) and clears single-file false positives. * - **per-file** supplies the file each finding belongs to. * * A per-file finding is kept only if it survives in the all-files pass (drops * cross-file-resolved false positives). An all-files finding with no per-file * match is a genuine cross-file finding, labelled `CROSS_FILE`. */ function auditLexicon(lexicon: AuditLexicon, files: AuditInput[], checks: PostSynthCheck[]): AuditFinding[] { const perFindings: AuditFinding[] = []; const perKeys = new Set(); for (const file of files) { const diags = runChecks(checks, new Map([[file.path, toOutput(file)]])); for (const d of diags) { perFindings.push({ checkId: d.checkId, severity: d.severity, message: d.message, file: file.path, lexicon: d.lexicon ?? lexicon, entity: d.entity }); perKeys.add(diagKey(d)); } } const allOutputs = new Map(files.map((f) => [f.path, toOutput(f)])); const allDiags = runChecks(checks, allOutputs); const allKeys = new Set(allDiags.map(diagKey)); const out: AuditFinding[] = perFindings.filter((f) => allKeys.has(diagKey(f))); for (const d of allDiags) { if (!perKeys.has(diagKey(d))) { out.push({ checkId: d.checkId, severity: d.severity, message: d.message, file: CROSS_FILE, lexicon: d.lexicon ?? lexicon, entity: d.entity }); } } return out; }