Unmasked Secret Exposure Rule Overview #
This rule detects unmasked secret exposure patterns in GitHub Actions workflows. When secrets are derived from other secrets using operations like fromJson(), the derived values are NOT automatically masked by GitHub Actions. This can lead to accidental exposure of sensitive information in workflow logs.
Vulnerable Example:
name: Deploy
on: push
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Parse secrets
run: |
# DANGEROUS: Derived secrets are NOT masked!
TOKEN=${{ fromJson(secrets.CONFIG).api_token }}
echo "Using token: $TOKEN" # This will be visible in logs!
Detection Output:
vulnerable.yaml:10:11: unmasked secret exposure: secrets derived using fromJson() are not automatically masked and may be exposed in workflow logs. Use '::add-mask::' to mask derived values. See https://codeql.github.com/codeql-query-help/actions/actions-unmasked-secret-exposure/ [unmasked-secret-exposure]
10 | TOKEN=${{ fromJson(secrets.CONFIG).api_token }}
Security Background #
What is Unmasked Secret Exposure? #
GitHub Actions automatically masks secrets stored in the repository settings when they appear in logs. However, this masking only applies to the original secret values. When you derive new values from secrets (e.g., parsing JSON, string manipulation), these derived values are not masked.
| Scenario | Masking Behavior |
|---|---|
${{ secrets.API_TOKEN }} | Automatically masked |
${{ fromJson(secrets.CONFIG).token }} | NOT masked |
${{ secrets.PREFIX }}_suffix | NOT masked |
Why is this dangerous? #
- Log Exposure: Derived secret values appear in plain text in workflow logs
- Public Visibility: For public repositories, anyone can view the logs
- Downstream Access: Forks and Actions artifacts may contain exposed secrets
- Audit Trail: Exposed secrets persist in log history
OWASP and CWE Mapping #
- CWE-532: Insertion of Sensitive Information into Log File
- CWE-200: Exposure of Sensitive Information to an Unauthorized Actor
- OWASP Top 10 CI/CD Security Risks:
- CICD-SEC-6: Insufficient Credential Hygiene
Detection Logic #
What Gets Detected #
fromJson() with secrets
${{ fromJson(secrets.JSON_CONFIG).api_key }}Nested secret access
${{ fromJson(secrets.CONFIG).nested.secret }}Secrets in env variables
env: PARSED_TOKEN: ${{ fromJson(secrets.CONFIG).token }}
What Is NOT Detected (Safe Patterns) #
Direct secret usage (automatically masked):
- run: echo "token is ${{ secrets.API_TOKEN }}"
Properly masked derived secrets:
- run: |
TOKEN=${{ fromJson(secrets.CONFIG).api_token }}
echo "::add-mask::$TOKEN"
echo "Using token: $TOKEN" # Now masked
Auto-Fix #
This rule supports automatic fixing. When you run sisakulint with the -fix on flag, it will add the ::add-mask:: command before using derived secrets.
Example:
Before auto-fix:
- run: |
TOKEN=${{ fromJson(secrets.CONFIG).api_token }}
echo "Deploying with token"
After running sisakulint -fix on:
- run: |
TOKEN=${{ fromJson(secrets.CONFIG).api_token }}
echo "::add-mask::$TOKEN"
echo "Deploying with token"
Remediation Steps #
When this rule triggers:
Add masking for derived secrets
- run: | DERIVED_SECRET=${{ fromJson(secrets.CONFIG).token }} echo "::add-mask::$DERIVED_SECRET"Use environment variables with masking
- name: Set up secrets run: echo "::add-mask::$DERIVED_SECRET" env: DERIVED_SECRET: ${{ fromJson(secrets.CONFIG).token }}Consider restructuring secrets
- Store individual secrets separately instead of JSON blobs
- Use separate secret entries for each value
Best Practices #
Always mask derived secrets immediately
- run: | VALUE=${{ fromJson(secrets.CONFIG).value }} echo "::add-mask::$VALUE" # Now safe to use $VALUEPrefer direct secret references
# Instead of: fromJson(secrets.CONFIG).api_key # Use: secrets.API_KEYAudit workflow logs
- Regularly check logs for exposed secrets
- Enable secret scanning in repository settings
Use structured secret management
- Consider HashiCorp Vault or AWS Secrets Manager
- These provide better access control and audit trails