The pipeline is the work here; the application is a fixture. The app is PyGoat — a deliberately vulnerable Django app, MIT-licensed — vendored by way of nanuchi/devsecops-crash-course-pygoat. No application code is modified here. Nothing under
pygoat/,chatbot/,Solutions/orintroduction/has been touched, and the upstream README is preserved below. See Provenance and licence.What this repository adds is everything around the application: a five-stage security pipeline, a policy gate, hardened deployment manifests, and the documentation for all of it.
The interesting problem here is not "run some scanners". It is that the application is supposed to be vulnerable. A pipeline that fails on every critical finding would be red forever and switched off within a week. So the gate blocks on novelty, not on severity alone: 2904 findings are accepted in a committed baseline with a written justification each, and the build fails only on findings that are not.
| Stage | Tools | |
|---|---|---|
| 1 | Code | Gitleaks · Bandit · Semgrep |
| 2 | Dependencies | pip-audit · Trivy fs |
| 3 | Build and package | Hadolint · Trivy image · Docker Scout · Syft SBOM · Cosign |
| 4 | Provision | Checkov · Trivy config · Conftest/OPA · Terraform |
| 5 | Deploy and DAST | hardened Compose deployment · OWASP ZAP |
| → | Security gate | .security/gate.py against .security/baseline/ |
| → | Evidence | 7 SARIF categories to code scanning · SBOM · optional DefectDojo |
Roughly six minutes end to end on GitHub-hosted runners. Full detail, including why there are two image scanners and three IaC scanners, is in docs/PIPELINE.md.
A few properties that are easy to get wrong and are worth calling out:
- Scanners never fail their own job on findings — only on scanner errors. One place decides whether findings are acceptable, and it is the gate.
- The gate fails closed. No reports is a failure, not a pass. Partial
coverage is a failure too: it reads
toJSON(needs)and refuses to go green if any stage did not complete. - Fingerprints are location-independent. Moving a function does not invalidate its acceptance — and, after three separate bugs of this shape, nothing environment- or run-specific is allowed into a fingerprint.
- Stage 5 tests the exact bytes Stage 3 built, handed over as an artifact, because Docker builds are not bit-reproducible and scanning a rebuild means scanning something that was never shipped.
- Stage 5 is also the only test
deploy/has. PyGoat runs under the same constraints as the Kubernetes manifest — read-only root filesystem, UID 10001, no capabilities — so a hardening change that breaks the app fails on a pull request rather than in a cluster. - Everything is pinned by digest or commit SHA, including
actions/*. Scanners run as digest-pinned containers inrun:steps, not as third-party actions, so CI andmake scanare one code path.
Docker and Python 3 are the only prerequisites — nothing is installed on the host, and every target runs the same digest-pinned container as CI with the same flags.
make # list every target
make scan # every scanner, then the gate
make gate # re-evaluate the reports already in reports/
make lint # actionlint + yamllint
make check-pins # the eleven scanner digests match ci.yml
make policy-test # the 34 Rego unit tests for policy/
make dast # deploy hardened, ZAP baseline, tear downTriage what came out, in a local DefectDojo:
make defectdojo-up # first run migrates the database, ~2 min
make defectdojo-import
make defectdojo-down$ python3 .security/gate.py gate --reports reports
✅ PASS — 0 non-baselined blocking finding(s), baseline holds 2904 accepted finding(s).
| Scanner | Total | Baselined | New | New blocking | Blocks on |
| ------------ | ----: | --------: | --: | -----------: | ------------------------------------- |
| bandit | 11 | 11 | 0 | 0 | CRITICAL, HIGH |
| gitleaks | 10 | 10 | 0 | 0 | CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN |
| pip-audit | 108 | 108 | 0 | 0 | CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN |
| semgrep | 31 | 31 | 0 | 0 | CRITICAL, HIGH |
| trivy-config | 1 | 1 | 0 | 0 | CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN |
| trivy-fs | 80 | 80 | 0 | 0 | CRITICAL, HIGH |
| trivy-image | 2671 | 2671 | 0 | 0 | CRITICAL, HIGH |
| zap | 16 | 16 | 0 | 0 | CRITICAL, HIGH |
That is a local run on arm64. CI sees trivy-image 2675 — an amd64 runner has
four packages (libmpx2, libquadmath0) that do not exist on arm64, which is
why that one scanner is baselined from CI artifacts rather than from a laptop.
Gitleaks shows 10 here and 0 in CI, because a pull request run scans only the
PR's own commits; the full history is walked nightly. checkov, conftest and
hadolint have no row because they report nothing — the intended steady state
for the three scanners aimed at files this repository owns. pip-audit totals
108 against 80 baseline entries because it reports one advisory once per matching
dependency path; the fingerprints, not the rows, are what the gate compares.
Per-scanner thresholds are not decoration. pip-audit and Checkov community
edition emit no severity at all, so severity filtering would silently drop
every finding — for those, any new finding blocks. Gitleaks blocks on everything
because a newly committed secret is never acceptable. The reasoning for each
override is in .security/policy.json next to the
value.
Accepting a finding requires a justification, and accepting a finding in the
pipeline's own code (deploy/, policy/, .security/) additionally requires a
compensating control and an expiry date. There are currently two such exceptions,
both expiring 2026-10-31. See .security/POLICY.md.
.github/workflows/ci.yml the pipeline
.github/workflows/nightly.yml full-history secret scan + unfiltered image scan
.security/gate.py normalizer, baseline manager, policy gate
.security/policy.json thresholds - what CI reads
.security/POLICY.md the same policy, explained and justified
.security/baseline/ accepted findings, one file per scanner
.security/defectdojo.py push normalized findings to DefectDojo
policy/kubernetes.rego the controls this repo will not ship without
policy/kubernetes_test.rego 34 unit tests for that policy
deploy/k8s/ hardened manifests (kubeconform-validated)
deploy/overlay/ the settings overlay both k8s and DAST use
deploy/terraform/ ECR module - validate and plan only, never apply
deploy/compose/ the DAST deployment, and a local DefectDojo
diagrams/ the pipeline diagram (mermaid source + renders)
Makefile the same containers and flags CI runs
- docs/PIPELINE.md — every stage, what it scans, and why
- docs/THREAT-MODEL.md — STRIDE over the pipeline itself, and the accepted risks in one table
- docs/BRANCH-PROTECTION.md — what
mainmust require, and the trap of requiring a check that does not always run - .security/POLICY.md — the baseline mechanism, severity thresholds, and how to request an exception
Stated here rather than left to be discovered:
- Publishing is only ever exercised on
main. The GHCR push, the Cosign keyless signature and the SBOM attestation are gated topushonmain, so no pull request run can reach them — by design, because a pull request must not be able to put a signed artefact in a registry. The path is verified end to end (push → sign → attest →cosign verify, all in-job), but a change to it is proven only after it lands. - The committed image digest reads one build behind.
deploy/k8s/30-deployment.yamlpins a specific published digest; every latermainbuild publishes a newer one. That is intended — a deploy pipeline substitutes the digest it promotes, and re-pinning per release is a release process, not a repository file. - No cluster. The Kubernetes manifests are schema-validated with
kubeconform -strictagainst 1.31 and policy-tested with Conftest, and Stage 5 exercises the runtime constraints in Docker. Scheduling, probe behaviour,fsGroupownership and NetworkPolicy enforcement are unexercised — the last also needs a CNI that enforces it. - Four
introduction/endpoints are deliberately broken. They write executable Python into the image at runtime, which the read-only root filesystem now refuses. That is the control working; it is recorded as a trade-off rather than worked around.
This repository is not a GitHub fork — it is a standalone repository that
vendors PyGoat as a test fixture. The application source under pygoat/,
chatbot/, Solutions/, introduction/, and the root-level manage.py,
setup.py, PyGoatBot.py, installer.sh, uninstaller.*, gh-md-toc,
Procfile, runtime.txt, requirements.txt and docker-compose.yml come from
adeyosemanputra/pygoat (MIT), taken
via nanuchi/devsecops-crash-course-pygoat.
Upstream's MIT licence and copyright are reproduced in LICENSE, which
covers that code and the pipeline added around it alike. NOTICE states
which paths came from where.
The upstream .github/workflows/main.yml was replaced, not extended. Its
contributor list is preserved below.
Because the app is a vulnerability teaching fixture, it must not be deployed
anywhere reachable. deploy/ exists to be scanned and policy-tested, and Stage 5
brings the app up only on a private Docker network for the duration of a ZAP
scan.
Everything below is the upstream project's README, unchanged.
intentionally vuln web Application Security in django.
our roadmap build intentionally vuln web Application in django. The Vulnerability can based on OWASP top ten
To setup the project on your local machine:
First, Clone the repository using GitHub website or git in Terminal
git clone https://github.com/adeyosemanputra/pygoat.git
### To Download a specific branch
git clone -b <branch_name> https://github.com/adeyosemanputra/pygoat.git
- Install all app and python requirements using installer file -
bash installer.sh - Apply the migrations
python3 manage.py migrate. - Finally, run the development server
python3 manage.py runserver. - The project will be available at http://127.0.0.1:8000
- Install python3 requirements
pip install -r requirements.txt. - Apply the migrations
python3 manage.py migrate. - Finally, run the development server
python3 manage.py runserver. - The project will be available at http://127.0.0.1:8000
- Install all app and python requirements using
setup.pyfile -pip3 install . - Apply the migrations
python3 manage.py migrate. - Finally, run the development server
python3 manage.py runserver. - The project will be available at http://127.0.0.1:8000
- Install Docker
- Run
docker pull pygoat/pygoatordocker pull pygoat/pygoat:latest - Run
docker run --rm -p 8000:8000 pygoat/pygoat:latest - Browse to http://127.0.0.1:8000
- Remove existing image using
docker image rm pygoat/pygoatand pull again incase of any error
- Install Docker
- Run
docker-compose upordocker-compose up -d
- Clone the repository
git clone https://github.com/adeyosemanputra/pygoat.git - Build the docker image from Dockerfile using
docker build -f Dockerfile -t pygoat . - Run the docker image
docker run --rm -p 8000:8000 pygoat:latest - Browse to http://127.0.0.1:8000 or http://0.0.0.0:8000
- From Source using
installer.sh
- Without using
installer.sh
- On Debian/Ubuntu based systems, you can use the
uninstaller.shscript to uninstallpygoatalong with all it's dependencies. - To uninstall
pygoat, simply run:
$ bash ./uninstaller.sh- On other systems, you can use the
uninstaller.pyscript to uninstallpygoatalong with all it's dependencies - To uninstall
pygoat, simply run:
$ python3 uninstaller.pyThanks goes to these wonderful people (emoji key):
pwned-17 💻 |
Aman Singh 💻 |
adeyosemanputra 💻 📖 |
gaurav618618 💻 📖 |
MajAK 💻 |
JustinPerkins 💻 |
Liu Peng 💻 |
Metaphor 💻 |
whokilleddb 💻 |
This project follows the all-contributors specification. Contributions of any kind welcome!
