DevSecOps projects

CI/CD and supply-chain security

Secure delivery pipeline

A small Python API delivered the DevSecOps way: every change is tested and scanned, the image is built once, signed and deployed by GitOps, and the cluster only runs images signed by this pipeline.

  • GitHub Actions
  • Python
  • Docker
  • Trivy
  • Semgrep
  • gitleaks
  • Syft
  • cosign
  • SLSA
  • Kustomize
  • Argo CD
  • Kyverno
  • Kubernetes

At a glance

6
automatic security checks
0
stored signing keys
1
human approval for production
< 3 min
from merge to dev

The goal

Most attacks on software today do not break the app: they break the way it is built and shipped. A leaked token, a poisoned image or a manual change in production. The goal: a delivery system where every step is automatic, checked and traceable, and where production only accepts what the pipeline built and signed. The app is small on purpose; the value is the delivery system around it.

What it shows

Continuous integration
ruff (with bandit rules), pytest with a 90 % coverage gate, a smoke test of the real container.
Shift-left security
gitleaks for secrets, Semgrep for code, Trivy and kubeconform for the Dockerfile and the Kubernetes files.
Supply chain
Trivy image scan, Syft SBOM (CycloneDX), cosign keyless signing, SBOM attestation, SLSA build provenance.
Delivery
Kustomize overlays, the image pinned by digest, Argo CD syncs dev by itself, production through a reviewed pull request.
Runtime security
Pod Security "restricted", non-root, read-only file system, no capabilities, seccomp, default-deny network policy.
Admission control
Kyverno lets in only images signed by this repository, and only pods with limits and probes.

Architecture

One change, from the developer to the cluster. The pipeline never gets cluster credentials: Argo CD pulls from Git.

From commit to production: checks, build, scan, sign, GitOps, admissionGitHub Actions (every change)Developerbranch + PRTests + lintruff · pytest ≥ 90 %SecretsgitleaksSASTSemgrepIaCTrivy config · kubeconformBuild + smoke testdocker · non-rootruns the containerScan + SBOMTrivy image (gate)Syft CycloneDXSign + attestcosign keyless (OIDC)SBOM + SLSA provenanceGHCRimage by digestsha256:…Git: overlays/devcommit by the pipelineGit: overlays/prodpull request + human mergecommit digestpromotion PRKubernetes clusterArgo CDpulls from Gitno CI credentialsKyvernosignature + identitylimits + probesshop-api podsrestricted PSAread-only, non-rootdefault-deny networkpull

Step by step

  1. Pull request

    Lint, unit tests, secret scan, SAST and IaC checks run in parallel. The image is built, smoke-tested, scanned and gets an SBOM. Nothing is published yet.

  2. Merge to main

    The same checks run again. The image is pushed to GHCR by digest, signed with cosign (keyless, the GitHub identity) and gets an SBOM attestation and SLSA provenance.

  3. Deploy to dev

    The pipeline commits the new digest to the dev overlay. Argo CD sees the commit and syncs dev by itself.

  4. Promote to production

    The pipeline opens a pull request for the prod overlay with the same digest. A human reviews and merges it: that merge is the release. Rollback = git revert.

  5. Admission

    Before a pod starts, Kyverno checks the signature and the identity of the pipeline, and the limits and probes. Anything else is refused.

Proof from a real run

Run on the main branch on 2026-10-27, from GitHub Actions. Time per job:

  • Lint and unit tests 12s
  • Secret scan (gitleaks) 10s
  • SAST (semgrep) 33s
  • IaC and manifest checks 15s
  • Build, scan, SBOM, sign 1m 53s
  • Deploy to dev (GitOps commit) 4s

Open this run (opens in a new tab) · deploy(dev): shop-api bf471203925f

Security controls

ControlWhat it stops
gitleaks in pre-commit and CIPasswords and tokens in Git.
Semgrep SASTInjection, unsafe functions and weak crypto in the code.
Trivy image scan with a gateShipping known critical vulnerabilities.
Digest instead of tagA different image behind the same tag.
cosign keyless + KyvernoImages that the pipeline did not build and sign.
Pull-based GitOpsStolen CI credentials that reach the cluster.
Restricted pods + default-deny networkBreak-out and lateral movement from one hacked pod.
Actions pinned to commit SHAsA changed third-party action in the pipeline.

Try it yourself

make install test          # Python 3.12+: lint, tests, 90 % coverage gate
make run                   # http://127.0.0.1:8080/docs
docker compose up --build  # the hardened container: read-only, non-root, no capabilities
make kind-up               # local Kubernetes with ingress, Kyverno and Argo CD

# check the signature of a published image (keyless, GitHub OIDC identity)
cosign verify ghcr.io/s0sta/secure-delivery-pipeline@sha256:… \
  --certificate-identity-regexp 'https://github.com/s0sta/secure-delivery-pipeline/' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

What I learned

  • Fair gates get accepted: fail on new critical findings first, then raise the bar step by step.
  • Signing only matters when something checks it: the policy in the cluster is the real control.
  • A real CI run finds what local tests miss: a deleted tool release, a missing path, a repository setting.
Next case study Azure k3s platform