Skip to content

About

CI/CD where security checks are gates: gitleaks, semgrep, trivy, hadolint, kube-linter, SBOM, ZAP, cosign signing and Kyverno admission; same stage scripts on GitHub Actions and Jenkins

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

4 Commits

Folders and files

Repository files navigation

devsecops-pipeline

A CI/CD pipeline where security checks are gates, not reports, and the cluster only runs what the pipeline signed. It builds a small Python API (app/) through ten stages:

# Gate Tool (pinned by digest) Fails the build on
10 Secrets gitleaks Any secret in the whole git history
20 SAST semgrep (public packs + project rules) Any finding
30 Dependencies trivy fs HIGH/CRITICAL CVEs with a fix, across the hashed lockfile
40 Dockerfile hadolint Warnings and errors (unpinned base, root user, ...)
50 Kubernetes manifests kube-linter, trivy config Privileged, root, writable root FS, missing limits/probes, ...
60 Tests and build pytest, docker Failing tests
70 Image trivy image HIGH/CRITICAL CVEs with a fix in OS or language packages
80 SBOM syft (produces CycloneDX + SPDX)
90 DAST OWASP ZAP baseline against the running container Missing security headers, version leaks (rules)
95 Sign and admit cosign, Kyverno in kind Image not signed by the pipeline or without a signed SBOM: rejected by the cluster

Each stage is a script in ci/; GitHub Actions (pipeline.yml) and Jenkins (Jenkinsfile) only order them, hold credentials and keep reports. On main, GitHub also publishes the scanned image to GHCR with a keyless Sigstore signature (identity = this workflow), a signed SBOM attestation and SLSA build provenance, then verifies them the way a consumer would.

It's a personal project built for interviews, not code from a former employer.

Result

It goes red on real problems. demo/break-it.sh (a CI job) seeds one typical flaw per gate in a throwaway copy and requires that gate to fail:

Seeded flaw Caught by
GitHub token committed to config gitleaks: leaks found: 1
SQL built with an f-string semgrep project rule sql-built-from-strings
requests==2.19.1 added trivy: CVE-2018-18074 (HIGH, fixed in 2.20.0)
FROM python:latest, USER root hadolint: DL3007, DL3002
privileged: true, runAsNonRoot: false kube-linter and trivy config
Old base image (python 3.12.0) trivy image: 89 fixable (76 HIGH, 13 CRITICAL)
Security-headers middleware removed ZAP: FAIL on X-Content-Type-Options missing [10021] (its CSP and anti-clickjacking rules only fire on HTML responses; this API returns JSON)
Unsigned build of the same app, or an image from another registry Kyverno: rejected at admission

And it found real problems while being built:

  • The image scan found 3 HIGH CVEs in Starlette 0.41.3, a transitive dependency of FastAPI that the dependency scan couldn't see, because requirements.txt listed only direct dependencies. Fixed with a lockfile: every transitive dependency pinned with hashes (pip install --require-hashes), so the scan sees everything and a tampered package can't install.
  • semgrep's public packs (p/python, p/sql-injection, p/owasp-top-ten) all missed an f-string SQL injection passed to sqlite3.execute(). Hence the project rule, and the seeded test that keeps it honest.
  • kube-linter flagged the PodDisruptionBudget for not setting unhealthyPodEvictionPolicy (a crash-looping pod could block node drains).

And it keeps checking after release. A daily job (rescan.yml, ci/96-rescan.sh) takes the newest image published from main and verifies its SBOM attestation. Only an SBOM signed by this pipeline's identity is trusted. It then scans that SBOM against today's vulnerability database, with no pull and no rebuild, and opens or updates an issue on new fixable HIGH/CRITICAL CVEs. First run (2026-11-01):

Step Result
Self-test: SBOM of an old base image (python 3.12.0) 14 fixable HIGH/CRITICAL found, as required
Shipped image notes-api:ac427a462b5d attestation verified (keyless, this workflow on main), 103 components, 0 fixable HIGH/CRITICAL today

Runs on both CI systems, unchanged: GitHub Actions, and Jenkins in my homelab (job defined as code, source from Gitea): build #2 passed all nine gates in about two minutes.

Run it locally

Needs Docker (and kind, kubectl, helm for stage 95).

bash ci/run-all.sh              # gates 10-90, reports in reports/
bash ci/95-sign-and-admit.sh    # throwaway registry + kind cluster + Kyverno; removed afterwards (KEEP=1 to keep)
bash demo/break-it.sh           # every gate must catch its seeded flaw

Design choices

Choice Why
Stages are scripts; CI YAML only calls them Same gates on GitHub, Jenkins, GitLab or a laptop; switching CI isn't a rewrite
Scanners run as containers pinned by digest; actions pinned by commit SHA A moved tag or compromised publisher account can't change what runs in the pipeline
Source copied into tool containers (docker cp), not bind-mounted Jenkins here runs in a container against the host's Docker daemon: -v $PWD:/src would mount an empty directory from the wrong machine
Fail on HIGH/CRITICAL with a fix A gate the team can always act on (bump the version); unfixed CVEs are visible in the reports, not blocking
Full-history secret scan A key deleted in a later commit is still in the history, and still has to be rotated
The image that's scanned is the image that's signed and pushed Rebuilding after the scan would sign something nobody scanned
Admission control in the cluster (Kyverno verifyImages, digest pinning) Without it, anyone with kubectl apply bypasses the whole pipeline
Key-based signing locally/Jenkins, keyless (OIDC) on GitHub Keyless needs an OIDC identity (GitHub provides one); key-based works offline. Kyverno can verify either

More in docs/design.md: threat model, what each control stops, and what's not covered.

About

CI/CD where security checks are gates: gitleaks, semgrep, trivy, hadolint, kube-linter, SBOM, ZAP, cosign signing and Kyverno admission; same stage scripts on GitHub Actions and Jenkins

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages