Security is no longer a task for the last stage of a project — it’s part of the pipeline itself.
In modern DevOps, we call this “shift-left security”: catching vulnerabilities and secrets as early as possible.
In this guide, I’ll show you how to integrate Static Application Security Testing (SAST) directly in GitLab CI using open-source tools that work perfectly on GitLab Free.
We’ll combine:
- Trivy – for vulnerability, license, and misconfiguration scanning
- Gitleaks – for secret detection
- An optional custom Docker image with Trivy preinstalled for faster caching
By the end, you’ll have a plug-and-play DevSecOps pipeline any engineer can reuse.
This post summarizes how I implemented static analysis in a GitLab Free CI/CD pipeline using open-source tools only.
The goal: integrate DevSecOps checks directly into CI without paid features or external services.
2. Why Static Analysis Matters in DevSecOps
Static analysis helps identify vulnerabilities, dependency issues, and hardcoded secrets before they reach production.
Benefits:
- Catch vulnerabilities earlier (no late firefighting)
- Prevent credential leaks in repositories
- Build a repeatable security baseline, even with free tooling
Open-source tools like Trivy and Gitleaks make this possible for every team — no paid GitLab tiers required.
3. Architecture Overview

Here’s a simplified view of the DevSecOps pipeline we’ll build:
build → test → security → deploy
| Stage | Tool | Description |
|---|---|---|
| Security | Trivy | Scans code, dependencies, containers, and IaC for vulnerabilities |
| Security | Gitleaks | Detects secrets and credentials in codebase |
Each tool runs independently in the security stage and generates JSON reports stored as artifacts.
4. Prerequisites
Before you start:
- A GitLab Free account
- A GitLab Runner (Docker or Shell executor)
- A project with a
.gitlab-ci.ymlfile - Optional: jq to parse JSON results
5. Step-by-Step Implementation

5.1. Add the Security Stage
stages:
- build
- test
- security
- deploy
5.2. Trivy Filesystem Scan (Dependencies & Licenses)
This scans your repository for known CVEs and license issues.
dependency-scanning-trivy-fs:
image: your-registry/custom-trivy-ci:latest
stage: security
variables:
TRIVY_CACHE_DIR: "/trivy-cache"
TRIVY_TIMEOUT: "5m"
TRIVY_SCANNERS: "vuln,license"
TRIVY_SEVERITY: "CRITICAL,HIGH,MEDIUM,LOW,UNKNOWN"
cache:
key: "trivy-cache"
paths: ["/trivy-cache/"]
policy: pull-push
before_script:
- set -euo pipefail
# Install Node deps only when package.json exists
- |
if [ -f package-lock.json ] || [ -f npm-shrinkwrap.json ]; then
npm ci --ignore-scripts --no-audit --no-fund
elif [ -f yarn.lock ]; then
yarn install --ignore-scripts --frozen-lockfile --silent
elif [ -f pnpm-lock.yaml ]; then
pnpm install --ignore-scripts --frozen-lockfile --silent
fi
# (optional) install deps for nested workspaces if needed:
# find . -name package.json -not -path "*/node_modules/*" -execdir npm ci --ignore-scripts --no-audit --no-fund \;
# Also install in each sub-project that has a lockfile (monorepo / non-workspace packages)
- |
echo "Installing Node deps where lockfiles exist in subfolders..."
# npm
find . -path "*/node_modules" -prune -o -name package-lock.json -print0 \
| xargs -0 -I{} bash -c 'd=$(dirname "{}"); echo "npm ci in $d"; (cd "$d" && npm ci --ignore-scripts --no-audit --no-fund)'
# yarn (if yarn is available)
if command -v yarn >/dev/null 2>&1; then
find . -path "*/node_modules" -prune -o -name yarn.lock -print0 \
| xargs -0 -I{} bash -c 'd=$(dirname "{}"); echo "yarn install in $d"; (cd "$d" && yarn install --ignore-scripts --frozen-lockfile --silent)'
fi
# pnpm (if pnpm is available)
if command -v pnpm >/dev/null 2>&1; then
find . -path "*/node_modules" -prune -o -name pnpm-lock.yaml -print0 \
| xargs -0 -I{} bash -c 'd=$(dirname "{}"); echo "pnpm install in $d"; (cd "$d" && pnpm install --ignore-scripts --frozen-lockfile --silent)'
fi
script: |
set -eo pipefail
trivy fs "$CI_PROJECT_DIR" \
--scanners "$TRIVY_SCANNERS" \
--severity "$TRIVY_SEVERITY" \
--pkg-types os,library \
--include-dev-deps \
--skip-db-update \
--cache-dir "$TRIVY_CACHE_DIR" \
--timeout "$TRIVY_TIMEOUT" \
--format json --output trivy-fs.json
artifacts:
when: always
expire_in: 2 weeks
paths:
- trivy-fs.json
5.3. Trivy Image Scan (Containers)
If your project builds Docker images, scan them too.
container-scanning-trivy-image:
image: your-registry/custom-trivy-ci:latest
stage: security
variables:
IMAGE_REF: "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"
script:
- |
set -eo pipefail
trivy image "$IMAGE_REF" \
--scanners "$TRIVY_SCANNERS" \
--severity "$TRIVY_SEVERITY" \
--pkg-types os,library \
--ignore-unfixed \
--skip-db-update \
--cache-dir "$TRIVY_CACHE_DIR" \
--timeout "$TRIVY_TIMEOUT" \
--format json --output trivy-image.jsonjson --output trivy-image.json
artifacts:
when: always
paths: [trivy-image.json]
5.4. Trivy Config Scan (IaC Misconfigurations)
Scan Terraform, Kubernetes manifests, and Docker Compose files for misconfigurations.
iac-scanning-trivy-config:
image: aquasec/trivy:latest
stage: security
script:
- |
set -eo pipefail
# Build safe --skip-dirs (absolute and existing)
SKIPS=( ".git" "node_modules" "doc" "docs" "examples" "tmp" "dist" "build" )
ARGS=()
for d in "${SKIPS[@]}"; do
[ -d "$d" ] && ARGS+=( --skip-dirs "$(pwd)/$d" )
done
echo "Trivy args: ${ARGS[*]}"
RESULT=0
trivy config \
--severity HIGH,CRITICAL \
--timeout "$TRIVY_TIMEOUT" \
--cache-dir "$TRIVY_CACHE_DIR" \
--format json -o trivy-config.json \
--exit-code 1 \
"${ARGS[@]}" \
. || RESULT=$?; : ${RESULT:=0}
exit $RESULT
artifacts:
when: always
paths: [trivy-config.json]
5.5. Gitleaks Secret Detection
Detect hardcoded secrets like tokens, passwords, or API keys.
secret-detection-gitleaks:
image:
name: zricethezav/gitleaks:latest
entrypoint: [""]
stage: security
needs: []
variables:
GITLEAKS_REPORT_JSON: "gitleaks-report.json"
GITLEAKS_IGNORE_PATH: "."
GITLEAKS_EXIT_CODE: "1"
GITLEAKS_REDACT: "100"
GITLEAKS_MAX_DECODE_DEPTH: "2"
GITLEAKS_MAX_ARCHIVE_DEPTH: "2"
script:
- gitleaks version || true
- |
set -eo pipefail
RESULT=0
# Ensure env vars don't implicitly force a missing config
unset GITLEAKS_CONFIG GITLEAKS_CONFIG_TOML || true
echo "[gitleaks:dir] scanning working directory..."
gitleaks dir . \
--verbose \
--no-banner --no-color \
--redact "${GITLEAKS_REDACT}" \
--max-decode-depth "${GITLEAKS_MAX_DECODE_DEPTH}" \
--max-archive-depth "${GITLEAKS_MAX_ARCHIVE_DEPTH}" \
-i "${GITLEAKS_IGNORE_PATH}" \
--report-format json --report-path "${GITLEAKS_REPORT_JSON}" \
--exit-code "${GITLEAKS_EXIT_CODE}" || RESULT=$?; : ${RESULT:=0}
exit $RESULT
artifacts:
<<: *security_artifacts_defaults
paths:
- gitleaks-report.json
extends: .security_scan_rules
6. Optimizing Your CI Pipeline with a Custom Trivy Image
By default, every Trivy job downloads its vulnerability databases on each run — adding 60–120 seconds to the pipeline.
To eliminate that, we can build a custom base image that includes Trivy and its pre-downloaded databases.
6.1. Dockerfile for a Pre-Cached Trivy Image
FROM debian:stable-slim
# Bring VERSION arg into scope for build steps
ARG VERSION
# Base tools
RUN apt-get update -y \
&& apt-get install -y --no-install-recommends \
ca-certificates curl jq git gettext-base \
&& update-ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# yq (useful to manipulate YAML in CI scripts)
RUN curl -fsSL https://github.com/mikefarah/yq/releases/download/v4.44.3/yq_linux_amd64 \
-o /usr/local/bin/yq && chmod +x /usr/local/bin/yq
# Trivy (install via official release)
RUN curl -fsSL https://github.com/aquasecurity/trivy/releases/download/v${VERSION}/trivy_${VERSION}_Linux-64bit.tar.gz \
| tar -xz -C /usr/local/bin trivy
# Trivy HTML templates
RUN mkdir -p /usr/local/share/trivy/templates \
&& curl -fsSL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/html.tpl \
-o /usr/local/share/trivy/templates/html.tpl
ENV TRIVY_CACHE_DIR=/trivy-cache
ENV TRIVY_TIMEOUT=10m
RUN mkdir -p ${TRIVY_CACHE_DIR}
# Pre-download vulnerability databases (base + Java)
RUN trivy fs --download-db-only --cache-dir ${TRIVY_CACHE_DIR} -d \
&& trivy fs --download-java-db-only --cache-dir ${TRIVY_CACHE_DIR} -d
VOLUME ["/trivy-cache"]
ENTRYPOINT [""]
6.2. Build and Push the Image
You can build and push this image to your own registry:
docker build -t your-registry/custom-trivy-ci:latest --build-arg VERSION=0.58.1 .
docker push your-registry/custom-trivy-ci:latest
This improves CI efficiency by:
- Removing repeated DB downloads
- Reusing cached vulnerability definitions
- Keeping all jobs on a consistent Trivy version
7. Handling Noise and False Positives
Reduce unnecessary findings with ignore files.
.trivyignore
CVE-2024-12345
CVE-2023-67890
**/tests/**
**/examples/**
.gitleaksignore
tests/**
examples/**
.env.sample
8. Performance Tips
- Use cache mounts for
/trivy-cacheto persist DBs between jobs. - Run
securityjobs in parallel for speed. - Start with
allow_failure: trueduring adoption, then enforce once stable.
9. Troubleshooting
| Issue | Cause | Fix |
|---|---|---|
| Slow scan | DB redownload each run | Use custom Trivy image |
| Too many false positives | Low severity filters | Adjust TRIVY_SEVERITY |
| Secrets flagged in test data | Non-production content | Add to .gitleaksignore |
10. Conclusion
Security shouldn’t be a luxury feature — it’s part of a healthy CI/CD workflow.
With this setup, you’ve built a fully open-source DevSecOps pipeline for GitLab Free that runs efficiently, detects vulnerabilities early, and prevents secret leaks.
From here, you can extend the stack with:
- Trivy HTML reports
- Dependency dashboards in GitLab
- Notifications on findings severity
Shift left. Stay secure. And keep your pipelines fast.
11. References
All configs are available here: GitHub – mayens/gitlab-ci-devsecops-sast