chant audit
Synopsis
Section titled “Synopsis”chant audit [path] [flags]chant audit <repo-url> [flags]Description
Section titled “Description”chant audit runs chant’s security, correctness, and best-practice rules against an existing repository’s CI/CD pipelines, IaC and manifests (Kubernetes, Helm, Docker, CloudFormation, ARM, Config Connector, fountain), including files chant didn’t generate. Point it at a local directory or a public repo URL and it produces a tiered report grouped by category, with findings worth a pull request split from lower-priority hygiene.
It is read-only and runs outside a chant project. It reads the repo’s CI files directly and runs the same post-synth security checks the build pipeline uses (GitHub Actions GHA*, GitLab CI WGL*; Forgejo workflows are GitHub-dialect, so the GitHub tier applies to them too).
What it scans:
.github/workflows/*.{yml,yaml}(GitHub Actions / Forgejo).forgejo/workflows/*.{yml,yaml}(Forgejo / Codeberg / Gitea).gitlab-ci.yml(GitLab CI)- Kubernetes manifests (any
*.{yml,yaml}withapiVersion+kind), for a local path — runs theWK8*/ARGO*checks (privileged containers, host namespaces, hardcoded secrets, image pinning, …) - Docker —
Dockerfile/*.Dockerfileand Compose files (*.{yml,yaml}withservices:), for a local path — runs theDKRD*checks (root user,:latestimages, exposed SSH, …) - AWS CloudFormation —
.json/.yaml/.yml/.templatewithAWSTemplateFormatVersionorAWS::*resources, for a local path — runs theWAW*/COR*/EXT*checks (public S3, open security groups, IAM wildcards, unencrypted storage, …). Both JSON and YAML templates. - Azure ARM —
.jsondeployment templates ($schemawithdeploymentTemplate+resources), for a local path — runs theAZR*checks (public blob access, missing encryption/TDE, HTTPS-only, TLS 1.2, …) - GCP Config Connector —
*.{yml,yaml}with acnrm.cloud.google.comapiVersion, for a local path — runs theWGC*checks (public IAM, open firewalls, public Cloud SQL, missing encryption, Workload Identity, …) - Helm charts — any directory with a
Chart.yaml, for a local path — runs theWHM*checks (privileged/root containers,:latestimages, inline Secrets, chart structure, …). The whole chart is audited as one unit. - Terraform root modules, any directory with at least one
.tffile directly inside it (or.tf.json,.tofu,.tofu.json), for a local path. A root that holds a.tofufile is read as OpenTofu reads it: its.tofufiles count, andx.tofureplacesx.tf. Not recursive: a nested directory with its own.tffiles (e.g.modules/foo) is its own root module and its own audit unit. Install@intentius/chant-lexicon-terraformlike any other lexicon. - fountain manifests —
*.{yml,yaml}withapiVersion: fountain.dev/v1(fountain applyfiles, single or multi-document), for a local path — runs theFTN*checks (unrestricted networking, credential-shapedenv_vars, vault shadowing, unresolvable${VAR}references, …). The documents are parsed back into the entity graph, so these are the same ruleschant buildruns — and a fountain document is never misread as a Kubernetes manifest. - Secrets & credentials (
SEC*) — every file above, plus.env*,.pem,.key,.crt,.cer,.pfx, and.p12files, are scanned for likely secrets regardless of which lexicons are installed. See Secrets & credentials detection below. - Wrangler config (
WRG*) —wrangler.toml, Cloudflare Workers’ native deploy config. No lexicon parses TOML, so this is audit-only (#446): there is no lexicon package to install, and chant reads Wrangler config for these checks and never writes it. Runs regardless of which lexicons are installed, the same asSEC*. See Wrangler config detection below. - nginx config (
NGX*) —nginx.conf, and.conffiles under nginx-ish directories (nginx/,conf.d/,sites-available/,sites-enabled/,snippets/) whose parsed content confirms they are nginx. LikeWRG*, this is audit-only (#1979). See nginx config detection below.
--agents audits the agent configuration installed on the machine instead of a repository, running the AGT* rules. See Auditing agent configuration below.
Every rule a finding cites links to its entry in the audit rules reference.
The report groups findings into:
- Quick wins (deterministic) — safe mechanical fixes shown as a ready-to-apply unified
diff(add a least-privilegepermissions:block, pin actions to a commit SHA, pin images to a digest). - Needs review (guidance) — findings that need a judgement call (script injection,
pull_request_targethardening, secret scope), clustered by the security area they belong to. - Report-only (hygiene) — style/perf/deprecation, never something to open a PR over.
Example
Section titled “Example”The minimized (markdown) report leads with ready-to-apply fix diffs; rule IDs link to the rules reference:
# chant audit
4 findings — 3 quick-win, 0 needs-review, 1 report-only (0 error, 3 warning, 1 info).
## Quick wins (deterministic)
### `.github/workflows/ci.yml`
Addresses [GHA033](/chant/lint-rules/audit-rules/#gha033) (Blanket write-all permissions):
```diff-permissions: write-all+permissions:+ contents: read```
Needs a value before it can be auto-patched:- **[GHA021](/chant/lint-rules/audit-rules/#gha021)** (`build`) — Pin `actions/checkout` to a full 40-char commit SHA.
<details><summary>Report-only (hygiene) — 1</summary>…</details>The expanded (HTML) report renders the same findings with badges, cards, and syntax-highlighted diffs. View a sample report ->
| Flag | Description |
|---|---|
-f, --format <fmt> | Output format: stylish (default), json, sarif, markdown, html |
--json | Shortcut for --format json |
-o, --output <file> | Write the report to a file instead of stdout |
--tier <tier> | all (default) or merge-worthy to show only PR-worthy findings |
--fail-on <level> | Exit non-zero on merge-worthy, warning, error, or none (default none) |
--template <file> | html only: a full HTML template override (see below) |
--theme <file> | html only: a JSON file of theme knobs (title, logo, accent, footer) |
--max-files <n> | Local path only: how many files the walk takes before it stops (default 1000). See Scan limits |
--agents | Audit this machine’s agent configuration instead of a repository (see below) |
--scope <list> | --agents only: comma-separated system, user, project (default: all) |
--runtime <list> | --agents only: comma-separated claude, codex, gemini, opencode, cursor (default: all) |
--all-projects | --agents only: scan every project registered in ~/.claude.json, not just the path argument |
Auditing agent configuration (--agents)
Section titled “Auditing agent configuration (--agents)”chant audit --agents changes the subject from a repository to the machine
you are on: the instruction files, MCP servers, skills, plugins, and tool
permissions that configure your coding agents.
It is audited apart from CI because its threat model differs. An agent config is executable surface that runs with your own credentials on your own machine, usually with no review step. An MCP server entry is a command line that runs at session start, a skill is instructions the model will follow, and a permission allowlist decides what runs without anyone being asked. All of it is typically installed with a single command and rarely re-read afterwards.
chant audit --agents # every scope, every harnesschant audit --agents --scope user # just the home directorychant audit --agents --format html -o agents.html # shareable reportchant audit --agents --tier merge-worthy --fail-on merge-worthy # for CIThree scopes are scanned, in the order harnesses merge them:
| Scope | Where | Why it matters |
|---|---|---|
system | machine-wide managed policy | Administrator-set, not user-editable |
user | your home directory | Applies to every project you open, including repos you don’t own |
project | the path argument (default .), or every registered project with --all-projects | Narrowest, but most likely to have arrived from someone else |
--all-projects reads the project list the harness already maintains in
~/.claude.json, so it needs no disk crawl and no configuration. Registrations
whose directory no longer exists are skipped. The path argument is still
included, so a project you are in but haven’t opened before is still
scanned:
chant audit --agents --scope project --all-projects --tier merge-worthyWhen a scan does not cover everything, the report says so (“Scanned 1 project root; 64 more are registered in ~/.claude.json and were not visited”), and when it does, it states the breadth instead, because “5 findings” reads very differently across one project than across sixty-five.
Five harnesses are recognized (Claude Code, codex, Gemini, opencode and Cursor), and each is normalized onto one model, so a finding reads the same regardless of which tool’s dialect declared it.
The report leads with an inventory (what is configured, before any judgement
about it), because for most users that list has never been seen in one place. It
then states its own edges: locations probed, files that could not be parsed, and
registered projects it did not visit. The rules it applies are the AGT family
in the audit rules reference.
To turn what it finds into chant code, see
chant import --agents.
Scan limits
Section titled “Scan limits”A local audit walks the tree once and stops after 1000 files. Every file the walk reaches counts toward that limit, including the ones it does not audit. A repository with a large docs/ directory can therefore hit it early. Directories the walk skips (node_modules, .git, dist, and most dot-directories) are never counted.
Pass --max-files <n> to raise the limit. A repository URL is fetched with its own limits, so the flag is refused there.
When the walk stops with files left over, the report says so. Text output (stylish, markdown and html) leads with a note:
Note: The scan of ./repo stopped at 1000 files, so part of the tree was not audited. The limit counts every file the walk reaches, not only the ones it audits. Raise it with --max-files <n>.JSON output carries a truncated field with the limit and the flag that raises it. The field is absent when the whole tree was read:
{ "schemaVersion": "1.0", "status": "ok", "truncated": { "limit": 1000, "flag": "--max-files" }, "...": "..." }SARIF has no place for the note, so with --format sarif it goes to stderr instead.
Up to v0.80.0 a truncated scan read as a whole one on stdout, and v0.80.0 warned about it on stderr. This is one of the level-0 changes on the exception list (#2528).
.gitignore and TF023
Section titled “.gitignore and TF023”TF023 reports Terraform state and .terraform/ directories that are committed. On a local path, a path that any .gitignore between it and the scan root ignores is not committed, so it is not reported. Auditing from the repository root and from a subdirectory such as infra/ agree about infra/terraform.tfstate whenever the .gitignore that ignores it is at or below infra/. Up to v0.80.0 only the scan root’s own .gitignore was read (#2528).
Report modes
Section titled “Report modes”- Minimized:
--format markdown(orstylish/json/sarif). Compact, pastes into a PR or issue; quick wins lead with ready-to-apply diffs, the rest collapses into<details>. - Expanded:
--format html. A self-contained, presentable HTML document (inline CSS, opens in a browser, shareable as one file): severity badges, tier counts, styled finding cards, syntax-highlighted fix diffs, and a provenance snapshot (host, repo, commit SHA, files scanned, timestamp, chant version) so every finding is anchored to an exact commit.
Customizing the HTML report
Section titled “Customizing the HTML report”-
--theme theme.jsonfills knobs into the default template:{ "title": "Acme CI Audit", "logo": "https://acme.example/logo.svg", "accent": "#0b7285", "footer": "Internal use only." } -
--template my.htmlreplaces the whole document. Available slots:{{title}},{{accent}},{{logo}},{{styles}},{{body}},{{footer}}. The findings render into{{body}}.
Machine-readable output
Section titled “Machine-readable output”For tooling, --format json emits a versioned envelope (stable contract). The same object is embedded in the HTML report inside <script type="application/json" id="chant-audit-report">, so the expanded report is both human- and machine-readable.
{ "schemaVersion": "1.0", "tool": { "name": "chant-audit", "version": "0.7.0" }, "status": "ok", "snapshot": { "target": "...", "host": "github.com", "repo": "owner/repo", "commit": "…", "files": ["..."], "generatedAt": "…", "toolVersion": "0.7.0" }, "summary": { "total": 0, "quickWin": 0, "needsReview": 0, "reportOnly": 0, "errors": 0, "warnings": 0, "infos": 0, "security": 0, "correctness": 0, "bestPractice": 0 }, "findings": [ { "checkId": "GHA033", "severity": "warning", "message": "…", "file": "…", "entity": "…", "lexicon": "github", "tier": "merge-worthy", "fixKind": "deterministic", "category": "security", "title": "…", "remediation": "…", "authority": [{ "name": "…", "url": "…" }], "lineage": [{ "tool": "zizmor", "rule": "excessive-permissions", "url": "…", "relation": "equivalent" }], "docUrl": "https://intentius.io/chant/lint-rules/audit-rules/#gha033" } ]}Stability contract:
- Additive changes (new fields) keep the same
schemaVersion; renamed or removed fields bump it. - Consumers should check the major version and ignore unknown fields.
- The
snapshotanchors findings to an exact commit for reproducibility. statusis"ok"when at least one lexicon ran,"no-lexicons"when none could (see Prerequisites). Check it before trusting an emptyfindings. An optionalunclaimedlist names files that wanted a lexicon that is not installed.- An optional
truncatedobject (limit,flag) means the local walk stopped at its file limit, sofindingscovers only part of the tree (see Scan limits). lineagecredits the open-source tools whose rules check the same condition, with the upstream rule id and how the two relate. It is credit, not authority, and is empty for a rule with no known counterpart. The audit rules reference lists every tool credited.- For maximum stability, use
--format sarif, an industry-standard format versioned independently of chant.
--format sarif emits a SARIF 2.1.0 document, the interchange format most CI platforms and code-review surfaces (GitHub code scanning, GitLab, Azure DevOps, many SAST/IDE viewers) render as inline, file-and-line annotations instead of scraped text:
- Each
run.tool.driver.rules[]entry carries the finding’shelpUri(the same authority citation as the other formats) plus ahelp.textwith the catalog’s remediation guidance, and aproperties.category/properties.dimensionpair (security,correctness, orbest-practice) for rule-level triage. A rule with prior art also carriesproperties.priorArt, the samelineageentries the JSON report emits. - Each
run.results[]entry carriesproperties.tier(merge-worthyorreport-only) alongside the standardruleId,level,message, andlocations(file + line, when known). - Secrets findings (
SEC001–SEC010) redact the same as every other format — a result’smessage.textnever carries the matched value, only the rule id, kind, location, and fingerprint. --agentsemits the same property bag under achant audit --agentsdriver name, so one consumer can triage both. Its rules’helpUripoints at the rule’s own entry in the audit rules reference rather than an external authority, since anAGTfinding is about a local file.
Upload it to GitHub code scanning from a workflow:
- name: Audit run: npx @intentius/chant audit . --format sarif --output audit.sarif
- name: Upload to code scanning uses: github/codeql-action/upload-sarif@v3 with: sarif_file: audit.sarifTokens
Section titled “Tokens”Auditing a URL calls the host’s API. Auditing a local path reaches no network, and packages/core/src/audit/fetch.ts is the only audit module that touches the network at all (Network Egress). Set a host-specific token to lift rate limits (and, for GitHub, to resolve action SHAs and image digests for the inline fix diffs):
GITHUB_TOKEN: GitHub, plus action-SHA and image-digest resolution (always queries the GitHub/registry APIs)GITLAB_TOKEN: GitLabCODEBERG_TOKEN: Codeberg
Without a token, unauthenticated GitHub is limited to 60 requests/hour and pin fixes fall back to “needs a value” instead of an inline diff. Tokens are never sent across hosts.
Prerequisites
Section titled “Prerequisites”chant audit runs each lexicon’s security checks, so it needs the lexicon packages for the file types you want audited installed alongside @intentius/chant:
# CI/CD pipelinesnpm i @intentius/chant-lexicon-github # GitHub Actions (also Forgejo workflows)npm i @intentius/chant-lexicon-gitlab # GitLab CInpm i @intentius/chant-lexicon-forgejo # Forgejo dialect
# Infrastructure & manifestsnpm i @intentius/chant-lexicon-k8s # Kubernetes manifestsnpm i @intentius/chant-lexicon-helm # Helm chartsnpm i @intentius/chant-lexicon-docker # Dockerfiles / Composenpm i @intentius/chant-lexicon-aws # CloudFormationnpm i @intentius/chant-lexicon-azure # ARM templatesnpm i @intentius/chant-lexicon-gcp # Config Connectornpm i @intentius/chant-lexicon-fountain # fountain manifestsWithout a project to install into, put every lexicon you need on the same npx path:
npx -p @intentius/chant -p @intentius/chant-lexicon-github -p @intentius/chant-lexicon-k8s chant audit .A bare npx @intentius/chant audit . has no lexicons and cannot detect anything. It does not report clean in that case. It says it had nothing to look with, lists each file that looked like it wanted a lexicon (by path, filename, and a few content markers), prints the npx -p ... line that covers exactly those files, and exits with code 2:
chant audit had nothing to look with: no audit lexicon is installed, so nothing under . was inspected.This is not a clean result. Detection and checks live in the lexicon packages.
Files that wanted a lexicon: .github/workflows/ci.yml -> github Dockerfile -> docker infra/main.tf -> terraform k8s/deploy.yaml -> k8s
Run it with the lexicons those files need: npx -p @intentius/chant -p @intentius/chant-lexicon-github -p @intentius/chant-lexicon-docker -p @intentius/chant-lexicon-terraform -p @intentius/chant-lexicon-k8s chant audit .With --json the same run emits "status": "no-lexicons" plus unclaimed, missingLexicons, and install fields, and no findings.
When some lexicons are installed and files on disk look like they belong to one that is not, the report leads with a one-line note naming the skipped files and the npm i to fix it, and --json carries them in unclaimed. Terraform is listed the same way, npm i @intentius/chant-lexicon-terraform included.
If a lexicon package is present for detection but its checks fail to load, the command reports an error naming the package.
Lexicons a project declares by path
Section titled “Lexicons a project declares by path”A chant project can declare a lexicon by module path in chant.config.ts (A lexicon in your own project). Such a lexicon has no package. The install line leaves it out, and the messages above name its path instead. Audit reads the target’s config without running it, since it audits code it must not execute. When the config’s lexicons cannot be read that way, audit prints a note saying so. Every lexicon is then treated as a package. It never loads a lexicon’s module from a path either: its checks always come from installed lexicon packages. See Commands that read chant.config.ts without running it.
Secrets & credentials detection
Section titled “Secrets & credentials detection”The SEC* rules (see the rules reference) scan the raw text of every scanned file for likely credentials. They are independent of lexicon, so they run even for file types no installed lexicon claims. Two layers:
- Pattern detectors for well-known credential shapes:
SEC001AWS access key ID,SEC002AWS secret access key,SEC003GitHub token,SEC004Slack token,SEC005Google API key,SEC006Stripe live secret key,SEC007PEM private key block,SEC008bearer/authorization token,SEC009credentials embedded in a connection string (scheme://user:pass@host). SEC010, a high-entropy heuristic: a catch-all for a random-looking token (real character-class diversity, not a pure-hex run like a git SHA) that doesn’t match a known shape. Its entropy threshold defaults to a conservative4.0bits/char and is tunable via.chant-audit.json(below).
Redaction is unconditional. A secrets finding’s message, JSON, SARIF, and HTML report output carry only the rule id, the kind of secret, the file/line location, and a non-reversible fingerprint of the value. The matched value itself never appears.
Suppressing a secrets finding
Section titled “Suppressing a secrets finding”Two ways, the same as for other false positives:
-
Inline marker: a comment containing
chant-audit-ignoreon the flagged line (or the line immediately above it, for a PEM block whose-----BEGIN…-----line can’t carry a trailing comment) suppresses every secrets finding on that line. Scope it to specific rules withchant-audit-ignore: SEC001,SEC010; barechant-audit-ignoresuppresses all of them.# chant-audit-ignore: SEC010fixture_token: "not-a-real-secret-just-looks-random-9f3k2" -
Config allowlist: a
.chant-audit.jsonat the audited root:{"secrets": {"entropyThreshold": 4.5,"allow": [{ "ruleId": "SEC010", "file": "fixtures/example.env", "fingerprint": "a1b2c3d4e5f6a1b2" }]}}Copy the
fingerprintstraight from the finding’s message or JSON output. It is a hash of the value, so allowlisting a false positive never means writing the secret into the config. An allow entry needsruleIdand/orfingerprint;filealone never blanket-suppresses everything in a file.
A Terraform finding (TF*) is a different mechanism: it reads # chant-ignore, # chant-ignore-file, and # chant-ignore-block comments straight out of the .tf source, so it works identically through chant audit and chant build. See the terraform lexicon’s Suppressing a finding section.
Wrangler config detection
Section titled “Wrangler config detection”The WRG* rules (see the rules reference) read wrangler.toml with a small dependency-free TOML parser (packages/core/src/toml.ts) that covers what real deploy configs use (tables, arrays of tables, dotted/nested [env.<name>] blocks, inline tables) short of the full spec. Like SEC*, it runs with no @intentius/chant-lexicon-* package installed. Wrangler config is audit-only, and chant has no authoring surface or serializer for it.
Checks cover: a production-named environment exposed on the public *.workers.dev subdomain (WRG001), a credential-shaped key committed in [vars] instead of wrangler secret (WRG002), observability explicitly disabled (WRG003), an unscoped wildcard route matching every zone on the account (WRG004), a non-production environment sharing a KV/R2/D1 binding id with production (WRG005), and static assets served from the project root (WRG006).
A file that fails to parse (or isn’t found) contributes no findings rather than failing the audit, as elsewhere in the engine.
nginx config detection
Section titled “nginx config detection”The NGX* rules (see the rules reference) read nginx’s native directive/block config, parsed with a small dependency-free recursive-descent parser built for this (packages/core/src/audit/nginx.ts), which reads directives, nested blocks, quoted arguments and comments with line numbers. Like SEC*/WRG*, it runs independent of lexicon: nginx config is audit-only, and chant has no authoring surface or serializer for it.
Detection is two-stage, because .conf names are shared with unrelated software (systemd drop-ins live in *.d/ directories too): a path candidate (nginx.conf anywhere, or .conf under nginx/, conf.d/, sites-available/, sites-enabled/, snippets/) only produces findings when its parsed form also carries an nginx marker directive (http, server, location, upstream, …).
Checks cover: deprecated TLS protocols in ssl_protocols (NGX001), weak cipher suites in ssl_ciphers (NGX002), directory listing via autoindex on (NGX003), the alias path-traversal shape — a location prefix without a trailing slash over an alias with one (NGX004), a stub_status endpoint with no access restriction (NGX005), server version disclosure — server_tokens never disabled (NGX006), and access logging disabled at http/server scope (NGX007).
An nginx file that fails to parse is skipped without failing the audit.
# Audit the current repochant audit
# Audit a local checkoutchant audit ../some-repo
# Audit a public repo over the networkchant audit https://github.com/owner/repochant audit https://gitlab.com/group/projectchant audit https://codeberg.org/owner/repo
# Save a shareable markdown reportchant audit https://github.com/owner/repo -f markdown -o report.md
# Generate a presentable, branded HTML reportchant audit https://github.com/owner/repo -f html --theme theme.json -o report.html
# Only the PR-worthy findings, as JSON, failing CI if any existchant audit . --tier merge-worthy --json --fail-on merge-worthyExit codes
Section titled “Exit codes”--fail-on | Exit 1 when… |
|---|---|
none (default) | never (read-only) |
merge-worthy | any merge-worthy finding exists |
warning | any error- or warning-severity finding exists |
error | any error-severity finding exists (chant #2113) |
error is a severity floor, decoupled from merge-worthy/warning’s tier-
based checks: it’s the value that lets a new rule ship reporting at
"warning" and be promoted to "error" later without breaking a pipeline
pinned to --fail-on error. See Rule
Configuration
for the full explanation.
Independent of --fail-on, the command exits 1 when no audit lexicon is installed (nothing was inspected, see Prerequisites) and on a hard failure such as an unreachable repo or a missing path. Exit 2 is the drift code and audit never returns it.