Build and supply chain

Argo CD hardening, the secure way

Argo CD is the one component in your cluster that can create anything, anywhere, from whatever a Git repository says. Out of the box it trusts that repository, its admin password and its default project a great deal. Time to trust them less.

The short answer

Disable the built-in admin after SSO works, set policy.default to empty, and give each team a role that can get and sync only its own project. Empty the default AppProject. Scope every AppProject to one repository and its own namespaces, with no cluster-scoped kinds and never the argocd namespace. Keep exec and anonymous access off.

Updated Houssam Hammoudi, CTOTested with Argo CD v3.5.3 (server and CLI), Kubernetes 1.34 (kind), kubeconform v0.8.0

On this page
  1. What goes wrong
  2. What the docs say
  3. The secure configuration
  4. Prove it
  5. Mistakes people make
  6. Checklist

What goes wrong

Argo CD runs with enough Kubernetes permissions to apply whatever the Git repository says. So Argo CD's own controls, not the cluster's, decide who can deploy what. The defaults are made for a first demo:

  • A built-in admin user with full access and a password in a Secret.
  • A default project that accepts any repository, any cluster and any kind.
  • RBAC examples that give everyone role:readonly, which shows every application, its manifests and its parameters to any logged-in user.

The worst case is not a stolen UI password. It is an Application in a project that can deploy into the argocd namespace. That Application can change Argo CD itself, and from there, everything.

What the docs say

In short, gaining unauthorized write access to a git repository trusted by Argo CD will have serious security implications outlined below.

Source: Argo CD docs, Security

If unspecified, an application belongs to the default project, which is created automatically and by default, permits deployments from any source repo, to any cluster, and all resource Kinds.

Source: Argo CD docs, Projects

Projects with access to the namespace in which Argo CD is installed effectively have admin-level privileges.

Source: Argo CD docs, Cluster Bootstrapping

All authenticated users get at least the permissions granted by the default policies. This access cannot be blocked by a deny rule.

Source: Argo CD docs, RBAC Configuration

The docs explain each risk on its own page. They do not put the fixes in one place, and the reference argocd-rbac-cm.yaml still shows policy.default: role:readonly, which is the setting the bold warning above is about.

The secure configuration

Apply these after SSO login works for at least two platform admins.

yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: argocd-cm
  namespace: argocd
  labels:
    app.kubernetes.io/name: argocd-cm
    app.kubernetes.io/part-of: argocd
data:
  url: https://argocd.example.com
  admin.enabled: "false"            # after SSO works; the built-in admin has full access
  users.anonymous.enabled: "false"  # no access without login
  exec.enabled: "false"             # no web terminal into pods (the default; keep it explicit)
  accounts.ci: apiKey               # a token-only account for CI, no UI login
  statusbadge.enabled: "false"      # badges leak app names and health to anyone with the URL
  helm.valuesFileSchemes: ""        # no remote Helm values files fetched at render time
  oidc.config: |
    name: SSO
    issuer: https://sso.example.com
    clientID: argocd
    clientSecret: $oidc.sso.clientSecret
    requestedScopes: ["openid", "profile", "email", "groups"]
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: argocd-rbac-cm
  namespace: argocd
  labels:
    app.kubernetes.io/name: argocd-rbac-cm
    app.kubernetes.io/part-of: argocd
data:
  policy.default: ""                # logged-in users with no role see nothing
  scopes: "[groups]"
  policy.csv: |
    # Platform team: full control.
    g, platform-admins, role:admin
    # Team A: read and sync its own project only. No create, no delete, no exec.
    p, role:team-a, applications, get, team-a/*, allow
    p, role:team-a, applications, sync, team-a/*, allow
    p, role:team-a, logs, get, team-a/*, allow
    g, team-a-developers, role:team-a
    # CI: may only sync the apps it deploys; it never edits Applications.
    # "argocd app sync" reads the app first, so get is needed too.
    p, role:ci, applications, get, team-a/*, allow
    p, role:ci, applications, sync, team-a/*, allow
    g, ci, role:ci
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: argocd-cmd-params-cm
  namespace: argocd
  labels:
    app.kubernetes.io/name: argocd-cmd-params-cm
    app.kubernetes.io/part-of: argocd
data:
  server.insecure: "false"          # the API server terminates TLS itself
---
# The built-in "default" project allows any repo, any cluster, any kind.
# Empty it so an Application that forgets its project cannot deploy anything.
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
  name: default
  namespace: argocd
spec:
  sourceRepos: []
  destinations: []
  clusterResourceWhitelist: []
  namespaceResourceWhitelist: []
---
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
  name: team-a
  namespace: argocd
spec:
  description: Team A applications
  sourceRepos:
    - https://git.example.com/team-a/deploy.git     # one repository, not "*"
  destinations:
    - server: https://kubernetes.default.svc
      namespace: team-a                              # never the argocd namespace
  clusterResourceWhitelist: []                       # no cluster-scoped objects at all
  namespaceResourceBlacklist:
    - { group: "", kind: ResourceQuota }
    - { group: "", kind: LimitRange }
    - { group: networking.k8s.io, kind: NetworkPolicy }
    - { group: rbac.authorization.k8s.io, kind: Role }
    - { group: rbac.authorization.k8s.io, kind: RoleBinding }
  orphanedResources:
    warn: true

Four more settings live outside these files:

  • Repository credentials: one read-only deploy key per repository, scoped to its project with the project field of the repository Secret. The docs say a credential without that field "will be used as the default for all projects without a scoped credential".
  • The argocd namespace gets a NetworkPolicy: the API server accepts traffic only from your ingress, and the repo server accepts traffic only from the application controller and the API server. Limit the repo server's egress too, to DNS and your Git and Helm hosts: it fetches whatever URL an Application names.
  • Only platform admins can push to the repository that holds Applications and AppProjects.
  • Generate the CI token with argocd account generate-token --account ci --expires-in 720h and rotate it before it expires.

Prove it

The manifests pass schema validation:

bash
kubeconform -strict -summary -kubernetes-version 1.34.0 \
  -schema-location default \
  -schema-location 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json' \
  argocd-hardening.yaml

Then on a lab cluster with Argo CD v3.5.3 (install.yaml), in two steps: everything except admin.enabled: "false" first, so the admin could create the CI token, then the full files. The team project's repository was the public argoproj/argocd-example-apps in place of the team's own.

1. Before hardening, default deploys anything:

text
$ argocd app create gb-default --project default --repo https://github.com/argoproj/argocd-example-apps.git --path guestbook ...
application 'gb-default' created
$ argocd app get gb-default -o json | jq -r '"\(.spec.project) \(.status.sync.status)"'
default Synced

2. After hardening, the projects hold:

text
$ argocd app create gb-default2 --project default ...
... InvalidSpecError: application repo https://github.com/argoproj/argocd-example-apps.git is not permitted in project 'default'
$ argocd app get gb-default -o json | jq -r '.status.conditions[] | "\(.type): \(.message)"'
InvalidSpecError: application destination server 'https://kubernetes.default.svc' and namespace 'guestbook-default' do not match any of the allowed destinations in project 'default'
InvalidSpecError: application repo https://github.com/argoproj/argocd-example-apps.git is not permitted in project 'default'
$ argocd app create web --project team-a --repo <the allowed repo> --path guestbook --dest-namespace team-a ...
application 'web' created
Phase:              Succeeded
$ argocd app create web-argocd --project team-a ... --dest-namespace argocd
... InvalidSpecError: application destination server 'https://kubernetes.default.svc' and namespace 'argocd' do not match any of the allowed dest...
$ argocd app create web-other2 --project team-a --repo https://github.com/stefanprodan/podinfo.git ...
... InvalidSpecError: application repo https://github.com/stefanprodan/podinfo.git is not permitted in project 'team-a'

The existing app in default stopped syncing the moment the project was emptied. Nothing new could be deployed through it.

3. The built-in admin:

text
$ argocd login --username admin --password ...
{"level":"fatal","msg":"rpc error: code = Unauthenticated desc = Account admin is disabled"}

4. The CI token. With only sync in its role, as in the first version of this page, the CLI could not sync:

text
$ ARGOCD_AUTH_TOKEN=... argocd app sync web
{"level":"fatal","msg":"rpc error: code = PermissionDenied desc = permission denied"}

argocd app sync calls Get on the application first, even with --async. With get and sync:

text
sync:            Phase:              Succeeded
set (update):    {"level":"fatal","msg":"rpc error: code = PermissionDenied desc = permission denied"}
delete:          {"level":"fatal","msg":"rpc error: code = PermissionDenied desc = permission denied: applications, delete, team-a/web, s...

5. The RBAC table, against the live argocd-rbac-cm saved to a file:

text
$ argocd admin settings rbac validate --policy-file argocd-rbac-cm.yaml
Policy is valid.
team-a-developers  get      applications  team-a/web   Yes
team-a-developers  sync     applications  team-a/web   Yes
team-a-developers  delete   applications  team-a/web   No
team-a-developers  sync     applications  team-b/api   No
team-a-developers  create   exec          team-a/web   No
ci                 get      applications  team-a/web   Yes
ci                 sync     applications  team-a/web   Yes
ci                 update   applications  team-a/web   No
someone-else       get      applications  team-a/web   No
platform-admins    delete   applications  team-b/api   Yes

Each line is argocd admin settings rbac can <subject> <action> <resource> <object> --policy-file argocd-rbac-cm.yaml. A No exits with code 1 and a Yes with 0, so the same lines work as a CI check on every change to argocd-rbac-cm. Without --policy-file, the command reads the live ConfigMap through your current kubeconfig, so always pass it in CI.

Mistakes people make

policy.default: role:readonly

It looks harmless. It lets every logged-in user read every application's manifests, parameters and events, and a deny rule cannot take it back. Use an empty default and grant read per project.

Letting a project deploy to the argocd namespace

A destination of namespace: "*" includes argocd. Anyone who can merge to that project's repository can then rewrite Argo CD's ConfigMaps. List the namespaces a project may use; never use * for application teams.

sourceRepos: ["*"]

With a wildcard, a developer can point an Application at any repository, including a fork they control. One repository, or a short list, per project.

Turning off admin before SSO works

Test SSO and the platform-admins group first. Keep a break-glass path, such as a documented way to re-enable admin through the ConfigMap by someone with cluster access, and audit its use.

One CI token with role:admin

CI only needs to sync. A pipeline token with admin can create Applications in any project, which is the same as admin on every cluster Argo CD manages.

Checklist

  • SSO works for at least two platform admins.
  • admin.enabled: "false" and users.anonymous.enabled: "false" are set.
  • exec.enabled is "false" unless a documented need exists.
  • policy.default is empty.
  • Each team role can get and sync only its own project.
  • The CI account has apiKey only, sync only, and a token with an expiry.
  • The default AppProject has no sources, destinations or kinds.
  • Every AppProject lists its repositories and namespaces; none includes argocd.
  • Cluster-scoped kinds are allowed only in the platform project.
  • Repository credentials are read-only and scoped with the project field.
  • RBAC checks with argocd admin settings rbac can --policy-file run in CI.

Argo CD does exactly what Git says. Hardening it is deciding, in writing, whose Git that is.

H2-CSDE

Learn it on a live range

Policy gates and scanning, in DevSecOps and Supply Chain: a real host in your browser, and every objective checked on the machine.

Start free

H2 Scanner

Want this caught before it merges?

The H2 Scanner runs in your CI and flags the weaknesses pages like this one warn about, on every pull request.

Talk to us