Three reusable security pipelines (SAST, SCA, and Container Scanning) that run identically on your laptop and in GitHub Actions.
Each pipeline runs open source scanners, normalises their output to SARIF, and applies a shared gate. Locally they run in purpose-built Docker images through a make command. In CI they run natively on the runner through the same ci/ scripts. Same scripts, same pinned tool versions, same thresholds, so a finding you reproduce locally is the finding CI reports.
Developed as part of Google Summer of Code 2026 with EBRAINS, following the OWASP DevSecOps Guideline, and deployed in the Medical Informatics Platform.
- Repository Structure
- The Three Pipelines
- Quick Start
- Running Locally: the Makefile
- Running in CI: GitHub Actions
- The Gate
- Reading SARIF Reports
- Configuring Semgrep Rulesets
- Adding Your Own Ecosystem
- Suppressing a False Positive
- Environment Variables
- Reproducibility
- Related Resources
.
├── Makefile # local entrypoint: make sast | sca | container-scan
├── toolbox.sh # builds and runs the Docker toolbox images
│
├── ci/ # everything the pipelines need, vendor this folder
│ ├── setup-tools.sh # installs scanners (SHA256 pinned) and generates SBOMs
│ ├── sast_scan.py # SAST orchestrator (OpenGrep)
│ ├── sca_scan.py # SCA orchestrator (Trivy + OSV-Scanner)
│ ├── container_scan.py # Container orchestrator (Hadolint, OpenGrep, Trivy, OSV)
│ ├── parse_sarif.py # shared CVSS gate logic
│ │
│ ├── suppress_trivy.yaml # your Trivy suppressions
│ ├── suppress_osv_scanner.toml # your OSV-Scanner suppressions
│ │
│ └── docker/
│ ├── Dockerfile # 3 toolbox images from one shared installer stage
│ ├── entrypoints/ # sca-entrypoint.sh
│ └── env/ # sast.env | sca.env | container-scan.env
│
├── .github/workflows/ # reference CI, also this repo's own test suite
│ ├── sast.yml
│ ├── sca.yml
│ └── container-scan.yml
│
└── test-*/ # sample targets used to exercise the pipelines
To adopt these pipelines in your own project, copy ci/ plus the workflow file you need. Nothing else is required.
| Pipeline | Scanners | What it inspects | Gate |
|---|---|---|---|
| SAST | OpenGrep with semgrep-rules | Your source code | Any ERROR severity rule match |
| SCA | Trivy, OSV-Scanner | A CycloneDX SBOM of your dependencies | CVSS threshold |
| Container Scanning | Hadolint, OpenGrep, Trivy, OSV-Scanner | Dockerfile and the built image | CVSS threshold + ERROR severity rule (combine both SAST and SCA) |
The pipelines are deliberately independent. No shared state, no ordering constraints. Run one, run all three, run them in parallel.
SAST is static analysis of your own code. OpenGrep (an open source fork of Semgrep) matches pattern based rules against the source tree.
SCA is Software Composition Analysis, meaning your third party dependencies. It generates a CycloneDX SBOM first, then scans that SBOM. This decouples the scanners from the package manager, so adding a language means teaching the SBOM step how to produce a bill of materials, not modifying the scanners.
Container Scanning combines both approaches against one image. It runs a SAST pass over the Dockerfile (Hadolint plus OpenGrep Dockerfile rules) and an SCA pass over the built image layers (Trivy plus OSV-Scanner), then merges all four SARIF reports into one before applying the gate. So a container scan can fail on a bad RUN instruction, on a vulnerable base image package, or on both.
Requirements: Docker, make, and Bash. Nothing else, since the scanners are installed inside the images.
Container Scanning also needs the image built beforehand, and toolbox.sh mounts the Docker socket (-v /var/run/docker.sock:/var/run/docker.sock) so the toolbox can inspect it.
git clone https://github.com/moghit-eou/DevSecOps-CI-pipelines.git
cd DevSecOps-CI-pipelines
make sast PROJECT=./test-golang
make sca PROJECT=./test-maven ECOSYSTEM=mavenThe first run builds the toolbox image, which takes a few minutes because it downloads the scanners and the Trivy vulnerability database. Later runs reuse the cached layers and take seconds.
SARIF reports are written inside PROJECT.
# SAST, scan source code
make sast PROJECT=<dir>
# SCA, resolve dependencies, build an SBOM, scan it
make sca PROJECT=<dir> ECOSYSTEM=<maven|npm|golang|generic|none>
# Container Scanning, the image must be built first
docker build -t myapp:local ./my-service
make container-scan IMAGE=myapp:local SCAN_TYPE=sast PROJECT=./my-service # scan the Dockerfile
make container-scan IMAGE=myapp:local SCAN_TYPE=sca # scan the image
# Remove SARIF outputs and toolbox images
make cleanmake calls toolbox.sh, which calls docker run with your project bind mounted at /workspace, which is the image's WORKDIR.
flowchart LR
M["make sca PROJECT=./svc"] --> T[toolbox.sh]
T --> B["docker build --target sca-toolbox"]
B --> R["docker run<br/>-v ./svc:/workspace<br/>--env-file ci/docker/env/sca.env"]
R --> S["setup-tools.sh --sbom-ecosystem<br/>then sca_scan.py"]
S --> O["SARIF written into ./svc/"]
Runtime configuration lives in ci/docker/env/*.env. Edit those to change thresholds, output names, or suppression paths without rebuilding the image.
The scanners and the Trivy database are installed once, in a shared toolbox-installer stage. All three toolbox images copy from it, so switching between make sast and make sca costs nothing.
The workflows in .github/workflows/ are plain steps calling the same ci/ scripts. No composite actions, no hidden indirection. Copy one into your repository and change the env: block.
Every workflow follows the same shape:
flowchart LR
A["1 · Install scanners<br/>ci/setup-tools.sh"] --> B["2 · Run the scan<br/>cd into PROJECT"] --> C["3 · Upload SARIF<br/>Security tab + artifact"]
Every job needs:
permissions:
contents: read
security-events: write # to upload SARIF to the Security tabThe rules are cloned outside the checkout, then referenced by absolute path:
- name: Install scanner and rules
run: |
mkdir -p "$GITHUB_WORKSPACE/../tools" && cd "$GITHUB_WORKSPACE/../tools"
bash "$GITHUB_WORKSPACE/ci/setup-tools.sh" --install-tool opengrep,semgrep-rules
- name: Run SAST scan
working-directory: ${{ env.PROJECT }}
run: python3 "$GITHUB_WORKSPACE/ci/sast_scan.py"Why outside the checkout: the semgrep-rules repository ships thousands of deliberately vulnerable test fixtures. If it lands inside the folder being scanned, OpenGrep will scan those fixtures and flood you with findings. $GITHUB_WORKSPACE/../tools is outside the checkout and always safe. The local Docker flow achieves the same separation by baking rules into /app/semgrep-rules, outside the /workspace mount.
Two mandatory settings, what to scan and which ecosystem it is:
env:
PROJECT: .
ECOSYSTEM: maven # maven | npm | golang | generic | none # cd into PROJECT, because setup-tools.sh writes the SBOM to the current directory
- name: Install scanners and generate SBOM
working-directory: ${{ env.PROJECT }}
run: |
bash "$GITHUB_WORKSPACE/ci/setup-tools.sh" \
--install-tool trivy,osv-scanner \
--sbom-ecosystem "$ECOSYSTEM"
- name: Run SCA scan
working-directory: ${{ env.PROJECT }}
run: python3 "$GITHUB_WORKSPACE/ci/sca_scan.py"Add your build toolchain before those steps if the runner lacks it (actions/setup-node for npm, actions/setup-go for Go). Maven is preinstalled on ubuntu-latest, and generic needs nothing.
Dependency caching by ecosystem:
| Ecosystem | Cache path | Key file |
|---|---|---|
maven |
~/.m2/repository |
pom.xml |
npm |
~/.npm |
package-lock.json |
golang |
~/go/pkg/mod, ~/.cache/go-build |
go.sum |
generic |
none, nothing is resolved |
- name: Cache Maven packages
uses: actions/cache@v6
with:
path: ~/.m2/repository
key: ${{ runner.os }}-m2-v1-${{ hashFiles(format('{0}/**/pom.xml', env.PROJECT)) }}
restore-keys: ${{ runner.os }}-m2-v1-Note the key uses format() with PROJECT. A bare **/pom.xml hashes every project in a multi project repository, so the key churns on unrelated changes.
Dependency resolution (mvn dependency:resolve, npm ci, go mod download) lives in ci/setup-tools.sh, not in the workflow, so make sca resolves dependencies exactly the way CI does. The cache step only warms the download directory.
One caveat: actions/cache skips its save step when the job fails. Because a security gate is designed to fail, a project with an unfixable CVE will never populate its cache.
Two passes over one image, then a merge:
- name: Scan Dockerfile (Hadolint + OpenGrep)
working-directory: ${{ env.PROJECT }}
run: python3 "$GITHUB_WORKSPACE/ci/container_scan.py" --scan-type sast
- name: Scan image for CVEs (Trivy + OSV-Scanner)
working-directory: ${{ env.PROJECT }}
run: python3 "$GITHUB_WORKSPACE/ci/container_scan.py" --scan-type sca --image "$IMAGE_NAME"
- name: Merge SARIF reports
working-directory: ${{ env.PROJECT }}
run: |
python3 "$GITHUB_WORKSPACE/ci/container_scan.py" \
--merge-sarif "$TRIVY_SCA_SARIF_OUTPUT" "$OSV_SCA_SARIF_OUTPUT" \
"$OPENGREP_SAST_SARIF_OUTPUT" "$HADOLINT_SAST_SARIF_OUTPUT" \
--merge-output "$MERGED_SARIF_OUTPUT"The Dockerfile pass runs inside PROJECT on purpose. container_scan.py scans with --include=Dockerfile against the current directory, so running it from the repository root would pick up every Dockerfile in the repository.
SCA and Container Scanning read the security-severity property (CVSS score) from every SARIF result and take the maximum:
| Max CVSS | Status | Exit code |
|---|---|---|
at or above GATE_FAIL_THRESHOLD |
FAILED | 1, blocks the pipeline |
at or above GATE_WARN_THRESHOLD, below fail |
WARNING | 0, logged only |
| below warn | PASSED | 0 |
| tool crashed or no SARIF | ERROR | 1, a broken scanner is never a pass |
Defaults are 8.0 and 5.0. Override per project in a workflow:
GATE_FAIL_THRESHOLD: "7.0"
GATE_WARN_THRESHOLD: "4.0"or locally in ci/docker/env/sca.env, without quotes, since --env-file does not strip them:
GATE_FAIL_THRESHOLD=7.0
GATE_WARN_THRESHOLD=4.0SAST gates differently. OpenGrep runs with --severity=ERROR --error, so any ERROR level rule match fails the job. There is no CVSS score to threshold on.
Reports upload even when the gate fails, because the upload steps are guarded on the SARIF file existing, not on the scan's exit code. A blocked build still publishes its findings.
SARIF is JSON, so it is readable but unpleasant by hand. Four ways to look at it:
- GitHub Security tab. Uploaded automatically by the workflows, with findings mapped to lines of code.
- VS Code. Install the SARIF Viewer extension, then open any
.sariffile. It gives you a sortable findings list that jumps straight to the offending line, which is the fastest way to triage a localmakerun. - In the browser. Drop a
.sariffile into the SARIF Web Viewer. Nothing to install, and it works for sharing a report with someone who does not have the repository checked out. - The workflow artifact. Every job uploads its SARIF as a downloadable artifact, useful when a run fails before the Security tab upload.
SEMGREP_CONFIG_RULESETS is a space separated list. Two kinds of entry.
1. Local folders from the pinned semgrep-rules clone. Browse that repository and add any top level directory:
SEMGREP_CONFIG_RULESETS: >-
${{ github.workspace }}/../tools/semgrep-rules/generic
${{ github.workspace }}/../tools/semgrep-rules/java
${{ github.workspace }}/../tools/semgrep-rules/python
${{ github.workspace }}/../tools/semgrep-rules/terraform2. Registry packs from semgrep.dev/explore, referenced as p/<name>:
p/default p/owasp-top-ten p/secrets autoauto lets OpenGrep pick language appropriate rules automatically.
| Entry | Behaviour |
|---|---|
| Local folder path | Uses the pinned ruleset commit, fully reproducible |
p/<name> |
Fetched from the registry at scan time, may drift between runs |
auto |
Language detected registry rules |
For reproducible results, prefer local folders. SEMGREP_RULES_REF in ci/setup-tools.sh pins the exact ruleset commit, so the same input always yields the same findings.
In the local Docker flow the equivalent setting lives in ci/docker/env/sast.env, pointing at /app/semgrep-rules/....
SCA works with any language. Adding one means teaching ci/setup-tools.sh how to produce a CycloneDX SBOM for it. The scanners never change, because they only ever see an SBOM.
Step 1. Add a case branch in ci/setup-tools.sh:
gradle)
echo "Generating SBOM for Gradle project"
mkdir -p target
./gradlew cyclonedxBom -q
cp build/reports/bom.json target/bom.json
;;Step 2. Install the build tool in the sca-toolbox stage of ci/docker/Dockerfile, so the local flow has it:
RUN apt-get update && apt-get install -y --no-install-recommends \
maven gradle \
&& rm -rf /var/lib/apt/lists/*Step 3 (CI only, optional). Add an actions/cache block for the ecosystem's dependency directory.
Then use it:
make sca PROJECT=./my-gradle-service ECOSYSTEM=gradleECOSYSTEM: gradleECOSYSTEM: generic falls back to a Trivy filesystem scan, which needs no toolchain and is meant to work on any language. In practice it depends on Trivy recognising whatever lockfile your project uses, so coverage varies and it can quietly miss dependencies. It is a convenient starting point, but it is not the recommended long term choice:
| Native CycloneDX plugin | generic (Trivy filesystem) |
|
|---|---|---|
| Dependency resolution | Yes, before the SBOM is built | No |
| Transitive dependencies | Complete | Only what appears in lockfiles |
| Version accuracy | Resolved versions | Declared ranges in some cases |
| Rate limiting | Avoided, dependencies are already cached | Can hit registry rate limits on cold runs |
Without a resolve step, dependencies may be fetched during the scan itself, which is where public registry rate limits (Maven Central in particular) start failing your builds intermittently. The built in ecosystems all run their resolve step first (mvn dependency:resolve, npm ci, go mod download) inside setup-tools.sh, so the SBOM is generated from an already populated local cache.
Use generic to get coverage quickly, then move to a proper ecosystem branch once the project matters.
Trivy and OSV-Scanner read ignore files whose paths come from the environment, so you can point them at your own files without touching pipeline code.
Trivy, in ci/suppress_trivy.yaml:
vulnerabilities:
- id: CVE-2025-12345
statement: "Vulnerable code path is unreachable in our configuration."
expires: 2026-12-31OSV-Scanner, in ci/suppress_osv_scanner.toml:
[[IgnoredVulns]]
id = "GHSA-xxxx-yyyy-zzzz"
ignoreUntil = 2026-12-31
reason = "No fix available, mitigated at the network layer."To use files from your own repository instead, repoint the variables:
TRIVY_IGNOREFILE: ${{ github.workspace }}/security/trivy.yaml
OSV_IGNOREFILE: ${{ github.workspace }}/security/osv-scanner.tomlAlways set an expiry and a reason. A suppression without a review date is a permanent blind spot.
SAST has no ignore file. OpenGrep findings are suppressed either at the rule level, or by excluding paths:
OPENGREP_EXCLUDE: "*.sarif vendor/ testdata/ *.generated.go"Patterns are relative to PROJECT, not the repository root.
- Trivy: Filtering and ignore files
- OSV-Scanner: Ignore vulnerabilities by ID
Set these in a workflow env: block for CI, or in ci/docker/env/*.env for local runs.
Shared
| Variable | Default | Purpose |
|---|---|---|
GATE_FAIL_THRESHOLD |
8.0 |
CVSS at or above this fails the pipeline |
GATE_WARN_THRESHOLD |
5.0 |
CVSS at or above this warns |
SAST
| Variable | Default | Purpose |
|---|---|---|
SEMGREP_CONFIG_RULESETS |
built in list | Space separated rulesets |
OPENGREP_EXCLUDE |
*.sarif ci/ Dockerfile* ... |
Paths to skip, relative to PROJECT |
OPENGREP_SARIF_OUTPUT |
sast-opengrep.sarif |
Report filename |
SCA
| Variable | Default | Purpose |
|---|---|---|
SBOM_ECOSYSTEM |
none |
maven, npm, golang, generic, or none |
SBOM_PATH |
target/bom.json |
SBOM location, relative to PROJECT |
TRIVY_IGNOREFILE |
ci/suppress_trivy.yaml |
Trivy suppressions |
OSV_IGNOREFILE |
ci/suppress_osv_scanner.toml |
OSV-Scanner suppressions |
TRIVY_SARIF_OUTPUT |
sca-trivy.sarif |
Trivy report |
OSV_SARIF_OUTPUT |
sca-osv-scanner.sarif |
OSV-Scanner report |
SCA_MERGED_SARIF_OUTPUT |
sca-merged.sarif |
Combined report |
Container Scanning
| Variable | Default | Purpose |
|---|---|---|
DOCKERFILE_PATH |
Dockerfile |
Dockerfile name, relative to PROJECT |
TRIVY_SCA_SARIF_OUTPUT |
Image CVE report from Trivy | |
OSV_SCA_SARIF_OUTPUT |
Image CVE report from OSV-Scanner | |
OPENGREP_SAST_SARIF_OUTPUT |
Dockerfile findings from OpenGrep | |
HADOLINT_SAST_SARIF_OUTPUT |
Dockerfile findings from Hadolint |
Tool versions are pinned in ci/setup-tools.sh and overridable by environment variable. Every binary is verified against a pinned SHA256, so overriding a *_VERSION requires overriding the matching *_SHA256 too. Renovate markers keep both in sync automatically.
Design choices that make results deterministic and comparable:
| Concern | How it is handled |
|---|---|
| Scanner versions | Pinned in ci/setup-tools.sh, one source for local and CI |
| Binary integrity | Every download verified against a pinned SHA256 |
| Ruleset version | SEMGREP_RULES_REF pins an exact semgrep-rules commit |
| Action versions | GitHub Actions pinned by commit SHA, not floating tags |
| Local equals CI | Same ci/ scripts, same variables, same thresholds on both paths |
| Dependency resolution | Lives in setup-tools.sh, so both paths resolve identically |
The Trivy vulnerability database is downloaded at image build time locally, and at scan time in CI. Because that database is updated continuously, the same code scanned on different dates can produce different findings. This is expected, and reflects newly disclosed vulnerabilities rather than pipeline nondeterminism.
- Live implementations, these pipelines in production: platform-backend (Maven), platform-ui (npm)
- EBRAINS DevSecOps Handbook, extended guidance and case studies: moghit-eou/EBRAINS-DevSecOps-handbook
- Guideline: OWASP DevSecOps Guideline
- Scanners: Trivy, OSV-Scanner, OpenGrep, Hadolint
- Rulesets: semgrep-rules, Semgrep Registry
- Standards: SARIF, CycloneDX
Free to use. No restrictions.