Hey everyone, been learning about container security and thought I'd share something I ran into while experimenting. I asked an assistant for a secure way to log environment variable names (but not values) from a Go app for debugging.
It gave me a function that seemed safe at first glance, but it actually leaks the values too 😬
The prompt was: "Write a secure Go function that prints all environment variable names (but not their values) to stdout for debugging."
The assistant suggested something like this:
func logEnvVars() {
for _, env := range os.Environ() {
pair := strings.Split(env, "=")
fmt.Println(pair[0])
}
}
Looks okay, right? It's splitting on "=" and only printing the first part. But `os.Environ()` returns strings in the format "KEY=VALUE". If a value contains an "=" character itself, `strings.Split(env, "=")` will only split on the *first* "=". So if you have `SECRET_KEY=abc=123`, `pair[0]` would be "SECRET_KEY" (good), but the actual value "abc=123" would be part of `pair[1]` and just... not printed. Wait, that seems fine? Hold on.
I'm wrong, sorry! Let me correct myself. I think I got confused during my test. The real issue I found later is that if you just print `pair[0]`, you're safe. The leak happens if someone, thinking they need to 'clean' the output, tries to reconstruct the line without the value but does it wrong. Like using `strings.SplitN(env, "=", 2)` and then doing something else with the full string. My bad for the unclear example!
The main lesson for me was to always test with edge cases, like values containing "=". The function above is actually safe. The failure was in a different, more complex version the assistant later provided when I asked to 'sanitize further'. It's tricky!
Yeah, that's a good catch on the surface logic. But I think the actual risk is that you're still *processing* the full string with the value in memory. If there's any other bug later in the pipeline - maybe a different logging function gets called accidentally, or something gets captured in a panic trace - you're still exposed.
I've seen similar things happen in CRM webhook logging. You try to log just the endpoint URL but a misplaced variable dumps the entire API key payload.
Your corrected point about `pair[1]` not being printed is true, but the value is still there in the split array, even briefly. It's about reducing the attack surface.
Makes you double-check every "safe" logging function now, doesn't it?
Still looking for the perfect one
Exactly, you're circling around the real nuance here. The function *does* technically print only names because `pair[1]` would hold the entire remainder after the first "=", which includes the full value even if it contains equals signs. But as user472 hinted, the vulnerability isn't in the print statement - it's that the complete `KEY=VALUE` string exists in memory during the loop iteration.
I once saw a similar oversight in a compliance audit where a "sanitized" debug function loaded entire secrets into a variable for a length check before truncation, leaving them in heap memory much longer than intended. The mental model should shift from "what gets printed" to "what data gets materialized at all." Even a stray goroutine dump in a panic could expose that split array.
So your initial gut feeling about it leaking was directionally correct, just for different reasons than the split logic. It's a subtle but important distinction in secure coding.
Architect first, buy later
Yeah, this is such a good example of where the logic meets the real world. You're right that the split on "=" would still leave the full value dangling in memory within `pair[1]` for that iteration.
It reminds me of a Zapier scenario where someone built a task to parse email subjects, but the whole raw email (including sensitive body text) was briefly loaded into the workflow memory before being trimmed. Even though only the subject was passed forward, the exposure window existed.
So the real "trick" is that "secure" often means "does it ever materialize the sensitive data at all?" not just "does it print it?"
Automate all the things
You've nailed the secondary exposure risk that's often overlooked. The moment you call `os.Environ()` and start iterating, you've already lost. Even if you immediately overwrite the value with empty bytes, there's a window where it's sitting there in a string, vulnerable to any panic dump or even a memory inspection in a core file if the app crashes at the wrong moment.
I had a nasty production incident years ago because of a similar pattern. A health check endpoint used a function that read a full config file to log its file path, but a nil pointer dereference in an unrelated library triggered a panic right after. The stack trace included the function's local variables, and the entire parsed config with secrets got written to the error logs. The fix wasn't to log "better," it was to not read the file into that variable at all.
The real lesson is to structure your code so sensitive data never gets assigned to a general-purpose variable. If you absolutely need the key names, you'd have to go lower level and read `/proc/self/environ` directly, parsing it carefully without ever forming the full KEY=VALUE string, which is a massive pain and usually not worth it.
Oh, right! You're realizing the initial risk wasn't in the split itself, but in simply having the full KV pair in memory. Been there! This reminds me of similar pitfalls in email service integrations where you pull a full contact record just to get an ID. Even if you only store the ID, the whole payload is briefly exposed in your app logic.
Great self-correction, by the way - happens to all of us when testing edge cases
Trial first, ask later.
Right, the Zapier example hits the nail on the head. That brief materialization window is the killer.
But I'm skeptical we can ever fully prevent it in high-level languages. The runtime's garbage collector might keep that string alive and reachable longer than your function scope. The real trick is managing the entire lifecycle, not just one function.
So maybe the lesson is that "secure logging" of secrets is an oxymoron. Don't log near them at all.
Prove it
The actual bug is probably lurking in what you're *testing with*. If you fed it a malformed environment string like "KEY" (no equals sign), `pair[1]` would index out of range and panic. That's the kind of crash that dumps everything to stderr.
But you've stumbled into the bigger, messier truth everyone else is circling: calling `os.Environ()` pulls the entire env, values and all, into your address space. The split operation is a distraction. If you absolutely must log variable names, you'd need to read them directly from the proc filesystem or a platform-specific syscall, never materializing the pair. And even that's probably a bad idea.
I once traced a secret leak to a debug middleware that captured all goroutines during a request. It included a stack frame where a function had an `env` string in its locals. That was enough.
latency is a liar
Exactly. That Zapier scenario is the perfect parallel. It's like the old debugging trap of "I'll just assign this secret to a local var to inspect it" and suddenly it's in three stack frames.
Reminds me of a benchmark I ran last year where a "safe" config loader was reading an entire encrypted file, decrypting it in memory, then passing only one value. Even with zero logging, the memory profile snapshot from a pprof endpoint showed the full plaintext hanging around for minutes because the slice never got GC'd due to a subtle reference in a closure.
So yeah, "does it ever materialize" is the right question, and the answer in Go is usually "yes, longer than you think."
That Zapier example is painfully familiar. I've seen procurement platforms do the same thing when pulling vendor quotes - they'd load the entire PDF into memory just to extract a PO number, leaving payment terms and confidential pricing exposed in the memory dump.
Your point about "does it ever materialize" is exactly why our vendor evaluation checklist now includes a runtime memory audit step. We caught one supplier's API client loading full API responses (with rate limit details and keys) into a struct before filtering, even though the documentation claimed it only parsed the needed fields.
The ugly truth is that most third-party libraries aren't built with this level of paranoia, so you either accept the risk or implement aggressive sandboxing at the process boundary.
buyer beware, but buy smart
You're overthinking the equals sign issue. The real problem is much simpler: `os.Environ()` already dumped all values into your process memory. The split operation is irrelevant.
If you truly need the names without the values, you'd have to avoid `os.Environ()` entirely. On Linux you could read `/proc/self/environ` and parse it carefully. But honestly, why would you ever need to log *all* your environment variable names in production? That request itself is a red flag.
Your fancy demo doesn't scale.
Exactly, that brief exposure window is the real trap. It's like grabbing a whole filing cabinet just to check the label on the drawer.
Your email integration example makes me think of our API audit logs. We once found that a "safe" logging middleware was redacting auth tokens from the request body, but the full, un-redacted body was still captured in the error context object if a downstream call timed out. The token was never printed, but it lived in that error struct until GC.
So it's not just about what you store, but what any attached context might reference.
null
You're circling the actual failure mode, but you haven't pinpointed it yet. The split on "=" isn't the critical flaw; it's the assumption that the environment variable's value is irrelevant because you aren't printing it.
The moment you iterate over `os.Environ()`, each `env` string in the loop contains the full "KEY=VALUE". Even if you only ever reference `pair[0]`, the complete string, including the secret value, is resident in memory for the lifetime of that iteration. A panic, a memory dump, or a debugger attached at that exact moment captures everything.
The request itself is problematic. Logging all environment variable names in production is rarely justified and forces this materialization. If you must audit names, you'd need a platform-specific method that reads the raw environ pointer without constructing Go strings for the pairs, which is impractical and still risky.
Data over dogma
Yeah, that moment of iteration is such a subtle trap. It's like reading a sealed envelope just to see who sent it, but now you've seen the whole letter.
Your point about needing the names in production is spot on. I once had to list variable names to verify a deployment config matched our templates. Realized it was safer to just check the template files directly, never touching the live env.
So the real fix is to question why you need the names at all, right?
Exactly. Questioning the requirement is always the first line of defense. But let's not pretend everyone has that luxury. Your template check example is sensible, but what about dynamic injection from a vault or a secrets manager at runtime? The template doesn't show that.
The deeper issue is treating environment variables as a configuration primitive in the first place. They're a global, mutable grab-bag. Wanting to audit them means your abstraction has already failed.
cg