July 27, 2026

Application Security Testing on EKS: SAST, DAST, and a Runtime Agent Blocking the Same Exploit

Trivy, OWASP ZAP, OpenRASP, and Amazon Inspector layered on an EKS pipeline, verified by a live SQL injection blocked twice: once by ZAP at scan time and once inside the running JVM.

A dependency scanner catches a known-vulnerable library before it ships. A dynamic scanner attacks a running endpoint and catches the injection flaw in application logic that the dependency scanner had no way to see, since it is not a package-version problem. Neither tool is watching when an exploit attempt hits the running process. Each answers a narrower question than “is this application secure.”

This project builds a pipeline that catches one vulnerability class from more than one independent technique and vantage point. The proof: a live exploit attempt that a dynamic scanner flags during a pipeline run, and that a runtime agent separately blocks inside the running process when someone actually tries it.

The stages: Trivy scans source and dependencies at commit. The built image deploys to a dedicated EKS test cluster. OWASP ZAP attacks the running endpoints from outside. OpenRASP, a Java runtime agent, sits inside the application and evaluates every request as it executes. Trivy and Amazon Inspector both scan the built container image independently. Inspector’s agent-based scanning covers the worker nodes themselves. Every finding above threshold converges on one gate before anything gets called deployable.

Application security testing pipeline as built: SAST gate, ECR image scan, deploy to an EKS test cluster, ZAP active scan and a live OpenRASP block inside the running process, worker-node host scan, and a manual Inspector query standing in for the promotion gate

Choosing a Runtime Agent

Commercial RASP tooling (Contrast, Datadog ASM) is not something I have access to in this environment. OpenRASP filled that gap: an open source RASP, Apache 2.0 licensed, with agents for Java, Python, PHP, and Node, built out of work at Baidu. It hooks into a running application and evaluates requests for injection patterns (SQLi, RCE, SSRF) at the point of execution, the same category of enforcement the commercial tools sell.

The sample application was originally scaffolded on Java 17 and Spring Boot 3.3.4, but OpenRASP’s documented support matrix stops at Spring Boot 1 and 2, Tomcat 6 through 9, with nothing confirming Java 17. Rather than gamble the project’s centerpiece on an unverified attach, I downgraded the sample app to Java 11 and Spring Boot 2.7.x, the pairing OpenRASP actually documents. I also rejected a WAF-mode reverse-proxy sidecar like Coraza, which would have turned the in-process runtime-defense argument into two perimeter checks agreeing with each other, a different claim than the one this project sets out to prove.

Building the Test Cluster

A dedicated EKS test cluster, ast-test-eks-cluster, kept this project independent of an earlier eBPF runtime security project’s cluster. Defined as an eksctl ClusterConfig, not a one-off CLI invocation, so the whole cluster shape is versioned:

apiVersion: eksctl.io/v1alpha5
kind: ClusterConfig

metadata:
  name: ast-test-eks-cluster
  region: us-east-1
  version: "1.31"

managedNodeGroups:
  - name: ast-test-ng-default
    instanceType: t3.medium
    amiFamily: AmazonLinux2023
    desiredCapacity: 2
    minSize: 2
    maxSize: 3
    volumeSize: 20
    privateNetworking: false
    ssh:
      allow: false
    iam:
      withAddonPolicies:
        ebs: true
        cloudWatch: true

cloudWatch:
  clusterLogging:
    enableTypes: ["api", "audit", "authenticator"]
eksctl create cluster -f infra/cluster.yaml --profile <aws-profile>

What Amazon Inspector calls “agent-based” EC2 scanning is not a separate Inspector-specific agent, it is the AWS Systems Manager Agent. An EC2 instance has to be SSM-managed, with the AmazonSSMManagedInstanceCore policy attached, before Inspector’s agent-based scan method can see it at all. I confirmed both worker nodes as SSM-managed, cross-checking the SSM instance IDs against the EKS nodes’ own providerID values directly rather than trusting the policy attachment alone:

aws ssm describe-instance-information \
  --query 'InstanceInformationList[].{InstanceId:InstanceId,PingStatus:PingStatus}'

kubectl get nodes -o jsonpath='{range .items[*]}{.spec.providerID}{"\n"}{end}'

EKS cluster ast-test-eks-cluster ACTIVE with both managed nodes Ready

Both worker nodes confirmed Online in Systems Manager, the actual prerequisite for Inspector's host-based scanning

The sample application: a Java/Spring Boot service with a dashboard and two widget endpoints, one deliberately SQL-injection-vulnerable through raw string concatenation (/widget1), one parameterized for contrast (/widget2). The vulnerability is documented inline in the code as intentional, load-bearing for the adversarial test later, not a bug anyone should fix.

astdb Postgres pod running in the ast-test namespace

The SAST Gate

The pipeline’s SAST/SCA gate does exactly what it’s built to do: it finds fixable vulnerabilities and stops the build before an image reaches ECR. Here’s the gate step itself, run before AWS credentials are ever assumed:

name: sast-gate

on:
  push:
    branches: [main]
  workflow_dispatch: {}

permissions:
  id-token: write
  contents: read

env:
  AWS_REGION: us-east-1
  ECR_REPO: ast-test-ecr-dashboard-service
  ECR_URI: <account-id>.dkr.ecr.us-east-1.amazonaws.com/ast-test-ecr-dashboard-service
  ROLE_ARN: arn:aws:iam::<account-id>:role/ast-test-role-gha-pipeline
  APP_DIR: app/dashboard-service

jobs:
  sast-and-push:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v7

      - name: Run Trivy filesystem/repo scan (SAST + SCA)
        uses: aquasecurity/trivy-action@v0.36.0
        with:
          scan-type: fs
          scan-ref: ${{ env.APP_DIR }}
          format: table
          exit-code: '1'
          severity: 'CRITICAL,HIGH'
          ignore-unfixed: true
          trivyignores: '${{ env.APP_DIR }}/.trivyignore'

      # If the step above exits 1, the job stops here. Nothing after this
      # point runs, and no image is built or pushed.

      - name: Assume AWS role (keyless OIDC)
        uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: ${{ env.ROLE_ARN }}
          aws-region: ${{ env.AWS_REGION }}

      - name: ECR login
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build and push image
        run: |
          TAG="git-${GITHUB_SHA::12}"
          IMAGE_REF="${ECR_URI}:${TAG}"
          docker build -t "$IMAGE_REF" "${APP_DIR}"
          docker push "$IMAGE_REF"
          echo "Pushed $IMAGE_REF"

The first run caught thirty-five findings, six CRITICAL, all in transitive dependencies pulled in by the Java 11 / Spring Boot 2.7.18 downgrade, the bill for trading a newer, better-patched dependency tree for the version pairing OpenRASP documents.

First gate run failing on 35 Trivy findings, six CRITICAL, before AWS auth or the image build ever ran

Trivy's findings table: package, CVE, severity, and fixed version for each of the 35

Twenty-nine of the thirty-five got fixed: explicit pinned versions for Tomcat, Jackson, the PostgreSQL driver, SnakeYAML, and Spring Framework, all within the Java 11 line:

<properties>
  <java.version>11</java.version>
  <tomcat.version>9.0.118</tomcat.version>
  <jackson-bom.version>2.18.8</jackson-bom.version>
  <snakeyaml.version>2.0</snakeyaml.version>
  <spring-framework.version>5.3.34</spring-framework.version>
</properties>

plus an explicit version on the PostgreSQL driver dependency itself, since it isn’t one of Spring Boot’s overridable version properties:

<dependency>
  <groupId>org.postgresql</groupId>
  <artifactId>postgresql</artifactId>
  <version>42.7.12</version>
  <scope>runtime</scope>
</dependency>

The remaining six, spring-boot, spring-core, spring-web, spring-webmvc, and spring-boot-starter-actuator, have CVEs whose only fix requires Spring Framework 6.x or Spring Boot 3.x, which requires Java 17. Bumping to Java 17 for a clean gate would have gambled the project’s centerpiece, a runtime-defense agent blocking an exploit, on a compatibility question nobody has answered, so those six went into .trivyignore with the reason attached, keeping the exception visible instead of silently suppressed:

CVE-2025-22235
CVE-2026-40973
CVE-2026-22733
CVE-2025-41249
CVE-2016-1000027
CVE-2024-38816
CVE-2024-38819

The AWS auth step now assumes the pipeline role cleanly via GitHub OIDC, scoped to the account’s actual subject-claim format: repo:owner@accountID/repo@repoID:ref:refs/heads/branch, with immutable numeric IDs embedded alongside the names rather than the classic repo:owner/repo:ref:refs/heads/branch format. That format is confirmed via GitHub’s own API:

gh api repos/<owner>/<repo>/actions/oidc/customization/sub

so a trust policy cannot be fooled by an account rename or a deleted-and-reclaimed repo name pointing at the same string. The trust policy’s condition ends up scoped to that exact returned prefix:

{
  "Effect": "Allow",
  "Principal": { "Federated": "arn:aws:iam::<account-id>:oidc-provider/token.actions.githubusercontent.com" },
  "Action": "sts:AssumeRoleWithWebIdentity",
  "Condition": {
    "StringLike": {
      "token.actions.githubusercontent.com:sub": "repo:<owner>@<account-id>/<repo>@<repo-id>:*"
    },
    "StringEquals": {
      "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
    }
  }
}

IAM role trust policy scoped to the exact GitHub OIDC sub claim, immutable account and repo IDs included

Every step green: the gate passes, image builds, pushes to ECR

The pushed image tag visible in ECR with its Inspector scan status

DAST: Passive vs Active Scanning

OpenRASP installs via a plain -javaagent JVM flag, no build step, no external dependency, following the v1.3.7 release’s actual config file, openrasp.yml (the project wiki still references the older rasp.properties). Staged into the image but not wired into ENTRYPOINT:

FROM eclipse-temurin:11-jdk-jammy AS build
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN apt-get update && apt-get install -y maven && mvn -q -DskipTests package

FROM eclipse-temurin:11-jre-jammy
WORKDIR /app
COPY --from=build /app/target/dashboard-service-0.1.0.jar app.jar

# OpenRASP Java agent, v1.3.7, the last tagged release. Downloaded and
# extracted here, but NOT wired into ENTRYPOINT. Whether the agent
# attaches is controlled per-pod via the Kubernetes deployment's `command`
# override, not baked into the image, so the same image serves both the
# agent-off and agent-on comparison.
RUN apt-get update && apt-get install -y wget && \
    wget -q https://github.com/baidu/openrasp/releases/download/v1.3.7/rasp-java.tar.gz && \
    tar -xzf rasp-java.tar.gz && \
    rm rasp-java.tar.gz && \
    mv rasp-* /opt/rasp && \
    apt-get remove -y wget && apt-get autoremove -y

EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

rm rasp-java.tar.gz has to run before mv rasp-* /opt/rasp, not after. The first build put it after and failed with mv: target '/opt/rasp' is not a directory, because with the tarball still present the glob matched two things (the extracted directory and the archive file itself), and mv with two sources needs an existing destination directory to move into.

The agent lives in the image at /opt/rasp/rasp/rasp.jar but is not wired into ENTRYPOINT. Whether it attaches is controlled entirely by the deployment’s command field, so one image serves both the agent-off baseline and the agent-on comparison, and the difference between the two test runs is a one-line manifest diff, not two separate builds. That matters for the adversarial test later, since the only variable between “before” and “after” is that one line:

containers:
  - name: dashboard-service
    image: <account-id>.dkr.ecr.us-east-1.amazonaws.com/ast-test-ecr-dashboard-service:git-<sha>
    # Agent-off (default): no `command`, falls back to the image's own
    # ENTRYPOINT (`java -jar app.jar`), no OpenRASP.
    #
    # Agent-on, for the adversarial comparison, uncomment:
    # command: ["java", "--add-opens=java.base/jdk.internal.loader=ALL-UNNAMED", "-javaagent:/opt/rasp/rasp/rasp.jar", "-jar", "app.jar"]
    ports:
      - containerPort: 8080

Applied without a rebuild, directly against the running deployment:

kubectl patch deployment dashboard-service -n ast-test --type='json' \
  -p='[{"op": "add", "path": "/spec/template/spec/containers/0/command", "value": ["java", "--add-opens=java.base/jdk.internal.loader=ALL-UNNAMED", "-javaagent:/opt/rasp/rasp/rasp.jar", "-jar", "app.jar"]}]'

ZAP’s active scan, seeded directly at the vulnerable parameter, is what actually exercises the SQL injection:

zap-full-scan.py -t "http://dashboard-service.../widget1?name=test"

zap-baseline.py, by contrast, only spiders and observes; it never sends attack payloads, so it cannot find an injection even when pointed at the right URL. Run against /dashboard (the one endpoint answering a bare GET), the baseline scan returns a clean 200 with three legitimate findings.

ZAP baseline scan targeting /dashboard directly: real 200 response, legitimate findings, zero failures

Run active and seeded at /widget1?name=test with the agent off, ZAP confirms the injection directly: SQL Injection [40018], a single-quote payload returning HTTP 500, the raw concatenated query breaking exactly the way an unparameterized string-built SQL statement should.

Active scan seeded directly at /widget1: SQL Injection confirmed, agent off

The general lesson: before treating a missing finding as a limit of what a technique can do, confirm the scan that ran was even capable of finding it.

Turning the Agent On

With the agent off, the exploit succeeds cleanly, run from a throwaway pod inside the cluster since dashboard-service is ClusterIP-only:

kubectl run curl-baseline-test --image=curlimages/curl -n ast-test --restart=Never -- \
  curl -s -w "\nHTTP_CODE:%{http_code}\n" "http://dashboard-service.ast-test.svc.cluster.local/widget1?name=%27"

That is the confirmed baseline against which the agent-on result gets measured.

The single-quote exploit against /widget1 succeeding with HTTP 500, agent off, the confirmed baseline

Getting OpenRASP to actually block the exploit requires two configuration details beyond just attaching the agent. First, Java’s module system (introduced in Java 9) blocks the reflective classpath injection OpenRASP depends on unless the JVM is launched with --add-opens=java.base/jdk.internal.loader=ALL-UNNAMED, added ahead of -javaagent in the deployment’s command. Without that flag, OpenRASP logs a fail-open warning and the application keeps running unprotected, no crash, no obvious signal beyond the log line itself:

[OpenRASP] Failed to initialize module jar: rasp-engine.jar
[OpenRASP] Failed to initialize, will continue without security protection.
java.lang.reflect.InaccessibleObjectException: Unable to make void jdk.internal.loader.ClassLoaders$AppClassLoader.appendToClassPathForInstrumentation(java.lang.String) accessible

This is a general Java 9+ compatibility issue, not something specific to OpenRASP; an unrelated Java application (Traccar, a GPS tracking platform) hits the identical stack trace and resolves it the same way.

OpenRASP's startup log showing Engine Initialized after the --add-opens fix

Second, OpenRASP’s official plugin ships with algorithmConfig.meta.all_log: true, which downgrades every algorithm’s block action to log at load time regardless of the algorithm’s own declared action: 'block' setting. A single-quote payload alone is too minimal for sql_userinput to evaluate (the algorithm needs at least eight characters), so a full injection payload (' OR '1'='1) is required to trigger detection at all; with that payload and the all_log: true default in place, OpenRASP detects it correctly at 90% confidence but does not block it:

"plugin_algorithm": "sql_userinput", "plugin_confidence": 90, "intercept_state": "log"

Setting all_log: false turns detection into enforcement:

RUN sed -i 's/all_log: true/all_log: false/' /opt/rasp/rasp/plugins/official.js

With both the --add-opens flag and all_log: false in place, the same payload against the same endpoint returns:

"plugin_algorithm": "sql_userinput", "plugin_confidence": 90, "intercept_state": "block"

HTTP response: a 302 redirect to OpenRASP’s block page, and the query never reaches Postgres. The distinction between intercept_state: log and intercept_state: block matters because a tool confirming it saw an attack is not the same claim as it stopping the attack. Confirmed against the running pod itself, not just the HTTP response:

kubectl run curl-block-test --image=curlimages/curl -n ast-test --restart=Never -- \
  curl -s -w "\nHTTP_CODE:%{http_code}\n" -G "http://dashboard-service.ast-test.svc.cluster.local/widget1" \
  --data-urlencode "name=' OR '1'='1"

kubectl exec -n ast-test deployment/dashboard-service -- tail -c 2000 /opt/rasp/rasp/logs/alarm/alarm.log

The exact same exploit attempt against /widget1, agent on, returning a 302 block instead of a 500

OpenRASP's alarm log showing intercept_state: block for the SQL injection payload, 90% confidence

Four request attempts against the identical URL and payload, in sequence, isolate the two fixes:

AttemptConfig stateResult
Agent offN/AHTTP 500, exploit succeeds
Agent on, before --add-opensFailed to initializeIndistinguishable from agent-off, had it been tested
Agent on, initialized, all_log: true (default)Detected, intercept_state: logHTTP 200, exploit succeeds, attack logged not blocked
Agent on, initialized, all_log: false (fixed)Detected, intercept_state: blockHTTP 302, exploit blocked, query never reaches the database

Each row changes exactly one variable from the row above it. That is what makes the final block attributable specifically to the all_log fix and not to some other difference between the runs.

ZAP’s own alarm log confirms the same result at scale: run active agent-on against the identical URL, OpenRASP blocks 56 of the SQLi payloads ZAP throws at the endpoint, all intercept_state: block, sql_userinput. The one exception is a bare %27, too short for sql_userinput to evaluate, the same detection floor covered above. ZAP’s own one-line summary still reports SQL Injection [40018] for the run (it surfaces the first WARN per rule ID regardless of how many later attempts got blocked), so the alarm log, not the summary line, is the source of truth here.

Trivy vs Inspector on the Same CVE

Trivy and Amazon Inspector both scanned the same built image, independently, and disagreed on one CVE. Inspector listed CVE-2016-1000027 as fixAvailable: YES; Trivy’s own findings said its only fix requires Spring Framework 6.x, the same Java 17 wall already documented in .trivyignore.

aws inspector2 list-findings --region us-east-1 \
  --filter-criteria "{\"ecrImageTags\":[{\"comparison\":\"EQUALS\",\"value\":\"<latest-tag>\"}]}" \
  --query 'findings[?title==`CVE-2016-1000027 - org.springframework:spring-web`]'

Inspector's finding detail for CVE-2016-1000027: Fix available: Yes, with no version attached anywhere in the detail view

The host scanner (T5 in the threat model, the worker nodes independent of what runs on them) needs its coverage confirmed first, then its findings pulled directly:

aws inspector2 list-coverage --region us-east-1 \
  --filter-criteria "{\"resourceType\":[{\"comparison\":\"EQUALS\",\"value\":\"AWS_EC2_INSTANCE\"}]}"

aws inspector2 list-findings --region us-east-1 \
  --filter-criteria "{\"resourceType\":[{\"comparison\":\"EQUALS\",\"value\":\"AWS_EC2_INSTANCE\"}]}" \
  --query 'findings[].{Severity:severity,FixAvailable:fixAvailable,Title:title}' --output table

That turned up a populated findings table for both nodes: vim-data, glib2, python3, krb5-libs, dracut, libacl, all Amazon Linux 2023 OS packages, unrelated to the Java application running in containers on top of them. A distinct package set from the container-image findings, confirming the two scanning stages look at different things rather than double-counting the same coverage.

Host-agent findings for both worker nodes, a distinct set of OS packages from anything the container scanner reported

The Promotion Gate

The last piece was whether an open finding stays visible and blocks promotion, not just absent from the current run’s diff:

aws inspector2 list-findings --region us-east-1 \
  --filter-criteria "{\"severity\":[{\"comparison\":\"EQUALS\",\"value\":\"CRITICAL\"}],\"fixAvailable\":[{\"comparison\":\"EQUALS\",\"value\":\"YES\"}]}" \
  --query 'length(findings)'

aws inspector2 list-findings --region us-east-1 \
  --filter-criteria "{\"severity\":[{\"comparison\":\"EQUALS\",\"value\":\"CRITICAL\"}],\"fixAvailable\":[{\"comparison\":\"EQUALS\",\"value\":\"YES\"}]}" \
  --query 'findings[].{Type:type,Title:title,Resource:resources[0].type}' --output table

Querying Inspector for every open CRITICAL with a fix available, across the whole environment, returned 8, all container-image findings, none from the two EC2 hosts, across two distinct CVEs.

Five were CVE-2016-1000027, the already-reconciled accepted risk. Three were CVE-2022-1471, a SnakeYAML CVE not in .trivyignore at all, and app.jar itself ships the correctly patched snakeyaml-2.0.jar with no 1.23 anywhere in it. The finding’s own vulnerablePackages detail resolves the apparent contradiction:

"filePath": "/opt/rasp/rasp/rasp-engine.jar/META-INF/maven/org.yaml/snakeyaml/pom.properties", "version": "1.23"
"filePath": "/opt/rasp/RaspInstall.jar/META-INF/maven/org.yaml/snakeyaml/pom.properties", "version": "1.26"

Both vulnerable copies are bundled inside the OpenRASP agent’s own jars, rasp-engine.jar and RaspInstall.jar, added to the image for the runtime-defense stage, not anywhere in the application’s dependency tree. pom.xml’s SnakeYAML pin fixed app.jar’s copy correctly, but has no effect on a completely separate jar shipping its own outdated bundled dependency. Inspector’s finding was accurate the entire time.

This is a real, unresolved finding, not a documented exception like the Spring CVE above. OpenRASP’s last tagged release shipped in January 2022, with no newer release to upgrade to. RaspInstall.jar is only used at manual install time and never touches the running application’s classpath, but rasp-engine.jar is loaded by the agent itself, a live dependency of the tool providing this project’s runtime defense. There is no version-pin fix here; it would require either a newer OpenRASP release, which does not exist, or manually patching a shipped jar, out of scope. Recorded as a second acknowledged risk alongside the Spring CVE, not suppressed and not claimed as resolved.

The open-findings gate: 8 CRITICAL+fixable findings, broken down by CVE and cause rather than treated as one undifferentiated count

A gate built on this data has to hold both kinds of finding at once: a documented exception with a clear reason it cannot be fixed within current constraints, and a second, newly surfaced one with the same property, a vulnerable dependency bundled inside the security tool itself, with no upstream fix available. Both are real. Neither gets silently dropped from the count just because there is a good explanation for it.

What This Doesn’t Cover

OpenRASP’s rule coverage is not exhaustive. This project proves the mechanism works for the injection class it was tested against, SQLi specifically, not universal coverage against every attack class OpenRASP’s agent claims to detect.

Compromise of the CI/CD pipeline identity itself is out of scope. Keyless OIDC federation removed the long-lived-key risk, but the identity a workflow run holds is still a target, and defending that identity properly is its own project.

Every scanner here, Trivy, ZAP, OpenRASP, Inspector, is signature or rule-based to some degree, and none claim to catch a vulnerability class with no known pattern yet. Defense in depth across four independent techniques narrows the gap between what a single tool misses and what reaches production, but it does not close a gap all four tools share by construction.

The SnakeYAML finding inside OpenRASP’s own bundled jars is a reminder that a runtime defense tool is itself a dependency with its own attack surface.

Back to blog