Summary
A model-catalogue sync utility read the ARTIFICIAL_ANALYSIS_API_KEY environment variable with a bare os.getenv() call and passed whatever it found straight into fetch_intelligence_from_api(). Any malformed value — a trailing newline from an --env-file, a whitespace-only placeholder, or a pasted Bearer … prefix — was treated as a valid credential and forwarded into an outbound HTTP request, where it could surface in CI error output. The fix normalizes the value, validates it against a strict character class, and drops it with a warning that never prints the key itself.
Introduction
A maintenance script that keeps a local model catalogue in sync does two network calls in its main() function. First it fetches the list of available models from the NVIDIA models endpoint. Then, optionally, it enriches those models with intelligence scores from the Artificial Analysis API — optionally, because that second call needs a credential and the script is designed to work without one.
That optionality is where the problem lived. The entire decision of "do I have a credential?" was this:
api_key = os.getenv("ARTIFICIAL_ANALYSIS_API_KEY")
aa_scores = fetch_intelligence_from_api(api_key) if api_key else {}
Reading secrets from the environment is the right pattern — nothing is hardcoded here, and that is worth saying plainly. But os.getenv() returns an arbitrary, attacker-or-operator-controlled string, and if api_key accepts every one of them that is not empty. A single space passes. A key with a trailing \n passes. The literal string $AA_KEY, left unexpanded by a shell quoting mistake, passes. All of those get handed to fetch_intelligence_from_api() and embedded in an authenticated request.
For anyone writing scripts that bridge an environment variable to an HTTP client, this is the interesting part: the credential never gets validated at the boundary, so every downstream consumer — the request builder, the exception formatter, the CI log — inherits an unverified blob and decides for itself what to do with it.
Affected Versions
| Affected | not applicable (first-party code) |
| Fixed in | not applicable (first-party code) — fixed by a hardening change to the sync utility's main() function |
| Ecosystem | unknown |
| CVE / GHSA | not assigned |
| CWE | CWE-798 (Use of Hard-coded Credentials) |
The Vulnerability Explained
Here is the pre-fix pattern, in full:
api_key = os.getenv("ARTIFICIAL_ANALYSIS_API_KEY")
aa_scores = fetch_intelligence_from_api(api_key) if api_key else {}
if aa_scores:
print(f"Fetched {len(aa_scores)} intelligence scores from Artificial Analysis")
Three distinct failure modes hide behind that two-line read.
1. Whitespace-only values are truthy. Python's " " is a non-empty string. A CI configuration that sets ARTIFICIAL_ANALYSIS_API_KEY to a blank value — a very common outcome when a secret is unavailable to a forked-PR workflow and the templating engine substitutes an empty-looking placeholder — makes the script believe it is authenticated. It then makes a live request with a garbage credential instead of taking the clean "no key, skip enrichment" path it was designed to take. The upstream returns 401 or 403, and the error-handling path is now processing a response for a request it should never have sent.
2. Trailing newlines turn a credential into header content. ARTIFICIAL_ANALYSIS_API_KEY=$(cat key.txt) in a shell, or a Docker --env-file entry, routinely leaves a trailing \n. When that value is interpolated into an Authorization header, well-behaved HTTP clients raise on the invalid header value — and the exception message frequently includes the offending value. That exception lands in the CI job log, which is often retained far longer and shared far more widely than the secret store the key came from. A credential that leaks into build output has to be rotated; the script offered no barrier against that.
More aggressively: if a value containing CRLF sequences reaches a client that does not validate header content, those sequences split the header block. An operator or workflow author who can influence that environment variable — in a shared CI runner, a composite action, or a container entrypoint — can append extra headers to the outbound Artificial Analysis request. That is the header-injection shape of this bug, and it exists because the script performs zero inspection of the string between os.getenv() and the request.
3. Format drift goes unnoticed. A pasted value of Bearer sk-abc123, or an entire copied line like ARTIFICIAL_ANALYSIS_API_KEY=sk-abc123, is syntactically fine to os.getenv() and semantically useless to the API. The failure manifests as a confusing authentication error far from its cause, and debugging it tends to involve exactly the thing you must not do: printing the key to find out what it actually contains.
The real-world impact for a service running this sync on a schedule is credential exposure in logs plus a silent loss of data quality — the model catalogue gets written without intelligence scores while the job still exits successfully enough that nobody investigates.
The Fix
The change keeps the environment-variable pattern and adds a validation gate immediately after the read.
Before:
api_key = os.getenv("ARTIFICIAL_ANALYSIS_API_KEY")
After:
api_key = os.getenv("ARTIFICIAL_ANALYSIS_API_KEY", "").strip()
if api_key and not re.fullmatch(r"[A-Za-z0-9_\-]+", api_key):
# Never log the key itself -- only report that it failed validation.
print("Warning: ARTIFICIAL_ANALYSIS_API_KEY is not in the expected format; ignoring it", file=sys.stderr)
api_key = ""
Each element is doing specific work:
os.getenv(..., "")supplies an empty-string default so the chained.strip()cannot raiseAttributeErroronNone. Without the default, an unset variable would crash the script before it printed anything useful..strip()is the direct answer to failure mode 1 and 2. A whitespace-only value becomes""and therefore falsy, so theif api_key else {}guard correctly takes the no-credential path. A key with a trailing newline is normalized before it can ever reach a header.re.fullmatch(r"[A-Za-z0-9_\-]+", api_key)is the answer to failure mode 3 and to embedded CRLF.fullmatch— notmatchorsearch— requires the entire string to consist of alphanumerics, underscores, and hyphens.Bearer sk-abcfails on the space.KEY=valuefails on the=. Anything containing\r,\n, or a quote character fails. Nothing that can split a header block survives this check.api_key = ""converts a validation failure into the script's existing, well-tested "no key" behaviour:aa_scoresbecomes{}, the enrichment step is skipped, and the sync completes with the models it fetched from the NVIDIA catalogue endpoint. The failure mode is explicit and bounded rather than propagating into an HTTP client.- The warning text is the quiet highlight. It names the variable and says the format was wrong, and that is all. The inline comment — "Never log the key itself" — exists so the next person to edit this message does not helpfully add
{api_key}to it while debugging. That one-line comment is doing more long-term security work than the regex.
Two review notes worth carrying forward. The fix uses re and sys, so both must be imported at module scope — confirm that before merging a change of this shape. And the character class is tuned to the current key format; a provider that moves to base64-padded or dot-separated tokens would start failing validation silently apart from the stderr warning, so the regex is a thing to keep in sync with the credential format rather than set-and-forget.
This was filed as defence-in-depth, not a demonstrated exploit, and the hardening change was not covered by an automated check in this repository — treat it as reviewed-by-humans rather than test-verified.
Key Takeaways
if api_key:is not a credential check." ","$AA_KEY", and"sk-abc\n"are all truthy strings, and each one causes a different wrong behaviour than the intended "skip enrichment" path.- Always chain
.strip()onto a secret read from the environment, and giveos.getenv()an""default so the strip is safe — trailing newlines from--env-fileand$(cat …)are the single most common way a key becomes an invalid HTTP header value. - Use
re.fullmatch(), notre.match()orre.search(), when validating a credential's shape. A partial match would happily acceptsk-abc\r\nX-Injected: 1, which is the exact value you are trying to reject. - A validation-failure message about a secret must name the variable and nothing else. The inline "Never log the key itself" comment next to that
print(..., file=sys.stderr)call is what stops a future debugging session from interpolating the key into build output. - Degrade to the already-handled empty case. Resetting
api_key = ""reuses the script's existing no-credential path, so a misconfiguration produces a catalogue without intelligence scores instead of an authenticated request built from an unverified string.
How Orbis AppSec Detected This
- Source: the
ARTIFICIAL_ANALYSIS_API_KEYenvironment variable, read throughos.getenv()in the sync utility'smain()entry point. - Sink:
fetch_intelligence_from_api(api_key), which embeds the value in an outbound authenticated HTTP request to the Artificial Analysis API, plus the surroundingprint()and exception-reporting paths where that value could be echoed. - Missing control: no default value, no whitespace normalization, and no format validation between the environment read and the credential's use — the only gate was a truthiness test, which admits whitespace-only strings, values carrying
\r\n, and pasted prefixes likeBearer. - CWE: CWE-798 — Use of Hard-coded Credentials. The scan flagged a dense cluster of API-key references across the codebase (58 occurrences) and traced this one as the unguarded environment-to-request boundary.
- Fix: the lookup now defaults to
"", is.strip()-ed, and must satisfyre.fullmatch(r"[A-Za-z0-9_\-]+", api_key); on failure the key is discarded with a stderr warning that deliberately does not include the value.
Orbis AppSec detected this vulnerability automatically. Try Orbis AppSec on your repositories to find and fix issues like this.
Conclusion
Nothing was hardcoded here, and that matters — the script was already doing secret management correctly in the sense most checklists measure. What it was missing was a boundary. os.getenv("ARTIFICIAL_ANALYSIS_API_KEY") produces an untrusted string, and every line between that read and fetch_intelligence_from_api() inherited the assumption that the string was a usable credential.
Four lines closed that gap: an empty-string default, a .strip(), a re.fullmatch() against [A-Za-z0-9_\-]+, and a reset to "" that routes misconfiguration back into the script's existing no-credential path. The warning on failure names the variable and never the value. If you have a script that reads a token from the environment and immediately hands it to an HTTP client, that four-line gate is worth copying — it costs nothing, and it is the difference between "intelligence scores were skipped" and "a credential is now in a public build log."