Safeguard
Vulnerability Analysis•

Argo CD CVE-2026-77459: Delete Hooks That Skip Your AppProject Limits

Argo CD PreDelete and PostDelete hooks skipped AppProject checks from 2.10 on. Fixed versions, why Helm hooks count, no 2.x patch, and an audit script.

Safeguard Security Team
Security Research
10 min read

Argo CD has never applied AppProject restrictions to PostDelete hooks, and since PreDelete hooks arrived in 3.3, to those too. Anyone who can push to a Git repository an Application syncs from can commit a hook that the project would refuse on sync, such as a ClusterRoleBinding or an object in a forbidden namespace. When the Application is deleted, the application controller creates that object with its own credentials. The bug is CVE-2026-77459 (GHSA-fmxq-cgp8-87wp, CVSS 9.9). Argo CD fixed it on 6 October in 3.3.15, 3.4.10, 3.5.4 and 3.6.0-rc2. Versions 2.10 to 3.2 are out of support and won't be patched.

This post is for platform teams that use AppProjects to separate tenants in Argo CD. It explains the gap, why Helm charts can trigger it too, what the fix does, and how to find the Applications and hooks that matter. It includes an audit script for exported Argo CD objects and rendered manifests.

The short answer

Upgrade Argo CD to 3.3.15, 3.4.10, 3.5.4 or later. If you're on 2.10 to 3.2, you have to move to a supported line, because there's no patch. Until you do, treat Git write access to any repository behind a restricted AppProject as equivalent to the application controller's access. Restrict who can delete Applications, and check Applications that already carry a pre-delete-finalizer.argocd.argoproj.io or post-delete-finalizer.argocd.argoproj.io finalizer. Hooks annotated helm.sh/hook: pre-delete or post-delete are treated the same way.

How the gap works

An AppProject is Argo CD's tenancy boundary. It limits which clusters and namespaces an Application can deploy to, and which resource kinds it can create, including cluster-scoped ones. Argo CD enforces those limits whenever it syncs, and that includes the PreSync, Sync, PostSync and SyncFail hooks.

Delete hooks follow a different path. The advisory explains that when Argo CD sees a PreDelete or PostDelete hook in an Application's manifests, it adds a delete finalizer to the Application and leaves the hook alone. The Application stays Synced. When someone later deletes the Application, the controller creates the hook resources directly on the destination cluster, without the project check.

So the attack needs two steps, possibly by different people. A tenant with write access to the Git repository commits a hook the project wouldn't allow. Later, the Application is deleted, maybe by the tenant if they're allowed to delete it, or by a platform engineer cleaning up. The hook is then created with the application controller's permissions, which in a typical install cover most of the cluster. The advisory's examples are a cluster-scoped ClusterRoleBinding and objects in namespaces the project forbids. A hook that's a Job can go on to create more objects that Argo CD never tracks.

Two details in the advisory change how long the effect lasts. A hook with no deletion policy is removed once it succeeds, so a ClusterRoleBinding used that way exists briefly. A deletion policy that doesn't remove the hook on success leaves the object in place.

Helm charts count too

Argo CD's controller treats Helm's own delete hooks as delete hooks. In the 3.5.4 source, controller/hook.go maps PreDelete to either argocd.argoproj.io/hook: PreDelete or helm.sh/hook: pre-delete, and PostDelete to argocd.argoproj.io/hook: PostDelete or helm.sh/hook: post-delete.

That widens who can trigger the gap. It isn't only tenants writing their own manifests. A third-party chart with a pre-delete hook that creates cluster-scoped RBAC will have that hook created outside the project's limits when the Application is deleted. Usually that's the chart working as designed. But it means a project's restrictions don't describe what an Application can do when it's deleted.

Affected versions and the fix

LineAffectedFixed
2.10 to 2.14All releases (PostDelete only)None, out of support
3.0 to 3.2All releases (PostDelete only)None, out of support
3.3Before 3.3.15 (PreDelete and PostDelete)3.3.15
3.4Before 3.4.103.4.10
3.5Before 3.5.43.5.4
3.63.6.0-rc13.6.0-rc2

Versions 2.9.22 and earlier aren't affected, because PostDelete hooks first shipped in 2.10.0. The fixed releases came out on 6 October, the repository advisory on 7 October, the CVE record on 13 October, and GitHub's reviewed advisory on 15 October. The fix runs the same AppProject destination and resource checks used on sync before creating a delete hook.

The advisory credits @RealFakeAccount, @AdamKorcz, @0xshylines, @thevilledev, @HenriWilliams, @cherez0ff, @ARSB1 and @zwindler.

Find what's exposed

There's no setting that applies the project check to delete hooks while still using them, so the job before upgrading is to find where it matters. This script reads kubectl exports of Applications and AppProjects and, optionally, a directory of rendered manifests. It reports:

  • whether your version is fixed;
  • every Application that already carries a delete-hook finalizer, with the project limits its hooks would skip;
  • every PreDelete or PostDelete hook in the manifests, including Helm pre-delete and post-delete hooks.

It uses only the Python standard library.

#!/usr/bin/env python3
"""Audit Argo CD for CVE-2026-77459 (GHSA-fmxq-cgp8-87wp): delete hooks that skip
AppProject restrictions.

  kubectl get applications -A -o json > apps.json
  kubectl get appprojects -A -o json  > projects.json
  python3 argocd_77459_audit.py --version v3.4.9 apps.json projects.json [manifests_dir]

Reports whether the version is fixed, every Application that already carries a
pre-delete or post-delete finalizer (Argo CD adds one when it sees a delete hook),
and the AppProject limits those hooks would skip. With a directory of rendered
manifests (for example `helm template` or `kustomize build` output), it also lists
PreDelete/PostDelete hooks, including Helm pre-delete/post-delete hooks, which
Argo CD runs the same way. Standard library only.
"""
import json, os, re, sys

FIXED = {(3, 3): (3, 3, 15), (3, 4): (3, 4, 10), (3, 5): (3, 5, 4)}
FINALIZERS = ("pre-delete-finalizer.argocd.argoproj.io", "post-delete-finalizer.argocd.argoproj.io")
HOOK = re.compile(r"(argocd\.argoproj\.io/hook|helm\.sh/hook)\s*:\s*['\"]?([\w,-]+)")


def check_version(v):
    m = re.match(r"v?(\d+)\.(\d+)\.(\d+)", v or "")
    if not m:
        return "unknown version; check it by hand"
    t = tuple(map(int, m.groups()))
    if t[:2] >= (3, 6):
        return f"{v}: 3.6 line, fixed from 3.6.0-rc2"
    if t[:2] in FIXED:
        return f"{v}: fixed" if t >= FIXED[t[:2]] else f"{v}: VULNERABLE, upgrade to {'.'.join(map(str, FIXED[t[:2]]))}"
    if t >= (2, 10, 0):
        return f"{v}: VULNERABLE and out of support, no patch; move to 3.3.15 or later"
    return f"{v}: not affected (before 2.10.0)"


def project_limits(p):
    s = p.get("spec", {})
    dests = [f"{d.get('server') or d.get('name')}/{d.get('namespace')}" for d in s.get("destinations", [])]
    notes = []
    if not any(d.endswith("/*") for d in dests):
        notes.append("namespaces limited to " + ", ".join(dests))
    cw = s.get("clusterResourceWhitelist")
    if not cw:
        notes.append("no cluster-scoped resources allowed")
    elif not any(r.get("group") == "*" and r.get("kind") == "*" for r in cw):
        notes.append("cluster-scoped kinds limited")
    if s.get("namespaceResourceBlacklist") or s.get("namespaceResourceWhitelist"):
        notes.append("namespaced kinds limited")
    return notes


def scan_manifests(root):
    hooks = []
    for d, _, files in os.walk(root):
        for f in files:
            if not f.endswith((".yaml", ".yml", ".json")):
                continue
            text = open(os.path.join(d, f), errors="replace").read()
            for doc in re.split(r"(?m)^---\s*$", text):
                m = HOOK.search(doc)
                if not m or not re.search(r"(?i)(pre|post)-?delete", m.group(2)):
                    continue
                kind = re.search(r"(?m)^kind:\s*(\S+)", doc)
                meta = re.search(r"(?m)^metadata:\n((?:[ \t]+.*\n?)*)", doc)
                meta = meta.group(1) if meta else ""
                ns = re.search(r"(?m)^  namespace:\s*(\S+)", meta)
                name = re.search(r"(?m)^  name:\s*(\S+)", meta)
                hooks.append((os.path.relpath(os.path.join(d, f), root), kind and kind.group(1),
                              name and name.group(1), ns and ns.group(1), f"{m.group(1)}: {m.group(2)}"))
    return hooks


def main(a):
    if "--version" not in a or len(a) < 4:
        sys.exit(__doc__)
    i = a.index("--version")
    version = a[i + 1]
    rest = a[:i] + a[i + 2:]
    apps = json.load(open(rest[0]))["items"]
    projects = {p["metadata"]["name"]: p for p in json.load(open(rest[1]))["items"]}
    print("Argo CD", check_version(version))
    flagged = 0
    print("\nApplications with delete-hook finalizers:")
    for app in apps:
        fins = [f for f in app["metadata"].get("finalizers", []) if f.startswith(FINALIZERS)]
        if not fins:
            continue
        proj = app.get("spec", {}).get("project", "default")
        limits = project_limits(projects.get(proj, {})) if proj in projects else ["project not found"]
        flagged += 1
        print(f"  {app['metadata'].get('namespace')}/{app['metadata']['name']} (project {proj}): {', '.join(fins)}")
        if limits:
            print(f"      hooks skip these project limits: {'; '.join(limits)}")
        else:
            print("      project is unrestricted, so the bypass gains nothing extra")
    if not flagged:
        print("  none")
    if len(rest) > 2:
        hooks = scan_manifests(rest[2])
        print(f"\nDelete hooks in {rest[2]}:")
        for path, kind, name, ns, ann in hooks:
            print(f"  {path}: {kind} {name} (namespace {ns or 'none set'}) [{ann}]")
        if not hooks:
            print("  none")
    sys.exit(1 if flagged or "VULNERABLE" in check_version(version) else 0)


if __name__ == "__main__":
    main(sys.argv[1:])

We ran it on 16 October against a fixture: three Applications, one restricted project with a PostDelete Job, an unrestricted default project, and a rendered chart with a pre-delete ClusterRoleBinding:

$ python3 argocd_77459_audit.py --version v3.4.9 apps.json projects.json manifests
Argo CD v3.4.9: VULNERABLE, upgrade to 3.4.10

Applications with delete-hook finalizers:
  argocd/team-a-web (project team-a): post-delete-finalizer.argocd.argoproj.io
      hooks skip these project limits: namespaces limited to https://kubernetes.default.svc/team-a; no cluster-scoped resources allowed; namespaced kinds limited
  argocd/platform-ingress (project default): pre-delete-finalizer.argocd.argoproj.io
      project is unrestricted, so the bypass gains nothing extra

Delete hooks in manifests:
  charts/rendered.yaml: ClusterRoleBinding uninstall-helper (namespace none set) [helm.sh/hook: pre-delete]
  team-a/cleanup.yaml: Job drain-queue (namespace none set) [argocd.argoproj.io/hook: PostDelete]
exit 1

$ python3 argocd_77459_audit.py --version v2.14.21 apps.json projects.json | head -1
Argo CD v2.14.21: VULNERABLE and out of support, no patch; move to 3.3.15 or later

team-a-web is the case the advisory describes. Its project limits it to one namespace and no cluster-scoped resources, but its delete hook will be created regardless. platform-ingress has a delete hook too. Its project already allows everything, so the bug adds nothing there. The ClusterRoleBinding from the chart is exactly what a restricted project should have refused.

What to do

  1. Upgrade to 3.3.15, 3.4.10 or 3.5.4, or a later patch on those lines. On 2.x or 3.0 to 3.2, plan the move to 3.3 or later now. Those lines will stay vulnerable.
  2. Hold deletes. Until you've upgraded, don't delete Applications in restricted projects without first reading their delete hooks. Limit applications, delete in Argo CD RBAC to people who'll do that.
  3. Review Git access. For every repository behind a restricted AppProject, assume a writer can reach whatever the application controller can reach on deletion.
  4. Narrow the controller. The advisory's third workaround is to tighten the application controller's Kubernetes permissions, so a hook can't reach sensitive namespaces or APIs at all.
  5. Look back. Check Kubernetes audit logs for objects created by the application controller's service account around past Application deletions, especially RBAC objects and anything in namespaces outside the project.

The fair view

The trigger needs a deletion, and deletions are rarer than syncs. The attacker also needs Git write access to an Application in a restricted project, which a single-team installation doesn't have. And many teams use AppProjects for organisation rather than as a security boundary.

For those that do rely on them, this gap has existed since PostDelete hooks shipped in 2.10.0. The project check covered everything except the one path that runs when an Application goes away. It's also one more release where the fix lands only on supported lines: 2.x users who were patching within their line now face a major upgrade. Our Argo CD GitOps security guide covers the broader controls around AppProjects and repository access.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.