Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 49 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
35 changes: 35 additions & 0 deletions .github/workflows/zap.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
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: |
# 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 \
-r report.html -J report.json

- name: Report
if: always()
uses: actions/upload-artifact@v7
with:
name: zap-report
path: zap/report.*
10 changes: 10 additions & 0 deletions .zap/rules.tsv
Original file line number Diff line number Diff line change
@@ -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)
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).

Expand Down
2 changes: 2 additions & 0 deletions deploy/Caddyfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
49 changes: 49 additions & 0 deletions docs/decisions/0020-security-scans.md
Original file line number Diff line number Diff line change
@@ -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 <https://extpulse.mccprogrammer.com>:
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.
1 change: 1 addition & 0 deletions docs/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
13 changes: 13 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@
<jsoup.version>1.23.2</jsoup.version>
<resilience4j.version>2.4.0</resilience4j.version>
<springdoc.version>3.1.1</springdoc.version>
<!-- springdoc 3.1.1 brings Swagger UI 5.32.14, whose DOMPurify (3.4.13) has known
vulnerabilities (ZAP flagged it); 5.33.2 updates it to 3.4.16. Drop this once
springdoc brings a newer Swagger UI itself. -->
<swagger-ui.version>5.33.2</swagger-ui.version>
<jacoco.version>0.8.15</jacoco.version>
<greenmail.version>2.1.14</greenmail.version>
<spring-javaformat.version>0.0.48</spring-javaformat.version>
Expand All @@ -44,6 +48,15 @@
<!-- Tests against the real stores run only in the weekly live-stores workflow. -->
<excludedGroups>live</excludedGroups>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>swagger-ui</artifactId>
<version>${swagger-ui.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
Expand Down
6 changes: 6 additions & 0 deletions src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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@"
Loading