i
Install
airing is a Node CLI (Node 18.17 or newer). The hosts you deploy to are all npm CLIs, so you already have what it needs. Run it straight from GitHub:
npx github:Blackman99/airing .
or install it once (do this if you use the Claude Code hook — it then starts in milliseconds instead of re-fetching on every command):
npm i -g https://github.com/Blackman99/airing/releases/latest/download/airing.tgz airing --version
Pinned to a release:
npm i -g https://github.com/Blackman99/airing/releases/download/v0.1.0/airing-0.1.0.tgz
airing is not on the npm registry yet, so npx airing will not find it — use npx github:Blackman99/airing or the release tarball. Don't use npm i -g github:…: npm 11 links a temporary clone and leaves a dead airing command. The package has no runtime dependencies; dist/ is committed, so nothing is built on install.
It reads files and makes no network requests, except for airing probe, which makes GET requests to the URL you give it.
ii
Usage
Before deploying: airing [dir]
Run it in your project. airing works out which host and directory you deploy from your config. If it finds no config, it checks every host and shows a matrix of what each one would put in public.
airing # this folder, host from config airing --host cf-pages # one host, with the fix for it airing --dir dist # override the deploy directory airing --matrix # every host, even if config names one
After deploying: airing probe <url>
The probe GETs about 40 well-known paths, including /.env and its variants, /.git/config, /.git/HEAD, /.npmrc, private keys, SQL and SQLite dumps, /.aws/credentials, service-account JSON, agent settings and /.DS_Store. It also fetches the source maps of the page's own scripts. A request to a random path runs first so that an SPA's catch-all page is never reported as a hit.
airing probe https://my-site.pages.dev airing probe https://blackman99.github.io/airing/leaky-demo/ # our deliberately leaky page
Only probe sites you own. If a key turns up, rotate it before anything else. Deleting the file does not undo a download, and on Cloudflare Pages your old deployments (<hash>.<project>.pages.dev) keep serving it.
Stop the deploy: airing guard -- <cmd>
Put airing guard -- in front of a deploy command. airing works out what that exact command would upload (wrangler, vercel, netlify, firebase or gh-pages, including through npx, pnpm dlx, bunx and env prefixes) and runs the command only if nothing would leak. If something would leak, it exits 1 and the deploy does not run.
airing guard -- wrangler pages deploy dist airing guard -- npx vercel --prod
Claude Code hook: airing hook
This is a PreToolUse hook. It reads every Bash command Claude Code is about to run, and if the command is a deploy whose upload set would leak, it denies it. The denial carries the findings and the fix, so the agent can correct itself. Every other command passes through silently. Bad input never blocks anything.
Install it globally first (see install), then add to .claude/settings.json:
{
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "airing hook" }]
}]
}
}
MCP server: airing mcp
A read-only stdio server with three tools: scan_deploy (path, host?, dir?), probe_url (url) and host_rules (host?).
{
"mcpServers": {
"airing": { "command": "npx", "args": ["-y", "github:Blackman99/airing", "mcp"] }
}
}
GitHub Action
- uses: Blackman99/airing@v0
with:
path: . # project to scan
host: "" # empty: from config, or every host
dir: "" # deploy dir override, e.g. dist
probe-url: "" # optional live URL to probe after deploy
fail-on: leak # leak | risk | never
Outputs: leaks, risks and json. The findings also go into the job summary and appear as ::error annotations.
iii
Configuration
airing has no config file of its own. It reads yours, in this order:
| file | what it tells airing |
|---|---|
wrangler.toml / .json / .jsonc | pages_build_output_dir → Cloudflare Pages. [assets] directory or site.bucket → Workers assets. |
netlify.toml | build.publish (and build.base) |
firebase.json | hosting.public and hosting.ignore |
vercel.json, .vercel/project.json | a Vercel project. With no build script it is served as-is, otherwise files go to the build machine. |
package.json scripts | any wrangler / vercel / netlify / firebase / gh-pages deploy command |
.github/workflows/*.yml | actions/upload-pages-artifact path and include-hidden-files, plus cloudflare/wrangler-action commands |
.assetsignore, .vercelignore | applied the way the host applies them (the ignore package, i.e. gitignore syntax) |
Flags
--host <id> | cf-pages · cf-workers · vercel · netlify · firebase · github-pages · gh-pages |
--dir <path> | the directory you deploy, relative to [dir] |
--fail-on | leak (default), risk or never |
--json / --markdown | machine output and PR-comment output. Neither ever contains a value. |
--all | also show INFO notes (.DS_Store, node_modules, config files) |
--timeout <s> | probe: seconds per request (default 10) |
Exit codes: 0 means nothing at the --fail-on level, 1 means it would leak or is leaking, 2 means a usage or I/O error.
iv
Reference
What each host uploads
Every rule is ported from the host CLI's own source, which each host name links to. CI cross-checks the ports against @vercel/client's buildFileTree, glob@10 (firebase-tools), tinyglobby (gh-pages), real GNU tar (upload-pages-artifact) and verbatim copies of the wrangler and netlify walkers.
| host | skips | so this goes public |
|---|---|---|
| Cloudflare Pages | _worker.js, _redirects, _headers, _routes.json, functions, **/.DS_Store, **/node_modules, **/.git, .wrangler | .env, .dev.vars, everything else. Direct upload has no ignore file (workers-sdk#14500). |
| Workers assets | .assetsignore, _redirects, _headers + .assetsignore lines | everything else, .git/ included |
| Vercel | .git, .vercel, node_modules, .env.local, .env.*.local, logs… + .vercelignore (/target next to Cargo.toml) | .env, .env.production. These are served when there is no build step, and sent to the build machine when there is one. |
| Netlify | every dotfile and dot-directory except .well-known, plus __MACOSX, _redirects, _headers, and node_modules at the site root | backup.sql, secrets.json, *.map: anything without a leading dot |
| Firebase Hosting | firebase-debug*.log, .firebase/ + hosting.ignore | dotfiles too (dot: true). The default firebase init ignore list is the only protection. |
| GitHub Pages | .git, .github, and every dotfile unless include-hidden-files: true | non-dot files such as backup.sql and *.map, plus dotfiles when hidden files are included |
| gh-pages | dotfiles unless --dotfiles | node_modules/ and every non-dot file |
Findings
| level | what |
|---|---|
| LEAK | env files with secret-looking values (.env*, .dev.vars, .envrc), .git/ (flagged harder if the remote URL embeds a token), private keys, cloud credentials, .npmrc tokens, shell history, database dumps, backups, Supabase service_role JWTs and provider keys inside served files |
| RISK | source maps with sourcesContent, agent settings (.claude/, .codex/, .cursor/…), Google keys in bundles (fine only if restricted), and LEAKs that only reach Vercel's build machine |
| INFO | .DS_Store (a directory listing), node_modules/, host config files |
Detected providers: Anthropic, OpenAI, OpenRouter, Google, xAI, Groq, Hugging Face, AWS, Stripe, GitHub, Slack, SendGrid, Supabase, Cloudflare, Vercel, Netlify, npm, Resend, Mapbox and database URLs. Each finding links to the page where that provider's key can be rotated.
v
FAQ
My .gitignore has .env. Am I safe?
Not when you deploy with a CLI. wrangler, vercel, netlify, firebase and gh-pages all upload from your disk and none of them read .gitignore. Git-connected builds are different: there the host clones your repo, so ignored files never reach it.
Does airing upload or print my keys?
No. It reads files locally and reports the variable name, the provider, a shape such as AIza…(39 chars) and the first 8 hex characters of the value's SHA-256. That is enough to tell which key it is, and not enough to use it. The tests check that values never show up in any output format.
Why does Vercel show "uploaded" instead of "public"?
When your project has a build step, Vercel sends the source to its build machine and serves only the build output. Your .env still leaves your laptop, and it is served if the build copies it into the output. airing reports that as RISK.
Will the probe flag my SPA's catch-all page?
No. It fetches a random path first to learn what your soft-404 looks like, and every hit has to match the file's real format: KEY=VALUE lines for .env, [core] for .git/config, SQL for a dump, and so on.
I found a leak. What now?
Rotate the key first, using the link in the finding. Then fix the deploy as airing's "keep it home" line says, redeploy, and run airing probe on the live site. On Cloudflare Pages, also delete the old deployments, because each one keeps its own URL.
Something's wrong for my host.
Open an issue with the deploy command and the output. Output is safe to paste because it contains no values.