September 4, 2026
End-to-End DevSecOps on EKS: Every Stage Blocks or It’s Theater
Every company I have worked with in the last decade had a security dashboard. Most of them had several. Scanners dutifully filling…

By Filipe Motta
20 min read
Every company I have worked with in the last decade had a security dashboard. Most of them had several. Scanners dutifully filling databases with findings, weekly PDFs landing in inboxes, a wall of red counters that everyone had learned to walk past. Ask when a deploy was last stopped by any of it and you get silence. That silence has a name: security theater.
I have spent most of my career on the platform side, building and operating pipelines and Kubernetes fleets, and the pattern repeats with depressing regularity. Teams do not lack tools. The average CI pipeline I audit has more security tooling than the average security team had ten years ago. What it lacks is consequences. A scanner whose findings cannot stop anything controls nothing. It just subscribes you to bad news.
So here is the thesis of this article, and the test I apply to every stage of every pipeline: a DevSecOps stage is real only if it can block a change or page a human. Everything else is reporting. Reporting has its place, and we will give it one, a good one. But the moment you confuse the memory of your security program with its enforcement, you get dashboards that glow red for years while anything can still reach production.
This article builds the whole chain on EKS, stage by stage, and every stage earns its place by answering one question: what does it block, and where does the finding go? Secrets detection with Gitleaks, SAST with Semgrep, dependency and IaC scanning with Trivy, image scanning as a build gate, Cosign signatures and SBOM attestations as identity and provenance gates, short-lived OIDC credentials in place of static keys, external secrets in place of hardcoded ones, GitOps as the delivery model, Kyverno as the admission gate (the break-glass path of ephemeral debug containers included), Falco as the runtime tripwire, and DefectDojo as the single memory of the program. Everything here is reproducible. The companion repository has the app, the pipeline and the policies, and it also carries the test evidence, because I ran every gate I could run locally before writing about it. The repo's evidence file is explicit about which gates those were, and two of them surprised me.
The Contract: Gate, Pager, or Memory
Before any tool comes a decision. Every security signal in the pipeline gets one of three roles, and being explicit about which one is what keeps the theater out.
A gate blocks. CI fails, the image never gets pushed, the Pod never gets admitted. Gates belong where a change is still cheap to stop, which means the pull request, the build, and the admission webhook. A gate that only warns is not a gate.
A pager interrupts a human. This is what runtime security does. By the time Falco sees a shell spawning inside a production container there is no pipeline left to fail, so the only useful output is an alert that reaches someone who can act on it, fast.
A memory keeps every finding, strips the duplicates, and tracks what is still open. That is DefectDojo's job, and it is deliberately not a gate. A memory answers the questions a gate cannot. What is open across all my services? How long has it been sitting there? Is the trend improving or not? Get the direction of that role wrong and it fails both ways. A memory that blocks gets routed around, and gates with no memory let every finding die inside the CI log that found it.
With the three roles named, the chain almost designs itself. Let us walk it in the order a change actually travels.
Gate 1 — Secrets, With a Confession
The first gate is the cheapest, and it has the best ratio of effort to catastrophe prevented of anything in this article. Gitleaks scans the repository, full history included, and fails the build on any hardcoded credential:
docker run --rm -v "$PWD:/repo" ghcr.io/gitleaks/gitleaks:latest \
git --redact --report-format json --report-path /repo/gitleaks.json /repodocker run --rm -v "$PWD:/repo" ghcr.io/gitleaks/gitleaks:latest \
git --redact --report-format json --report-path /repo/gitleaks.json /repoTwo details in that command matter more than they look. Using git instead of dir scans history, because a secret that was committed and later removed is still leaked, and attackers read history too. And --redact keeps the secret itself out of the CI logs, because a secrets scanner that prints secrets into a log aggregator has a certain irony to it.
Now the confession. While building the companion repository for this article, I pointed Gitleaks at an old directory of mine that held Falco configuration from a previous experiment. It found a real Slack webhook URL, hardcoded in a Falcosidekick config, sitting in plain text since 2024. The alerting configuration for a security tool was itself a leaked credential. I revoked the webhook and kept the finding, because it captures why this gate exists better than any argument could. Nobody hardcodes secrets on purpose. It happens in the config files nobody reviews, written in a hurry, by people who write security tooling for a living. The gate is not there for the careless. It is there for everyone.
Catching a leaked secret only raises the next question, and it happens to be the one this gate cannot answer. Where should the secret live? Not in a manifest. A Kubernetes Secret is base64, which is encoding rather than encryption, and committing one to Git is the same mistake with an extra step. The GitOps-friendly answer keeps a reference in Git and never the value itself. The External Secrets Operator pulls the real secret at runtime from an external store, whether that is AWS Secrets Manager, SSM, or Vault, and writes it into a normal Kubernetes Secret, so the repository holds only a pointer:
apiVersion: external-secrets.io/v1
kind: ExternalSecret
spec:
secretStoreRef: { name: aws-parameter-store, kind: SecretStore }
target: { name: payments-demo-db }
data:
- secretKey: DATABASE_URL
remoteRef: { key: /payments-demo/prod/database-url }apiVersion: external-secrets.io/v1
kind: ExternalSecret
spec:
secretStoreRef: { name: aws-parameter-store, kind: SecretStore }
target: { name: payments-demo-db }
data:
- secretKey: DATABASE_URL
remoteRef: { key: /payments-demo/prod/database-url }Sealed Secrets is the alternative if you want the encrypted blob to live in Git itself. Either way the rule holds: the value never appears in a manifest, and Gitleaks is the backstop for the day someone forgets.
One more thing here costs almost nothing. The same Gitleaks scan belongs in a pre-commit hook, so the secret gets caught before the commit exists rather than in CI once it is already in history. The companion repo ships a .pre-commit-config.yaml that runs Gitleaks and trivy config locally on every commit. There is one honest wrinkle worth knowing. Gitleaks publishes an official pre-commit hook and Trivy does not, only community ones, so rather than pull a third-party repository into a security pipeline (the supply-chain irony writes itself) the config calls the local Trivy binary directly. Pre-commit is fast feedback and not a replacement for the CI gate, since developers can always skip hooks and the pipeline still runs the same checks. But the cheapest finding is the one that never leaves the laptop.
Gate 2 — SAST, Scoped to What You Will Actually Fix
Static analysis is where DevSecOps initiatives most often drown. Turn everything on, generate four thousand findings, and watch the team learn to ignore the stage within a month. The gate contract forces a healthier posture, because only rules whose findings you are prepared to block on belong in the blocking run.
semgrep scan --config p/owasp-top-ten --error --json --output semgrep.jsonsemgrep scan --config p/owasp-top-ten --error --json --output semgrep.jsonSemgrep with the OWASP Top Ten ruleset and --error, which turns findings into a nonzero exit code, is a defensible starting point. It is a small, high-signal rule pack that the team actually agrees means "do not ship". Broader rulesets can run in a non-blocking lane and feed the memory. There is nothing lazy about that split. A rule you will always override is just theater with extra steps, and keeping it in the blocking run fools only you.
Gate 3 — Trivy, One Scanner and Three Gates
Trivy consolidated what used to be a small zoo of tools. It absorbed tfsec for infrastructure-as-code some time ago, and today one binary covers dependencies, misconfigurations, secrets, and SBOM generation. In the pipeline it appears twice. First against the repository:
trivy fs --scanners vuln,secret,misconfig \
--exit-code 1 --severity HIGH,CRITICAL \
--format json --output trivy-fs.json .trivy fs --scanners vuln,secret,misconfig \
--exit-code 1 --severity HIGH,CRITICAL \
--format json --output trivy-fs.json .One command, three gates. Vulnerable dependencies in the lockfiles, dangerous Kubernetes and Terraform in the manifests, and a second independent net for secrets. --exit-code 1 is the whole point, because without it this is a report generator.
The infrastructure-as-code half deserves its own note, since it is where people expect Checkov and instead find Trivy. Trivy's misconfig scanner, invoked directly as trivy config infra/ for a Terraform-only pass, is that Checkov equivalent, folded into the same binary as everything else. I ran it against the companion repo's Terraform and it behaved the way a gate should. A bucket whose public_access_block sets every flag to false fails HIGH, and the fixed version passes. One honest caveat, because consolidation has limits. Checkov still carries a larger library of Terraform-specific and graph-aware policies, so a Terraform-heavy platform team may reasonably run both, Trivy as the always-on gate and Checkov for depth. The thesis was never that one tool wins. It is that the gate must fail the build, and either tool does.
There is a subtlety I only caught by running it. My "secure" bucket, encrypted with sse_algorithm = "aws:kms", still failed HIGH. Trivy's rule (AVD-AWS-0132) wants a customer-managed key rather than the AWS-managed default. It is right to insist, because "encrypted" without owning the key is a weaker claim than it looks. The bucket only passed once I created an actual aws_kms_key and pointed the encryption at it. The scanner taught me my own infrastructure was less encrypted than I thought, which is exactly the job of a good gate.
Gate 4 — The Image: Measure, Don't Guess
The second Trivy appearance is the one that surprised me. It is why I insist on running gates before writing about them. The demo app is a deliberately boring Flask service. Its first Dockerfile did everything the hardening checklists ask for. Pinned dependencies, a non-root user with an explicit UID, no shell-form CMD, and python:3.12-slim as the sensibly minimal base. Then the gate ran:
trivy image --exit-code 1 --severity HIGH,CRITICAL payments-demo:v0.1.0trivy image --exit-code 1 --severity HIGH,CRITICAL payments-demo:v0.1.0Fifty-three HIGH and CRITICAL findings. Not in my code, not in my dependencies, but in the Debian packages of the slim base image. And before you reach for --ignore-unfixed, the legitimate flag for vulnerabilities that have no fix anywhere, thirty-six of those findings had fixes already published in the distribution. The slim tag I pulled was simply behind. The same app rebuilt on python:3.12-alpine scanned with zero HIGH and CRITICAL findings, and the gate passed with no exceptions, no ignore files, and no negotiation.
The lesson generalizes. Base image choice is a measurable security decision. The measurement regularly contradicts intuition. "Slim" promises a smaller image and says nothing about a safer one. Run the scanner against the candidates and let the numbers pick, and in production pin the winner by digest so the image you scanned is provably the image you run.
Gate 5 — Signing: The Workflow Is the Key
Everything so far proves the image was checked. Nothing yet proves the image the cluster runs is the one that got checked. That link is the signature, and Sigstore's keyless flow makes it nearly free on GitHub Actions. The workflow authenticates through OIDC, Cosign obtains a short-lived certificate that binds the signature to the workflow's identity, and the signature lands in the registry next to the image.
permissions:
id-token: write # the workflow identity IS the signing key
steps:
- run: |
DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' "$IMAGE:$TAG")
cosign sign --yes "$DIGEST"permissions:
id-token: write # the workflow identity IS the signing key
steps:
- run: |
DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' "$IMAGE:$TAG")
cosign sign --yes "$DIGEST"Sign the digest, not the tag, because tags move and digests do not. There is no key to store, rotate, or leak, since the identity is the workflow itself, which is the very property the next gate will verify. One honest caveat: keyless signatures land in a public transparency log, identity included. For most teams that is a feature. Know it before your first signature rather than after.
A signature proves we built this. It says nothing about what is inside. That second half is the SBOM, the CycloneDX bill of materials Trivy already generated back at the image gate, and it earns trust the same way the image did, by being signed. cosign attest binds the SBOM to the image digest as an in-toto attestation, using the same keyless identity:
cosign attest --yes --type cyclonedx --predicate sbom.cdx.json "$DIGEST"cosign attest --yes --type cyclonedx --predicate sbom.cdx.json "$DIGEST"Now the image carries a signed inventory of its own contents, and admission can demand it. Not only "is this signed?" but "is this signed, and did it arrive with an SBOM attestation from our pipeline?". That closes the supply-chain loop. When a new CVE lands you already have a signed, queryable record of which images contain the affected component, instead of rebuilding to find out.
The Credential That Isn't There
Look again at that signing step. It needed no key. The line permissions: id-token: write let the workflow prove who it was to Sigstore through OIDC. Now hold that against how the same pipeline usually talks to AWS: an access key and secret, generated once, pasted into GitHub Secrets, and valid until somebody remembers to rotate it. We spent a whole gate at the top of this article teaching Gitleaks to catch leaked credentials. The deeper move is to have no long-lived credential to leak in the first place. The best secret is the one that expires before anyone can use it.
The mechanism is the one we already trust for signing, GitHub's OIDC provider. Instead of storing AWS keys, the workflow assumes an IAM role by presenting its OIDC token, and gets credentials that live for minutes.
permissions:
id-token: write # the same token that signs the image, now for AWS
steps:
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::123456789012:role/payments-demo-ci
aws-region: us-east-1
role-duration-seconds: 900 # 15 minutes, then gonepermissions:
id-token: write # the same token that signs the image, now for AWS
steps:
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::123456789012:role/payments-demo-ci
aws-region: us-east-1
role-duration-seconds: 900 # 15 minutes, then goneNo aws-access-key-id, no aws-secret-access-key, nothing in GitHub Secrets for an attacker to find or a fork to inherit. Think the theft through. Someone exfiltrates these credentials from a running job. In the static-key world that is an incident measured in months, the window between leak and rotation, and during that window the key opens the whole account. With a fifteen-minute role session, by the time anyone has the credentials they have already expired, and the blast radius collapses from months to minutes without a single dashboard involved. The IAM role's trust policy narrows it further still, because it can pin the exact repository and branch allowed to assume the role, so even another repo in your own org cannot borrow it.
This is also where the infrastructure itself enters the pipeline honestly. The companion repo provisions its own support infrastructure, the ECR registry and an artifacts bucket, as Terraform, gated by trivy config like any other code and applied by a CI job that authenticates through exactly this OIDC flow. Teams that outgrow per-pipeline roles can reach for HashiCorp Vault, which generalizes the same idea by brokering short-lived, dynamically generated credentials for many consumers. That is its own article. The principle behind it is identical. Long-lived credentials are a standing liability. Make them expire.
Gate 6 — Admission: Where the Cluster Says No
Admission control is the gate people skip because it feels redundant. "CI already checked everything." CI checked everything that went through CI. The kubectl apply from someone's laptop, the Helm chart with an image nobody scanned, the manifest lifted from a tutorial: none of that met your pipeline. Kyverno is the cluster's own opinion. In the companion repo it enforces a handful of policies. Images must carry our pipeline's Cosign signature. Pods must pass the baseline Pod Security Standard. And :latest is banned, because an unpinned tag breaks the whole scanned-signed-deployed chain of custody. A fourth policy covers a case these three quietly miss, and it gets its own section shortly.
The signature policy closes the loop, verifying at admission the exact identity that Gate 5 created. Its shape is the verifyImages rule below. A companion policy reuses the same keyless attestor but swaps the attestors block for an attestations one demanding the CycloneDX SBOM, so an image with no signed inventory of its contents is refused as firmly as an unsigned one:
verifyImages:
- imageReferences:
- "ghcr.io/OWNER/payments-demo*"
failureAction: Enforce
attestors:
- entries:
- keyless:
subject: "https://github.com/OWNER/REPO/.github/workflows/*"
issuer: "https://token.actions.githubusercontent.com"
rekor:
url: https://rekor.sigstore.devverifyImages:
- imageReferences:
- "ghcr.io/OWNER/payments-demo*"
failureAction: Enforce
attestors:
- entries:
- keyless:
subject: "https://github.com/OWNER/REPO/.github/workflows/*"
issuer: "https://token.actions.githubusercontent.com"
rekor:
url: https://rekor.sigstore.devI ran the denial tests on a throwaway cluster before writing this, and they behave the way the contract demands. A Pod with nginx:latest bounces with the policy's own message, and a Pod requesting privileged: true bounces citing the Pod Security Standard violation, field by field. That is the difference between a gate and a scanner. The API server refuses the object, and the bad configuration simply never exists in the cluster.
Two things I learned in that test cluster are worth putting in print. The first is that current Kyverno, 1.19 at the time of writing, marks the classic ClusterPolicy kind as deprecated in favor of new CEL-based policy types such as ValidatingPolicy and ImageValidatingPolicy. That deprecation has been in place since 1.17, with removal already on the roadmap. The classic kind still works and is still what most of the installed base runs, but if you are starting today, start with the new types. The companion repo carries both forms of the signature policy for that reason. The second thing is my favorite finding of the whole exercise, because hardening broke the app. With readOnlyRootFilesystem: true, the demo service crash-looped. Gunicorn needs a writable temp directory for its worker heartbeat and a writable home for its control socket. The fix is a five-line emptyDir mounted at /tmp plus HOME=/tmp, which gives the process the exact writable surface it needs and nothing more. When a gate breaks a workload, the temptation is to relax the gate. Resist that. Fix the workload. Every relaxation is permanent, because nobody ever comes back to re-tighten.
How the Change Should Arrive: GitOps
I opened the admission section with the threat of a kubectl apply from someone's laptop. Admission control is the safety net for exactly that. There is also a delivery model that removes the temptation in the first place. It is enough of a DevSecOps concern that leaving it unnamed would be a gap. GitOps. Instead of CI pushing changes into the cluster, CI writes the desired state to a Git repository and an in-cluster controller, Argo CD or Flux, continuously reconciles the cluster to match. The demo in this article uses kubectl apply for brevity. A production platform should not.
The payoff is security, and it is easy to miss under all the workflow talk. When Git is the only way in, every change to production is a commit. It gets reviewed in a pull request before it lands, it carries an author, and it can be undone with git revert when it goes wrong. Direct kubectl access to mutate workloads stops being a routine tool and becomes the break-glass exception we just spent a section constraining, which means the RBAC and admission gates are no longer racing against a human with a terminal, because there is no sanctioned path for that human to push an unreviewed change. Admission and GitOps reinforce each other. GitOps makes changes arrive reviewed and recorded, and admission makes sure that whatever arrives, through Git or around it, still meets the cluster's policy. Doing GitOps justice, with Argo CD and environment promotion through Kustomize overlays, is its own article. The point here is only that a mature EKS pipeline delivers through Git. The gates in this one assume that world.
The DAST Question
Sharp-eyed readers will notice one classic stage missing from the gate list. DAST, dynamic scanning of the running application, with ZAP as the usual suspect. The omission is deliberate, and the reasoning is worth stating, because it is the gate contract doing its job. DAST needs a deployed, reachable environment, it is slow, and against a real application it is noisy in ways that push teams to override the stage. A stage the team always overrides is theater by definition. So in this design DAST still runs, just not as a gate. It runs on a schedule against staging, its findings flow to the memory like everything else, and it gets promoted to a blocking check only for the narrow cases where it has proven signal, authentication flows being the classic one. If your product profile is different, with public APIs and a heavy attack surface, move it up the ladder. Just make the role explicit. Gate, pager, or memory, never "it runs somewhere".
Runtime — Falco Pages, It Does Not File
Everything above governs what enters the cluster. Falco watches what already runs, from the kernel, through eBPF: syscalls, spawned processes, file access. The default ruleset is useful on day one, and custom rules are short enough to read in one breath:
- rule: Package manager in running container
desc: >
apt/apk/pip executing inside a running container: images are immutable,
so runtime installs signal drift or compromise.
condition: >
spawned_process and container
and proc.name in (apt, apt-get, apk, pip, pip3)
and k8s.ns.name = "demo"
output: >
Package manager in running container (command=%proc.cmdline
container=%container.name image=%container.image.repository ns=%k8s.ns.name)
priority: ERROR
tags: [container, drift, demo]- rule: Package manager in running container
desc: >
apt/apk/pip executing inside a running container: images are immutable,
so runtime installs signal drift or compromise.
condition: >
spawned_process and container
and proc.name in (apt, apt-get, apk, pip, pip3)
and k8s.ns.name = "demo"
output: >
Package manager in running container (command=%proc.cmdline
container=%container.name image=%container.image.repository ns=%k8s.ns.name)
priority: ERROR
tags: [container, drift, demo]The rule encodes a promise the rest of the pipeline already made. Images are immutable, so apt-get install inside a running container is either an engineer improvising or an attacker settling in. Both deserve a page. Routing that page is Falcosidekick's job, to Slack or, better, to your on-call system, because the runtime role is pager, and an alert that lands in a log file is theater at its purest, detection followed by silence. Ship the webhook through a Secret and never in values committed to Git. My Gate 1 confession is the case law here.
A bit of deployment honesty, because most articles skip it. Current Falco charts ship a single eBPF path, the modern CO-RE driver, having removed the legacy eBPF probe entirely. It needs a kernel with BTF support. On EKS with recent AMIs that is simply the environment, and a Helm install with driver.kind: modern_ebpf works out of the box. On laptop clusters it depends on the VM's kernel. My kind-on-macOS attempt failed right there, with the syscall engine refusing to load, so conclusive runtime validation belongs on a Linux host or on the real cluster. Falco earns its keep in production, and the fact that it demos at all is a bonus.
The Break-Glass Problem: Ephemeral Containers
Follow the logic this far and you hit a wall the hardening built. The image is distroless or alpine with nothing useful in it, the root filesystem is read-only, the container runs non-root, and Falco pages the moment anyone opens a shell inside it. All correct. Now production is on fire and an engineer needs to look inside that pod. What do they do?
The sanctioned answer is kubectl debug, which injects an ephemeral container into the running pod, a temporary container that shares the target's network and process namespaces and can carry the tools the hardened image deliberately lacks. It is the right feature, and also a gate bypass hiding in plain sight. Testing it against my own policies is where this section earned its place, because two of my supposedly-solid gates had a side door I had not noticed.
Here is the trap, and I only believed it after watching it happen on a live cluster. My disallow-latest-tag and pod-security policies match spec.containers. An ephemeral container does not land there. It lands in spec.ephemeralContainers, arriving through a separate subresource, pods/ephemeralcontainers, on an update to an already-admitted pod. So in a namespace where :latest was forbidden and enforced, this worked without a murmur:
kubectl debug target --image=alpine:latest --target=target -- sleep 300
# admitted. alpine:latest, running as root, inside a pod that bans both.kubectl debug target --image=alpine:latest --target=target -- sleep 300
# admitted. alpine:latest, running as root, inside a pod that bans both.A gate with a side door still fails at being a gate, and it fails loudly, because it reports "policy enforced" while the very thing it forbids walks in through the back. The fix is a policy that matches the ephemeral field specifically. Kyverno's webhook already registers the pods/ephemeralcontainers subresource whenever a policy targets Pods, so the rule just has to iterate the ephemeral list on update:
- name: ephemeral-must-be-non-root-and-tagged
match:
any:
- resources:
kinds: [Pod]
operations: [UPDATE]
preconditions:
all:
- key: "{{ request.object.spec.ephemeralContainers[] | length(@) }}"
operator: GreaterThanOrEquals
value: 1
validate:
failureAction: Enforce
foreach:
- list: "request.object.spec.ephemeralContainers"
pattern:
image: "!*:latest & *:*"
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: false- name: ephemeral-must-be-non-root-and-tagged
match:
any:
- resources:
kinds: [Pod]
operations: [UPDATE]
preconditions:
all:
- key: "{{ request.object.spec.ephemeralContainers[] | length(@) }}"
operator: GreaterThanOrEquals
value: 1
validate:
failureAction: Enforce
foreach:
- list: "request.object.spec.ephemeralContainers"
pattern:
image: "!*:latest & *:*"
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: falseTwo details cost me a rebuild each. Both contradict what you will find copied around the web. The debug container shows up in request.object.spec.ephemeralContainers and not under status, so the policies I saw online that pattern-match status.ephemeralContainers quietly never fire. And in current Kyverno you do not add a subresources: matcher for this at all. The schema rejects it outright, because the webhook already covers the subresource once you target Pods. With the corrected policy applied, the same alpine:latest debug is denied at /image, a root debug container is denied at /securityContext, and a compliant one, with a pinned tag, non-root, and no privilege escalation, is admitted. Break-glass, with the glass intact.
Admission decides what a debug container may be. RBAC decides who may create one at all, and that gate comes first. The pods/ephemeralcontainers subresource is an ordinary RBAC target, so bind update on it to a small SRE group and developers simply cannot inject debug containers into production, policy-compliant or not. The two controls stack. RBAC narrows the door to named operators, the Kyverno policy constrains what they can bring through it, and Falco, watching from the kernel and indifferent to whether a process lives in an ephemeral container or a "real" one, pages if the debugging turns into something else. Break-glass is supposed to leave broken glass. Log every ephemeral injection as a high-severity event, and delete the pod afterward, because an ephemeral container lingers in the pod's spec until the pod itself is gone.
DefectDojo — The Memory That Must Not Block
Every gate so far produces a report as a side effect. Those reports need one home. DefectDojo remains the open-source standard for this, with parsers for hundreds of tools, deduplication, and SLA tracking (SecObserve is the closest open-source alternative if you want something lighter). In this pipeline it has exactly one job. After the gates have done their blocking, a final CI job imports every report into one product view.
curl -sf -X POST "$DD_URL/api/v2/import-scan/" \
-H "Authorization: Token $DD_TOKEN" \
-F "product_type_name=Demo" \
-F "product_name=payments-demo" \
-F "engagement_name=ci-pipeline" \
-F "auto_create_context=true" \
-F "scan_type=Trivy Scan" \
-F "close_old_findings=true" \
-F "file=@trivy-image.json"curl -sf -X POST "$DD_URL/api/v2/import-scan/" \
-H "Authorization: Token $DD_TOKEN" \
-F "product_type_name=Demo" \
-F "product_name=payments-demo" \
-F "engagement_name=ci-pipeline" \
-F "auto_create_context=true" \
-F "scan_type=Trivy Scan" \
-F "close_old_findings=true" \
-F "file=@trivy-image.json"Two flags carry the philosophy. auto_create_context means the pipeline needs no pre-provisioned structure, since the first import creates the product and engagement on its own, which is what lets fifty services adopt this without a ticket queue. close_old_findings makes each run supersede the last, so a finding that disappears from the report gets closed automatically instead of haunting the dashboard forever. The memory shows what is broken right now. And the import job runs with if: always() yet never fails the build, because the memory observing the pipeline must not become one more gate. That inversion is how teams end up routing around their own vulnerability management.
What the memory buys you is the program view no single pipeline can see. The same CVE open across nine services, the mean time to remediate trending the wrong way, the one team whose findings never close. Gates win the battles. The memory tells you whether you are winning the war.
The Whole Chain, on One Diagram
Step back and the pipeline is one continuous chain of custody, each link handing the next something it can trust. A commit gets scanned for secrets and static bugs before it earns the right to become an image. The image gets scanned, then signed, its contents attested, so what leaves CI is a known, verifiable artifact. The pipeline reaches AWS through a short-lived OIDC credential, so there is no standing key to steal along the way. At the door, Kyverno checks the signature, the SBOM attestation, and the pod's own posture. Only that verified artifact, configured safely, gets admitted. Once it is running, Falco watches from the kernel. The one sanctioned way back inside a hardened pod, the ephemeral debug container, stays fenced by RBAC and policy. Underneath all of it, DefectDojo keeps the single record of what every stage found.
The picture below is the one worth keeping on a wall. Every arrow is a handoff. Every box is one of the three roles from the start of this article: a gate that blocks, a pager that interrupts, or the memory that remembers. Follow it left to right and you are tracing a change from a developer's editor to a running pod. Follow the colors and you can see at a glance where a bad change dies.
A POC You Can Run
The companion repository compresses all of this into a sequence you can rehearse locally. Clone it, then run it before adapting anything, because several of the steps are built to fail in front of you.
Step one, run the static gates against the repo itself: Gitleaks on the directory, the Trivy filesystem scan, and the repository's own validation script. Step two, build the image and run the image gate, then rebuild from the slim base just to watch fifty-three findings stop the build, because feeling a gate close teaches more than reading about it does; while you are there, rename insecure.tf.example to .tf and run trivy config infra/ to watch five Terraform misconfigurations fail the same way. Step three, create a kind cluster, install Kyverno, apply the policies, and try to run nginx:latest and a privileged Pod, then read the denial messages, which are the product working. Step four, deploy the demo app and see it admitted, which it is because it was written to pass, down to the emptyDir that keeps gunicorn alive under a read-only root. Step five, before applying the ephemeral-container policy, kubectl debug an alpine:latest container into that pod and watch it stroll past the :latest ban, then apply the policy and watch the same command get denied. The side door, opened and closed in two commands. Step six, on a Linux host or a real EKS cluster, install Falco with Falcosidekick pointed at a webhook from a Secret, then kubectl exec a shell into the demo Pod and watch the page arrive. Step seven, docker compose up a DefectDojo, export a token, and run the import script, then open the product and see every gate's findings, deduplicated, in one place. Then tear the cluster down, because the whole thing is disposable by design.
From there to production the mechanics barely change. The same policies apply to EKS as-is, the pipeline needs only your registry path and two DefectDojo secrets, and the signature policy's OWNER/REPO placeholders become the identity of your actual build workflow.
Security stops being theater the moment a finding can stop a deploy. Build the gates first, wire the pager, keep one memory, and let the dashboards be a consequence rather than the product.
Filipe Motta is a Senior DevOps Engineer with 20 plus years of experience, including platform work on fleets of hundreds of EKS clusters. He has earned the CKA, CKAD, CKS, AWS DevOps Professional, and GCP Professional DevOps Engineer certifications.