How Credential Leakage in GitHub Actions Happens in Node.js and How to Fix It
The Incident
In the action.js file of a Node.js GitHub Actions workflow, we discovered a critical credential leakage vulnerability where GitHub authentication tokens were being stored in plain variables without any masking protection. When debug mode was enabled via the ACTIONS_STEP_DEBUG secret or when errors occurred, the token values could be exposed in console output and permanently stored in GitHub Actions logs—accessible to anyone with read access to the repository.
The vulnerability was particularly dangerous because:
- Line 8 retrieved the token: const token = getInput('token', {required: true})
- No masking was applied to this sensitive credential
- Error stack traces printed via console.error(error.stack) could include the token value
- Debug logs enabled by ACTIONS_STEP_DEBUG would expose the entire execution context
This created an attack vector where a malicious actor could trigger an error condition, enable debug logging, and extract the token from the published logs.
The Vulnerability Explained
What Went Wrong
The original code retrieved a GitHub authentication token at line 8 but never registered it with GitHub Actions' masking system:
// VULNERABLE CODE - before the fix
import {getInput, isDebug, setFailed, setOutput, info} from '@actions/core'
;(async () => {
try {
const token = getInput('token', {required: true})
// ❌ Token is now in memory but NOT masked in logs
const enterprise = getInput('enterprise', {required: false}) || null
// ... rest of code
The problem stems from a misunderstanding of how GitHub Actions logging works. By default, GitHub Actions does not automatically mask arbitrary variables—you must explicitly register them using the setSecret() API.
The Attack Scenario
Here's how an attacker could exploit this:
- Attacker triggers the action with a malicious input designed to cause an error in the policy loading logic
- Attacker enables debug mode by setting the
ACTIONS_STEP_DEBUGsecret totruein the repository - Debug logs are generated showing the full execution context, including the
tokenvariable value - Error stack trace prints via
console.error(error.stack)at line 56, potentially including the token in the error context - Token is exposed in the public GitHub Actions logs, which any repository collaborator can read
- Attacker uses the token to make authenticated API calls to GitHub, potentially accessing private repositories, modifying organization settings, or compromising infrastructure
The vulnerability is particularly insidious because:
- It's invisible by default—the code looks normal
- It only manifests when debug mode is enabled (which developers often use during troubleshooting)
- The exposure is permanent in GitHub's log storage
- The token is high-value—GitHub tokens grant broad API access
Real-World Impact
For this specific action, the compromised token could allow an attacker to:
- Modify GitHub Actions allow lists across the entire organization
- Access enterprise-level policy settings
- Potentially pivot to other systems authenticated via the same token
- Conduct supply chain attacks through modified action policies
The Fix
What Changed
The fix involves two critical changes to action.js:
Change 1: Import the setSecret function
// BEFORE
import {getInput, isDebug, setFailed, setOutput, info} from '@actions/core'
// AFTER
import {getInput, isDebug, setFailed, setOutput, setSecret, info} from '@actions/core'
Change 2: Mask the token immediately after retrieval
// BEFORE
;(async () => {
try {
const token = getInput('token', {required: true})
const enterprise = getInput('enterprise', {required: false}) || null
// AFTER
;(async () => {
try {
const token = getInput('token', {required: true})
setSecret(token) // ✅ Register token with GitHub's masking system
const enterprise = getInput('enterprise', {required: false}) || null
This single line—setSecret(token)—tells GitHub Actions to automatically replace all occurrences of the token value with *** in all logs, both in real-time and in the permanent log storage.
Why This Works
When you call setSecret(token):
- GitHub Actions registers the token value in an internal masking list
- All subsequent output (via
console.log(),console.error(),info(), etc.) is scanned - Any occurrence of the token value is replaced with
***before being written to logs - The masking is irreversible—even if the token appears in error messages or stack traces, it's automatically redacted
- The protection applies globally—not just to specific log statements
Additional Hardening: String Concatenation
The PR also made a subtle but important change to prevent tokens from leaking through template literals:
// BEFORE - Template literals can expose values in certain contexts
info(`✅ Loaded Existing GitHub Actions allow list for ${enterprise || organization}`)
// AFTER - String concatenation is more explicit and reduces accidental exposure
info('✅ Loaded Existing GitHub Actions allow list for ' + (enterprise || organization))
While this change alone doesn't prevent the vulnerability (since enterprise and organization are not secrets), it demonstrates defense-in-depth by reducing the use of template literals, which can sometimes bypass masking in edge cases.
Prevention & Best Practices
For GitHub Actions in Node.js
-
Always mask sensitive inputs immediately
javascript const apiKey = getInput('api_key', {required: true}) setSecret(apiKey) // Do this first, before any other operations -
Use
@actions/corefor all sensitive operations
-setSecret()for credentials
-info()instead ofconsole.log()for consistency
-setFailed()for error handling that respects masking -
Never concatenate secrets into error messages
``javascript // ❌ BAD - Token might appear if API returns it in error try { await api.call(token) } catch (error) { throw new Error(API call failed: ${error.message}`)
}
// ✅ GOOD - Log error without secret context
try {
await api.call(token)
} catch (error) {
throw new Error('API call failed: authentication error')
}
```
-
Test with debug mode enabled
- SetACTIONS_STEP_DEBUG: truein your workflow
- Verify that sensitive values show as***in the logs
- Check error messages don't leak credentials -
Use Semgrep to detect unmasked secrets
bash semgrep --config=p/security-audit --config=p/github-actions .
CWE and OWASP References
- CWE-532: Insertion of Sensitive Information into Log File
- CWE-798: Use of Hard-Coded Credentials
- OWASP A09:2021: Security Logging and Monitoring Failures
The vulnerability falls under the broader category of Sensitive Data Exposure and relates to improper logging practices.
Key Takeaways
- Never retrieve credentials without immediately masking them: The
setSecret()call must happen on the very next line after credential retrieval in GitHub Actions - Debug mode is a liability: Always assume
ACTIONS_STEP_DEBUGcould be enabled by a developer, and ensure your code is safe under that condition - Template literals don't prevent masking, but string concatenation is more explicit: While GitHub's masking works with template literals, explicit string concatenation reduces cognitive load and makes the intent clearer
- Error handling is a common leak vector: Even if your main code is secure, error messages and stack traces can expose secrets—always sanitize error output
- The fix is a single line but has massive impact: Adding
setSecret(token)costs nothing but prevents the entire attack surface for credential leakage
How Orbis AppSec Detected This
Source: The token input parameter retrieved via getInput('token', {required: true}) at line 8 of action.js
Sink: The unmasked token variable used throughout the function, particularly in error handling via console.error(error.stack) at line 56 and potential inclusion in log messages
Missing control: The setSecret() function from @actions/core was not called on the token after retrieval, leaving it unmasked in logs and debug output
CWE: CWE-532 (Insertion of Sensitive Information into Log File)
Fix: Added setSecret(token) immediately after token retrieval (line 9) and imported the setSecret function from @actions/core (line 2)
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
Credential leakage through debug logs is a silent but critical vulnerability in GitHub Actions workflows. The fix—calling setSecret() on sensitive credentials—is trivial to implement but transformative in security impact.
The key lesson is that security is a series of deliberate actions, not automatic defaults. GitHub Actions provides powerful masking capabilities, but they only work when developers explicitly register their secrets. By making setSecret() a reflexive habit—called immediately after every credential retrieval—you eliminate an entire class of vulnerabilities.
This vulnerability affected production code used by downstream consumers of this package, making the fix essential. If you maintain GitHub Actions workflows or libraries, audit your code today for unmasked credentials and apply this pattern consistently across all sensitive inputs.
References
- CWE-532: Insertion of Sensitive Information into Log File
- GitHub Actions Security Hardening: Using Secrets
- @actions/core API Documentation
- OWASP A09:2021 – Security Logging and Monitoring Failures
- Semgrep GitHub Actions Security Rules
- fix: github tokens are stored in plain variables and... in action.js