Build a DevSecOps CI/CD Pipeline

Scan code, containers, and running apps for vulnerabilities automatically.

Introduction

30 Second Summary

Every major security breach has something in common. The vulnerability was sitting in the code for weeks or months before anyone noticed.

In this project, you will build a DevSecOps CI/CD pipeline that automatically scans your code, container images, and running application for security vulnerabilities on every push.

What You'll Build

A GitHub Actions workflow that implements three layers of automated security scanning across your entire software delivery process.

By the end of this project, you'll have:

  • A Node.js application with deliberate security vulnerabilities that serve as a scanning target.
  • A hardened multi-stage Dockerfile that produces minimal, non-root Docker container images.
  • A DevSecOps pipeline with SAST, container scanning, and DAST security gates that block merges on HIGH/CRITICAL findings.
  • Secret Mission: Add OIDC-based AWS authentication and AI-powered vulnerability explanations to your pipeline.

Are there any prerequisites?

You should have basic Node.js/Express knowledge and basic Docker knowledge. You also need a GitHub account, VS Code, Docker Desktop, and Node.js installed on your machine.

Set Up Your Repository

Every CI/CD pipeline needs a home. Your pipeline will live inside a GitHub repository, triggered automatically whenever you push code or open a pull request.

In this step, you will create that repository, initialize a Node.js project with Express, and confirm your local tools are ready to go.

In this step, get ready to:
  • Create and clone a GitHub repository.
  • Initialize the Node.js project and install Express.
  • Verify your local development environment.
Create a GitHub repository and clone it locally

Your pipeline will run inside GitHub Actions, which is tightly integrated with your repository. Every push triggers the workflow automatically.

  • Open your browser and go to github.com/new.
  • Enter devsecops-pipeline-demo as the repository name.
  • Select Public for the repository visibility.
  • Check the box for Add a README file.
  • Click Create repository.

Why make it public?

GitHub Actions is free and unlimited for public repositories using standard runners. Private repos on the GitHub Free plan get 2,000 minutes per month. A public repo means your pipeline runs at zero cost with no minute cap.

Now clone the repository to your local machine.

  • On the repository page, click the green Code button.
  • Copy the HTTPS URL.
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Clone the repository and move into it by running these commands:
git clone [[REPO_URL="https://github.com/your-username/devsecops-pipeline-demo.git"]]
cd devsecops-pipeline-demo
  • Confirm the clone worked by running:
ls

You should see a README.md file in the output.

Clone failed or permission denied?

Make sure you copied the HTTPS URL (not SSH) from the green Code button. If prompted for credentials, enter your GitHub username and a personal access token (not your password).

help me fix a git clone authentication error

Now open the project folder in VS Code.

  • Switch to VS Code.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the devsecops-pipeline-demo folder, and click Open.
Initialize the Node.js project and install Express

The package.json file defines your project's metadata, scripts, and dependencies. You will create it with the exact configuration your pipeline needs.

  • In the VS Code Explorer sidebar, right-click in the empty space below README.md and select New File.
  • Name the file package.json and press Enter.
  • Paste the following content into package.json:
{
  "name": "devsecops-pipeline-demo",
  "version": "1.0.0",
  "description": "DevSecOps pipeline demonstration with intentional vulnerabilities",
  "main": "src/server.js",
  "scripts": {
    "start": "node src/server.js",
    "test": "node src/test.js"
  },
  "dependencies": {
    "express": "5.2.1"
  }
}

What does this file do?

The scripts section defines shortcuts you can run with npm start and npm test. The dependencies section tells npm to install Express 5.2.1, the web framework your API will use.

The main field points to src/server.js, which you will create in the next step.

  • Save package.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Install the dependencies by running this command in your VS Code terminal:
npm install

You should see output showing packages being added. A new node_modules folder appears in the Explorer sidebar.

Seeing permission errors or EACCES?

Make sure your terminal is in the devsecops-pipeline-demo folder. Run pwd to check your current directory. If you are somewhere else, run cd ~/Desktop/devsecops-pipeline-demo first.

help me fix npm install permission errors

Now create the .gitignore file to keep generated files and secrets out of version control.

  • In the VS Code Explorer sidebar, right-click in the empty space and select New File.
  • Name the file .gitignore and press Enter.
  • Paste the following content into .gitignore:
node_modules/
.env
*.sarif

What does this file do?

Each line tells Git to ignore matching files. node_modules/ keeps the bulky dependency folder out of your repo. .env prevents secrets from being committed.

The *.sarif pattern excludes security scan output files that your pipeline will generate later.

  • Save .gitignore by pressing Cmd+S (macOS) or Ctrl+S (Windows).

✔️ Awesome, I've got everything!

Great. Double check you have saved both files.

ⓧ I'd like to double check the full code

Here is package.json:

{
  "name": "devsecops-pipeline-demo",
  "version": "1.0.0",
  "description": "DevSecOps pipeline demonstration with intentional vulnerabilities",
  "main": "src/server.js",
  "scripts": {
    "start": "node src/server.js",
    "test": "node src/test.js"
  },
  "dependencies": {
    "express": "5.2.1"
  }
}

Here is .gitignore:

node_modules/
.env
*.sarif
Verify your local environment

Before building the app, confirm that all three tools your pipeline depends on are accessible from inside the project folder.

  • Run these version checks in your terminal:
docker --version
node --version
git --version

You should see version numbers for all three tools. Docker should show 24 or higher, Node should show v22 or higher, and Git should show any recent version.

One of the commands shows 'not found'?

If docker --version fails, make sure Docker Desktop is running. If node --version or git --version fails, the tool may not be on your system PATH. Try closing and reopening your terminal.

help me fix a command not found error for Docker, Node, or Git

Your repository is ready and your tools are confirmed. Next up, you will build the Express application with deliberate security vulnerabilities that your pipeline will learn to catch.

Build the Vulnerable Express Application

Your repository is set up and all your tools are confirmed working. Now it's time to build the application that your security scanners will analyze.

Security scanners need something to scan. In this step, you'll create a Node.js API with deliberate vulnerabilities that demonstrate how each type of scanner catches different classes of bugs.

In this step, get ready to:
  • Create an Express API server with three intentional security vulnerabilities.
  • Write a test script to verify the server starts and responds correctly.
  • Run the app locally and confirm all endpoints respond.
Create the Express API server with intentional vulnerabilities

Your scanners need real code patterns to detect. You'll build an Express API that contains a hardcoded secret, a SQL injection pattern, and a command injection vulnerability.

  • Create the src directory by running this command:
mkdir src
  • Confirm the directory was created by running:
ls

You should see src listed alongside node_modules and package.json.

  • Create a new file called src/server.js in VS Code by clicking File then New File.
  • Save it inside the src folder with the name server.js.
  • Add the server setup and health check endpoint by pasting this code:
const express = require('express');
const { exec } = require('child_process');

const app = express();
const PORT = process.env.PORT || 3000;

// VULNERABILITY: Hardcoded secret (Semgrep will catch this)
const API_SECRET = 'sk-prod-a1b2c3d4e5f6g7h8i9j0';

app.use(express.json());

// Health check endpoint
app.get('/health', (req, res) => {
  res.json({ status: 'healthy', timestamp: new Date().toISOString() });
});

What does this code do?

  • express and exec are imported. exec runs shell commands, which you'll use for the command injection demo later.
  • API_SECRET is hardcoded directly in the source code. Semgrep will flag this as a security risk because secrets should live in environment variables, not in code.
  • The /health endpoint returns a simple JSON response confirming the server is alive.
  • You won't see a change yet. The next chunk adds the first vulnerability endpoint.
  • Below the health endpoint in src/server.js, add the search endpoint with the SQL injection vulnerability:
// VULNERABILITY: SQL injection pattern (Semgrep will catch this)
app.get('/search', (req, res) => {
  const userInput = req.query.q;
  const query = "SELECT * FROM products WHERE name = '" + userInput + "'";
  // In a real app this would execute against a database
  res.json({ query: query, results: [] });
});

What makes this a SQL injection?

The user's search input is concatenated directly into a SQL query string with no sanitization. An attacker could type ' OR 1=1 -- to manipulate the query and access all records.

In production, you'd use parameterized queries to prevent this. Here, it's deliberately unsafe so Semgrep can detect the pattern.

  • You won't see a change yet. The next chunk completes the server.
  • Below the search endpoint, add the ping endpoint with command injection, plus the server startup code:
// VULNERABILITY: Command injection (Semgrep will catch this)
app.get('/ping', (req, res) => {
  const host = req.query.host;
  exec('ping -c 1 ' + host, (error, stdout, stderr) => {
    if (error) {
      res.status(500).json({ error: stderr });
      return;
    }
    res.json({ output: stdout });
  });
});

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

module.exports = app;

What makes this a command injection?

The host parameter is passed directly to exec() without validation. An attacker could send google.com; rm -rf / to execute arbitrary shell commands on your server.

The app.listen call starts the server on port 3000. module.exports makes the app importable for testing.

  • Save src/server.js.
  • You'll run the server and test all three endpoints in the final substep.

Seeing red underlines in VS Code?

  • Check that line 2 includes const { exec } = require('child_process'); with the curly braces around exec.
  • Make sure the file is saved inside the src folder, not the project root.
  • help me fix red underlines in my server.js file

✔️ Awesome, I've got everything!

Great. Make sure you've saved src/server.js before moving on.

ⓧ I'd like to double check the full code

const express = require('express');
const { exec } = require('child_process');

const app = express();
const PORT = process.env.PORT || 3000;

// VULNERABILITY: Hardcoded secret (Semgrep will catch this)
const API_SECRET = 'sk-prod-a1b2c3d4e5f6g7h8i9j0';

app.use(express.json());

// Health check endpoint
app.get('/health', (req, res) => {
  res.json({ status: 'healthy', timestamp: new Date().toISOString() });
});

// VULNERABILITY: SQL injection pattern (Semgrep will catch this)
app.get('/search', (req, res) => {
  const userInput = req.query.q;
  const query = "SELECT * FROM products WHERE name = '" + userInput + "'";
  // In a real app this would execute against a database
  res.json({ query: query, results: [] });
});

// VULNERABILITY: Command injection (Semgrep will catch this)
app.get('/ping', (req, res) => {
  const host = req.query.host;
  exec('ping -c 1 ' + host, (error, stdout, stderr) => {
    if (error) {
      res.status(500).json({ error: stderr });
      return;
    }
    res.json({ output: stdout });
  });
});

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

module.exports = app;

Why are we deliberately adding vulnerabilities?

These vulnerabilities verify that your security scanners actually detect issues. In production DevSecOps, teams often use known vulnerabilities in test fixtures to validate their scanning pipeline is working.

If the scanner misses a known vulnerability, the pipeline itself is broken. You'll see each of these three patterns flagged when you run Semgrep in a later step.

Create a test script to verify the server

A quick test script confirms your server starts and responds before you push it to GitHub Actions. This catches typos locally instead of waiting for a remote pipeline failure.

  • Create a new file called src/test.js inside the src folder.
  • Add the following test code:
const http = require('http');

const BASE_URL = 'http://localhost:3000';

async function runTests() {
  console.log('Starting server tests...\n');

  // Test health endpoint
  const healthRes = await fetch(`${BASE_URL}/health`);
  const healthData = await healthRes.json();
  console.log('[PASS] Health check:', healthData.status === 'healthy' ? 'OK' : 'FAIL');

  // Test search endpoint
  const searchRes = await fetch(`${BASE_URL}/search?q=test`);
  const searchData = await searchRes.json();
  console.log('[PASS] Search endpoint:', searchData.results ? 'OK' : 'FAIL');

  console.log('\nAll tests passed.');
  process.exit(0);
}

runTests().catch((err) => {
  console.error('[FAIL] Test error:', err.message);
  process.exit(1);
});

What does this test do?

  • fetch is available natively in Node.js 22 with no extra imports needed.
  • The script hits /health and /search, checking that each returns the expected JSON fields.
  • If any request fails, the .catch() block logs the error and exits with code 1, signaling a test failure.
  • Save src/test.js.

VS Code not showing the src folder?

  • Make sure your VS Code workspace is open at the devsecops-pipeline-demo folder level, not a parent directory.
  • Click File then Open Folder and select the devsecops-pipeline-demo folder directly.
  • help me find where my src folder is

✔️ Awesome, I've got everything!

Great. Make sure both src/server.js and src/test.js are saved.

ⓧ I'd like to double check the full code

const http = require('http');

const BASE_URL = 'http://localhost:3000';

async function runTests() {
  console.log('Starting server tests...\n');

  // Test health endpoint
  const healthRes = await fetch(`${BASE_URL}/health`);
  const healthData = await healthRes.json();
  console.log('[PASS] Health check:', healthData.status === 'healthy' ? 'OK' : 'FAIL');

  // Test search endpoint
  const searchRes = await fetch(`${BASE_URL}/search?q=test`);
  const searchData = await searchRes.json();
  console.log('[PASS] Search endpoint:', searchData.results ? 'OK' : 'FAIL');

  console.log('\nAll tests passed.');
  process.exit(0);
}

runTests().catch((err) => {
  console.error('[FAIL] Test error:', err.message);
  process.exit(1);
});
Run the app locally and test the endpoints

Time to confirm everything works before you push to GitHub. You'll start the server and hit each endpoint with curl.

  • Start the server by running this command in your terminal:
node src/server.js

You should see Server running on port 3000 printed in the terminal.

Seeing an error instead?

  • If you see Cannot find module 'express', make sure you ran npm install in the previous step.
  • If you see EADDRINUSE, port 3000 is already in use. Stop whatever is running on that port and try again.
  • help me fix the error when starting my Express server
  • Open a second terminal in VS Code by pressing Cmd+Shift+` (macOS) or Ctrl+Shift+` (Windows).
  • Test the health endpoint by running:
curl http://localhost:3000/health

You should see a JSON response like {"status":"healthy","timestamp":"..."}.

  • Test the search endpoint by running:
curl http://localhost:3000/search?q=test

You should see the SQL query echoed back with the user input concatenated directly into it.

  • Test the ping endpoint by running:
curl http://localhost:3000/ping?host=localhost

You should see ping output from your machine.

  • Switch back to your first terminal and press Ctrl+C to stop the server.

What should I see?

All three endpoints returned JSON responses successfully. The /search response proves the SQL query is built with raw string concatenation. The /ping response proves user input flows directly into a shell command.

These are the exact patterns that SAST scanning will flag in your pipeline.

Extra for Experts: Three layers of scanning

Your pipeline will scan at three stages. SAST scans source code before building. Container scanning checks the Docker image after building. DAST tests the running application.

Each layer catches different vulnerability classes. The hardcoded secret and injection patterns are code-level issues that SAST catches earliest.

Your vulnerable app is ready to be scanned. Next up, you'll containerize it with a hardened multi-stage Dockerfile that follows container security best practices.

Create a Secure Multi-Stage Dockerfile

Your vulnerable app is running and ready to be scanned. Before your CI/CD pipeline can test your Docker image for vulnerabilities, you need to build an image that follows container security best practices.

A secure container image minimizes the attack surface by including only what the app needs to run. In this step, you will write a multi-stage Dockerfile that separates the build environment from the runtime environment, then scan it with Trivy to see what vulnerabilities exist.

In this step, get ready to:
  • Write a secure multi-stage Dockerfile with a non-root user.
  • Build the image and scan it with Trivy to find container vulnerabilities.
  • Compare vulnerability counts between the alpine and full node images.
Write the multi-stage Dockerfile

A multi-stage Docker build uses separate stages for building and running your app. The build stage can contain compilers, dev tools, and full dependency trees. The runtime stage copies only what the app needs to execute.

  • In VS Code, create a new file called Dockerfile in the root of your project folder (the same level as package.json).
  • Paste the following code into Dockerfile:
# Stage 1: Build
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production

# Stage 2: Runtime
FROM node:22-alpine AS runtime
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY src ./src
COPY package.json ./
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "src/server.js"]

What does this Dockerfile do?

This Dockerfile has two stages that serve different purposes:

  • Stage 1 (builder) installs production dependencies using npm ci --only=production. This stage is discarded after the build completes.
  • Stage 2 (runtime) starts fresh from node:22-alpine, creates a non-root user called appuser, and copies only the production node_modules from the builder.
  • USER appuser means the container never runs as root, preventing container escape attacks.
  • HEALTHCHECK tells Docker to periodically verify the app is responding on the /health endpoint.

💡 Why use multi-stage builds?

The build stage may contain compilers, dev tools, and secrets like npm tokens. Multi-stage ensures none of that leaks into the production image.

It also reduces image size, which means fewer packages that could contain CVEs.

  • Save Dockerfile.
  • Confirm the file exists by running:
ls Dockerfile

You should see Dockerfile listed in the output.

Don't see the Dockerfile?

Make sure you created the file in the project root (the same folder where package.json lives), not inside the src/ directory.

Help me figure out where my Dockerfile should be.

Build and scan the image with Trivy

Now that your Dockerfile is ready, you will build the image and scan it with Trivy to see what vulnerabilities exist in the base image and your dependencies.

  • Build your Docker image by running this command:
docker build -t devsecops-app:latest .

What does this command do?

This builds a Docker image from your Dockerfile and tags it as devsecops-app:latest. Docker executes both stages and produces a final image from the runtime stage only.

  • Verify the image runs as a non-root user by running:
docker run --rm devsecops-app:latest whoami

You should see appuser printed in the terminal, confirming the container does not run as root.

Seeing a build error?

If you see an error about package-lock.json not being found, you need to generate one first. Run npm install in your terminal to create it, then rebuild with the docker build command above.

Help me troubleshoot my Docker build error.

Now scan your image with Trivy. Since Trivy may not be installed on your machine, you can run it as a Docker container that scans your local images.

  • Scan your Docker image for vulnerabilities by running:
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy image --severity HIGH,CRITICAL devsecops-app:latest

What does this command do?

  • docker run --rm runs the Trivy scanner as a temporary container that deletes itself after finishing.
  • -v /var/run/docker.sock:/var/run/docker.sock gives Trivy access to your local Docker daemon so it can find your built image.
  • --severity HIGH,CRITICAL filters results to only show the most dangerous vulnerabilities.
  • devsecops-app:latest is the image you just built.

You should see a table of vulnerabilities found in the image. With the alpine base image, the count should be relatively low. Take note of how many HIGH and CRITICAL findings appear.

Trivy can't find your image?

Make sure Docker Desktop is running. Run docker images first to confirm devsecops-app:latest appears in the list. On macOS, if you still see errors, your Docker socket path may differ. Try adding --docker-host or setting the DOCKER_HOST environment variable to your socket path.

Help me fix Trivy not finding my local Docker image.

Compare vulnerability counts with the full node image

Your Dockerfile uses node:22-alpine as the base image. Alpine is a minimal Linux distribution that ships with far fewer packages than the default Debian-based node:22 image.

  • See the vulnerability count of the full node:22 image by scanning it directly:
docker run --rm aquasec/trivy image --severity HIGH,CRITICAL node:22

Compare the results. The full node:22 image typically shows hundreds of HIGH/CRITICAL vulnerabilities from its Debian packages. Your devsecops-app:latest image based on alpine should show significantly fewer.

Why does the full image have so many more CVEs?

The standard node:22 image is based on Debian and includes hundreds of system packages (compilers, shells, networking tools) that your Node.js app never uses. Each of those packages can contain known vulnerabilities.

Alpine strips the OS down to the bare minimum. Your app only needs Node.js and its dependencies to run, so alpine gives you exactly that without the extra attack surface.

  • Check the image size difference by running:
docker images | grep -E 'devsecops-app|node'

You should see your devsecops-app image is much smaller than the node:22 image. Smaller images mean faster pulls, faster deployments, and fewer things that can go wrong.

✔️ Awesome, I've got everything!

Great. Double check that you have saved your Dockerfile and that your image builds successfully.

ⓧ I'd like to double check the full code

Here is the complete Dockerfile for this step:

# Stage 1: Build
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production

# Stage 2: Runtime
FROM node:22-alpine AS runtime
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY src ./src
COPY package.json ./
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "src/server.js"]

Your secure Docker image is built and scanned. Next up, you will automate all of this inside a GitHub Actions pipeline that runs SAST scanning with Semgrep alongside the container scanning you just did manually.

Build the SAST and Container Scanning Pipeline

You have a Dockerized application with known vulnerabilities and a hardened container image. Now it is time to automate the security scanning so it happens on every single code change.

In this step, you will create a GitHub Actions workflow that runs two security jobs in parallel: a SAST scan using Semgrep to catch code-level vulnerabilities, and a container scan using Trivy to catch vulnerabilities in your Docker image.

In this step, get ready to:
  • Create a GitHub Actions workflow with a SAST job that scans source code.
  • Add a container security job that scans your Docker image for vulnerabilities.
  • Push to GitHub and verify findings appear in the Security tab.
Create the workflow file with the SAST job

GitHub Actions workflows live in a special .github/workflows directory at the root of your repository. GitHub automatically discovers and runs any YAML file in this directory when the configured trigger events occur.

  • Create the workflow directory by running this command in your terminal:
mkdir -p .github/workflows
  • Create the workflow file and add the SAST job by pasting the following into .github/workflows/devsecops.yml:
name: DevSecOps Pipeline

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

permissions:
  contents: read
  security-events: write

jobs:
  sast:
    name: SAST - Semgrep
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Install Semgrep
        run: pip install semgrep

      - name: Run Semgrep SAST scan
        run: |
          semgrep scan \
            --config p/nodejs \
            --config p/javascript \
            --sarif \
            --output semgrep.sarif \
            src/
        continue-on-error: true

      - name: Upload SARIF to GitHub Security tab
        uses: github/codeql-action/upload-sarif@v4
        if: always()
        with:
          sarif_file: semgrep.sarif
          category: semgrep-sast

What does this workflow do?

The on block triggers the pipeline on every push or pull request to main. The permissions block grants read access to your code and write access to the Security events API (required to upload scan results).

The sast job installs Semgrep CE (Community Edition) via pip, then runs it against your src/ directory using Node.js and JavaScript rule configurations. The --sarif flag outputs results in SARIF format, which GitHub natively understands.

The final step uploads the SARIF file to GitHub's Security tab using the official github/codeql-action/upload-sarif@v4 action. The category field distinguishes this scan from other tools that might also upload results.

💡 What is SARIF?

SARIF stands for Static Analysis Results Interchange Format. It is a standard JSON format for security tool output that GitHub understands natively.

Any tool that outputs SARIF can feed findings into GitHub's Security tab, giving you one centralized dashboard regardless of which scanner produced the finding.

Add the container security job

The SAST job catches code-level issues. Now you need a second job to catch vulnerabilities hiding in your Docker image's base layers and dependencies.

  • In .github/workflows/devsecops.yml, add the following container-scan job directly below the sast job (at the same indentation level as sast:):
  container-scan:
    name: Container Security - Trivy
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Build Docker image
        run: docker build -t devsecops-app:${{ github.sha }} .

      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@v0.36.0
        with:
          image-ref: devsecops-app:${{ github.sha }}
          format: 'sarif'
          output: 'trivy-results.sarif'
          severity: 'CRITICAL,HIGH'
          exit-code: '1'

      - name: Upload Trivy SARIF to GitHub Security tab
        uses: github/codeql-action/upload-sarif@v4
        if: always()
        with:
          sarif_file: trivy-results.sarif
          category: trivy-container

What does the container-scan job do?

This job builds your Docker image and tags it with the commit SHA (${{ github.sha }}) so every build is uniquely identifiable.

It then runs Trivy using the official aquasecurity/trivy-action@v0.36.0 action. The severity: 'CRITICAL,HIGH' setting filters for only the most dangerous vulnerabilities. The exit-code: '1' setting makes the job fail if any CRITICAL or HIGH vulnerabilities are found. This is your security gate.

Like the SAST job, Trivy outputs SARIF and uploads it to the Security tab under a separate category.

  • Save .github/workflows/devsecops.yml.

YAML indentation errors?

YAML is whitespace-sensitive. Make sure container-scan: is at the same indentation level as sast: (two spaces under jobs:). Each step should be indented with six spaces from the left margin.

If you are unsure, compare your file against the full code in the tab below.

help me fix YAML indentation in my GitHub Actions workflow

✔️ Awesome, I've got everything!

Great. Double check you have saved .github/workflows/devsecops.yml before moving on.

ⓧ I'd like to double check the full code

Here is the complete .github/workflows/devsecops.yml file:

name: DevSecOps Pipeline

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

permissions:
  contents: read
  security-events: write

jobs:
  sast:
    name: SAST - Semgrep
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Install Semgrep
        run: pip install semgrep

      - name: Run Semgrep SAST scan
        run: |
          semgrep scan \
            --config p/nodejs \
            --config p/javascript \
            --sarif \
            --output semgrep.sarif \
            src/
        continue-on-error: true

      - name: Upload SARIF to GitHub Security tab
        uses: github/codeql-action/upload-sarif@v4
        if: always()
        with:
          sarif_file: semgrep.sarif
          category: semgrep-sast

  container-scan:
    name: Container Security - Trivy
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Build Docker image
        run: docker build -t devsecops-app:${{ github.sha }} .

      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@v0.36.0
        with:
          image-ref: devsecops-app:${{ github.sha }}
          format: 'sarif'
          output: 'trivy-results.sarif'
          severity: 'CRITICAL,HIGH'
          exit-code: '1'

      - name: Upload Trivy SARIF to GitHub Security tab
        uses: github/codeql-action/upload-sarif@v4
        if: always()
        with:
          sarif_file: trivy-results.sarif
          category: trivy-container
Push to GitHub and verify findings

With the workflow file in place, pushing your code to GitHub will trigger the pipeline automatically.

  • Commit and push all your changes by running these commands:
git add .
git commit -m "Add DevSecOps pipeline with SAST and container scanning"
git push origin main
  • Open your repository on GitHub in your browser.
  • Click the Actions tab at the top of the repository page.
  • You should see a workflow run called DevSecOps Pipeline in progress or recently completed.
  • Click on the workflow run to see both jobs: SAST - Semgrep and Container Security - Trivy.

What should I see in the Actions tab?

The SAST job should show a green checkmark. It uses continue-on-error: true so that findings don't fail the job itself. This lets the SARIF upload step always run.

The container-scan job may show a red X if Trivy finds CRITICAL or HIGH vulnerabilities in the base image. That is expected and correct. The if: always() condition on the upload step ensures findings still reach the Security tab even when the scan fails the job.

  • Now check the Security tab. Click the Security and quality tab (or select it from the dropdown menu if it is not visible).
  • In the left sidebar, click Code scanning.
  • You should see alerts from Semgrep identifying the SQL injection pattern, command injection, and hardcoded secret in your code.

Why two separate categories?

Each scanner uploads its SARIF with a unique category value (semgrep-sast and trivy-container). This prevents one scanner's results from overwriting the other's. You can filter by tool in the Code scanning view to see findings from each scanner separately.

🙋‍♀️ Don't see any findings in the Security tab?

SARIF upload requires the security-events: write permission in your workflow. Check that the permissions block is present at the top level of your YAML file.

For public repositories, code scanning is available by default. For private repositories, you may need to enable GitHub Code Security in your repository settings under Settings then Code security.

Also verify the workflow actually completed. Click on the failed job in the Actions tab to read the error logs.

help me debug why my SARIF findings are not appearing in the GitHub Security tab

Your pipeline now automatically scans code and containers on every push. Next up, you will add DAST scanning against the running application and configure security gates that block pull requests when critical vulnerabilities are found.

Add DAST Scanning and Security Gates

Your pipeline already catches vulnerabilities in source code and container images. But some security issues only reveal themselves when the application is actually running.

In this step, you will add the final scanning layer: DAST (Dynamic Application Security Testing). DAST sends real HTTP requests to your running application and inspects the responses for security weaknesses like missing headers, reflected inputs, and misconfigurations.

In this step, get ready to:
  • Add a DAST job that spins up your container and scans it with OWASP ZAP.
  • Wire up job dependencies and security gates that block merges on findings.
  • Test the full pipeline by creating a pull request with a new vulnerability.
Add the DAST job to your workflow

DAST needs a running application to scan. Your new job will build the Docker image, start the container, confirm it is healthy, then unleash OWASP ZAP against it.

  • In VS Code, open .github/workflows/devsecops.yml.
  • Scroll to the bottom of the file, after the closing line of the container-scan job.
  • Add the following DAST job below the container-scan job:
  dast:
    name: DAST - OWASP ZAP
    runs-on: ubuntu-latest
    needs: container-scan
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Build and start application
        run: |
          docker build -t devsecops-app:test .
          docker run -d -p 3000:3000 --name app devsecops-app:test
          sleep 5

      - name: Wait for application to be ready
        run: |
          for i in $(seq 1 30); do
            if curl -s http://localhost:3000/health | grep -q healthy; then
              echo "Application is ready"
              exit 0
            fi
            sleep 2
          done
          echo "Application failed to start"
          exit 1

      - name: Run OWASP ZAP Baseline Scan
        uses: zaproxy/action-baseline@v0.15.0
        with:
          target: 'http://localhost:3000'
          fail_action: true
          artifact_name: zap-report

      - name: Stop application
        if: always()
        run: docker stop app && docker rm app

What does this job do?

  • needs: container-scan ensures the DAST job only runs after the container image builds successfully. If the image fails to build, there is nothing to scan.
  • The "Build and start application" step builds a fresh image, runs it in detached mode on port 3000, and pauses 5 seconds for initial startup.
  • The "Wait for application to be ready" step polls the /health endpoint up to 30 times (60 seconds total). It exits successfully only when it sees the word healthy in the response.
  • zaproxy/action-baseline@v0.15.0 runs a passive baseline scan. Passive means it inspects real responses without launching active attacks, making it safe for CI.
  • fail_action: true tells ZAP to mark the workflow step as failed if it finds any alerts. This is what creates the security gate.
  • The final "Stop application" step runs with if: always() so the container is cleaned up even if ZAP fails.

💡 Why does DAST run last?

DAST requires the application to be running. It sends actual HTTP requests and looks for vulnerabilities that only manifest at runtime, like missing security headers or reflected inputs. It cannot run until the container is built and healthy.

By placing it after the container scan, you avoid wasting CI minutes building and starting the app if the image itself has critical CVEs.

  • Save .github/workflows/devsecops.yml.

YAML indentation errors?

YAML is sensitive to spaces. The dast: key must be indented at the same level as sast: and container-scan: (two spaces from the left margin, under the jobs: key). If your editor uses tabs instead of spaces, you will see parse errors in GitHub Actions.

help me fix YAML indentation in my GitHub Actions workflow

Understand the security gates

Your pipeline now has three security gates that can block a merge. Each gate uses a different mechanism to signal failure to GitHub.

  • Trivy uses exit-code: '1' to fail the workflow step whenever it finds CRITICAL or HIGH severity CVEs in your container image.
  • OWASP ZAP uses fail_action: true to fail the workflow step when it detects any security alerts during the baseline scan.
  • The needs: container-scan dependency means if Trivy fails, the DAST job is automatically skipped. A failed check blocks the entire pipeline.

How do security gates block a merge?

When a workflow job fails, GitHub shows a red X next to the check on your pull request. On its own, this is a warning. To enforce the gate, you enable branch protection rules in your repository settings.

Navigate to Settings > Branches > Add branch protection rule. Enable Require status checks to pass before merging and select your job names (SAST - Semgrep, Container Security - Trivy, DAST - OWASP ZAP). Now no one can merge until all three pass.

Test the pipeline with a pull request

The best way to see security gates in action is to create a pull request that introduces a new vulnerability. You will watch the pipeline catch it, then fix it and watch the pipeline pass.

  • First, push your DAST workflow changes to main so the pipeline definition is in place by running:
git add .
git commit -m "Add DAST scanning with OWASP ZAP"
git push origin main
  • Create a new feature branch by running:
git checkout -b feature/add-eval-endpoint
  • Open src/server.js and add the following vulnerable endpoint just above the app.listen line:
// VULNERABILITY: Unsafe eval (Semgrep will catch this)
app.post('/calculate', (req, res) => {
  const expression = req.body.expression;
  const result = eval(expression);
  res.json({ result: result });
});

Why eval is dangerous

The eval() function executes arbitrary JavaScript code. If an attacker controls the input, they can run any command on your server. Semgrep flags this as a critical code injection risk.

  • Save src/server.js.
  • Push the branch and open a pull request by running:
git add .
git commit -m "Add calculate endpoint"
git push origin feature/add-eval-endpoint
  • Open your repository on GitHub.
  • Click the Compare & pull request banner that appears at the top.
  • Click Create pull request.
  • Navigate to the Actions tab to watch the pipeline run.

You should see three jobs running: SAST, Container Security, and DAST. The pipeline will report findings from each scanner.

  • After the pipeline finishes, go back to the Pull requests tab and open your PR.
  • Scroll down to the status checks section at the bottom of the PR.

You will see red X marks next to the failed checks. If you enabled branch protection rules, the merge button is disabled.

  • Now fix the vulnerability. Switch back to your terminal and remove the /calculate endpoint you just added from src/server.js.
  • Push the fix by running:
git add .
git commit -m "Remove unsafe eval endpoint"
git push origin feature/add-eval-endpoint
  • Return to the Actions tab and watch the pipeline run again on your updated PR.

With the eval() vulnerability removed, the SAST job no longer flags that finding. You have just seen the full shift-left cycle: catch, block, fix, pass.

Check the Security tab

Navigate to the Security tab in your repository to see all SARIF findings from every scanner in one centralized dashboard. Each finding shows its severity, the file and line number, and which scanner detected it.

🙋‍♀️ Pipeline stuck or not triggering?

Make sure your branch was pushed and the PR targets main. The workflow only triggers on push and pull_request events to the main branch.

If the DAST job fails with a connection error, the container may not have started in time. Check the "Wait for application to be ready" step logs to see if the health check timed out.

help me debug why my GitHub Actions pipeline is not running on my pull request

✔️ Awesome, I've got everything!

Great. Double check you have saved .github/workflows/devsecops.yml with all three jobs: sast, container-scan, and dast.

ⓧ I'd like to double check the full code

name: DevSecOps Pipeline

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

permissions:
  contents: read
  security-events: write

jobs:
  sast:
    name: SAST - Semgrep
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Install Semgrep
        run: pip install semgrep

      - name: Run Semgrep SAST scan
        run: |
          semgrep scan \
            --config p/nodejs \
            --config p/javascript \
            --sarif \
            --output semgrep.sarif \
            src/
        continue-on-error: true

      - name: Upload SARIF to GitHub Security tab
        uses: github/codeql-action/upload-sarif@v4
        if: always()
        with:
          sarif_file: semgrep.sarif
          category: semgrep-sast

  container-scan:
    name: Container Security - Trivy
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Build Docker image
        run: docker build -t devsecops-app:${{ github.sha }} .

      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@v0.36.0
        with:
          image-ref: devsecops-app:${{ github.sha }}
          format: 'sarif'
          output: 'trivy-results.sarif'
          severity: 'CRITICAL,HIGH'
          exit-code: '1'

      - name: Upload Trivy SARIF to GitHub Security tab
        uses: github/codeql-action/upload-sarif@v4
        if: always()
        with:
          sarif_file: trivy-results.sarif
          category: trivy-container

  dast:
    name: DAST - OWASP ZAP
    runs-on: ubuntu-latest
    needs: container-scan
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Build and start application
        run: |
          docker build -t devsecops-app:test .
          docker run -d -p 3000:3000 --name app devsecops-app:test
          sleep 5

      - name: Wait for application to be ready
        run: |
          for i in $(seq 1 30); do
            if curl -s http://localhost:3000/health | grep -q healthy; then
              echo "Application is ready"
              exit 0
            fi
            sleep 2
          done
          echo "Application failed to start"
          exit 1

      - name: Run OWASP ZAP Baseline Scan
        uses: zaproxy/action-baseline@v0.15.0
        with:
          target: 'http://localhost:3000'
          fail_action: true
          artifact_name: zap-report

      - name: Stop application
        if: always()
        run: docker stop app && docker rm app

Your DevSecOps pipeline is complete with three layers of automated security scanning: code-level SAST, container image scanning, and runtime DAST. Next up, you will add multi-environment deployment so that code only reaches production after passing every gate and surviving a staging smoke test.

Deploy to Staging and Production

Your pipeline catches vulnerabilities before they reach your main branch. But catching issues is only half the story. A real DevSecOps pipeline also controls how and when code reaches your users.

In this step, you will add multi-environment deployment to your pipeline. Code that passes all security gates deploys automatically to staging for validation. Production deployment requires a manual trigger, giving you an explicit approval gate before anything reaches real users.

In this step, get ready to:
  • Add a staging deployment job that runs after all security gates pass.
  • Create a separate production deployment workflow with a manual trigger.
  • Test the full flow from security scan to staging to production.
Add the staging deployment job

Staging is where you verify that your application works correctly before promoting it to production. Your new job simulates deploying to an ECS Fargate cluster by building the image, running a health check, and logging the deployment.

  • In VS Code, open .github/workflows/devsecops.yml.
  • Add the following deploy-staging job at the bottom of the file, after the dast job:
  deploy-staging:
    name: Deploy to Staging
    runs-on: ubuntu-latest
    needs: [sast, container-scan, dast]
    if: success()
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Build Docker image
        run: |
          docker build -t devsecops-app:${{ github.sha }} .
          echo "Image built: devsecops-app:${{ github.sha }}"

      - name: Simulate ECR push
        run: |
          echo "Pushing image to ECR..."
          echo "Image URI: 123456789012.dkr.ecr.us-east-1.amazonaws.com/devsecops-app:${{ github.sha }}"
          echo "Push complete."

      - name: Simulate ECS staging deployment
        run: |
          echo "Updating ECS task definition..."
          echo "  Cluster: devsecops-staging"
          echo "  Service: devsecops-app-staging"
          echo "  Image: devsecops-app:${{ github.sha }}"
          echo "  CPU: 256 | Memory: 512"
          echo "Waiting for service stability..."
          echo "Deployment to staging complete."

      - name: Run staging smoke test
        run: |
          # Start the container locally to verify it works
          docker run -d -p 3000:3000 --name staging-app devsecops-app:${{ github.sha }}
          sleep 5
          # Health check
          if curl -s http://localhost:3000/health | grep -q healthy; then
            echo "Staging smoke test PASSED"
          else
            echo "Staging smoke test FAILED"
            exit 1
          fi
          docker stop staging-app && docker rm staging-app

      - name: Record deployment
        run: |
          echo "STAGING_IMAGE=devsecops-app:${{ github.sha }}" >> $GITHUB_ENV
          echo "Staging deployment recorded at $(date -u +%Y-%m-%dT%H:%M:%SZ)"

What does this job do?

  • needs: [sast, container-scan, dast] with if: success() means this job only runs when ALL three security gates pass. If any scanner fails, staging is skipped.
  • The "Simulate ECR push" and "Simulate ECS staging deployment" steps log what a real deployment would do. In production pipelines, these would use aws-actions/amazon-ecr-login and aws-actions/amazon-ecs-deploy-task-definition.
  • The smoke test starts the container and hits the /health endpoint. If the health check fails, the staging deployment is marked as failed and production cannot proceed.

💡 Why simulate instead of deploying to real ECS?

AWS ECS Fargate is pay-per-use with no free tier. Simulating the deployment teaches the same pipeline patterns (multi-stage, approval gates, environment promotion) without incurring cloud costs. The echo commands show exactly which AWS API calls a real pipeline would make.

  • Save .github/workflows/devsecops.yml.

Staging job not running?

The if: success() condition requires ALL jobs listed in needs to pass. If any security gate fails, the staging job is intentionally skipped. Check that your SAST, container scan, and DAST jobs all pass before expecting staging to run.

help me debug why my staging deployment job is being skipped in GitHub Actions

Create the production deployment workflow

Production deployment should never be automatic. You need an explicit human decision before code reaches real users. Instead of relying on environment protection rules (which require a paid GitHub plan for private repos), you will use a separate workflow_dispatch workflow. The manual trigger IS your approval gate.

  • Create a new file at .github/workflows/deploy-production.yml by running:
touch .github/workflows/deploy-production.yml
  • Open .github/workflows/deploy-production.yml and paste the following workflow:
name: Deploy to Production

# Manual trigger only - this is the approval gate
on:
  workflow_dispatch:
    inputs:
      image_tag:
        description: 'Image tag to deploy (use the commit SHA from a successful staging run)'
        required: true
        type: string
      confirm_deployment:
        description: 'Type DEPLOY to confirm production deployment'
        required: true
        type: string

jobs:
  validate:
    name: Validate Deployment Request
    runs-on: ubuntu-latest
    steps:
      - name: Check confirmation
        run: |
          if [ "${{ github.event.inputs.confirm_deployment }}" != "DEPLOY" ]; then
            echo "Deployment not confirmed. You must type DEPLOY to proceed."
            exit 1
          fi
          echo "Deployment confirmed."

      - name: Verify image tag format
        run: |
          if [ -z "${{ github.event.inputs.image_tag }}" ]; then
            echo "Image tag is required."
            exit 1
          fi
          echo "Deploying image: ${{ github.event.inputs.image_tag }}"

  deploy-production:
    name: Deploy to Production
    runs-on: ubuntu-latest
    needs: validate
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Build Docker image
        run: |
          docker build -t devsecops-app:${{ github.event.inputs.image_tag }} .
          echo "Image built for production deployment."

      - name: Simulate ECS production deployment
        run: |
          echo "=== PRODUCTION DEPLOYMENT ==="
          echo "Updating ECS task definition..."
          echo "  Cluster: devsecops-production"
          echo "  Service: devsecops-app-production"
          echo "  Image: devsecops-app:${{ github.event.inputs.image_tag }}"
          echo "  CPU: 512 | Memory: 1024"
          echo "  Desired count: 2"
          echo "Waiting for service stability..."
          echo "Production deployment complete."

      - name: Run production health check
        run: |
          docker run -d -p 3000:3000 --name prod-app devsecops-app:${{ github.event.inputs.image_tag }}
          sleep 5
          if curl -s http://localhost:3000/health | grep -q healthy; then
            echo "Production health check PASSED"
          else
            echo "Production health check FAILED - ROLLBACK NEEDED"
            exit 1
          fi
          docker stop prod-app && docker rm prod-app

      - name: Record production deployment
        run: |
          echo "Production deployment successful"
          echo "  Deployed by: ${{ github.actor }}"
          echo "  Image: ${{ github.event.inputs.image_tag }}"
          echo "  Timestamp: $(date -u +%Y-%m-%dT%H:%M:%SZ)"

What does this workflow do?

  • workflow_dispatch means this workflow ONLY runs when someone manually clicks "Run workflow" in the Actions tab. It never triggers on push or pull request.
  • The confirm_deployment input requires typing DEPLOY to proceed. This is a deliberate friction point that prevents accidental production deployments.
  • The validate job gates the entire deployment. If validation fails, the deploy job is skipped.
  • The production configuration uses higher resources (512 CPU, 1024 memory) and a desired count of 2 for redundancy. In a real ECS deployment, these values go in your task definition JSON.

💡 Why workflow_dispatch instead of environment protection rules?

GitHub's environment protection rules (required reviewers, wait timers) require a public repository or a paid plan. Using workflow_dispatch with a confirmation input gives you an equivalent approval gate that works on free private repositories.

In a team setting, you would combine this with branch protection rules so that only certain team members can trigger the production workflow.

  • Save .github/workflows/deploy-production.yml.

Don't see the Run workflow button?

The workflow_dispatch trigger only appears in the Actions tab after the workflow file has been pushed to the default branch (main). If you created the file on a feature branch, it will not show up until you merge to main.

help me find the Run workflow button for my workflow_dispatch workflow

Test the full staging-to-production flow

Now you will push both workflow changes, watch staging deploy automatically, and then manually promote to production.

  • Push both workflow files to main by running:
git add .
git commit -m "Add staging and production deployment workflows"
git push origin main
  • Open the Actions tab in your GitHub repository.
  • Click on the running DevSecOps Pipeline workflow.
  • Watch all three security jobs run, then confirm the Deploy to Staging job starts automatically after they pass.

You should see the staging job build the image, run the smoke test, and log the simulated ECS deployment details.

  • Copy the commit SHA from the staging deployment logs (or from the workflow run URL).
  • In the Actions tab, find Deploy to Production in the left sidebar.
  • Click Run workflow.
  • Paste the commit SHA into the Image tag field.
  • Type DEPLOY in the confirmation field.
  • Click the green Run workflow button.

The production workflow validates your confirmation, builds the image, simulates the ECS deployment, and runs a health check. You have just walked through the full promotion path: security gates, automatic staging, manual production approval.

Try a failed confirmation

Run the production workflow again but type something other than DEPLOY in the confirmation field. You will see the validate job fail and the deploy job get skipped. This proves the approval gate works.

✔️ Awesome, I've got everything!

Great. Double check you have saved both .github/workflows/devsecops.yml (with the deploy-staging job) and .github/workflows/deploy-production.yml.

ⓧ I'd like to double check the full code

Here is the complete deploy-production.yml file:

name: Deploy to Production

on:
  workflow_dispatch:
    inputs:
      image_tag:
        description: 'Image tag to deploy (use the commit SHA from a successful staging run)'
        required: true
        type: string
      confirm_deployment:
        description: 'Type DEPLOY to confirm production deployment'
        required: true
        type: string

jobs:
  validate:
    name: Validate Deployment Request
    runs-on: ubuntu-latest
    steps:
      - name: Check confirmation
        run: |
          if [ "${{ github.event.inputs.confirm_deployment }}" != "DEPLOY" ]; then
            echo "Deployment not confirmed. You must type DEPLOY to proceed."
            exit 1
          fi
          echo "Deployment confirmed."

      - name: Verify image tag format
        run: |
          if [ -z "${{ github.event.inputs.image_tag }}" ]; then
            echo "Image tag is required."
            exit 1
          fi
          echo "Deploying image: ${{ github.event.inputs.image_tag }}"

  deploy-production:
    name: Deploy to Production
    runs-on: ubuntu-latest
    needs: validate
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Build Docker image
        run: |
          docker build -t devsecops-app:${{ github.event.inputs.image_tag }} .
          echo "Image built for production deployment."

      - name: Simulate ECS production deployment
        run: |
          echo "=== PRODUCTION DEPLOYMENT ==="
          echo "Updating ECS task definition..."
          echo "  Cluster: devsecops-production"
          echo "  Service: devsecops-app-production"
          echo "  Image: devsecops-app:${{ github.event.inputs.image_tag }}"
          echo "  CPU: 512 | Memory: 1024"
          echo "  Desired count: 2"
          echo "Waiting for service stability..."
          echo "Production deployment complete."

      - name: Run production health check
        run: |
          docker run -d -p 3000:3000 --name prod-app devsecops-app:${{ github.event.inputs.image_tag }}
          sleep 5
          if curl -s http://localhost:3000/health | grep -q healthy; then
            echo "Production health check PASSED"
          else
            echo "Production health check FAILED - ROLLBACK NEEDED"
            exit 1
          fi
          docker stop prod-app && docker rm prod-app

      - name: Record production deployment
        run: |
          echo "Production deployment successful"
          echo "  Deployed by: ${{ github.actor }}"
          echo "  Image: ${{ github.event.inputs.image_tag }}"
          echo "  Timestamp: $(date -u +%Y-%m-%dT%H:%M:%SZ)"

Your pipeline now covers the full DevSecOps lifecycle: scan, gate, deploy to staging, approve, deploy to production. Next up, the Secret Mission takes this further with keyless cloud authentication and AI-powered vulnerability explanations.

Secret mission

Add OIDC Auth and AI-Powered Vulnerability Explanations

Your pipeline works, but it still relies on static AWS access keys if you ever want to push reports to the cloud. In this secret mission, you will eliminate static credentials entirely using OIDC federation and add an AI-powered step that translates raw security findings into plain-English explanations with recommended fixes.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs on GitHub Actions, which is free for public repositories. If you completed the Secret Mission, you also have AWS resources that are free-tier eligible but should be cleaned up if you are done.

Resources you used:

  • GitHub repository (free, no ongoing cost).
  • GitHub Actions workflow runs (free for public repos, counts against 2,000 min/month for private repos).
  • Local project files on your Desktop.
  • AWS IAM OIDC provider and IAM role (Secret Mission only, no cost).
  • S3 bucket for audit reports (Secret Mission only, negligible cost under free tier).

Keep everything running

No action needed. Choose this if you are still actively building or want to keep the pipeline triggering on every push.

  • Your pipeline runs automatically on every push and pull request. Public repos have unlimited free GitHub Actions minutes.
  • If you completed the Secret Mission, your IAM role and S3 bucket remain in your AWS account. The S3 bucket stores small markdown files and stays well within the free tier.

Pause - I'll come back to this later

Disable the workflow so it stops triggering on every push. Your code and configuration stay intact for when you return.

  • Open your repository on GitHub.
  • Click the Actions tab.
  • In the left sidebar, click DevSecOps Pipeline.
  • Click the ... dropdown menu, then click Disable workflow.
  • To re-enable later, return to the same page and click Enable workflow.

Delete - I don't want to use this again

Remove all project resources completely. This cannot be undone.

Delete the GitHub repository:

  • Open your repository on GitHub.
  • Click Settings.
  • Scroll to the bottom of the General page to the Danger Zone section.
  • Click Delete this repository and follow the confirmation prompts.

Delete your local project files:

  • Remove the project folder from your Desktop by running this command:
cd ~/Desktop
rm -rf devsecops-pipeline-demo

If you completed the Secret Mission, delete the AWS resources:

  • Delete the S3 bucket and all its contents by running:
aws s3 rb s3://[[S3_BUCKET="your-bucket-name"]] --force
  • Delete the IAM role. You must detach any policies first, then delete the role:
aws iam detach-role-policy --role-name [[IAM_ROLE_NAME="your-role-name"]] --policy-arn [[POLICY_ARN="arn:aws:iam::YOUR_ACCOUNT_ID:policy/your-policy-name"]]
aws iam delete-role --role-name [[IAM_ROLE_NAME="your-role-name"]]
  • Delete the OIDC identity provider:
aws iam delete-open-id-connect-provider --open-id-connect-provider-arn [[OIDC_PROVIDER_ARN="arn:aws:iam::YOUR_ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"]]

How do I find these ARNs?

If you did not save the ARNs from the Secret Mission, you can list them. Run aws iam list-open-id-connect-providers to find the OIDC provider ARN. Run aws iam list-roles and look for the role you created for GitHub Actions.

Nice Work!

Nice Work!

Nice work! You just built a production-ready DevSecOps pipeline that catches security vulnerabilities at three layers before they ever reach production.

You've learned how to:

  • Build a complete GitHub Actions DevSecOps pipeline with three layers of automated security scanning: SAST (Semgrep), container scanning (Trivy), and DAST (OWASP ZAP).
  • Create a hardened multi-stage Dockerfile with a non-root user, minimal Alpine base image, and health checks following container security best practices.
  • Configure security gates that block pull requests on HIGH/CRITICAL findings and upload all results to GitHub's Security tab via SARIF for centralized vulnerability tracking.
  • Secret Mission: Configured keyless OIDC authentication to AWS and AI-powered vulnerability explanations that translate scanner output into plain-English reports.

Ready to quiz yourself?