Projects

Zero-Trust Secure Supply Chain Platform

Seven independent enforcement layers on Kubernetes — signed images, admission policy, Pod Security, NetworkPolicy, Istio mTLS and egress, Falco — and seven attacks run against them on purpose. Six held. The seventh failed silently, and why it failed is the most useful result on the page.

Kubernetes (k3s) · Kyverno · Sigstore / cosign · Istio · Falco · ArgoCD · Trivy · OPA · Terraform · GitHub Actions

Built & drilled

View repository →

Lab build on a single t3.large in a personal AWS account, provisioned by Terraform and destroyed between sessions. Every error message quoted on this page is copied from the attack transcripts in the repository. The addresses shown are private, inside a VPC that no longer exists.

At a glance

Stackk3s v1.36.5 · Istio 1.26.2 (CNI mode) · Kyverno chart 3.9.1 · Falco chart 9.2.0 · ArgoCD v2.13.2 · cosign v2.4.1 · Terraform
ShapeTwo repositories — application source and declared cluster state, deliberately separated
Verified before attacking15 passed, 0 failed, 0 skipped behavioural checks
Attacks7 run, twice — once in Audit, once in Enforce. 6 held. 1 failed, and is published as a failure
Defects15 found — 6 in the platform and its dependencies, 9 in my own tooling
Total costUnder $1 — one t3.large for about six hours

The claim this project had to earn

Most security write-ups show a configuration and assert that it works. This one assumes the opposite until proven: a policy that is accepted by the API server is not a policy that is enforced, and the only way to tell the difference is to attack it and read which component answers.

So the platform was built, and then seven attacks were run against it. Each attack targets one named layer. Each result records the actual error message, because the message is the evidence — it names which control spoke. “Rejected” in a results column could have come from any of four places.

Six of the seven held. One did not, and the reason it did not is the most useful thing on this page.

Terminal: attack-02-privileged-container.sh. Part 1, in zt-prod where Pod Security Admission is restricted: 'pods attack-priv is forbidden: violates PodSecurity restricted:latest: privileged…', followed by 'Refused by PodSecurity — the BUILT-IN plugin, not Kyverno'. Part 2, in zt-psa-off where PSA is disabled: a PodSecurity warning only, then 'admission webhook validate.kyverno.svc-fail denied the request', naming the disallow-privileged and require-readonly-rootfs policies and the failing paths, followed by 'Refused by Kyverno — layer 3 works on its own'.
One attack, two layers, two messages. The same privileged pod is refused in zt-prod by the built-in PodSecurity plugin — no mention of Kyverno, which was never asked — and in zt-psa-off by Kyverno, naming the policy and the exact field path.

The second namespace is a test fixture with Pod Security Admission deliberately turned off, and its annotation says so. Without it the Kyverno rule is untestable: in every governed namespace PSA refuses the pod first, so the Kyverno policy would appear to pass while never having been exercised.

Terminal: the end of attack 2. After Kyverno's refusal it prints 'CONTROL HELD — both layers refuse a privileged container, independently' and 'Two rejections, two different messages, two different mechanisms. That is what makes this defence in depth rather than one control with a spare written next to it.'
Independent, not redundant: either layer alone refuses the pod.

Architecture

supply-chain-app CI L1 Trivy gate — fails on fixable HIGH / CRITICAL cosign keyless · Fulcio certificate · Rekor log SBOM attestation · no kubeconfig commits the digest never a tag zerotrust-gitops namespaces · 5 Kyverno policies · NetworkPolicies Istio security · workloads pinned by digest ArgoCD Applications in sync waves 0–4 push GHCR signed image + SBOM attestation, by digest pulls ArgoCD L2 · attack 6 automated sync · selfHeal · prune apply API SERVER ADMISSION — TWO INDEPENDENT LAYERS Pod Security Admission L4 · attack 2 restricted in every governed namespace the built-in plugin, not a webhook Kyverno — 5 ClusterPolicies L3 · attacks 1 2 3 signature identity · trusted registries · privilege requests and limits · read-only root filesystem admitted POD IN zt-prod NetworkPolicy L5 default-deny, explicit allows L3/L4, on labels Istio sidecar L6 · attacks 4 5 STRICT mTLS · AuthorizationPolicy REGISTRY_ONLY egress allowlist Falco L7 · attack 7 watches syscalls on the node rules scoped to this cluster detects — does not prevent
A workload's path from commit to running, with the layer that guards each step and the attack that tests it. CI has no kubeconfig: a compromised application pipeline can push a bad image, and Kyverno then refuses it at admission — which is the point of splitting the repositories.
LayerControlWhat it stops
1Build provenance — cosign keyless signing, Fulcio certificate, Rekor transparency logAn image that did not come from this pipeline
2GitOps — ArgoCD, automated sync, selfHeal, pruningThe cluster quietly diverging from git
3Admission policy — Kyverno: signature, registry, privilege, resources, read-only rootA non-compliant workload being created at all
4Pod Security Admission — restricted in every governed namespacePrivileged containers, via the built-in plugin rather than a webhook
5NetworkPolicy — default-deny, explicit allowsLateral movement at L3/L4
6Service mesh — Istio STRICT mTLS, AuthorizationPolicy, REGISTRY_ONLY egressUnauthenticated callers and undeclared outbound destinations
7Runtime detection — Falco with rules scoped to this clusterWhat the first six did not prevent

The design argument is that these are independent, not redundant. Attack 2, above, is the proof.

What was measured

Controls verified before any attack ran

03-verify-enforcement.sh tests behaviour, not YAML. It opens a plaintext connection from outside the mesh, submits a privileged pod, counts the loaded policies, and reads the mesh’s outbound mode.

================ RESULT ================
  passed 15   failed 0   skipped 0

The check that matters most is the first one:

CHECK 1 of 6 — Istio traffic redirection (the silent one)
      BLOCKED ConnectionResetError [Errno 104] Connection reset by peer
  PASS  Plaintext from outside the mesh was REFUSED - redirection works

That connection reset is the only honest proof that traffic is actually passing through the sidecars. Every mesh guarantee is enforced by an Envoy that traffic has to traverse — not by the policy objects, which apply cleanly whether or not anything reads them.

Terminal: ./scripts/03-verify-enforcement.sh. Preconditions pass for the image under test, pinned by sha256 digest. Check 1, Istio traffic redirection: 'BLOCKED ConnectionResetError [Errno 104] Connection reset by peer', PASS. Check 2, NetworkPolicy: an unlisted workload blocked below HTTP, PASS. Check 3, Pod Security Admission: zt-prod and zt-attack enforce restricted, and a privileged pod is refused. Check 4: seven Kyverno webhooks registered, all five ClusterPolicies present, SKIP because all five are still in Audit. Check 5: outboundTrafficPolicy.mode = REGISTRY_ONLY and one ServiceEntry declared.
The first pass, with every Kyverno policy still in Audit — so check 4 is reported as a skip, not quietly counted as a pass.
Terminal: the end of the verification run in Enforce mode. Check 4 now reads 'Every policy is in Enforce'; checks 5 and 6 pass for REGISTRY_ONLY egress and for Falco with the k3s containerd socket present. RESULT: passed 15, failed 0, skipped 0. Below, the policy-mode table lists all five policies — allowed-registries, disallow-privileged, require-readonly-rootfs, require-resources, verify-image-signature — as Enforce.
15 passed, 0 failed, 0 skipped — with all five policies in Enforce.

The seven attacks

#AttackLayerResultThe message that proves it
1Unsigned image3Heldverify-ghcr-signature: 'failed to verify image … .attestors[0].entries[0].keyless: no signatures found'
2Privileged container4 and 3, separatelyHeld twicePSA: violates PodSecurity "restricted:latest": privileged. Kyverno: no-privileged-containers … failed at path /spec/containers/0/securityContext/privileged/
3Image from an undeclared registry3Heldonly-trusted-registries … failed at path /spec/containers/0/image/
4Call from an unauthorised workload6HeldWith a sidecar: HTTP 403 RBAC: access denied. Without: ConnectionResetError [Errno 104]
5Exfiltration to an undeclared host6 over 5HeldDeclared host REACHED status=200; example.com and pastebin.com both BLOCKED
6Manual edit of a live resource2FailedComparisonError: .status.terminatingReplicas: field not declared in schema
7Shell in a running container7Held[Critical] Shell spawned in a governed namespace ns=zt-prod pod=api-… proc=sh
Terminal: attack-01-unsigned-image.sh in Enforce. Submitting a pod referencing ghcr.io/syed-amjad/zt-unsigned-control:latest returns 'admission webhook mutate.kyverno.svc-fail denied the request: resource Pod/zt-prod/attack-unsigned was blocked due to the following policies: verify-image-signature: verify-ghcr-signature: failed to verify image … .attestors[0].entries[0].keyless: no signatures found'. CONTROL HELD.
Attack 1. The script also records a caveat about its own evidence: no matching signatures is what you get both when there is no signature and when a signature exists from the wrong identity — so a legitimate image rejected this way means checking the signing subject first.
Terminal: attack-03-untrusted-registry.sh in Enforce. A pod pulling docker.io/library/nginx:1.27-alpine is denied by allowed-registries: 'Images may only be pulled from ghcr.io/syed-amjad (our own published artifacts) or docker.io/istio (the sidecar, injected by the mesh). Everything else is refused. rule only-trusted-registries failed at path /spec/containers/0/image/'. CONTROL HELD, with notes that the message names the path rather than the image, and that this is a name check, not an identity check.
Attack 3. The message names a path, not an image. When a legitimate pod is refused, the path is usually /spec/containers/1/image — the Istio sidecar, added by a mutating webhook after submission — which is why docker.io/istio is on the allowlist and why searching the repository for the offending image finds nothing.
Terminal: attack-04-unauthorised-service-call.sh. Variant A, an imposter with a sidecar presenting a real but wrong identity: 'HTTP 403 body=RBAC: access denied — refused by the AuthorizationPolicy'. Variant B, an imposter without a sidecar: 'NO HTTP RESPONSE ConnectionResetError: [Errno 104] Connection reset by peer — connection refused before any HTTP response existed'. Two lines read 'line 35: web: command not found'.
Attack 4 — two different "denied"s. In A the mesh authenticated the caller and then declined to authorise it: an HTTP response exists. In B, STRICT mTLS refused the plaintext connection before there was any request to authorise. A control set that only ever sees B has never exercised its authorisation rules. (The web: command not found lines are defect 12 below — cosmetic, and fixed.)
Terminal: attack-05-egress-exfiltration.sh. Mesh outbound policy REGISTRY_ONLY. The declared destination api.github.com: 'REACHED status=200 body=Avoid administrative distraction.' — reachable, as declared. Undeclared destinations https://example.com/ and https://pastebin.com/raw/abc123: both BLOCKED ConnectError, '2/2 undeclared destinations reported BLOCKED'. The cloud metadata endpoint: http://169.254.169.254 returns status 502, https is BLOCKED.
Attack 5. The declared host must succeed: an egress policy that refuses everything is indistinguishable from a broken network. The refusal of the undeclared hosts comes from the workload's own sidecar, before a packet leaves the pod — no DNS query, no TCP connection. The 502 on plain-HTTP metadata is the sidecar's blackhole answering; nothing reached the instance.
Terminal: the end of attack 5. Three independent reasons the metadata endpoint fails: Istio REGISTRY_ONLY; a NetworkPolicy that excludes 169.254.0.0/16 from its 0.0.0.0/0 allowance and permits no plaintext port 80; and the instance itself, where Terraform sets IMDSv2 with http_put_response_hop_limit = 1 and attaches no IAM role. CONTROL HELD — declared egress works, undeclared egress and metadata are refused.
The metadata endpoint is where an SSRF goes first. Three independent reasons it fails here — and the script deliberately does not remove them one at a time, because that would leave the cluster weakened for the next attack.
Terminal: attack-07-shell-in-container.sh. A shell is executed in api-569c9fb655-d6pgn: running as uid 10001; reading /etc/shadow fails with Permission denied; touching /usr/bin/evil fails with Read-only file system. 'The shell ran. No preventive layer had anything to say about it.' Falco alerts since the timestamp: '[Critical] Shell spawned in a governed namespace ns=zt-prod pod=api-569c9fb655-d6pgn proc=sh' and '[Warning] Write attempt to a system directory in a governed namespace … proc=touch'. CONTROL HELD.
Attack 7. The shell succeeds by design — Falco detects, it does not prevent. The read of /etc/shadow is refused by file permissions and the write to /usr/bin by the read-only root filesystem: layer 3 stopped the persistence, layer 7 recorded it. The alert names the real namespace, ns=zt-prod — an alert reading <NA> would mean the default ruleset fired and the cluster-scoped rules did not.

Audit before Enforce, run as two passes

Every Kyverno policy ships with validationFailureAction: Audit. The attacks were run twice — once in Audit, once in Enforce — and both transcripts kept, because the pair is the demonstration:

AttackAuditEnforce
1 unsigned imagepod/attack-unsigned created — violation recorded onlyadmission webhook "mutate.kyverno.svc-fail" denied the request
2 privileged (PSA-off namespace)pod/attack-priv createdadmission webhook "validate.kyverno.svc-fail" denied the request
3 untrusted registrypod/attack-registry createddenied, naming /spec/containers/0/image
Terminal: ./scripts/policy-mode.sh lists the five policies — allowed-registries, disallow-privileged, require-readonly-rootfs, require-resources, verify-image-signature — all with action Audit, and prints '5 policy/policies in Audit: violations are RECORDED, NOT BLOCKED'.
The starting state, stated plainly by the tooling rather than left to be inferred.
Terminal: attack-03-untrusted-registry.sh in Audit. The pod pulling docker.io/library/nginx:1.27-alpine is created: 'Admitted, because allowed-registries is in Audit', followed by the policy report and 'CONTROL FAILED — not yet proven — still in Audit'.
The same attack in Audit: admitted and reported — and the script refuses to call that a pass. A policy set left in Audit forever reports beautifully and prevents nothing.

The switch to Enforce is a commit, not a kubectl patch — see the defects below for why that distinction turned out to be load-bearing.

Signature identity, verified by hand

The strongest claim here is that compromising the registry alone is not enough. A passing admission check cannot distinguish “the signature matched the pinned identity” from “my policy is looser than I think it is”, so cosign was run twice against the same digest — once with the correct identity, once with a wrong one from the same repository and branch.

Terminal: ./scripts/04-verify-signature-identity.sh for zt-api by sha256 digest. 1, correct identity: verified, subject https://github.com/Syed-Amjad/supply-chain-app/.github/workflows/build-sign.yml@refs/heads/main, issuer https://token.actions.githubusercontent.com, logIndex 3274429468. 2, wrong identity, same repo and branch: 'refused, as required — Error: no matching signatures: none of the expected identities matched what was in the certificate'. 3, the SBOM attestation verifies, bound to the same identity and digest, 116 packages.
Same registry, same digest, same repository, same branch — a different workflow file. Refused. An attacker who steals a GHCR token can push any bytes they like and cannot produce a Fulcio certificate naming this workflow. The logIndex is the entry in the public Rekor log, which is what makes a backdated signature detectable.
Terminal: the same verification for zt-web — the wrong identity refused, and the SBOM attestation verified with 119 packages — followed by 'What this does not prove: a verified signature says the artifact came from this pipeline. It says nothing about whether the pipeline produced something safe — a signed image containing a vulnerable dependency is signed, correctly. That is the scan gate's job, and the reason the CI workflow signs AFTER scanning rather than before.'
What it does not prove. A signed image with a vulnerable dependency is signed, correctly — so CI signs after the Trivy gate, which makes the signature mean "built here and passed the gate". The SBOM is an attestation: a signed statement about the image, so it cannot be swapped for one describing a cleaner image.

The one that failed, and why it is the most valuable result

Attack 6 scales a Deployment to 7 replicas by hand and watches for ArgoCD to revert it. Sixty seconds later it was still at 7.

Terminal: attack-06-manual-edit-drift.sh. Confirms workloads: syncPolicy.automated.selfHeal = true. Declared in git: replicas = 1. 'Scaling to 7 by hand — exactly as someone would during an incident.' Watching for the revert: t+1 s through t+12 s, replicas = 7 every second.
selfHeal = true confirmed first. Then the hand edit, and a revert that never comes.
Terminal: 'Still at 7 after 60s. workloads sync status = Unknown. THAT IS THE REASON. selfHeal corrects a computed difference, and with sync status Unknown there is no computed difference to correct.' Conditions ArgoCD is reporting: 'ComparisonError: Failed to compare desired state to live state: failed to calculate diff from cache: error calculating structured merge diff: error building typed value from live resource: .status.terminatingReplicas: field not declared in schema' and 'OrphanedResourceWarning: Application has 8 orphaned resources'. CONTROL FAILED — drift persisted.
The whole section hangs on this line: .status.terminatingReplicas: field not declared in schema.

ArgoCD v2.13.2 did not recognise .status.terminatingReplicas, a Deployment status field present in the Kubernetes the cluster was running (k3s v1.36.5). Its structured-merge diff could not compute a difference. With no computed difference, selfHeal had nothing to correct — and never ran.

Everything on the dashboard said this was fine. The Application reported Healthy. syncPolicy.automated.selfHeal reported true. The only visible symptom was a sync status of Unknown, which reads like a transient.

Pinning the GitOps controller to a version older than the cluster silently disabled drift correction. Nothing alerted. The control that is supposed to notice unauthorised change had itself stopped working, and the mechanism that would report that is the same mechanism that was broken.

That is worth more than a seventh passing row. The whole premise of this project is that a control which reads as enabled may not be enabled; attack 6 is that premise happening to the project itself.

Defects found

Fifteen defects surfaced. Six were in the platform or its dependencies; nine were in my own tooling, and those are listed too, because a results table containing only the expected outcomes tells a reader the interesting rows were deleted.

In the platform and its dependencies

#DefectHow it presented
1Falco values written against the chart 4.x schema. collectors.containerd.socket does not exist in chart 9.x.The chart ships no values.schema.json, so Helm accepted the unknown key silently, exit 0. Found by rendering the chart and reading the output — both files looked correct.
2kyverno.io/v1 ClusterPolicy deprecated as of Kyverno 1.19.A warning on every run. Pinned rather than worked around: an unpinned install would eventually stop enforcing on a cluster whose manifests had not changed, and kubectl get clusterpolicy would still list all five.
3starlette was never pinned. fastapi only declared a range, so both images shipped starlette 0.41.3 with three HIGH CVEs that had published fixes.The very first CI run failed, on both matrix legs, at the Trivy gate. The tempting fix was to loosen the gate. The gate was satisfiable: --ignore-unfixed already suppresses unfixable base-image noise, and what got through named a fixed version for each finding. A floor is not a pin.
4ArgoCD v2.13.2 against Kubernetes 1.36 — attack 6 above.selfHeal silently stopped working while reporting true.
5Kyverno’s webhook rewrites its own policies, so the Application is permanently OutOfSync while every policy is Ready and enforcing.Cosmetic here, but with selfHeal: true it means ArgoCD and Kyverno each continuously “correcting” the other.
6Istio 1.26 is past end of support.Pinned deliberately. The pin is correct for reproducibility and simultaneously means running an unsupported version — a tension real in every version-pinning policy, and not resolvable by choosing one side.
Terminal watch of kubectl -n argocd get applications: istio-security, namespaces, network-policies and root Synced and Healthy; kyverno-policies OutOfSync and Healthy; workloads Unknown and Healthy.
Defects 4 and 5 in one frame. kyverno-policies is permanently OutOfSync while enforcing; workloads reads Unknown and Healthy — the state in which drift correction had silently stopped.

In my own tooling — the honest half

Nine defects were mine, and they share one shape: a check that looked right and tested the wrong thing.

#DefectConsequence
7A hardcoded CNI plugin directory. The bootstrap passed cniBinDir=/var/lib/rancher/k3s/data/current/bin; k3s 1.36 resolves plugin names under /var/lib/rancher/k3s/data/cni. Both directories exist and both contain plugins.It took out the whole node. istio-cni was correctly chained into 10-flannel.conflist but unreachable, so every CNI ADD failed — mesh and non-mesh pods alike. flannel allocates the address before the chain reaches istio-cni, so each kubelet retry leaked one host-local lease. After ~254 retries the node’s /24 was empty and every pod failed with no IP addresses available — an error naming flannel and pointing nowhere near the cause. Fourteen minutes from one wrong path to a dead node.
8sudo grep -l PATTERN "$dir"/* — written twice, the second time inside the fix for the first.The glob is expanded by the calling shell, which is unprivileged, and /var/lib/rancher/k3s is 0700 root. The glob matched nothing, the literal * reached grep, and 2>/dev/null hid why. Once it reported a missing istio-cni on a healthy node; once it aborted the bootstrap silently, because a failing pipeline inside a command substitution under set -euo pipefail exits with no message at all.
9ls | grep istio to check the CNI config.Istio’s default chained mode writes no file of its own — it edits the conflist already there. A correct install leaves no filename containing “istio”, so the gate fired on a healthy cluster. A gate that fires on a healthy cluster is one an operator learns to ignore.
10kubectl wait --for=condition=Ready node --all straight after installing k3s.wait --all does not wait for a resource to exist. With an empty set it exits 1 at once — error: no matching resources found — and the --timeout=180s on the same line actively misleads by implying it waited.
11helm killed mid-install by an SSH drop.Helm keeps release state in a Secret; a killed process strands it in pending-install, and the retry says “another operation is in progress”, which invites waiting. There is nothing to wait for. The runbook’s fault, not helm’s: it told the operator to run multi-minute silent installs in a bare SSH session.
12Backticks in a YAML comment inside an unquoted heredoc.Bash ran web as a command, printed web: command not found twice per run, and stripped the word from the comment. A YAML comment is still a shell expansion context under <<POD rather than <<'POD'.
13attack-07 printed “The shell ran” unconditionally.On the run where kubectl exec failed with procReady not received, it said so anyway — then reported “layer 7 produced nothing” as a Falco failure. Falco was right. Nothing had happened. The script turned its own bug into another component’s failure.
14attack-05’s verdict was a hardcoded verdict 0, and its probe ended in 2>/dev/null.The probe returned the empty string whenever kubectl exec failed, so every destination printed as a bare URL — and the script read that silence as “blocked”, then printed CONTROL HELD beneath a line saying the declared host was unreachable. An absent result is not a block.
15lab-down.sh printed the AWS account instead of asserting it.Run against the wrong profile, terraform destroy does not error — it is told the resources do not exist, concludes they were already deleted, and removes them from state. The script then verified “against AWS” in that same wrong account, found nothing, and reported “Clean. Nothing is billing.” Both halves of the safety net failed in the same direction, leaving a t3.large running with an empty state file.

Every one of these is now fixed, and each fix was verified by reproducing the failure first: a 0700 directory for the glob, a stubbed kubectl returning no nodes, a simulated chained conflist, five fake k3s layouts for the path discovery, six helm release states, and a fake state file in the wrong account.

Terminal: Istio 1.26.2 installs with CNI and REGISTRY_ONLY egress; istio-cni-node, the ingress gateway and istiod are Running. The next step, 'Confirming the CNI plugin wrote its config where k3s will read it', reports 'No istio file in /var/lib/rancher/k3s/agent/etc/cni/net.d … mTLS, AuthorizationPolicy and REGISTRY_ONLY will all silently do nothing' and '!!! Refusing to continue - every mesh guarantee below depends on this.'
Defect 9. The gate looked for a file named after Istio — but chained mode edits the existing conflist and writes no file of its own, so this healthy install was refused. Right instinct (never continue on a silent mesh), wrong test.
Terminal: the first attack-05 run. Under '1. The DECLARED destination — must SUCCEED' the probe prints nothing at all, then 'The declared host was NOT reachable. Check the ServiceEntry and the Sidecar egress hosts for web'. Under '2. An UNDECLARED destination — must be BLOCKED' the two URLs are printed with no result next to them.
Defect 14, part one. Every destination prints as a bare URL: the probe failed and 2>/dev/null hid it.
Terminal: the same run continues to its verdict: '>>> CONTROL HELD — declared egress works, undeclared egress and metadata are refused', printed despite the declared host having been reported unreachable a few lines earlier. Attack 6 begins below.
Part two: the hardcoded verdict prints CONTROL HELD anyway. In a script whose entire output is evidence, that is the worst available failure.
Terminal: the fixed attack-05 script. Undeclared destinations and the metadata endpoint are listed with the three independent reasons they fail, ending in '>>> CONTROL FAILED — undeclared egress was refused, but so was the DECLARED host - so this run does not prove an allowlist, only that egress is closed'.
After the fix, the same situation is reported honestly: if the allowed host is also unreachable, "undeclared destination refused" is not evidence of an allowlist. The re-run, shown under attack 5 above, reached the declared host with a 200 and blocked both undeclared ones.
Terminal: an earlier attack-07 run. 'Executing a shell in api-569c9fb655-99km4' fails with 'OCI runtime exec failed: exec failed: unable to start container process: procReady not received', yet the script prints 'The shell ran. No preventive layer had anything to say about it.' It then reports 'No Falco output at all' and 'CONTROL FAILED — layer 7 produced nothing'.
Defect 13. The exec never started, the script said the shell ran, and then blamed Falco for staying silent. Falco was right — nothing had happened.

Defect 15 is the one I would want a reader to take away. A teardown script that prints the account rather than checking it is worse than no teardown script, because it is trusted. It now reads the owning account out of the state file’s resource ARNs, compares it to the caller, and refuses on a mismatch.

What this platform does not claim

These limitations are in the repository too.

  • Falco detects; it does not prevent. Attack 7 succeeded by design. In this lab the alert goes to a pod log and falcosidekick. An alert nobody is paged for is the same class of problem as a policy left in Audit.
  • Egress filtering is hostname-level, not path-level. For a TLS destination the mesh matches on SNI and does not terminate the connection. Path-level filtering of an external HTTPS call would mean intercepting your own TLS, with the certificate-distribution problem that brings. Hostname-from-a-named-workload is the honest description — and it still defeats the attack: exfiltration needs a destination, and an attacker cannot add one without write access to the repository.
  • selfHeal reverts resources ArgoCD manages. A pod created by hand is not drift — it is unmanaged, and reconciliation has no opinion about it. The layer that stops the hand-created pod is admission control, not GitOps. orphanedResources.warn is set for that reason, and a warning in a UI is weaker than a rejection.
  • An allowlist is a name check, not an identity check. Anyone who can push to ghcr.io/syed-amjad satisfies the registry policy. Closing that gap is what the signature policy is for. Neither is sufficient alone, which is the argument for having both.
  • Single node. Everything runs on one t3.large at about $0.083 an hour. That is enough to demonstrate every control and not enough to say anything about behaviour under scale or node failure.
Terminal: 'What this layer does and does not claim. It did not stop the shell and it cannot. The sequence was: an attacker with code execution opened a shell, read a credential file, and tried to persist a binary. Layer 3 stopped the persistence (read-only root filesystem). Layer 7 recorded all three. In this lab the alert goes to a pod log and falcosidekick. An alert nobody is paged for is the same class of problem as a policy left in Audit.'
The limitation is printed by the attack script itself, not added afterwards.

Stack and cost

ComponentVersionWhy pinned
k3sv1.36.5+k3s1The NetworkPolicy controller is on by default — layer 5 depends on it entirely
Istio1.26.2CNI plugin mode: restricted PSA forbids the NET_ADMIN an init container would need
Kyvernochart 3.9.1verifyImages is the legacy path as of 1.14; the install asserts the CRD still carries the field
Falcochart 9.2.0Driver load and the k3s containerd socket are both asserted, not assumed
ArgoCDv2.13.2Too old for this cluster — see defect 4
cosignv2.4.1Keyless, Fulcio + Rekor
TerraformAWS provider 5.xIMDSv2 required, http_put_response_hop_limit = 1, no IAM role attached

Total AWS spend for the build: one t3.large for roughly six hours, plus a 40 GB gp3 root volume — under $1. The instance is destroyed between sessions, and the whole platform rebuilds from git in about an hour.

Repositories

zerotrust-gitopsNamespaces, five Kyverno ClusterPolicies, NetworkPolicies, Istio security objects, workloads, ArgoCD Applications in sync waves 0–4, Terraform, the attack scripts — and both full transcripts, attack-run-audit.log and attack-run-enforce.log, under docs/
supply-chain-appTwo FastAPI services, non-root on numeric UID 10001 with a read-only root filesystem. CI builds, scans with Trivy, fails on fixable HIGH or CRITICAL, signs with cosign keyless, generates an SBOM attestation, and writes the resulting digest into the GitOps repository — never a tag

CI has no kubeconfig and no cluster credentials. A compromised application pipeline can push a bad image; it cannot rewrite cluster configuration directly — and Kyverno then refuses the bad image at admission, which is the point of the split.

Every quoted message on this page is in one of the two transcripts, in context — including the ones that record my own mistakes. The results were filled in from the transcript while it was in front of me, not written from memory afterwards.

Relationship to my production work

This is the same default-deny with an explicit allowlist architecture I ran in production for host firewalls across a Linux fleet — applied here at seven layers, from the CI pipeline down to the kernel.

The firewall enforces at L3/L4 on source address and port. Istio enforces at L7 on cryptographically verified workload identity. Same design, different layer — and the same lesson keeps arriving from both directions: the most dangerous control is the one that reports healthy while doing nothing.

The build, start to finish

Expand the walkthrough — offline tests to the Audit pass

1 · Policies tested before any cluster exists

Terminal on a local machine: 'opa check --strict policies/opa && opa test policies/opa' prints PASS: 15/15. Then 'kyverno test policies/kyverno/tests' loads the policies — each with a warning that kyverno.io/v1 ClusterPolicy is deprecated — applies 4 policies to 6 resources, and lists results with ID (24), every row Pass.
15 OPA tests and 24 Kyverno tests pass on a laptop before any cluster exists (the first 15 of the 24 rows are visible). The deprecation warnings are defect 2.

2 · The first verification, in Audit

Terminal: the end of 03-verify-enforcement.sh in Audit. Check 6 passes: Falco DaemonSet ready, custom rule file loaded, k3s containerd socket present. RESULT: passed 13, failed 0, skipped 1. 'All controls verified. Run ./scripts/attack-all.sh'.
13 passed, 0 failed, 1 skipped — the skip being the five policies still in Audit. The Enforce pass reads 15 / 0 / 0.

3 · The attacks in Audit — admitted and recorded

Terminal: attack 1 in Audit. 'verify-image-signature is in Audit (not Enforce). The pod will be ADMITTED and the violation only reported.' Kyverno warns 'failed to verify image ghcr.io/syed-amjad/zt-unsigned-control:latest: .attestors[0].entries[0].keyless: no signatures found', then 'pod/attack-unsigned created'. Below, the same attack through a Deployment is accepted, with the rejection surfacing one level down as ReplicaSet FailedCreate.
Attack 1 in Audit: admitted, with the violation recorded. Through a Deployment the rejection surfaces one level down, on the ReplicaSet — the shape the failure takes when it arrives through GitOps rather than from kubectl.
Terminal: attack 2 in Audit. Part 1 in zt-prod is refused by PodSecurity. Part 2 in zt-psa-off produces only a PodSecurity warning and 'pod/attack-priv created — Admitted, because disallow-privileged is in Audit', ending in 'CONTROL FAILED — not yet proven — the Kyverno layer is still in Audit'.
Attack 2 in Audit: PSA already refuses in zt-prod; in the PSA-off fixture the pod is admitted, and the script refuses to call that a pass.