Skip to content

chant audit

chant audit [path] [flags]
chant audit <repo-url> [flags]

chant audit runs chant’s security, correctness, and best-practice rules against an existing repository’s config — CI/CD pipelines plus 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 requires no 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} with apiVersion + kind), for a local path — runs the WK8*/ARGO* checks (privileged containers, host namespaces, hardcoded secrets, image pinning, …)
  • Docker — Dockerfile/*.Dockerfile and Compose files (*.{yml,yaml} with services:), for a local path — runs the DKRD* checks (root user, :latest images, exposed SSH, …)
  • AWS CloudFormation — .json/.yaml/.yml/.template with AWSTemplateFormatVersion or AWS::* resources, for a local path — runs the WAW*/COR*/EXT* checks (public S3, open security groups, IAM wildcards, unencrypted storage, …). Both JSON and YAML templates.
  • Azure ARM — .json deployment templates ($schema with deploymentTemplate + resources), for a local path — runs the AZR* checks (public blob access, missing encryption/TDE, HTTPS-only, TLS 1.2, …)
  • GCP Config Connector — *.{yml,yaml} with a cnrm.cloud.google.com apiVersion, for a local path — runs the WGC* 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 the WHM* checks (privileged/root containers, :latest images, inline Secrets, chart structure, …). The whole chart is audited as one unit.
  • Terraform root modules, any directory with at least one .tf file directly inside it, for a local path. Not recursive: a nested directory with its own .tf files (e.g. modules/foo) is its own root module and its own audit unit. Install @intentius/chant-lexicon-terraform like any other lexicon.
  • fountain manifests — *.{yml,yaml} with apiVersion: fountain.dev/v1 (fountain apply files, single or multi-document), for a local path — runs the FTN* checks (unrestricted networking, credential-shaped env_vars, vault shadowing, unresolvable ${VAR} references, …). The documents are parsed back into the entity graph, so these are the same rules chant build runs — 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 .p12 files, 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. TOML is not a format any lexicon parses, so this is audit-only (#446): no lexicon package to install, no authoring surface — chant never writes Wrangler config, it only reads it for these checks. Runs regardless of which lexicons are installed, the same as SEC*. See Wrangler config detection below.
  • nginx config (NGX*) — nginx.conf, and .conf files under nginx-ish directories (nginx/, conf.d/, sites-available/, sites-enabled/, snippets/) whose parsed content confirms they are nginx. Audit-only like WRG* (#1979): chant never writes nginx config, it only reads it for these checks. See nginx config detection below.

A different subject entirely, behind its own flag: --agents audits the agent configuration installed on the machine rather than a repository — instruction files, MCP servers, skills, plugins, and tool permissions, 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-privilege permissions: 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_target hardening, secret scope), clustered by the security area they belong to.
  • Report-only (hygiene) — style/perf/deprecation, never something to open a PR over.

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 ->.

FlagDescription
-f, --format <fmt>Output format: stylish (default), json, sarif, markdown, html
--jsonShortcut 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)
--agentsAudit 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

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 worth auditing separately from CI because it is a different threat model. 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.

Terminal window
chant audit --agents # every scope, every harness
chant audit --agents --scope user # just the home directory
chant audit --agents --format html -o agents.html # shareable report
chant audit --agents --tier merge-worthy --fail-on merge-worthy # for CI

Three scopes are scanned, in the order harnesses merge them:

ScopeWhereWhy it matters
systemmachine-wide managed policyAdministrator-set, not user-editable
useryour home directoryApplies to every project you open, including repos you don’t own
projectthe path argument (default .), or every registered project with --all-projectsNarrowest, 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 sitting in but haven’t opened before is not dropped:

Terminal window
chant audit --agents --scope project --all-projects --tier merge-worthy

When 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.

  • Minimized--format markdown (or stylish/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.
  • --theme theme.json fills knobs into the default template:

    { "title": "Acme CI Audit", "logo": "https://acme.example/logo.svg", "accent": "#0b7285", "footer": "Internal use only." }
  • --template my.html replaces the whole document. Available slots: {{title}}, {{accent}}, {{logo}}, {{styles}}, {{body}}, {{footer}}. The findings render into {{body}}.

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 snapshot anchors findings to an exact commit for reproducibility.
  • status is "ok" when at least one lexicon ran, "no-lexicons" when none could (see Prerequisites). Check it before trusting an empty findings. An optional unclaimed list names files that wanted a lexicon that is not installed.
  • lineage credits 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, integrate via --format sarif — an industry-standard, independently-versioned format.

--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’s helpUri (the same authority citation as the other formats) plus a help.text with the catalog’s remediation guidance, and a properties.category/properties.dimension pair (security, correctness, or best-practice) for rule-level triage. A rule with prior art also carries properties.priorArt, the same lineage entries the JSON report emits.
  • Each run.results[] entry carries properties.tier (merge-worthy or report-only) alongside the standard ruleId, level, message, and locations (file + line, when known).
  • Secrets findings (SEC001SEC010) redact the same as every other format — a result’s message.text never carries the matched value, only the rule id, kind, location, and fingerprint.
  • --agents emits the same property bag under a chant audit --agents driver name, so one consumer can triage both. Its rules’ helpUri points at the rule’s own entry in the audit rules reference rather than an external authority, since an AGT finding 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.sarif

Auditing a URL calls the host’s API — auditing a local path reaches nothing, 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 — GitLab
  • CODEBERG_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.

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:

Terminal window
# CI/CD pipelines
npm i @intentius/chant-lexicon-github # GitHub Actions (also Forgejo workflows)
npm i @intentius/chant-lexicon-gitlab # GitLab CI
npm i @intentius/chant-lexicon-forgejo # Forgejo dialect
# Infrastructure & manifests
npm i @intentius/chant-lexicon-k8s # Kubernetes manifests
npm i @intentius/chant-lexicon-helm # Helm charts
npm i @intentius/chant-lexicon-docker # Dockerfiles / Compose
npm i @intentius/chant-lexicon-aws # CloudFormation
npm i @intentius/chant-lexicon-azure # ARM templates
npm i @intentius/chant-lexicon-gcp # Config Connector
npm i @intentius/chant-lexicon-fountain # fountain manifests

Without a project to install into, put every lexicon you need on the same npx path:

Terminal window
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 surfaces that as an error naming the package.

The SEC* rules (see the rules reference) scan the raw text of every scanned file for likely credentials — independent of lexicon, so they run even for file types no installed lexicon claims. Two layers:

  • Pattern detectors for well-known credential shapes: SEC001 AWS access key ID, SEC002 AWS secret access key, SEC003 GitHub token, SEC004 Slack token, SEC005 Google API key, SEC006 Stripe live secret key, SEC007 PEM private key block, SEC008 bearer/authorization token, SEC009 credentials 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 conservative 4.0 bits/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 — never the matched value itself.

Two ways, matching the other false-positive escape hatches in the tool:

  • Inline marker — a comment containing chant-audit-ignore on 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 with chant-audit-ignore: SEC001,SEC010; bare chant-audit-ignore suppresses all of them.

    # chant-audit-ignore: SEC010
    fixture_token: "not-a-real-secret-just-looks-random-9f3k2"
  • Config allowlist — a .chant-audit.json at the audited root:

    {
    "secrets": {
    "entropyThreshold": 4.5,
    "allow": [
    { "ruleId": "SEC010", "file": "fixtures/example.env", "fingerprint": "a1b2c3d4e5f6a1b2" }
    ]
    }
    }

    Copy the fingerprint straight from the finding’s message or JSON output — it’s a hash of the value, so allowlisting a false positive never means writing the secret into the config. An allow entry needs ruleId and/or fingerprint; file alone 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.

The WRG* rules (see the rules reference) read wrangler.toml — parsed with a small dependency-free TOML parser built for this (packages/core/src/toml.ts), not a full spec implementation, but enough for real deploy configs (tables, arrays of tables, dotted/nested [env.<name>] blocks, inline tables). Like SEC*, this runs independent of lexicon — it does not need any @intentius/chant-lexicon-* package installed, since Wrangler config is audit-only: 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 — same tolerance as the rest of the engine.

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) — directives, nested blocks, quoted arguments, comments, with line numbers. Like SEC*/WRG*, this runs independent of lexicon: nginx config is audit-only — 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).

A file that fails to parse contributes no findings rather than failing the audit — same tolerance as the rest of the engine.

Terminal window
# Audit the current repo
chant audit
# Audit a local checkout
chant audit ../some-repo
# Audit a public repo over the network
chant audit https://github.com/owner/repo
chant audit https://gitlab.com/group/project
chant audit https://codeberg.org/owner/repo
# Save a shareable markdown report
chant audit https://github.com/owner/repo -f markdown -o report.md
# Generate a presentable, branded HTML report
chant 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 exist
chant audit . --tier merge-worthy --json --fail-on merge-worthy
--fail-onExit 1 when…
none (default)never (read-only)
merge-worthyany merge-worthy finding exists
warningany error- or warning-severity finding exists
errorany 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 2 when no audit lexicon is installed (nothing was inspected, see Prerequisites) and 1 on a hard failure such as an unreachable repo or a missing path.