From 731f3f4ed9f81a4c1f09202c9b67cefc46281f2e Mon Sep 17 00:00:00 2001 From: MCCarmo Date: Wed, 28 Oct 2026 19:09:40 +0000 Subject: [PATCH 1/2] Scan the image with Trivy and the live site with ZAP; fix what ZAP found Trivy reports HIGH and CRITICAL findings in the image and fails the build only on a fixable critical one in a library the app brings; the image from main carries an SBOM and a provenance attestation. A weekly OWASP ZAP baseline reads the live site. Its first run found a DOMPurify with known vulnerabilities in Swagger UI (now 5.33.2, DOMPurify 3.4.16) and no Permissions-Policy header (Caddy sets it now). --- .github/workflows/ci.yml | 53 +++++++++++++++++++++++++-- .github/workflows/zap.yml | 34 +++++++++++++++++ .zap/rules.tsv | 10 +++++ README.md | 14 ++++--- deploy/Caddyfile | 2 + docs/decisions/0020-security-scans.md | 49 +++++++++++++++++++++++++ docs/decisions/README.md | 1 + pom.xml | 13 +++++++ src/main/resources/application.yaml | 6 +++ 9 files changed, 172 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/zap.yml create mode 100644 .zap/rules.tsv create mode 100644 docs/decisions/0020-security-scans.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff3baea..5a37c8f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,6 +125,8 @@ jobs: permissions: contents: read packages: write + # Trivy's findings go to Security, Code scanning (from main). + security-events: write steps: - uses: actions/checkout@v7 @@ -151,18 +153,61 @@ jobs: type=sha,prefix=sha- type=raw,value=latest,enable={{is_default_branch}} - - name: Build (and push from main) + - name: Build uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . platforms: linux/arm64 - push: ${{ github.event_name == 'push' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} + # Into the runner's Docker, for Trivy to scan before anything is published. + load: true + tags: extpulse:scan # Reuse unchanged layers (the libraries) between runs. cache-from: type=gha cache-to: type=gha,mode=max + # Trivy reads the image's OS packages and the jars inside it. Only a CRITICAL + # vulnerability with a fix, in a library the app brings, fails the build: that one + # is ours to fix (a version bump). Everything HIGH or CRITICAL is reported. + - name: Scan the image (Trivy) and list its contents (SBOM) + run: | + trivy() { + docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -v "$HOME/.cache/trivy":/root/.cache/trivy \ + -v "$PWD":/work -w /work aquasec/trivy:0.76.0 image --quiet "$@" extpulse:scan + } + trivy --format sarif --output trivy.sarif --severity HIGH,CRITICAL + trivy --format cyclonedx --output sbom.cdx.json + trivy --format table --severity HIGH,CRITICAL + trivy --pkg-types library --severity CRITICAL --ignore-unfixed --exit-code 1 + + - name: Send the findings to code scanning + if: always() && github.event_name == 'push' && hashFiles('trivy.sarif') != '' + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: trivy.sarif + category: trivy + + - name: Keep the SBOM + if: always() && hashFiles('sbom.cdx.json') != '' + uses: actions/upload-artifact@v7 + with: + name: sbom + path: sbom.cdx.json + + # From main: the same build again (every layer cached), pushed with an SBOM and + # a provenance attestation (how and from which commit it was built) attached to it. + - name: Push + if: github.event_name == 'push' + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + platforms: linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + sbom: true + provenance: mode=max + cache-from: type=gha + deploy: # Pushes to main that built and published an image go to the server: the files in # deploy/ are copied to /opt/extpulse, and deploy.sh starts the new image (and goes diff --git a/.github/workflows/zap.yml b/.github/workflows/zap.yml new file mode 100644 index 0000000..10eafad --- /dev/null +++ b/.github/workflows/zap.yml @@ -0,0 +1,34 @@ +name: ZAP baseline + +# A passive scan of the live site once a week with OWASP ZAP: its spider walks the +# public pages and checks the answers (headers, cookies, known-vulnerable scripts). It +# only reads what any visitor gets; nothing is attacked. Rules marked FAIL in +# .zap/rules.tsv fail the run; the full report is the run's artifact. +on: + schedule: + - cron: '0 7 * * 4' # Thursdays + workflow_dispatch: + +permissions: + contents: read + +jobs: + baseline: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Scan https://extpulse.mccprogrammer.com + run: | + mkdir -p zap && cp .zap/rules.tsv zap/ && chmod -R a+rwX zap + # -I: warnings don't fail the run, only the rules marked FAIL. + docker run --rm -v "$PWD/zap":/zap/wrk:rw ghcr.io/zaproxy/zaproxy:2.17.0 \ + zap-baseline.py -t https://extpulse.mccprogrammer.com -m 2 -c rules.tsv -I \ + -r report.html -J report.json + + - name: Report + if: always() + uses: actions/upload-artifact@v7 + with: + name: zap-report + path: zap/report.* diff --git a/.zap/rules.tsv b/.zap/rules.tsv new file mode 100644 index 0000000..393db5d --- /dev/null +++ b/.zap/rules.tsv @@ -0,0 +1,10 @@ +10003 FAIL (Vulnerable JS Library: a known-vulnerable script served by the site, such as Swagger UI's) +10063 FAIL (Permissions Policy Header Not Set: Caddy sets it on every response) +10038 WARN (Content Security Policy Header Not Set: only Swagger UI, left out of the policy on purpose, ADR 0015) +10096 IGNORE (Timestamp Disclosure: numbers inside Swagger UI's bundled scripts, not timestamps) +10109 IGNORE (Modern Web Application: informational) +10111 IGNORE (Authentication Request Identified: informational, the sign-in form) +10112 IGNORE (Session Management Response Identified: informational, the session cookie) +10049 IGNORE (Non-Storable Content: informational) +10015 IGNORE (Re-examine Cache-control Directives: informational) +90004 IGNORE (Cross-Origin-Embedder-Policy: the site needs no cross-origin isolation) diff --git a/README.md b/README.md index d80ed51..2d5f1ac 100644 --- a/README.md +++ b/README.md @@ -76,13 +76,15 @@ flowchart LR Object Storage bucket, IAM policies and quotas that refuse anything outside Always Free. Backups go off the server to a bucket the server can write to but not delete from. -- **CI/CD:** build, tests and coverage gate, an Arm image on GHCR, a secret scan, - CodeQL, SonarQube Cloud, Terraform and monitoring checks; a merge to `main` deploys - over SSH, waits for the health check and rolls back if it fails. +- **CI/CD:** build, tests and coverage gate, an Arm image on GHCR scanned by Trivy and + published with an SBOM and provenance, a secret scan, CodeQL, SonarQube Cloud, + Terraform and monitoring checks, and a weekly OWASP ZAP scan of the live site; a + merge to `main` deploys over SSH, waits for the health check and rolls back if it + fails. - **Observability:** JSON logs with the collection run ID, Micrometer metrics with Prometheus, a Grafana dashboard kept as code, and alert rules with unit tests, delivered to Discord. -- **Decisions written down:** [19 architecture decision records](docs/decisions), from +- **Decisions written down:** [20 architecture decision records](docs/decisions), from the Java version to the trusted certificate authorities. ## Tech stack @@ -105,8 +107,8 @@ flowchart LR | Infrastructure | Terraform: Oracle Cloud Always Free (network, firewall, an Arm VM with cloud-init, an Object Storage bucket, IAM policies, quotas that allow only Always Free, a budget alert) | | Production | Docker Compose on the VM, Caddy (automatic HTTPS, Let's Encrypt), daily `pg_dump` backups, copied to Object Storage with rclone | | Observability | JSON logs (ECS), Micrometer metrics with Prometheus, Grafana dashboard, Alertmanager alerts to Discord, Pushgateway for the nightly backup jobs, alert rules unit-tested with promtool | -| CI/CD | GitHub Actions: build and tests, coverage gate, Arm image, secret scan (gitleaks), deploy over SSH with health check and rollback | -| Code quality | Spring Java Format (checked in the build), CodeQL, SonarQube Cloud, Dependabot | +| CI/CD | GitHub Actions: build and tests, coverage gate, Arm image, secret scan (gitleaks), image scan (Trivy) with an SBOM and provenance attestation, deploy over SSH with health check and rollback | +| Code quality | Spring Java Format (checked in the build), CodeQL, SonarQube Cloud, Dependabot, a weekly OWASP ZAP baseline scan of the live site | Why each choice was made: [docs/decisions](docs/decisions). diff --git a/deploy/Caddyfile b/deploy/Caddyfile index ba59c4f..ded394e 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -7,6 +7,8 @@ Strict-Transport-Security "max-age=31536000; includeSubDomains" X-Content-Type-Options nosniff Referrer-Policy strict-origin-when-cross-origin + # The site uses none of these browser features; no page can ask for them. + Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), browsing-topics=()" -Server } diff --git a/docs/decisions/0020-security-scans.md b/docs/decisions/0020-security-scans.md new file mode 100644 index 0000000..2ff634d --- /dev/null +++ b/docs/decisions/0020-security-scans.md @@ -0,0 +1,49 @@ +# 0020. Security scans: Trivy on the image, an SBOM, and ZAP on the live site + +**Date:** 2026-10-28 ยท **Status:** accepted + +## Context + +CodeQL reads the code and Dependabot watches the declared dependencies (ADR 0012), but +nothing looked at what actually ships: the image's operating system packages, the jars +inside it, or what the live site answers. A first ZAP scan found two real things: the +Swagger UI that springdoc brings had a DOMPurify with known vulnerabilities, and no +response set a `Permissions-Policy`. + +## Decision + +- **Trivy in the `image` job**, on the image before it is published: + - HIGH and CRITICAL findings, OS and libraries, are reported; from `main` they go to + Security, Code scanning (SARIF). + - Only a CRITICAL vulnerability with a fix, in a library the app brings, fails the + build. That one is ours to fix with a version bump; a base image waiting for its + own update doesn't block deploys. +- **An SBOM:** Trivy writes a CycloneDX list of everything in the image (the run's + artifact), and the image pushed from `main` carries BuildKit's SBOM and a provenance + attestation (which commit, which workflow). +- **OWASP ZAP baseline** every Thursday against : + passive, it only reads pages any visitor gets. `.zap/rules.tsv` says which findings + fail the run (known-vulnerable scripts, a missing `Permissions-Policy`), which are + warnings (Swagger UI without the CSP, ADR 0015) and which are noise. +- **What the scan found, fixed:** Swagger UI pinned to 5.33.2 (DOMPurify 3.4.16) until + springdoc brings it, and Caddy sets `Permissions-Policy` for features the site never + uses. + +## Consequences + +- A vulnerable library shipped in the image or a header lost in a Caddy change is + noticed within a week at most, and a fixable critical one never reaches production. +- The image job takes longer: Trivy's database download and three scans, about a + minute. +- The Trivy and ZAP images are pinned in the workflows, where Dependabot doesn't look; + they are updated by hand. + +## Alternatives considered + +- **Failing on every HIGH or CRITICAL:** most come from the base image and are fixed + upstream; deploys would stop for problems the project can't fix. +- **ZAP's full (active) scan:** it attacks forms and parameters, which on production + means junk accounts, alert rules and load; the baseline's passive checks are the + part that belongs on a live site. +- **Grype or Docker Scout instead of Trivy:** similar results; Trivy also writes the + SBOM and is the one most often met in CI pipelines. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index f63a864..a8bf66b 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -25,3 +25,4 @@ changes gets a new record that replaces the old one. | [0017](0017-offsite-backups.md) | Backups off the server: Object Storage, write-only for the server | | [0018](0018-always-free-quotas.md) | Quotas: the account only creates what Always Free covers | | [0019](0019-trusted-certificate-authorities.md) | Trusted certificate authorities follow Ubuntu's list | +| [0020](0020-security-scans.md) | Security scans: Trivy on the image, an SBOM, and ZAP on the live site | diff --git a/pom.xml b/pom.xml index db39129..892b9ac 100644 --- a/pom.xml +++ b/pom.xml @@ -31,6 +31,10 @@ 1.23.2 2.4.0 3.1.1 + + 5.33.2 0.8.15 2.1.14 0.0.48 @@ -44,6 +48,15 @@ live + + + + org.webjars + swagger-ui + ${swagger-ui.version} + + + org.springframework.boot diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 7035df4..e425be5 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -106,3 +106,9 @@ management: # Latency buckets for HTTP requests, so Prometheus can compute percentiles (p95). percentiles-histogram: http.server.requests: true + +springdoc: + swagger-ui: + # The Swagger UI files to serve: the version pom.xml pins (swagger-ui.version), filled + # in by the build. springdoc otherwise looks for the one it was built with. + version: "@swagger-ui.version@" From 397833128b5a2f67db52cdcee05f2d8d2fabf969 Mon Sep 17 00:00:00 2001 From: MCCarmo Date: Wed, 28 Oct 2026 19:16:24 +0000 Subject: [PATCH 2/2] ZAP workflow: give the report folder to ZAP's user instead of making it writable by all SonarQube Cloud (githubactions:S2612) flagged the chmod a+rwX. --- .github/workflows/zap.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/zap.yml b/.github/workflows/zap.yml index 10eafad..4403195 100644 --- a/.github/workflows/zap.yml +++ b/.github/workflows/zap.yml @@ -20,7 +20,8 @@ jobs: - name: Scan https://extpulse.mccprogrammer.com run: | - mkdir -p zap && cp .zap/rules.tsv zap/ && chmod -R a+rwX zap + # ZAP runs as the image's zap user (uid 1000) and writes its reports here. + mkdir -p zap && cp .zap/rules.tsv zap/ && sudo chown -R 1000:1000 zap # -I: warnings don't fail the run, only the rules marked FAIL. docker run --rm -v "$PWD/zap":/zap/wrk:rw ghcr.io/zaproxy/zaproxy:2.17.0 \ zap-baseline.py -t https://extpulse.mccprogrammer.com -m 2 -c rules.tsv -I \