Skip to content
mengfung.com
writing

Scanning for env vars without a language table

Every language reads the environment with the substring env. That turned out to be enough.

 ·  4 min read

One of the ways a .env setup rots is drift. The code starts reading a new variable, nobody adds it to the example file, and the next person to clone the repo finds out the hard way. dottyenv (introduced here) has a scan command for this. It reads your code and compares it with the schema.

The obvious way to build that is a table of languages: a parser or a pattern for process.env, another for os.environ, another for env::var, and so on. I did not want to maintain that table, and it would always be missing the language somebody actually uses. So scan knows no languages at all.

Two questions, two levels of certainty

scan answers two different questions, and they deserve different treatment.

Is this declared variable used anywhere? The schema already has the name, so this is a plain substring search over every file. It works in any language, and also in Dockerfiles, Helm charts and CI config, which no language table would cover. This is also the direction where a false positive hurts most, because the advice that follows is “delete this variable”. So this half is exact.

Is the code reading a variable the schema does not know about? This one has to recognise environment access without being told what to look for. That is the heuristic half.

$ dottyenv scan
used but not declared
  SENDGRID_API_KEY                 src/server.js:3

declared but never mentioned
  FEATURE_SEARCH
  SESSION_SECRET
  STRIPE_PUBLISHABLE_KEY

note: 1 file accesses the environment dynamically, so this list may be
      incomplete: src/server.js

Every language spells it env

The trick is that every language I could think of reads the environment with the substring env somewhere in the expression:

process.env.FOO      os.environ["FOO"]     env::var("FOO")
os.Getenv("FOO")     ENV["FOO"]            System.getenv("FOO")
$ENV{FOO}            System.get_env("FOO") getenv("FOO")
Environment.GetEnvironmentVariable("FOO")

So one pattern covers all of them, including languages nobody listed: env in any case, then a short stretch on the same line, then a delimiter, then a name in SCREAMING_SNAKE_CASE.

Regex::new(r#"(?i:env)[^\n]{0,24}?["'`.\[({,= ](?P<name>[A-Z][A-Z0-9_]{2,})"#)

Each piece of that is there because something went wrong without it.

  • The delimiter stops MY_ENV_VAR from matching itself. The character after ENV is an underscore, which is not a delimiter, and there is no later delimiter before the name ends. Without it, every constant with ENV in its name would report its own tail as a variable.
  • The 24-character budget is set by the longest real form I found, Environment.GetEnvironmentVariable(".
  • The case-insensitive part is scoped to (?i:env) instead of putting (?i) on the whole pattern. A blanket flag also loosens the name class, so Deno.env.get("FOO") captured get as a variable name.

The tests hold it to 23 real forms, from import.meta.env.FOO and Bun.env.FOO through os.LookupEnv, $_ENV and Swift’s ProcessInfo.processInfo.environment["FOO"]. They also check the cases that must not match: const MAX_RETRIES = 5; has no env nearby, and NODE_ENV_SETTING has one only inside an identifier.

Knowing when you cannot know

Some code reads the environment with a variable instead of a literal:

function flag(name) { return process.env[name] === "true"; }

No static scan can tell which variable that reads. A variable read only this way never shows up under “used but not declared”, and if the name is built at runtime, something like process.env["FEATURE_" + name], a variable your code really uses can show up as “never mentioned”. Pretending the lists are complete would be worse than saying nothing.

So scan detects dynamic access and says so. The test is simple: env access where the first character inside the bracket or parenthesis is not a quote. An earlier version also required a closing bracket, which missed dotted expressions like process.env[req.query.name].

Small details that keep it honest

  • It walks the tree with the same rules as .gitignore, so node_modules and target are skipped for free. A variable read only inside a vendored dependency is not your code reading it. Those rules apply even outside a git repository, so a directory does not scan differently depending on whether it happens to be checked in.
  • Files that are not valid UTF-8 are skipped, which skips binaries without needing a file-type sniffer.
  • The schema itself and every .env* file are excluded. They name every variable by definition, so counting them would make “never mentioned” always empty.
  • scan always exits 0. It is advisory. check is the gate.

That last point is the real design decision. A heuristic is fine as long as you never let it fail a build. Exact where being wrong is expensive, approximate where being wrong is cheap, and honest about which is which.