DEV Community

Cover image for My Env Var Scanner Gave My Own App a 0/100. So I Had to Figure Out Why
Isaac
Isaac

Posted on

My Env Var Scanner Gave My Own App a 0/100. So I Had to Figure Out Why

I built Pookoo to catch a boring but real problem: environment variable drift.

Dead variables nobody deleted. Secrets accidentally exposed in a public bundle. The same variable with different fallback values in different files. Required configuration that never made it into .env.example or the documentation.

The idea was deliberately simple: use static analysis, not an LLM.

Parse the code, build a picture of which environment variables are declared and how they're used, then surface places where those two disagree.

The first real thing I did once Pookoo could run end-to-end was point it at a production app of mine.

I wanted to know what it would say about actual, shipped code.

It gave the app a 0/100. 😶

And almost immediately, I could see why.

The first scan was wrong

Pookoo flagged NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY and NEXT_PUBLIC_GOOGLE_MAPS_API_KEY as CRITICAL secret leaks.

Except neither of those is supposed to be secret.

That's the entire point of the NEXT_PUBLIC_ prefix, and "publishable" is right there in the Clerk variable's name.

It also scanned .next/dev/server/chunks/, which is compiled build output, and treated it as application source.

Then it reported several SDK-consumed environment variables as dead.

Those were variables that Clerk and Stripe read internally, somewhere inside their own packages. Pookoo doesn't inspect node_modules intentionally so from the perspective of its dependency graph, those variables had no usages.

The app wasn't catastrophic.

Pookoo was making claims that its analysis couldn't actually justify.

That turned out to be much more interesting than a clean first scan would have been.

The problem with "unused"

I didn't want Pookoo to statically parse an entire dependency tree just to prove that CLERK_SECRET_KEY gets read somewhere inside @clerk/nextjs.

Walking through node_modules would make scans slower, noisier, and much harder to reason about.

But there was a consequence: any variable consumed internally by an SDK looked exactly like a dead variable, because nothing in the application's own source code referenced it.

So I added a small piece of domain knowledge.

Pookoo now knows about common SDK prefixes like Clerk, Stripe, Sentry, and others, as well as platform-level variables such as NODE_ENV and PORT that can be supplied by the runtime rather than explicitly referenced in application code.

It's not a clever algorithm.

It's an explicit acknowledgement that static analysis has boundaries.

If I already know that an SDK can consume a particular family of environment variables, pretending otherwise doesn't make the analysis more rigorous. It just makes the result less useful.

Narrowing "secret"

The secret detection rule had the opposite problem.

It was too eager.

The original keyword list included KEY.

At first glance, that seems reasonable. But "publishable key" is a real and intentional category.

Clerk has publishable keys. Stripe has publishable keys. Some APIs are explicitly designed to expose certain identifiers to the client.

So I narrowed the high-confidence secret indicators to:

  • SECRET
  • PASSWORD
  • PRIVATE

Then I added a small allowlist for known-safe publishable-key patterns.

The important change wasn't the exact list of keywords.

It was changing the question from:

"Does this variable contain a word that sounds sensitive?"

to:

"Does the evidence actually support calling this a secret?"

That's a much better question for a static analyzer.

Not guessing about what can't be known

The hardest change wasn't really a bug fix.

It was deciding what Pookoo should do when the answer simply isn't knowable statically.

Consider:

process.env.STRIPE_SECRET_KEY
Enter fullscreen mode Exit fullscreen mode

That's easy.

The analyzer can resolve the exact variable being accessed.

But what about:

process.env[someVar]
Enter fullscreen mode Exit fullscreen mode

Here, someVar could contain anything at runtime.

There is no static analysis trick that lets me magically know the value.

I could assume every environment variable is potentially being used.

That would reduce false positives, but it would also make dead-variable detection almost useless.

Or I could assume dynamic access means nothing is being used.

That gives me more findings, but some of them would obviously be wrong.

So I chose a third option:

don't guess.

A dynamic access still becomes part of Pookoo's internal graph. The access happened, so the analyzer records it.

But because it can't determine which environment variable the access resolves to, it doesn't create an edge to a specific variable.

In other words, Pookoo represents the uncertainty instead of pretending it doesn't exist.

That decision also simplified another part of the codebase.

The "is this variable unused?" check doesn't need another AST traversal or a pile of special cases.

It's just a graph query:

Find a declared environment variable with zero incoming usage edges.

That's it.

It does mean that a variable accessed only through an unresolved dynamic expression can still appear unreferenced.

I haven't patched that with another guess.

That's the tradeoff of refusing to claim more than the analysis can prove.

Renaming a rule until it stopped lying

The same idea led to another change that I think is more important than it initially sounds.

The rule used to be called:

NO_UNREFERENCED_ENV_VAR

It also failed CI by default.

But "unreferenced" is a much stronger claim than Pookoo can actually prove.

It implies that the variable isn't used anywhere in the codebase or at runtime.

Static analysis can't establish that.

What Pookoo can establish is much narrower:

I didn't find a reference that I could statically resolve.

So I renamed the rule to:

NO_STATIC_REFERENCE_FOUND

And changed its severity from blocking to informational.

It's a smaller claim.

That's exactly why it's a better one.

A static-analysis tool becomes more useful when its findings correspond closely to what it can actually prove.

I'd rather have Pookoo say:

"I couldn't find a static reference to this variable."

than:

"This variable is dead."

when the second statement isn't something the analyzer can actually know.

What Pookoo looks like now

Pookoo is still less than three weeks old.

So far, it's crossed roughly 1,000 npm downloads, with a much smaller GitHub footprint.

What I do have is a tool that encountered a real failure against a real application almost immediately.

And that failure gave me a much better understanding of what I actually want Pookoo to be.

Not a tool that produces the most warnings.

Not a tool that sounds confident.

A tool that can tell the difference between:

"I know this is wrong."

"I found something suspicious."

and

"I don't have enough information to say."

That distinction is becoming the interesting part of building it.

What's next

The next thing I'm working on is intra-file constant resolution.

For example:

const KEY = "STRIPE_SECRET_KEY";

process.env[KEY];
Enter fullscreen mode Exit fullscreen mode

Right now, Pookoo treats that similarly to a genuinely dynamic access because it doesn't yet trace the assignment back far enough to resolve KEY.

But this is a common pattern, and in this case the answer is actually knowable.

It's the natural next step for the same reason as everything above:

there's information Pookoo currently has access to, but isn't using yet.

That's the part of static analysis I'm enjoying most.

Every limitation forces a choice:

Do we make an assumption?

Do we add domain knowledge?

Do we improve the analysis?

Or do we explicitly model the uncertainty?

I'm starting to think the quality of a static-analysis tool isn't just about how much it can detect.

It's about how carefully it knows the difference between what it knows, what it suspects, and what it can't know.

If you want to see what Pookoo finds in your own project:

npx pookoo scan
Enter fullscreen mode Exit fullscreen mode

And if you want to generate an .env.example automatically:

npx pookoo init
Enter fullscreen mode Exit fullscreen mode

If you try it and find a case Pookoo doesn't understand, I'd genuinely like to hear about it.

Those edge cases are currently some of the best inputs for deciding what it should learn next.

Top comments (0)