airing

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:

filewhat it tells airing
wrangler.toml / .json / .jsoncpages_build_output_dir → Cloudflare Pages. [assets] directory or site.bucket → Workers assets.
netlify.tomlbuild.publish (and build.base)
firebase.jsonhosting.public and hosting.ignore
vercel.json, .vercel/project.jsona Vercel project. With no build script it is served as-is, otherwise files go to the build machine.
package.json scriptsany wrangler / vercel / netlify / firebase / gh-pages deploy command
.github/workflows/*.ymlactions/upload-pages-artifact path and include-hidden-files, plus cloudflare/wrangler-action commands
.assetsignore, .vercelignoreapplied 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-onleak (default), risk or never
--json / --markdownmachine output and PR-comment output. Neither ever contains a value.
--allalso 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.

hostskipsso 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 lineseverything 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.
Netlifyevery dotfile and dot-directory except .well-known, plus __MACOSX, _redirects, _headers, and node_modules at the site rootbackup.sql, secrets.json, *.map: anything without a leading dot
Firebase Hostingfirebase-debug*.log, .firebase/ + hosting.ignoredotfiles too (dot: true). The default firebase init ignore list is the only protection.
GitHub Pages.git, .github, and every dotfile unless include-hidden-files: truenon-dot files such as backup.sql and *.map, plus dotfiles when hidden files are included
gh-pagesdotfiles unless --dotfilesnode_modules/ and every non-dot file

Findings

levelwhat
LEAKenv 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
RISKsource 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.