How GitHub Token Exposure Happens in TypeScript CLI Utilities and How to Fix It
Introduction
The cli/src/utils/github.ts file is the backbone of a CLI tool's release management — it fetches releases, retrieves the latest version, and downloads binaries from GitHub. But a security assessment revealed a significant problem: all three of its outbound GitHub API calls lacked a safe, centralized mechanism for loading authentication tokens. This left the door open for tokens to be hardcoded directly into the source, leaked through error output, or silently omitted from requests in ways that could expose rate-limited or private repository data to unauthenticated callers.
This post breaks down exactly what the vulnerability was, how it could be exploited, and what the fix looks like — with real code from the pull request.
The Vulnerability Explained
What Was Missing
At lines 37, 56, and 73 of cli/src/utils/github.ts, three fetch calls are made to the GitHub API:
fetchReleases()— fetches all releases from the repogetLatestRelease()— retrieves the most recent releasedownloadRelease()— downloads a release binary to disk
Before the fix, each of these functions constructed its own headers object inline, with no shared mechanism for injecting an auth token:
// BEFORE — fetchReleases() at line 37
const response = await fetch(url, {
headers: {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'uipro-cli',
// ⚠️ No Authorization header — token handling undefined
},
});
The same pattern was repeated in getLatestRelease() and downloadRelease(). The security assessment confirmed these calls exist but could not confirm whether a GitHub token was being loaded safely from environment variables or hardcoded somewhere upstream. That ambiguity is itself the vulnerability.
Why This Is Dangerous
When there's no centralized, explicit token-loading function, developers filling in the "missing piece" often reach for the path of least resistance:
- Hardcoding the token directly in the source file or a config constant
- Interpolating the token into a URL or log string where it becomes visible in CI/CD output
- Leaving it out entirely, causing unauthenticated requests that hit GitHub's rate limits and fail silently in production
A hardcoded token in a TypeScript CLI file is particularly dangerous because:
- The CLI is often open-source or distributed as a package, meaning the source is publicly readable
- Build artifacts (compiled JS bundles) may be uploaded to npm or GitHub Releases, carrying the token with them
- CI/CD logs frequently echo environment setup steps, and a poorly written token injection can print the value to stdout
Example Attack Scenario
Imagine a developer adds the token like this to "just make it work":
// Dangerous pattern — token hardcoded or echoed
const TOKEN = 'ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${TOKEN}`,
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'uipro-cli',
},
});
If this code is committed and pushed — even briefly — the token is now in git history. An attacker with read access to the repository (or a public fork) can extract it, use it to access private repositories, create releases, delete tags, or exfiltrate code. GitHub's secret scanning may catch it, but by then the token has already been exposed.
The Fix
The getAuthHeaders() Function
The fix introduces a single, centralized function that safely reads the GitHub token from the environment and returns it as an Authorization header — or returns an empty object if no token is set:
// AFTER — new function added above fetchReleases()
function getAuthHeaders(): Record<string, string> {
const token = process.env['GITHUB_TOKEN'];
return token ? { 'Authorization': `Bearer ${token}` } : {};
}
This is a clean, safe pattern for three reasons:
- Single source of truth: The token is loaded in exactly one place. If the loading logic ever needs to change (e.g., to support a secrets manager), you change one function, not three call sites.
- Bracket notation for env access: Using
process.env['GITHUB_TOKEN']instead ofprocess.env.GITHUB_TOKENis a subtle but meaningful choice — it makes the key a string literal that static analysis tools can track, and it avoids accidental property access issues in strict TypeScript configs. - Graceful degradation: If
GITHUB_TOKENis not set, the function returns{}, so the spread operator adds nothing to the headers. The request proceeds unauthenticated rather than throwing an error — appropriate for public repository access.
Before and After: All Three Call Sites
fetchReleases() (line 43):
// BEFORE
headers: {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'uipro-cli',
},
// AFTER
headers: {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'uipro-cli',
...getAuthHeaders(),
},
getLatestRelease() (line 63):
// BEFORE
headers: {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'uipro-cli',
},
// AFTER
headers: {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'uipro-cli',
...getAuthHeaders(),
},
downloadRelease() (line 81):
// BEFORE
headers: {
'User-Agent': 'uipro-cli',
'Accept': 'application/octet-stream',
},
// AFTER
headers: {
'User-Agent': 'uipro-cli',
'Accept': 'application/octet-stream',
...getAuthHeaders(),
},
The spread operator (...getAuthHeaders()) is elegant here — it conditionally injects the header only when the token exists, keeping the code clean and avoiding undefined header values that some HTTP clients handle inconsistently.
Prevention & Best Practices
1. Always Centralize Credential Loading
Never scatter process.env['TOKEN'] calls across multiple functions. Create a single auth utility (like getAuthHeaders()) and import it wherever needed. This makes auditing trivial — one grep for the function name shows you every place credentials are used.
2. Use Environment Variables — And Validate Them at Startup
// Optional: validate required tokens at CLI startup
function validateEnv(): void {
if (!process.env['GITHUB_TOKEN']) {
console.warn('Warning: GITHUB_TOKEN not set. API rate limits apply.');
}
}
For private repository access where the token is mandatory, fail fast with a clear error rather than making unauthenticated requests that will fail with a cryptic 404.
3. Never Log Tokens — Even Partially
Avoid patterns like:
// ❌ Dangerous — token appears in logs
console.error(`Request failed with token: ${token?.substring(0, 8)}...`);
Even partial tokens can aid attackers in narrowing down brute-force attempts.
4. Use a Secrets Manager for Production
For CI/CD pipelines, use GitHub Actions secrets, AWS Secrets Manager, or HashiCorp Vault rather than plain environment variables in .env files that might be committed accidentally.
5. Scan Your Repository
- GitLeaks: Detects hardcoded secrets in git history
- GitHub Secret Scanning: Automatically revokes exposed GitHub tokens
- Semgrep: Can detect unsafe
process.envpatterns and missing auth headers
Relevant Standards
- CWE-312: Cleartext Storage of Sensitive Information
- CWE-798: Use of Hard-coded Credentials
- OWASP A02:2021: Cryptographic Failures (which includes credential exposure)
- OWASP A07:2021: Identification and Authentication Failures
Key Takeaways
- The absence of a token-loading mechanism is itself a vulnerability — in
github.ts, three API call sites had no shared, safe way to inject auth credentials, creating a gap that could be filled dangerously. - Centralizing credential access in
getAuthHeaders()eliminates three separate risk points — instead of auditing three fetch calls, security reviewers now audit one function. - Bracket notation
process.env['GITHUB_TOKEN']is preferable to dot notation in TypeScript for environment variable access, as it plays better with static analysis and strict type checking. - Graceful degradation matters — returning
{}when no token is set means unauthenticated requests proceed for public repos, rather than crashing the CLI with an unhelpful error. - Token exposure risk is amplified in CLI tools because they're often open-sourced, distributed as packages, and run in CI/CD environments where logs are broadly accessible.
How Orbis AppSec Detected This
- Source: GitHub API token — potentially hardcoded or unsafely loaded upstream of the three fetch call sites in
cli/src/utils/github.ts - Sink: Three
fetch()calls at lines 37, 56, and 73, each constructing headers inline with no shared auth-injection mechanism - Missing control: No centralized function to safely read
GITHUB_TOKENfrom environment variables; no guarantee the token wasn't being hardcoded or echoed in error output - CWE: CWE-312 (Cleartext Storage of Sensitive Information) and CWE-798 (Use of Hard-coded Credentials)
- Fix: Added
getAuthHeaders()function reading exclusively fromprocess.env['GITHUB_TOKEN']and spread into all three fetch call header objects
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
The vulnerability in cli/src/utils/github.ts is a textbook example of how credential exposure often isn't a single dramatic mistake — it's the absence of a safe pattern that creates space for dangerous ones. Three GitHub API calls with no centralized auth mechanism left the codebase one bad commit away from a hardcoded token or a leaked secret in CI logs.
The fix is small — 11 lines added — but architecturally significant. By introducing getAuthHeaders() as the single source of truth for GitHub authentication, the code is now auditable, safe, and extensible. Any future GitHub API calls added to this file will naturally reach for the same function, carrying the secure pattern forward.
For developers building CLI tools that interact with external APIs: credential loading is not a detail to handle ad-hoc at each call site. Treat it as infrastructure, centralize it early, and let static analysis tools verify it stays safe.