ZORL
How I Caught a Leaked Stripe Key in CI Before Push -- With One Rust Binary
9 min read

How I Caught a Leaked Stripe Key in CI Before Push -- With One Rust Binary

A walkthrough of catching a real leaked Stripe live key inside a polyglot .env file using zenv -- regex-based detection plus value-aware scanning in CI, with no Node runtime, no hosting, and no telemetry.

leaked secretsstripe api keydotenv validationci secret scanningzenv

A few weeks ago a contributor pasted a "test" .env file into a PR on one of our internal services. The values looked innocuous -- ports, feature flags, a database URL. But buried five lines down was a Stripe live key that should never have been on disk, let alone in a diff about to be pushed to a public mirror.

The PR opened a CI run. The CI run failed. The Stripe key never made it to the remote. No rotation needed.

This post walks through exactly how that happened -- what was in the file, what flagged it, how we wired the gate in 30 seconds of YAML, and why none of it required a Node runtime, a hosted service, or a vendor lock-in.

The file

I have permission to share an obfuscated version. Names changed, values shape-preserving but rotated:

# .env (proposed by contributor)
PORT=8080
LOG_LEVEL=info
FEATURE_NEW_CHECKOUT=true
DATABASE_URL=postgres://app:hunter2@db.internal:5432/orders
STRIPE_SECRET_KEY=sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
EMAIL_FROM=noreply@example.com

Two of those values would have been disasters to leak:

  • The Stripe live key (sk_live_...). Stripe revokes it the moment they detect public exposure, but only after damage is potentially done.
  • The Postgres URL with an embedded password (postgres://app:hunter2@...). The internal hostname tells an attacker exactly where to point credentials, even if the password is rotated.

Both got caught.

What I had set up in CI

A six-line job. That's it.

# .github/workflows/env-gate.yml
name: Env gate
on: pull_request
jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: zorl-engine/zorath-env/.github/actions/zenv-action@main
        with:
          schema: env.schema.json
          env-file: .env
          detect-secrets: true

The bundled GitHub Action installs the zenv binary, then runs zenv check --detect-secrets. No npm install, no Node runtime, no Docker layer, no hosted service. The binary downloads in roughly 200 ms on a cold runner.

The action exit code is the signal: zero if the file validates and no secrets matched, non-zero otherwise. CI fails the PR; the diff doesn't merge.

What zenv actually saw

If you run the same check locally, you can see the structured output the action consumes:

$ zenv check --detect-secrets --format json
{
  "valid": true,
  "errors": [],
  "warnings": [],
  "secrets": [
    {
      "key": "STRIPE_SECRET_KEY",
      "line": 5,
      "reason": "Stripe API key"
    },
    {
      "key": "DATABASE_URL",
      "line": 4,
      "reason": "URL contains embedded password"
    }
  ]
}

Two findings. Two different detection strategies. Both fired.

Why two findings, not one

This is the part that matters and that most .env linters miss.

The Stripe key matched a known-shape regex. zenv ships 22 patterns for live keys from popular providers -- AWS, OpenAI, Anthropic, Stripe, GitHub, GitLab, Slack, Discord webhooks, SendGrid, Mailchimp, Twilio, Heroku, npm, and so on. Stripe's keys have a stable shape: sk_live_ or sk_test_ followed by 24 or more alphanumerics. The regex is anchored on both ends, so it does not false-positive on git tree refs or hash digests. The contributor's value matched. Flag.

The Postgres URL matched on the value, not the key. This is the trick. The key name is DATABASE_URL -- which IS a sensitive name and IS masked by zenv when it appears in any other zenv command. But the detection here did not rely on the key name. zenv scans the value itself for shapes like scheme://user:password@host and flags them regardless of what the variable is called.

Why does this matter? Because innocuous key names hide leaks. We have all seen this:

# Looks fine, right?
FOO=postgres://admin:Tr0ub4dor!@db.internal:5432/prod

A key-name-only secret scanner sees FOO and shrugs. zenv reads the value, recognizes the user:password@host shape, and flags it. The same logic catches Slack webhook URLs and Discord webhook URLs whose path is the secret, regardless of what they are assigned to.

If you have ever pasted a connection string into a variable named CONFIG or URL or BACKUP_TARGET, you have shipped this class of leak. value-aware detection is the only way to catch it without prior knowledge of every key name in your codebase.

What this would have looked like without value-aware scanning

A linter that only checks key names against a sensitive list would have caught the Stripe key (key contained "secret" and "key" substrings) but probably NOT the Postgres URL (key was DATABASE_URL -- the regex catches that name today, but a slightly-renamed DB or MAIN_DSN would slip past it).

A linter that only validates types and required fields, no secret detection at all, would have caught neither. The file is type-correct. PORT parses as a port, LOG_LEVEL is one of the enum values, the connection string is shape-valid. Validation passes. Secrets ship.

The reason zenv catches both is that secret-detection is a first-class command, not a separate tool you have to bolt on.

The polyglot angle: this works in any language

The repository this happened in is a polyglot service: TypeScript frontend, Python data service, Rust ingestion daemon. Three runtimes, three deployment paths, three teams.

Most environment validators are language-bound. You install a TypeScript library that gives your Node code a typed env object. You install a Python library that does the same for FastAPI. You install a Go library for your sidecar. Three different sets of secret-detection rules. Three different sets of validation patterns. Three different update cadences.

zenv is a single static Rust binary. It runs the same in CI for the TypeScript repo, the Python repo, and the Rust repo. The 22 secret patterns are checked uniformly across all three. The schema language is the same. The exit codes are the same. The action is the same.

That uniformity is the actual product. Type validation is table stakes. Working across every language in your stack with one tool is not.

The performance budget

On a cold ubuntu-latest GitHub Action runner, the entire env-gate job takes:

  • Binary download: ~200 ms
  • zenv check --detect-secrets: ~30 ms on a 50-variable .env
  • Total job latency: under 5 seconds including the action setup overhead

This matters because env-gate is the kind of check you run on every PR. If it took 30 seconds, people would disable it. At sub-five-seconds total, nobody notices it's there until it catches something. Then it has paid for the rest of its existence in one finding.

Wiring it locally for pre-commit

The same binary also fits in a pre-commit hook so the leak never even reaches a PR:

#!/usr/bin/env bash
# .git/hooks/pre-commit
set -e
if [ -f .env ]; then
  zenv check --detect-secrets --quiet
fi

If you have committed an .env to your repo (which you should not -- check that line first), this hook fails the commit before the secret hits the index. Zero CI minutes spent.

You can install zenv with cargo install zorath-env, brew install zorath-env (if our Homebrew tap is published in your environment), or download a prebuilt binary from github.com/zorl-engine/zorath-env/releases. Linux, macOS Intel, macOS ARM, Windows.

Where this fits in your stack

This post is about secret detection. zenv also validates schemas (14 types, custom rules, severity per variable), scans your source code to cross-reference env-var usage against the schema, diffs two env files, exports to seven deployment formats, and so on. The full command surface is documented at github.com/zorl-engine/zorath-env.

A few specific notes for adopters:

  • Use a schema. Even a minimal env.schema.json is enough to start catching missing-required-var bugs alongside the secret detection. Run zenv init --example .env.example to bootstrap one from an existing file.
  • Pin remote schemas. If you fetch a schema over HTTPS, also pass --verify-hash <sha256> to pin it. This is the only way to detect a compromised schema host.
  • Wire it once. The action shipped with zenv works in any GitHub Actions workflow regardless of repo language. Set it up in one repo and you have the pattern for every repo.

Caveats worth knowing

No tool catches everything. Some honest limits:

  • A custom secret format that doesn't look like the 22 known patterns will slip through the regex layer. If your team uses an internal token format, add a custom validation rule with pattern: in your schema. zenv will fail validation if it's missing or malformed, which is the next-best signal.
  • A value-aware URL-password detector skips known placeholders (literal password, pass, secret, xxx*, example*, changeme*, your_*) to avoid false-positives on .env.example files. Real attackers don't use those strings, but if you have a service genuinely named "secret" in its credentials, that's a corner case the detector intentionally tolerates.
  • The action runs zenv check --detect-secrets against the committed .env -- not against staged unstaged changes in CI. For pre-commit catching of in-flight edits, the local hook is the right place.

Closing

The Stripe key never left the contributor's machine. The Postgres URL got rotated out of the example file. The action ran for under five seconds. The contributor learned what the patterns looked like and updated the team's .env.example accordingly.

The whole gate is six lines of YAML and a 4 MB binary. The total ongoing cost is zero -- no hosted service, no API key, no telemetry, no per-seat pricing. The repository is open source. The binary runs entirely on your machine or your CI runner. If the runner doesn't have network access at all, the check still works -- the secret patterns are baked in at compile time.

That's the kind of tool I want guarding my secrets. Specifically: one I can audit, one I can fork, and one that does not need to phone home to do its job.

Install with cargo install zorath-env and run zenv doctor in your repo to see what it surfaces. If you'd rather wire it into a Claude Code or Cursor session as an MCP server, run zenv mcp -- same binary, stdio MCP, no extra setup. The agent can drive every command this post mentions, plus the audit prompts shipped with the server.

Repo: github.com/zorl-engine/zorath-env. MIT, no paid tiers, no plans for them.

Share this article

Z

ZORL Team

Building developer tools that make configuration easier. Creators of zorath-env.

Previous
dotenv-linter vs envalid vs dotenvx vs zenv: Choosing the Right .env Tool (2026)

Related Articles

Never miss config bugs again

Use zorath-env to validate your environment variables before they cause production issues.