Back to Blog
critical SEVERITY9 min read

ARTIFICIAL_ANALYSIS_API_KEY: Unvalidated os.getenv in Model Sync

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 s

O
By Orbis AppSec
•Technically reviewed by Anupam Mediratta•Published October 14, 2026•Reviewed October 14, 2026

Answer Summary

This is a first-party fix in a model-catalogue sync utility whose `main()` entry point reads the `ARTIFICIAL_ANALYSIS_API_KEY` environment variable and forwards it to `fetch_intelligence_from_api()`. Before the fix, any non-empty string — including a whitespace-only placeholder or a value carrying a trailing newline from a container `--env-file` — passed the `if api_key` guard and was embedded in an outbound authenticated request, creating a path for the credential to appear in CI error output or for CRLF content to be spliced into request headers. The fix defaults the lookup to an empty string, applies `.strip()`, rejects anything that does not fully match `[A-Za-z0-9_\-]+`, and prints a warning that deliberately omits the key value; there is no package version because the change is first-party code. It is classified as CWE-798 (Use of Hard-coded Credentials), filed as defence-in-depth rather than a demonstrated exploit.

Vulnerability at a Glance

cweCWE-798
fixDefault to `""`, `.strip()` the value, require a full match on `[A-Za-z0-9_\-]+`, and discard it with a warning that never prints the key
riskA malformed or injected `ARTIFICIAL_ANALYSIS_API_KEY` value reaches an outbound HTTP request unchecked and can be echoed into CI logs
languagePython
root cause`os.getenv("ARTIFICIAL_ANALYSIS_API_KEY")` with no default, no whitespace trimming, and no format validation before the value is used as a credential
vulnerabilityUnvalidated credential retrieval from the environment, with credential-in-log and header-injection failure modes

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 raise AttributeError on None. 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 the if 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 — not match or search — requires the entire string to consist of alphanumerics, underscores, and hyphens. Bearer sk-abc fails on the space. KEY=value fails 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_scores becomes {}, 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 give os.getenv() an "" default so the strip is safe — trailing newlines from --env-file and $(cat …) are the single most common way a key becomes an invalid HTTP header value.
  • Use re.fullmatch(), not re.match() or re.search(), when validating a credential's shape. A partial match would happily accept sk-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_KEY environment variable, read through os.getenv() in the sync utility's main() 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 surrounding print() 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 like Bearer.
  • 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 satisfy re.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."

Prevention and further reading

Frequently Asked Questions

Does the new `[A-Za-z0-9_\-]+` check reject legitimate Artificial Analysis keys?

Not for the current key format, which is alphanumeric with underscores and hyphens. It would reject a key containing `.`, `=`, or `+` — so if the provider ever issues base64-padded or JWT-style keys, the character class needs widening or the script will silently skip intelligence-score enrichment.

Why does the fix add `, ""` to the `os.getenv` call when `None` is already falsy?

Because `.strip()` cannot be called on `None`. The empty-string default makes the chained `os.getenv(...).strip()` safe and collapses both "unset" and "set to spaces" into the same falsy value, so the `if api_key` guard behaves identically for either misconfiguration.

What happens to `aa_scores` when the key fails the format check?

`api_key` is reset to `""`, so the `fetch_intelligence_from_api(api_key) if api_key else {}` expression short-circuits and `aa_scores` becomes an empty dict. The sync still completes using the models fetched from the NVIDIA catalogue URL — it just omits intelligence scores and prints a warning to stderr.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #61

Related Articles

critical

redmine_drawio View Hook Inlines Base64 Redmine API Keys

The Redmine drawio plugin's body-bottom view listener embedded the logged-in user's Redmine REST API key into client-side JavaScript on every wiki and issue page, "protected" only by Base64 encoding of the reversed string. Any script on the page — or anyone with browser developer tools, a cached copy of the HTML, or a proxy log — could decode it in one line and act as that user through the Redmine REST API. The fix removes the embedded credential from the rendered page entirely; the plugin's dia

critical

Yandex Translate API Key Leaked via URL Query Parameter

The `translateYandex()` helper built its request URL by interpolating the caller-supplied API key directly into the query string, meaning every call leaked the credential into server access logs, proxy logs, and any Referer header sent by intermediaries. The fix switches the request from a GET with the key in the URL to a POST with the key in the request body via `URLSearchParams`, removing the credential from any URL-logging surface entirely.

critical

Google Generative AI Keys Exposed in Client-Side Fetch URLs

A client-side AI model discovery utility was embedding Google Generative AI API keys directly in fetch request URLs, making them visible to any user inspecting network traffic or browser DevTools. The fix moves the key from the URL query parameter to a secure HTTP header, eliminating exposure while maintaining API authentication.

critical

How credential leakage through console logging happens in JavaScript browser extensions and how to fix it

A browser extension's `src/background/credentials.js` printed the full Strava authentication cookie string — including a signed JWT and CloudFront-Signature values — straight into the extension console via `console.debug`. Anyone who could open DevTools on the background page (or any tooling that scraped the console) could copy a live session and impersonate the user. The fix replaces the credential payload in both log statements with `Boolean(credentials)` and strips a realistic-looking JWT out

critical

How Hardcoded Secrets Compromise Authentication in JavaScript and How to Fix It

A critical vulnerability in `Tool/QuantumultX/Rewrite/RRSP.js` exposed hardcoded API authentication credentials—a TOKEN and UMID device identifier—directly in source code. Anyone with repository access could extract these credentials to impersonate the legitimate user and gain full account access to the RRTV API service. The fix replaced hardcoded secrets with empty placeholders, forcing users to manually configure credentials through secure channels.

critical

ffmpeg.Probe() Protocol Abuse in the GetSnapshot Thumbnailer

The local storage driver's thumbnail helpers — `GetSnapshot()` and `resizeImageToBufferWithFFmpegGo()` — handed user-controlled paths to `ffmpeg.Input()` and `ffmpeg.Probe()` with no restriction on which FFmpeg protocols those processes could open. A file that passed the existing `sanitizeFilePath` check (absolute, cleaned, regular file) could still be an HLS playlist or concat script pointing at `http://`, `subfile:` or `file:` targets, turning thumbnail generation into SSRF and arbitrary file