Securing an AWS CodePipeline End-to-End: SAST, SCA, Dynamic Testing, and a Hard Security Gate

Securing an AWS CodePipeline End-to-End: SAST, SCA, Dynamic Testing, and a Hard Security Gate

A walkthrough of the planetary-api pipeline — 10 stages of AWS CodePipeline with Semgrep, Snyk, Postman, and Security Hub doing the heavy lifting.

I built planetary-api as a security training tool. It's a Flask API that deliberately contains vulnerable code — SQL injection, command injection, path traversal, SSRF — right alongside secure implementations of the same endpoints. The idea is to show the contrast in a realistic environment.

One side effect of that design is it makes a good test case for a security pipeline. The pipeline has to detect real vulnerabilities, report them centrally, and block the deployment — while also giving me a way to override the gate when the vulnerabilities are intentional. Here's how that works.

Planetary API — AWS CodePipeline (10 Stages) Stage 1 Source GitHub (master) Stage 2 Build Docker → ECR Stage 3 — SecurityScan Semgrep SAST Snyk SCA Postman Dyn Tests Stage 4 Security Gate HIGH/CRIT block Stage 5 Manual Approval SNS email Stage 6 Smoke Test Newman / Postman Stage 7 Deploy ECS Fargate Stage 8 DB Migrate ECS one-off task Stage 9 Lockdown ALB SG restrict Stage 10 Verify Health check AWS Security Hub Findings aggregation Semgrep ASFF import Snyk ASFF import S3 Artifact Store SSM Override /planetary-api/pipeline/ security-override default: "false" CDK Stacks PlanetaryEcr — ECR repository PlanetaryEcs — VPC, ECS Fargate cluster, ALB, RDS MySQL 8.0 PlanetaryPipeline — CodePipeline, CodeBuild projects, S3, SNS, SSM, IAM

Infrastructure at a glance

Three CDK stacks provision everything from scratch:

StackWhat it creates
PlanetaryEcrECR repository for the Docker image
PlanetaryEcsVPC, ECS Fargate cluster, ALB, RDS MySQL 8.0 (db.t3.micro)
PlanetaryPipelineCodePipeline, all CodeBuild projects, S3 bucket, SNS, SSM parameters, IAM roles

Deploy the stacks in order — ECR, then ECS, then Pipeline — and the pipeline connects to GitHub via CodeStar and triggers on pushes to master.

Stage 2 — Docker build and ECR push

I log into Docker Hub before authenticating to ECR. CodeBuild's shared outbound IPs hit Docker Hub rate limits without credentials, so this saves a lot of frustrating build failures. Both credentials come from Secrets Manager — nothing stored in the buildspec.

pre_build:
  commands:
    - echo $DOCKERHUB_PASSWORD | docker login -u $DOCKERHUB_USERNAME --password-stdin
    - aws ecr get-login-password | docker login --username AWS --password-stdin $ECR_REPO
build:
  commands:
    - docker build -t $IMAGE_REPO_NAME:$COMMIT_SHA .
    - docker tag $IMAGE_REPO_NAME:$COMMIT_SHA $ECR_REPO:$COMMIT_SHA
    - docker tag $IMAGE_REPO_NAME:$COMMIT_SHA $ECR_REPO:latest
post_build:
  commands:
    - docker push $ECR_REPO:$COMMIT_SHA && docker push $ECR_REPO:latest
    - printf '[{"name":"%s","imageUri":"%s"}]' "$CONTAINER_NAME" "$ECR_REPO:$COMMIT_SHA" > imagedefinitions.json

The image is tagged with the commit SHA and as :latest. The imagedefinitions.json is what ECS uses later in Stage 7 to know which specific image to deploy.

Stage 3 — three parallel security scans

Stage 3 runs three CodeBuild actions simultaneously, each covering a different layer of the security picture.

Security Scanning — Tools and Data Flow Semgrep Static Analysis (SAST) semgrep scan --sarif Detects code vulns + GitHub Actions (PRs) Snyk Software Composition (SCA) snyk test (requirements.txt) CycloneDX SBOM output Dep vulnerability scanning Postman / Newman Dynamic API testing SQLi, CMDi, XSS checks sarif_to_asff.py SARIF → ASFF format conversion snyk_to_asff.py JSON → ASFF format conversion AWS Security Hub Centralised findings store ASFF findings ingestion Severity classification WorkflowStatus tracking Security Gate queries S3 Artifact Store SARIF reports ASFF JSON CycloneDX SBOM Newman HTML reports Local dev: Talisman (secret scan pre-push) + Black + Flake8 (pre-commit)

Semgrep — static analysis (SAST)

Semgrep scans the source code looking for vulnerability patterns. I run it with --config=auto, which pulls in Semgrep's maintained ruleset automatically. Output is SARIF format.

semgrep scan --config=auto --sarif --output=semgrep.sarif .

AWS Security Hub expects findings in ASFF (Amazon Security Finding Format), not SARIF. I wrote sarif_to_asff.py to handle the conversion and batch-import the results. The raw SARIF and converted ASFF files both go to S3 for auditing.

Semgrep also runs in GitHub Actions as a PR check, so you get static analysis feedback before anything hits the main pipeline. Two bites at the same apple.

Snyk — dependency scanning (SCA)

Software Composition Analysis (SCA) scans your dependencies, not your code. Snyk checks requirements.txt against its database and flags anything with a known CVE. I also generate a CycloneDX SBOM alongside the scan:

snyk test --file=requirements.txt --json > snyk_results.json || true
cyclonedx-bom -r -o bom.xml

The || true stops Snyk's non-zero exit from killing the buildspec before I've exported the results. The conversion script snyk_to_asff.py then translates the JSON to ASFF and imports it to Security Hub. S3 gets the SBOM, raw results, and ASFF.

Postman — dynamic API testing

Dynamic testing means actually running the API and firing requests at it. The buildspec spins up MySQL and the API container in Docker-in-Docker, waits for health checks to pass, then runs Newman against the "Security" test folder. That folder has test cases for SQL injection payloads, command injection, and XSS.

docker-compose up -d
sleep 20
newman run postman/planetary-api.postman_collection.json \
  --folder Security \
  --environment postman/local.postman_environment.json \
  --reporters cli,junit,html \
  --reporter-junit-export newman-results.xml \
  --suppress-exit-code

The --suppress-exit-code is intentional. I don't want Newman failures to kill this stage immediately — I want all three scans to complete and report their findings to Security Hub. The gating happens in Stage 4.

Stage 4 — the security gate

Stage 4 — Security Gate Decision Flow Query Security Hub HIGH / CRITICAL severity WorkflowStatus=NEW • RecordState=ACTIVE Findings found? YES NO Override = true? NO YES PIPELINE BLOCKED CodeBuild exits with code 1 Pipeline Continues → Stage 5: Manual Approval (SNS email) SSM Override Parameter /planetary-api/pipeline/security-override Default: "false" (gate is active) Set "true" to bypass for known vulns Resets to "false" automatically after run Gate queries findings from: planetary-api-semgrep • planetary-api-snyk

Once all three scans finish, the security gate queries Security Hub for active findings at HIGH or CRITICAL severity from the Semgrep and Snyk generators:

aws securityhub get-findings \
  --filters '{
    "GeneratorId": [
      {"Value": "planetary-api-semgrep", "Comparison": "CONTAINS"},
      {"Value": "planetary-api-snyk", "Comparison": "CONTAINS"}
    ],
    "SeverityLabel": [
      {"Value": "HIGH", "Comparison": "EQUALS"},
      {"Value": "CRITICAL", "Comparison": "EQUALS"}
    ],
    "WorkflowStatus": [{"Value": "NEW", "Comparison": "EQUALS"}],
    "RecordState": [{"Value": "ACTIVE", "Comparison": "EQUALS"}]
  }'

If findings exist, it checks an SSM parameter: /planetary-api/pipeline/security-override. Default is "false". False + findings = exit code 1, pipeline blocked.

The override: To allow the pipeline through despite findings — which is necessary since the vulnerable endpoints are intentional — you set the SSM parameter to "true" via aws ssm put-parameter. The buildspec resets it back to "false" at the end of the gate stage, so it's a one-use bypass, not a persistent switch.

The gate doesn't try to prevent vulnerabilities from existing — it makes the decision to ship them explicit. You have to actively choose to override it. That's the point: risk becomes visible and auditable rather than quietly slipping through.

Stage 5 — manual approval

Even after the security gate passes, a human signs off before anything hits production. SNS sends an email with a link directly to Security Hub, so the approver can check the current findings before deciding. Nobody deploys without that step.

Stages 6–10 — the deployment sequence

Stage 6 runs the Postman "Basic" and "Negative" functional test folders via Newman against a local container — a quick sanity check before touching production.

Stage 7 triggers the ECS rolling deployment using the imagedefinitions.json from Stage 2. ECS pulls the specific commit-SHA-tagged image and updates the service.

Stage 8 runs a one-off ECS task to execute flask db_create && flask db_seed against the production RDS instance. Both commands are idempotent, so running them on every deploy is safe.

Stage 9 optionally locks the ALB security group to a specific CIDR. Set the AllowedIp pipeline variable and it revokes all existing port-80 inbound rules and replaces them with that single CIDR. Leave it blank and the ALB stays open.

Stage 10 waits 15 seconds for ECS to stabilise, then fires a live health check at the ALB DNS name. If the lockdown stage ran with a specific IP, this stage skips the check — CodeBuild runs from a different IP and would fail the curl regardless.

Secrets and storage

Every credential comes from Secrets Manager at runtime. Docker Hub credentials, Snyk token, Semgrep token, database password — none of it is in the buildspec or environment variables. The S3 artifact bucket is versioned, encrypted, and has all public access blocked.

env:
  secrets-manager:
    DOCKERHUB_USERNAME: "planetary-api/dockerhub:username"
    DOCKERHUB_PASSWORD: "planetary-api/dockerhub:password"
    SNYK_TOKEN: "planetary-api/snyk:token"
    SEMGREP_APP_TOKEN: "planetary-api/semgrep:token"

Local dev checks

The pipeline isn't doing this alone. Talisman blocks pushes that contain secrets before code even reaches GitHub. Black and Flake8 run as pre-commit hooks. And Semgrep runs again in GitHub Actions on PRs, so you're seeing static analysis at review time rather than waiting for a full pipeline run.

ToolTypeWhere it runs
SemgrepSASTCodeBuild (pipeline) + GitHub Actions (PRs)
SnykSCACodeBuild (pipeline)
CycloneDXSBOM generationCodeBuild (pipeline, alongside Snyk)
Postman/NewmanDynamic API testsCodeBuild (stages 3 and 6)
AWS Security HubFinding aggregationAll scan stages report here
TalismanSecret scanningLocal (pre-push hook)
Black + Flake8LintingLocal (pre-commit hook)

Wrapping up

The layered approach is what I'm happiest with here. Local hooks, GitHub Actions SAST on PRs, and then the full pipeline scan before anything ships — three chances to catch something before it matters.

The intentional vulnerabilities actually make this a better demo than a clean codebase would. The pipeline flags them, Security Hub logs them, and the gate blocks on them. You have to make an active decision to override. That feels like the right behaviour for a security pipeline: not quietly ignoring risk, but making it visible and forcing a conscious choice.

CDK stacks and buildspecs are all at github.com/andyrat33/planetary-api.

Comments

Popular posts from this blog

Squid Proxy with SOF-ELK Part 1

Squid Proxy with SOF-ELK Part 2 Analysis

Netflow analysis with SiLK - Part 1 Installation