TL;DR — Key Takeaways

  • Add four automated security gates to every push and pull request.
  • Use npm audit and Snyk to detect vulnerable Node.js dependencies before release.
  • Scan containers with Trivy and analyse application code with CodeQL.
  • Run OWASP ZAP against the live application after the first three checks pass.
  • Document false positives with owners, reasons and expiry dates rather than disabling security controls.
  • The tools are free or have free options, making stronger CI/CD security accessible without an enterprise licence.

A security researcher filed a report against our Node.js API two years ago. The vulnerability was prototype pollution in an npm package we had been shipping for eight months. The fix took about 20 minutes. The internal review to figure out how it got through took two days.

The package had a known CVE. It had been sitting in the NVD for months before we found it. Our CI never checked. Code review didn’t catch it. The researcher did.

There’s a stat from IBM’s Cost of a Data Breach 2024 report that I keep referencing when this comes up: The average breach costs $4.88 million. Teams running DevSecOps practices save $1.68 million from that cost on average. The number I actually find more useful is smaller. A vulnerability caught in CI costs about $80 to fix. Caught in production, the same costs $7,600.

That gap is why this article exists. Four automated security gates wired into GitHub Actions, running on every push and pull request — none of them require an enterprise license:

  • Gate 1: Dependency scanning — finds CVEs in your npm packages before they ship.
  • Gate 2: Container image scanning — finds CVEs in your Docker base image OS packages.
  • Gate 3: Static analysis (SAST) — finds code-level vulnerabilities such as SQL injection and hardcoded secrets.
  • Gate 4: Dynamic scanning (DAST) — finds runtime vulnerabilities in a running instance of your app.

Wired together, the pipeline looks like this:

Pull Request / Push to main

┌──────────────────────────────────────────────────────┐

│ GitHub Actions Pipeline │

│ │

│ Gate 1: Dependency Scan Gate 2: Image Scan │

│ (npm audit + Snyk) (Trivy) │

│ Finds: vulnerable packages Finds: OS + pkg CVEs│

│ ❌ CRITICAL → fail build ❌ CRITICAL → fail │

│ │

│ Gate 3: Static Analysis Gate 4: DAST │

│ (CodeQL) (OWASP ZAP Baseline)│

│ Finds: SQL injection, XSS, Finds: runtime vulns│

│ hardcoded secrets in running app │

│ ❌ High severity → fail ❌ Alerts → report │

│ │

│ ✅ All gates pass → merge allowed / deploy │

└──────────────────────────────────────────────────────┘

Gates 1 through 3 run in parallel on every push. Gate 4 only starts after all three pass. It boots the app and probes it live.

Prerequisites

Docker needs to be installed locally before you start. Most of the local testing this article highlights relies on it. The Snyk step needs a free account at snyk.io, but npm audit alone covers the basics if you’d rather not sign up. One other thing upfront: CodeQL runs free on public repositories; no paid GitHub plan is needed.

The sample app used throughout this article is an Express API with a Dockerfile and docker-compose.yml. The full code is on GitHub at github-actions-security-gates.

Gate 1: Dependency Scanning With Npm Audit and Snyk

Recently, I ran npm install on a fresh Express app. It pulled in 847 packages. I explicitly chose maybe 12 of them. The rest came along for the ride. Each one was a potential CVE.

Both tools scan the same dependency tree but do different things with what they find. npm audit catches what Node knows about and reports severity. Snyk goes further. The last time I ran it on a project, it surfaced issues npm audit missed, and gave me the exact upgrade version for each one, flagging which jumps would break the API. That detail cuts debug time significantly.

Drop this in .github/workflows/gate-1-dependency-scan.yml:

name: Gate 1 — Dependency Scan

on:

push:

branches: [main]

pull_request:

branches: [main]

jobs:

dependency-scan:

runs-on: ubuntu-latest

steps:

- name: Checkout code

uses: actions/checkout@v4

- name: Set up Node.js

uses: actions/setup-node@v4

with:

node-version: '20'

cache: 'npm'

- name: Install dependencies

run: npm ci

- name: npm audit

run: npm audit --audit-level=high

- name: Snyk dependency scan

uses: snyk/actions/[email protected]

continue-on-error: false

env:

SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}

with:

args: --severity-threshold=high

The --audit-level=high flag in npm audit means the job only fails on high and critical vulnerabilities, not on every minor advisory. Without this flag, most real codebases will fail immediately on low-severity issues that have no fix and no meaningful risk.

Snyk adds upgrade paths. When something gets flagged, the report shows which version resolves it and whether the jump is safe. I’ve had engineers close a failing PR in five minutes with that information. Without it, they’re opening changelogs and guessing.

Getting SNYK_TOKEN Into GitHub

Sign up at snyk.io, then go to your personal account settings at https://app.snyk.io/account. The Auth Token is there — not on the Organization Settings page, which only has the org API key. Copy it, then add it to your repo at Settings → Secrets and variables → Actions → New repository secret. Name it SNYK_TOKEN. That’s the exact name the workflow expects.

Handling False Positives With a `.snyk` Ignore File

Do not turn off a gate because one advisory is a false positive. Document the exception instead:

# .snyk

version: v1.25.0

ignore:

SNYK-JS-INFLIGHT-6095116:

- '*':

reason: No upstream fix available. Package used only in dev dependencies, not shipped to production.

expires: '2027-01-01T00:00:00.000Z'

created: '2026-07-01T00:00:00.000Z'

Three fields matter: reason explains the decision, expires forces a review date and created creates an audit trail. If a SOC 2 auditor asks why you suppressed a CVE, this file is your answer.

Gate 2: Container Image Scanning With Trivy

Dependency scanning only looks at your npm packages. Your Docker base image carries its own OS-level packages: glibc, curl, openssl. Those have CVEs too.

Trivy scans the entire container image: OS packages, language packages and configuration issues. It runs in CI before the image ever touches a registry.

Create .github/workflows/gate-2-image-scan.yml:

name: Gate 2 — Container Image Scan

on:

push:

branches: [main]

pull_request:

branches: [main]

jobs:

image-scan:

runs-on: ubuntu-latest

steps:

- name: Checkout code

uses: actions/checkout@v4

- name: Build Docker image

run: docker build -t myapp:${{ github.sha }} .

- name: Scan image with Trivy

uses: aquasecurity/[email protected]

with:

image-ref: myapp:${{ github.sha }}

format: table

exit-code: '1'

severity: 'CRITICAL,HIGH'

ignore-unfixed: true

Two decisions here are worth explaining.

First, ignore-unfixed: true. Some CVEs have no patch available. If you leave this out, your build fails on vulnerabilities that have no fix. It blocks your team with no action they can take. This flag filters those out. You still see them in the report; they just don’t block the build.

Second, scan before pushing to the registry. A lot of pipelines build the image, push it, then scan. That means a vulnerable image is sitting in your registry for however long the scan takes. Build it, scan it, push only if clean.

Suppressing Known False Positives With `.trivyignore`

# .trivyignore

# CVE-2023-44487 HTTP/2 Rapid Reset - handled at load balancer (nginx rate limits + conn caps)

# owner: [email protected] | next review: 2027-01-01

CVE-2023-44487

Same principle as the Snyk ignore file: Document the reason, add a review date.

Gate 3: Static Analysis With CodeQL

Dependency scanning and image scanning look at what you install. CodeQL looks at what you write.

It traces data flows through your code. SQL injection, where input from a route param reaches a database call without sanitization; XSS, where a user-controlled string ends up in an HTML response; hardcoded secrets buried in config files; path traversal — these aren’t hypothetical patterns; they’re the vulnerability classes in most breach reports.

Create .github/workflows/gate-3-codeql.yml:

name: Gate 3 — Static Analysis (CodeQL)

on:

push:

branches: [main]

pull_request:

branches: [main]

schedule:

- cron: '0 6 * * 1' # weekly scan on Monday at 6am

jobs:

static-analysis:

runs-on: ubuntu-latest

permissions:

actions: read

contents: read

security-events: write

steps:

- name: Checkout code

uses: actions/checkout@v4

- name: Initialize CodeQL

uses: github/codeql-action/init@v3

with:

languages: javascript, typescript

queries: security-extended

- name: Autobuild

uses: github/codeql-action/autobuild@v3

- name: Perform CodeQL Analysis

uses: github/codeql-action/analyze@v3

with:

category: "/language:javascript"

The queries: security-extended line matters. The default query set catches the most obvious issues. The extended set adds more SQL injection patterns, SSRF detection and stricter taint tracking. For a production application, use extended.

The security-events: write permission lets CodeQL post results to GitHub’s Security tab. You’ll see findings there even when the build passes.

CodeQL is free for public repositories. For private repositories, it requires GitHub Advanced Security. If you’re on a private repo without GHAS, Semgrep OSS is a free alternative:

- name: Semgrep scan

uses: returntocorp/semgrep-action@v1

with:

config: p/owasp-top-ten

Gate 4: DAST With OWASP ZAP

The first three gates analyze artifacts: Package manifests, container layers, source code. Gate 4 does something different. It actually starts your application and attacks it.

Gate 4 was the one I was most curious about when I first set this up. It actually boots the app and fires real HTTP requests at it. What comes back tells you things the source code never could. A 500 where the app should return a 400. Missing headers on endpoints that aren’t configured. Open redirects. None of that shows up in a static scan.

The OWASP ZAP baseline scan is fast enough for CI. It runs passive checks. No brute-forcing, no fuzzing. It observes and probes. Most pipelines complete it in under three minutes.

Create .github/workflows/gate-4-dast.yml:

name: Gate 4 — DAST (OWASP ZAP)

on:

push:

branches: [main]

pull_request:

branches: [main]

jobs:

dast:

runs-on: ubuntu-latest

steps:

- name: Checkout code

uses: actions/checkout@v4

- name: Start application

run: |

docker compose up -d

sleep 15

- name: Wait for app to be ready

run: |

for i in {1..10}; do

curl -s http://localhost:3000/health && break

echo "Waiting for app..."

sleep 3

done

- name: ZAP Baseline Scan

uses: zaproxy/[email protected]

with:

target: 'http://localhost:3000'

rules_file_name: '.zap/rules.tsv'

fail_action: warn

- name: Stop application

if: always()

run: docker compose down

fail_action: warn is the right default for DAST. ZAP has a higher false-positive rate than the other gates. On the first run against a JSON API, you’ll see alerts for missing CSP headers, missing HSTS and permissions policy. Most of those are intentional. Setting it to warn keeps the findings visible in the job output without blocking the build. Review the ZAP report on a schedule; don’t let warnings pile up unreviewed.

Cutting ZAP Noise With `.zap/rules.tsv`

10021IGNORE(Strict-Transport-Security not set on HTTP - intentional on local non-TLS endpoint)

10038IGNORE(Content Security Policy not set - handled at reverse proxy level)

The rule IDs are in the ZAP docs at zaproxy.org/docs/alerts. I kept that page open during the first few scans. 10038 is the CSP one. 10035 is HSTS. After looking them up a couple of times, you stop needing to.

Running All Four Gates in One Workflow

Four separate workflow files work fine for learning, but in practice, I merge them into one. The first three jobs run in parallel. Gate 4 holds until all three finish: No point scanning a live app if the code already has a known SQL injection in it.

Create .github/workflows/security-gates.yml:

name: Security Gates

on:

push:

branches: [main]

pull_request:

branches: [main]

jobs:

dependency-scan:

runs-on: ubuntu-latest

steps:

- uses: actions/checkout@v4

- uses: actions/setup-node@v4

with:

node-version: '20'

cache: 'npm'

- run: npm ci

- run: npm audit --audit-level=high

- uses: snyk/actions/[email protected]

continue-on-error: false

env:

SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}

with:

args: --severity-threshold=high

image-scan:

runs-on: ubuntu-latest

steps:

- uses: actions/checkout@v4

- run: docker build -t myapp:${{ github.sha }} .

- uses: aquasecurity/[email protected]

with:

image-ref: myapp:${{ github.sha }}

format: table

exit-code: '1'

severity: 'CRITICAL,HIGH'

ignore-unfixed: true

static-analysis:

runs-on: ubuntu-latest

permissions:

actions: read

contents: read

security-events: write

steps:

- uses: actions/checkout@v4

- uses: github/codeql-action/init@v3

with:

languages: javascript, typescript

queries: security-extended

- uses: github/codeql-action/autobuild@v3

- uses: github/codeql-action/analyze@v3

with:

category: "/language:javascript"

dast:

runs-on: ubuntu-latest

needs: [dependency-scan, image-scan, static-analysis]

steps:

- uses: actions/checkout@v4

- name: Start application

run: |

docker compose up -d

sleep 15

- name: Wait for health check

run: |

for i in {1..10}; do

curl -s http://localhost:3000/health && break

sleep 3

done

- uses: zaproxy/[email protected]

with:

target: 'http://localhost:3000'

rules_file_name: '.zap/rules.tsv'

fail_action: warn

- name: Stop application

if: always()

run: docker compose down

The needs: [dependency-scan, image-scan, static-analysis] line is the key. Gate 4 only starts if the first three complete without error.

Handling False Positives Without Turning Off Gates

Every team that installs security tooling eventually hits a false positive and considers disabling the rule. I’ve seen it happen within the first week on most teams. The right response is to suppress it with documentation, not remove it.

Each tool has its own mechanism.

Snyk uses .snyk with expiry dates. A suppression that never expires is forgotten. Set an expiry. When the date passes, the build fails again and forces a review.

Trivy uses .trivyignore with CVE IDs and comments. One CVE per line, one comment explaining why.

ZAP uses .zap/rules.tsv with rule IDs. The ZAP documentation lists every rule ID and what it detects. Rule 10038 is CSP. Rule 10035 is HSTS. Once you know the IDs, suppression is a one-liner.

CodeQL suppressions go in the code itself:

// codeql[js/sql-injection]

const result = await db.query(query);

The codeql comment tells CodeQL to ignore the finding on that line. Use it rarely, document why.

If a SOC 2 auditor asks about your suppression decisions, these files are your evidence. They show that you evaluated each finding, made a documented decision and set a review date. That’s more defensible than having no suppressions and no findings because the tool is turned off.

Troubleshooting

These are real issues that surfaced while building and testing this pipeline, not edge cases. Each one will stop your build until you know the fix.

ZAP Docker Image Not Found: `owasp/zap2docker-stable`

This image was deprecated and removed from Docker Hub. Any article or Stack Overflow post referencing it is out of date. The correct image is:

ghcr.io/zaproxy/zaproxy:stable

Update your local test command and any workflow files that reference the old image name. The GitHub Container Registry image is maintained by the ZAP project team and receives regular updates.

Healthcheck Shows `(unhealthy)` — Curl: Command Not Found

node:20-slim is a minimal Debian image. It does not include curl. A healthcheck that uses curl will silently fail and mark the container as unhealthy, which blocks ZAP from scanning a ready application.

Switch the healthcheck to use Node’s built-in http module instead:

healthcheck:

test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode === 200 ? 0 : 1))"]

interval: 10s

timeout: 5s

retries: 3

start_period: 10s

Node’s http module ships with every Node image. No extra packages, no install step. Works on alpine, slim and full Debian variants.

Docker Compose Fails With “port is Already Allocated”

This one caught me the first time I ran the local setup. I had npm start running in another terminal, so docker compose couldn’t bind port 3000. It also happens when a container from a previous run is still sitting there — docker ps will show it.

Before running docker compose, stop anything using the port:

npx kill-port 3000

docker ps -a # check for running containers from other projects

docker stop

If you see docker compose up succeed but the container exits immediately, the port conflict is the first thing to check.

ZAP Scan Fails With “Connection Refused” or Downloads With EOF Error

Two separate issues that look the same.

I hit this on the first local test. The container looked like it started fine, but ZAP couldn’t reach it. docker ps still showed (starting). The healthcheck needs to pass before ZAP can connect. If it’s stuck there, add more time to start_period in docker-compose.yml and try again.

The EOF error during the ZAP image download means the pull got cut off partway. Pull it separately first:

docker pull ghcr.io/zaproxy/zaproxy:stable

Then re-run your scan command. The cached layers will be used on the next attempt.

Snyk Returns 401 Unauthorized in GitHub Actions

It shows up as SNYK-0005 in the workflow logs. Usually, one of two things caused it.

First, the token location. Snyk has two types of keys: An Organization API key and a personal Auth Token. The GitHub Actions integration requires the personal Auth Token, not the org key. Get it from https://app.snyk.io/account under “Auth Token” — not from the Organization Settings page.

Second, the secret scope. The secret must be added to the specific repository where the workflow runs. Organization-level secrets are not automatically shared with all repositories. Go to the repository, then Settings → Secrets and variables → Actions → New repository secret. Name it SNYK_TOKEN.

Trivy Reports CVEs That Aren’t in Your Code

The first time I ran Trivy against this project, it flagged 18 findings. My application code had zero issues. Everything else came from two places: The Debian base image packages and npm’s own bundled toolchain. Trivy scans the whole container, not just what you wrote.

Debian packages such as libgnutls30 and libcap2 come bundled with node:20-slim. In our case, most of those CVEs were DTLS vulnerabilities in a library our HTTP-only API never touches. They flag, but there’s no exploitable path.

The npm ones took me a minute to understand. The path gave it away — /usr/local/lib/node_modules/npm/node_modules/. That’s not your app. That’s npm’s own private dependencies: tar, minimatch, cross-spawn. That directory is npm eating its own dog food — packages npm uses internally when it installs things. Once the build is done and the container starts, nothing calls that code again.

Handle both with .trivyignore. Document each suppression:

# .trivyignore

# libgnutls30 — DTLS vulnerability, not exploitable in this HTTP-only API

# Fixed version available in Debian but not yet in node:20-slim

# Approved: [email protected] | Review: 2027-01-01

CVE-2026-33845

# tar (npm internal) — npm's own toolchain, not in runtime dependencies

# Only used during npm install, not exposed in the running container

CVE-2026-23745

The pattern is the same as .snyk: Reason, owner, review date.

Node_modules Committed to Git — Missing .gitignore

If you initialize a repository and run npm install before creating a .gitignore, Git will track node_modules and .env. A push with node_modules sends thousands of files to GitHub and can expose a committed .env file.

Add this before you push anything:

node_modules/

.env

*.log

If you pushed before adding .gitignore, node_modules is already in Git’s history. You need to untrack it without deleting it from the disk:

git rm -r --cached node_modules

git rm --cached .env

git commit -m "Remove tracked files that should be ignored"

CodeQL Fails With “No Source Code Was Seen During the Build”

I hit this on a project where the build script wasn’t the default. CodeQL’s autobuild guesses your build command — it gets it right most of the time, but not always. When it misses, swap it out for an explicit run step:

- name: Manual build

run: npm ci && npm run build

ZAP Scan Fails Because the Application Won’t Start

The sleep timer in the workflow might not be long enough for your app to initialize. Increase sleep 15 to sleep 30 or use the healthcheck loop shown in the full workflow above. If the app needs environment variables to start, pass them with a .env.ci file in the repository (with non-production values).

Wrapping Up

Four gates; none requiring an enterprise license — each one catches a different class of vulnerability at the point where it’s cheapest to fix.

The math isn’t complicated. A critical CVE found in CI takes minutes to fix. The same CVE found after a breach starts the clock on $4.88 million in average costs. The tooling here is free. The GitHub Actions minutes are cheap. The only thing the pipeline costs is the time required to set it up. Once.

One thing teams usually don’t anticipate: False-positive management is ongoing work. You will suppress some findings. You will review them when the expiry dates pass. That’s the point. Security tooling that runs without maintenance is security tooling that nobody trusts.

The complete working example — including the Node.js app, Dockerfile, docker compose and all four workflow files — is available at github-actions-security-gates.

Frequently Asked Questions

What vulnerabilities can CodeQL detect?

CodeQL examines application code and data flows to find issues such as SQL injection, cross-site scripting, hardcoded secrets and path traversal.

How should teams handle false positives?

Teams should document individual exceptions rather than disabling an entire security gate. Suppressions should include a reason, an owner and a review or expiry date.

Why should vulnerabilities be caught in CI?

The article states that a vulnerability caught in CI costs about $80 to fix, compared with approximately $7,600 when the same issue reaches production.

Share.
Leave A Reply