Post

Week 3 — Day 19: Building a Full Secure CI/CD Pipeline

Assembling all Week 3 security tools into a single GitHub Actions pipeline — Semgrep, Snyk, Checkov, tfsec, Trivy, and ZAP running as sequential security gates on every PR and push.

Week 3 — Day 19: Building a Full Secure CI/CD Pipeline

The Goal

This day ties together everything from Week 3: SAST, SCA, IaC scanning, image scanning, and DAST into one cohesive pipeline. Each tool is a gate — findings at a certain severity block the merge.

Pipeline stages in order:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
PR opened
    │
    ▼
1. SAST (Semgrep)         ← code analysis, runs on every file change
    │
    ▼
2. SCA (Snyk)             ← dependency vulnerabilities
    │
    ▼
3. IaC Scan (Checkov + tfsec) ← Terraform/K8s misconfigs
    │
    ▼
4. Build Docker image
    │
    ▼
5. Image Scan (Trivy)     ← OS + library CVEs in the built image
    │
    ▼
6. Deploy to staging
    │
    ▼
7. DAST (ZAP Baseline)    ← runtime security checks on staging
    │
    ▼
Merge allowed (all gates passed)

Security Gates — What Blocks a Merge

StageBlocks merge onWarns on
SemgrepERROR severity findingsWARNING findings
SnykHigh + Critical CVEsMedium CVEs
CheckovHigh + Critical checksMedium checks
tfsecHIGH + CRITICALMEDIUM
TrivyCRITICAL CVEsHIGH CVEs
ZAPFAIL rules (High alerts)WARN rules

Philosophy: Block on things that are definitely exploitable or a clear misconfiguration. Warn on everything else — don’t make the pipeline so noisy it gets bypassed.


Full Pipeline — GitHub Actions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
# .github/workflows/secure-pipeline.yml
name: Secure CI/CD Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  IMAGE_NAME: myapp
  IMAGE_TAG: $

jobs:
  # ─────────────────────────────────────
  # Stage 1: SAST
  # ─────────────────────────────────────
  sast:
    name: SAST — Semgrep
    runs-on: ubuntu-latest
    container:
      image: semgrep/semgrep
    steps:
      - uses: actions/checkout@v4

      - name: Run Semgrep
        run: |
          semgrep scan \
            --config p/owasp-top-ten \
            --config p/secrets \
            --sarif \
            --output semgrep.sarif \
            --severity ERROR \
            --error \
            .

      - name: Upload SARIF
        uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: semgrep.sarif
          category: semgrep

  # ─────────────────────────────────────
  # Stage 2: SCA
  # ─────────────────────────────────────
  sca:
    name: SCA — Snyk
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run Snyk
        uses: snyk/actions/node@master
        env:
          SNYK_TOKEN: $
        with:
          args: >-
            --severity-threshold=high
            --sarif-file-output=snyk.sarif

      - name: Upload SARIF
        uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: snyk.sarif
          category: snyk

  # ─────────────────────────────────────
  # Stage 3: IaC Scanning
  # ─────────────────────────────────────
  iac-scan:
    name: IaC — Checkov + tfsec
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run Checkov
        uses: bridgecrewio/checkov-action@master
        with:
          directory: terraform/
          framework: terraform
          output_format: sarif
          output_file_path: checkov.sarif
          soft_fail: false

      - name: Upload Checkov SARIF
        uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: checkov.sarif
          category: checkov

      - name: Run tfsec
        uses: aquasecurity/tfsec-action@v1.0.0
        with:
          working_directory: terraform/
          minimum_severity: HIGH
          format: sarif
          sarif_file: tfsec.sarif

      - name: Upload tfsec SARIF
        uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: tfsec.sarif
          category: tfsec

  # ─────────────────────────────────────
  # Stage 4+5: Build + Image Scan
  # ─────────────────────────────────────
  build-and-scan:
    name: Build + Trivy Image Scan
    runs-on: ubuntu-latest
    needs: [sast, sca]     # only build if code gates passed
    steps:
      - uses: actions/checkout@v4

      - name: Build Docker image
        run: docker build -t $:$ .

      - name: Run Trivy image scan
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: $:$
          format: sarif
          output: trivy.sarif
          severity: CRITICAL,HIGH
          exit-code: 1

      - name: Upload Trivy SARIF
        uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: trivy.sarif
          category: trivy

      - name: Push image to ECR
        if: github.ref == 'refs/heads/main'
        run: |
          aws ecr get-login-password --region ap-southeast-1 | \
            docker login --username AWS --password-stdin $
          docker tag $:$ \
            $/$:$
          docker push $/$:$
        env:
          AWS_ACCESS_KEY_ID: $
          AWS_SECRET_ACCESS_KEY: $

  # ─────────────────────────────────────
  # Stage 6+7: Deploy to staging + DAST
  # ─────────────────────────────────────
  dast:
    name: DAST — ZAP Baseline
    runs-on: ubuntu-latest
    needs: [build-and-scan]
    steps:
      - uses: actions/checkout@v4

      - name: Start staging environment
        run: docker compose -f docker-compose.staging.yml up -d --wait

      - name: ZAP Baseline Scan
        uses: zaproxy/action-baseline@v0.12.0
        with:
          target: http://localhost:3000
          rules_file_name: .zap/rules.tsv
          cmd_options: -J zap.json -r zap.html

      - name: Upload ZAP report
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: zap-report
          path: |
            zap.html
            zap.json

      - name: Teardown staging
        if: always()
        run: docker compose -f docker-compose.staging.yml down

[SCREENSHOT]GitHub Actions showing the full pipeline with all 5 jobs (sast, sca, iac-scan, build-and-scan, dast) — all green on a clean run, with the dependency arrows showing the execution order

[SCREENSHOT]GitHub PR showing all 5 required status checks listed — Semgrep, Snyk, Checkov+tfsec, Trivy, ZAP — all with green checkmarks before merge is allowed


Branch Protection Rules

Set up branch protection to enforce the pipeline gates:

  1. GitHub repo → Settings → Branches → Add rule for main
  2. Enable: Require status checks to pass before merging
  3. Add required checks:
    • SAST — Semgrep
    • SCA — Snyk
    • IaC — Checkov + tfsec
    • Build + Trivy Image Scan
    • DAST — ZAP Baseline
  4. Enable: Require branches to be up to date before merging

[SCREENSHOT]GitHub → Branch protection rules settings showing the 5 required status checks listed and “Require branches to be up to date” enabled

Now a PR literally cannot be merged unless all security gates pass.


Secrets Management in the Pipeline

Never hardcode credentials in workflow files. Use GitHub Actions secrets:

1
2
3
4
5
env:
  SNYK_TOKEN: $
  AWS_ACCESS_KEY_ID: $
  AWS_SECRET_ACCESS_KEY: $
  ECR_REGISTRY: $

Set these in: repo → Settings → Secrets and variables → Actions → New repository secret

[SCREENSHOT]GitHub → Settings → Secrets and variables → Actions showing the list of repository secrets (names visible, values masked) — SNYK_TOKEN, AWS_ACCESS_KEY_ID, ECR_REGISTRY listed

Better for AWS: Use OIDC instead of long-lived access keys:

1
2
3
4
5
- name: Configure AWS credentials via OIDC
  uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::123456789:role/github-actions-role
    aws-region: ap-southeast-1

This generates temporary credentials via the GitHub OIDC provider — no static AWS keys stored anywhere.


Pipeline Performance Optimization

Running all scans sequentially is slow. Parallelize where possible:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Run SAST, SCA, and IaC in parallel — they're independent
jobs:
  sast:
    ...
  sca:
    ...
  iac-scan:
    ...

  # Build only after code gates pass
  build-and-scan:
    needs: [sast, sca]
    ...

  # DAST only after image is built
  dast:
    needs: [build-and-scan]
    ...

[SCREENSHOT]GitHub Actions run showing the parallel execution — sast, sca, and iac-scan running simultaneously, then build-and-scan starting once both complete, then dast last

Typical timing:

  • SAST + SCA + IaC in parallel: ~3-5 minutes
  • Build + image scan: ~3-5 minutes
  • DAST: ~2-3 minutes
  • Total: ~10-13 minutes — fast enough for a development workflow

Handling Pipeline Failures

Failed SAST (Semgrep)

1
2
3
4
# See the exact finding locally before pushing
semgrep scan --config p/owasp-top-ten --severity ERROR .
# Fix the code
# Re-push

Failed SCA (Snyk)

1
2
3
4
5
# See what needs fixing
snyk test
# Apply fixes
snyk fix
# Re-push

Failed IaC (Checkov)

1
2
3
4
# See what's failing
checkov -d terraform/ --compact
# Fix the Terraform resource
# Re-push

Failed Image Scan (Trivy)

1
2
3
4
# See the CVEs
trivy image --severity CRITICAL myapp:test
# Update the base image or vulnerable package
# Rebuild + re-push

Failed DAST (ZAP)

  • Read the ZAP report artifact from the Actions run
  • Add the missing security headers or fix the identified vulnerability
  • Re-push

Pipeline as Code Checklist

Before merging your pipeline definition:

  • All tools run with --exit-code 1 or equivalent on findings above threshold
  • SARIF output uploaded to GitHub Security tab for all tools
  • No static AWS credentials — use OIDC or short-lived tokens
  • Snyk token stored as a secret, not hardcoded
  • Branch protection rules require all checks to pass
  • IaC scan only triggers on terraform/** file changes (performance)
  • DAST only runs against staging, never production
  • Staging environment torn down after DAST completes
  • Pipeline runs on PRs AND on push to main (in case someone pushes directly)

Key Takeaways

  • A secure pipeline is a series of gates — each one catches a different class of problem
  • Run SAST, SCA, and IaC scans in parallel — they’re independent and this halves your pipeline time
  • Use needs: to enforce ordering — don’t build if the code scan fails, don’t DAST if the image is not built
  • Branch protection rules are what actually enforce the gates — without them, developers can merge despite failures
  • Use OIDC for AWS auth in GitHub Actions — eliminates long-lived access keys from your secrets store
  • The pipeline is itself code — scan it with Semgrep (p/ci ruleset) for misconfigurations

References


You can find me online at:

My signature image

This post is licensed under CC BY 4.0 by the author.