diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..1a68db5e5 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,13 @@ +## Goal + + +## Changes +- + +## Testing + + +## Checklist +- [ ] Title is a clear sentence (≤ 70 chars) +- [ ] Commits are signed (`git log --show-signature`) +- [ ] `submissions/labN.md` updated diff --git a/.gitignore b/.gitignore index 1c0a1e94b..dd9df2a06 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,4 @@ Thumbs.db # *.sbom.cdx.json, zap-*.html/json, trivy-*.txt (Lab 9 scan evidence) # flake.nix, flake.lock (Lab 11) # wasm/main.go, spin.toml, go.sum (Lab 12) +.env diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 000000000..4fe5c54b8 --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,32 @@ +# ─── builder stage ─── +# 1.26: Go 1.24 stdlib (1.24.13) carries 10 HIGH CVEs, fixes only land in 1.25.8+/1.26.x +FROM golang:1.26-alpine AS builder +WORKDIR /src + + +COPY go.mod ./ +RUN go mod download + +# Then the source. +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags='-s -w' -o /quicknotes . + +RUN mkdir -p /data && chown 65532:65532 /data + +# ─── runtime stage ─── +FROM gcr.io/distroless/static:nonroot + +COPY --from=builder /quicknotes /quicknotes +COPY --from=builder --chown=65532:65532 /data /data +COPY --chown=65532:65532 seed.json /seed.json + +ENV ADDR=":8080" \ + DATA_PATH="/data/notes.json" \ + SEED_PATH="/seed.json" + +USER nonroot +EXPOSE 8080 +# exec form: distroless has no shell; the binary self-probes /health (Trivy AVD-DS-0026) +HEALTHCHECK --interval=10s --timeout=3s --retries=5 CMD ["/quicknotes", "healthcheck"] +ENTRYPOINT ["/quicknotes"] diff --git a/app/main.go b/app/main.go index e258ffcfe..cceb4440e 100644 --- a/app/main.go +++ b/app/main.go @@ -16,6 +16,16 @@ func main() { dataPath := envOrDefault("DATA_PATH", "data/notes.json") seedPath := envOrDefault("SEED_PATH", "seed.json") + // `quicknotes healthcheck` probes /health on loopback and exits 0/1. + // The distroless image has no shell or curl, so the binary checks itself. + if len(os.Args) > 1 && os.Args[1] == "healthcheck" { + resp, err := http.Get("http://127.0.0.1" + addr + "/health") + if err != nil || resp.StatusCode != http.StatusOK { + os.Exit(1) + } + os.Exit(0) + } + if err := ensureSeeded(dataPath, seedPath); err != nil { log.Fatalf("seed: %v", err) } @@ -28,7 +38,7 @@ func main() { server := NewServer(store) srv := &http.Server{ Addr: addr, - Handler: server.Routes(), + Handler: server.Handler(), ReadHeaderTimeout: 5 * time.Second, } diff --git a/app/middleware.go b/app/middleware.go new file mode 100644 index 000000000..61c5d33ab --- /dev/null +++ b/app/middleware.go @@ -0,0 +1,26 @@ +package main + +import "net/http" + +// securityHeaders sets baseline security headers on every response. +// QuickNotes is a JSON API: the strictest CSP is safe because responses are +// never rendered as a document that loads sub-resources. +func securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'") + h.Set("X-Content-Type-Options", "nosniff") + h.Set("X-Frame-Options", "DENY") + h.Set("Referrer-Policy", "no-referrer") + h.Set("Cache-Control", "no-store") + h.Set("Cross-Origin-Resource-Policy", "same-origin") + next.ServeHTTP(w, r) + }) +} + +// Handler is the full HTTP stack: router wrapped in security middleware. +// main() and the tests must both use this, so the middleware can't be +// silently dropped from production wiring without a test failing. +func (s *Server) Handler() http.Handler { + return securityHeaders(s.Routes()) +} diff --git a/app/middleware_test.go b/app/middleware_test.go new file mode 100644 index 000000000..9e5af3231 --- /dev/null +++ b/app/middleware_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "strconv" + "testing" +) + +// TestSecurityHeaders_PresentOnAllRoutes goes through Server.Handler() — the +// exact stack main() serves. If securityHeaders is removed from Handler(), +// every assertion here fails. +func TestSecurityHeaders_PresentOnAllRoutes(t *testing.T) { + srv := newTestServer(t) + n, err := srv.store.Create("t", "b") + if err != nil { + t.Fatalf("create: %v", err) + } + + want := map[string]string{ + "Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Referrer-Policy": "no-referrer", + "Cache-Control": "no-store", + + "Cross-Origin-Resource-Policy": "same-origin", + } + + targets := []struct{ method, path string }{ + {http.MethodGet, "/health"}, + {http.MethodGet, "/metrics"}, + {http.MethodGet, "/notes"}, + {http.MethodGet, "/notes/" + strconv.Itoa(n.ID)}, + {http.MethodGet, "/no-such-route"}, // 404s must carry headers too + } + + for _, tc := range targets { + req := httptest.NewRequest(tc.method, tc.path, nil) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + for header, value := range want { + if got := rec.Header().Get(header); got != value { + t.Errorf("%s %s: header %s = %q, want %q", tc.method, tc.path, header, got, value) + } + } + } +} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 000000000..1c974819a --- /dev/null +++ b/compose.yaml @@ -0,0 +1,58 @@ +# compose.yaml (repo root) +services: + # ── From Lab 6 (shown for completeness; keep YOUR Lab 6 definition) ── + quicknotes: + build: ./app + image: quicknotes:lab6 + ports: + - "8080:8080" + environment: + ADDR: ":8080" + DATA_PATH: "/data/notes.json" + SEED_PATH: "/seed.json" + volumes: + - quicknotes-data:/data + healthcheck: + # Lab 6's healthcheck. If your runtime is distroless-static (no shell, no + # wget) and this never goes "healthy", either keep Lab 6's working check + # or change prometheus' depends_on below to `condition: service_started`. + test: ["CMD", "/quicknotes", "healthcheck"] + interval: 10s + timeout: 3s + retries: 5 + start_period: 5s + restart: unless-stopped + + # ── NEW: Prometheus (Task 1.4.1) ── + prometheus: + image: prom/prometheus:v3.5.0 # pinned, real version — not :latest + volumes: + - ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ./monitoring/prometheus/rules:/etc/prometheus/rules:ro + command: + - --config.file=/etc/prometheus/prometheus.yml + ports: + - "9090:9090" # browse the Prometheus UI + depends_on: + quicknotes: + condition: service_healthy # Lab 6 healthcheck pays off here + restart: unless-stopped + + # ── NEW: Grafana (Task 1.4.2) ── + grafana: + image: grafana/grafana:13.0.2 # pinned, real version + environment: + GF_SECURITY_ADMIN_USER: admin + # Don't ship default creds. Override at runtime; see section 5. + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-changeme-please} + volumes: + - ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro + - ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro + ports: + - "3000:3000" + depends_on: + - prometheus + restart: unless-stopped + +volumes: + quicknotes-data: diff --git a/docs/runbook/high-error-rate.md b/docs/runbook/high-error-rate.md new file mode 100644 index 000000000..8a52a7e1f --- /dev/null +++ b/docs/runbook/high-error-rate.md @@ -0,0 +1,34 @@ +# Runbook — QuickNotes High Error Rate + +**Alert:** `QuickNotesHighErrorRate` · **Severity:** page + +## What this alert means +More than 5% of HTTP responses from QuickNotes have been 4xx/5xx for at least +5 minutes — users are seeing real failures right now, not a one-off blip. + +## Triage steps (in order) +1. **Confirm it's real, not a probe artifact.** Open Grafana → + *QuickNotes — Golden Signals* (http://localhost:3000) and check the **Errors** + panel is still above the red 5% line, and **Traffic** is non-zero (a divide on + near-zero traffic can spike the ratio). +2. **Find which status code dominates.** In Prometheus (http://localhost:9090), + run `topk(5, sum by (code) (rate(quicknotes_http_responses_by_code_total[5m])))`. + `5xx` ⇒ server/app fault; mostly `4xx` ⇒ bad client traffic or a broken caller. +3. **Read the logs around the spike.** `docker compose logs --since=15m quicknotes`. + Look for panics, `failed to persist note`, or a flood of `invalid JSON body`. +4. **Check saturation & the host.** Glance at the **Saturation** panel and + `docker compose ps` / `docker stats quicknotes` — is the container restarting, + OOM-killed, or out of disk for `/data`? + +## Mitigations (stop the bleeding) +- **Roll back** to the last known-good image: redeploy the previous + `quicknotes` tag and `docker compose up -d quicknotes`. +- **Restart the service** to clear a wedged process: `docker compose restart quicknotes`. +- **Shed bad traffic** if the errors are an abusive/broken client: block the + source upstream (reverse proxy / firewall) until the caller is fixed. + +## Post-incident +Once errors are back below 5%, mark the alert resolved and write a blameless +postmortem using the Lecture 1 template (`lectures/` → postmortem template): +timeline, root cause, what detected it (this alert), and the follow-up actions +to prevent recurrence. diff --git a/labs/lab1.md b/labs/lab1.md index bb4e226d9..eb319e50f 100644 --- a/labs/lab1.md +++ b/labs/lab1.md @@ -91,9 +91,20 @@ git config --global commit.gpgsign true git config --global tag.gpgsign true ``` -Tell the platform your SSH key is a **signing key**: -- GitHub: Settings → SSH and GPG keys → **New SSH key**, key type **Signing Key** -- GitLab: Profile → SSH Keys → tick "Usage type: Authentication & signing" +Now register the key on the platform. GitHub treats **Authentication** and **Signing** as *separate* roles for the same key, so you add it under both: + +- **Authentication Key** — lets you `clone` / `fetch` / `push` over SSH (`git@github.com:…`). If you cloned over HTTPS, or have never seen `ssh -T git@github.com` greet you by name, you don't have one configured yet — add it now or the `upstream` SSH remote will fail in Lab 2. +- **Signing Key** — gives your commits the **Verified** badge. + +- 🐙 GitHub: Settings → SSH and GPG keys → **New SSH key** → add the **same** `~/.ssh/id_ed25519.pub` **twice**, once with Key type **Authentication Key** and once with **Signing Key**. +- 🦊 GitLab: Profile → SSH Keys → a single key with **Usage type: Authentication & signing** covers both. + +Confirm authentication works before moving on: + +```bash +ssh -T git@github.com +# expect: Hi YOUR_USERNAME! You've successfully authenticated... +``` ### 1.4: Make a Signed Commit @@ -303,7 +314,8 @@ In `submissions/lab1.md`: ## Common Pitfalls - 🪤 **PR template doesn't auto-populate** — make sure the template is on `main` *before* opening the PR -- 🪤 **Commits show "Unverified"** — the SSH key must be added as a *Signing Key* on GitHub (not just an authentication key) +- 🪤 **Commits show "Unverified"** — the key must also be added as a **Signing Key** on GitHub; an Authentication Key alone won't verify commits (they're separate roles — see §1.3) +- 🪤 **`git@github.com: Permission denied (publickey)` on clone/fetch/push** — the *reverse* gap: your key is registered for signing but not as an **Authentication Key**. Add it as Authentication too (§1.3) and confirm with `ssh -T git@github.com`. Quick unblock for the *public* upstream: `git remote set-url upstream https://github.com/inno-devops-labs/DevOps-Intro.git` - 🪤 **`git push` rejected on `main`** — that's the bonus rule working as designed; push to `feature/lab1` instead - 🪤 **`gpg.format=ssh` ignored** — confirm Git ≥ 2.34: `git --version` - 🪤 **Pushed to the wrong branch** — `git switch feature/lab1` before `git push` diff --git a/labs/lab2.md b/labs/lab2.md index fca7b3f22..aae9acfb3 100644 --- a/labs/lab2.md +++ b/labs/lab2.md @@ -223,6 +223,7 @@ git bisect reset ## Common Pitfalls +- 🪤 **`git@github.com: Permission denied (publickey)` on `git fetch upstream`** — *not* a remote-config bug (the error is at the SSH layer, before Git reads the repo). Your key isn't registered for **authentication** on GitHub — and a **Signing Key** (Lab 1) does *not* count for auth, they're separate roles. Add the same `~/.ssh/id_ed25519.pub` as an **Authentication Key** (Lab 1 §1.3), verify with `ssh -T git@github.com`, then re-run. To unblock right now, the public upstream fetches over HTTPS with no key: `git remote set-url upstream https://github.com/inno-devops-labs/DevOps-Intro.git` - 🪤 **`reset --hard` without committing first** — your *uncommitted* edits really *are* gone (reflog only saves committed work). Always check `git status` first - 🪤 **`tag -v` says "no signature"** — you used `git tag NAME` instead of `git tag -a -s NAME -m "..."` - 🪤 **Rebase conflicts** — resolve, then `git rebase --continue`. Never `git rebase --skip` unless you know what you're skipping diff --git a/labs/lab3.md b/labs/lab3.md index 9f0970b20..87344cfb3 100644 --- a/labs/lab3.md +++ b/labs/lab3.md @@ -160,6 +160,23 @@ Tips: - GitLab: `parallel:matrix:` - Set `fail-fast: false` (GH) or equivalent so a single bad cell doesn't cancel the others — you want to *see* which combo broke +> ⚠️ **The matrix renames your checks — update branch protection (1.6) or your PR blocks forever.** A matrixed `test` job reports as `test (1.23)` and `test (1.24)`; the old required check named `test` will sit at *"Expected — Waiting for status to be reported"* indefinitely, even though every real check is green. Two fixes: +> +> 1. **Quick:** in the branch-protection rule, replace `vet`/`test` with the matrixed names (`vet (1.23)`, `vet (1.24)`, `test (1.23)`, `test (1.24)`). +> 2. **Robust (recommended):** add one aggregation job and require *only* it — then the matrix can change freely without touching protection settings: +> +> ```yaml +> ci-ok: +> if: always() +> needs: [vet, test, lint] +> runs-on: ubuntu-24.04 +> steps: +> - run: | +> test "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "false" +> ``` +> +> The `if: always()` matters — without it, a failed `needs` job *skips* `ci-ok`, and a skipped required check lets the PR through on some configurations. + ### 2.3: Skip docs-only changes Edit your trigger so the pipeline runs **only** when something in `app/` or your CI config itself changes. README edits should not burn 4 minutes of CI time. @@ -179,6 +196,8 @@ Capture wall-clock times from the CI UI for three scenarios: > 💡 To get a clean baseline, temporarily disable each optimization with a commit, take a screenshot of the run time, then restore. +> 🧪 **Expect the cache rows to be boring — that's the finding, not a failure.** QuickNotes has **zero third-party dependencies** (look at `app/go.mod` — no `require` block, no `go.sum`), so the module cache has nothing to store and total wall-clock barely moves with `cache: true` vs `cache: false`. Most of your 60–80 s is runner provisioning, checkout, and the Go toolchain download — none of which `setup-go`'s cache touches. Report what you measured and *explain why* (that's design question **f** in disguise). To see where caching *would* pay, compare the **per-step** durations (`setup-go`, `go test`) instead of job totals, and note which step a real dependency-heavy project would save on. + ### 2.5: Document In `submissions/lab3.md`: @@ -284,6 +303,8 @@ Answer in 4-6 sentences: - 🪤 **Forgot `working-directory` (or `cd app`) for Go commands** — Go modules live in `app/`, not the repo root; commands run from the root will fail with "no Go files" - 🪤 **`fail-fast: true` (the GH Actions default) in a matrix** — one fail cancels the others; you can't see *which* combo broke - 🪤 **Branch protection set on someone else's fork's `main`** — you can only protect *your* fork's `main`. The upstream course repo has its own protection +- 🪤 **PR stuck on "Expected — Waiting for status to be reported" after adding the matrix** — the matrix renamed `test` → `test (1.23)`/`test (1.24)`, but branch protection still requires the old `test` context, which will never report again. Update the required-check names or switch to the `ci-ok` aggregation job (see §2.2) +- 🪤 **"Caching didn't speed anything up"** — on a zero-dependency module that's the *correct* result, not a mistake (see §2.4); don't pad the timing table with numbers you didn't observe - 🪤 **`golangci-lint` version not pinned** — "latest" pulls a new release tomorrow that may flag your code with new rules. Pin `v2.5.0` exactly - 🪤 **GitLab CI: incorrect anchor syntax** (`<<: *name`) — GitLab is strict; use the in-platform CI Lint tool (`Project → CI/CD → Editor → Validate`) - 🪤 **Cache hits expire after 7 days of inactivity on GH** — that's expected; the cache key is what protects you against poisoning diff --git a/monitoring/grafana/dashboards/golden-signals.json b/monitoring/grafana/dashboards/golden-signals.json new file mode 100644 index 000000000..18a1f62c3 --- /dev/null +++ b/monitoring/grafana/dashboards/golden-signals.json @@ -0,0 +1,115 @@ +{ + "annotations": { "list": [] }, + "editable": true, + "graphTooltip": 0, + "links": [], + "refresh": "10s", + "schemaVersion": 39, + "tags": ["golden-signals", "quicknotes"], + "templating": { "list": [] }, + "time": { "from": "now-15m", "to": "now" }, + "timepicker": {}, + "timezone": "", + "title": "QuickNotes — Golden Signals", + "uid": "quicknotes-golden", + "version": 1, + "panels": [ + { + "id": 1, + "type": "timeseries", + "title": "Latency (proxy: request rate — no histogram exposed)", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { "unit": "reqps", "custom": { "drawStyle": "line", "fillOpacity": 10 } }, + "overrides": [] + }, + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "single" } }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "sum(rate(quicknotes_http_requests_total[5m]))", + "legendFormat": "req/s (latency proxy)" + } + ] + }, + { + "id": 2, + "type": "timeseries", + "title": "Traffic (requests / sec)", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { "unit": "reqps", "custom": { "drawStyle": "line", "fillOpacity": 10 } }, + "overrides": [] + }, + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "single" } }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "sum(rate(quicknotes_http_requests_total[5m]))", + "legendFormat": "req/s" + } + ] + }, + { + "id": 3, + "type": "timeseries", + "title": "Errors (4xx+5xx ratio)", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "min": 0, + "max": 1, + "custom": { "drawStyle": "line", "fillOpacity": 20 }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 0.05 } + ] + } + }, + "overrides": [] + }, + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "single" } }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "sum(rate(quicknotes_http_responses_by_code_total{code=~\"4..|5..\"}[5m])) / sum(rate(quicknotes_http_requests_total[5m]))", + "legendFormat": "error ratio" + } + ] + }, + { + "id": 4, + "type": "stat", + "title": "Saturation (notes stored)", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { "unit": "short", "color": { "mode": "thresholds" } }, + "overrides": [] + }, + "options": { + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "colorMode": "value", + "graphMode": "area", + "textMode": "auto" + }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "quicknotes_notes_total", + "legendFormat": "notes" + } + ] + } + ] +} diff --git a/monitoring/grafana/provisioning/dashboards/dashboard.yml b/monitoring/grafana/provisioning/dashboards/dashboard.yml new file mode 100644 index 000000000..87cb9535b --- /dev/null +++ b/monitoring/grafana/provisioning/dashboards/dashboard.yml @@ -0,0 +1,13 @@ +# monitoring/grafana/provisioning/dashboards/dashboard.yml +apiVersion: 1 + +providers: + - name: golden-signals + orgId: 1 + type: file + disableDeletion: false + updateIntervalSeconds: 10 + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards # MUST equal the mount in compose.yaml + foldersFromFilesStructure: false diff --git a/monitoring/grafana/provisioning/datasources/datasource.yml b/monitoring/grafana/provisioning/datasources/datasource.yml new file mode 100644 index 000000000..6397920b4 --- /dev/null +++ b/monitoring/grafana/provisioning/datasources/datasource.yml @@ -0,0 +1,11 @@ +# monitoring/grafana/provisioning/datasources/datasource.yml +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + uid: prometheus # dashboard panels reference this uid + access: proxy + url: http://prometheus:9090 # Compose service name, not localhost + isDefault: true + editable: false diff --git a/monitoring/prometheus/prometheus.yml b/monitoring/prometheus/prometheus.yml new file mode 100644 index 000000000..a6c4cf2bd --- /dev/null +++ b/monitoring/prometheus/prometheus.yml @@ -0,0 +1,16 @@ +# monitoring/prometheus/prometheus.yml +global: + scrape_interval: 15s # Task 1.2.1 — default cadence + evaluation_interval: 15s # how often alert rules are evaluated + +rule_files: + - /etc/prometheus/rules/*.yml # Task 2 — alert rule lives here + +scrape_configs: + - job_name: quicknotes # Task 1.2.2 — exactly one scrape job + metrics_path: /metrics + static_configs: + # Task 1.2.3 — Compose DNS resolves "quicknotes" inside the network. + # Port 8080 is what the binary LISTENS on in the container, NOT a + # published host port. This is the #1 "up == 0" pitfall. + - targets: ["quicknotes:8080"] diff --git a/monitoring/prometheus/rules/alerts.yml b/monitoring/prometheus/rules/alerts.yml new file mode 100644 index 000000000..fa0c51b50 --- /dev/null +++ b/monitoring/prometheus/rules/alerts.yml @@ -0,0 +1,23 @@ +# monitoring/prometheus/rules/alerts.yml +groups: + - name: quicknotes-golden-signals + rules: + - alert: QuickNotesHighErrorRate + # 4xx+5xx responses ÷ all requests, over a 5m window. + # The 5m rate window smooths single bursts; "for: 5m" then requires the + # breach to PERSIST — together they satisfy "sustained, not single-event" + # (Task 2.1.4). If there is zero traffic the division is absent, so the + # alert simply stays Inactive instead of firing on NaN. + expr: | + ( + sum(rate(quicknotes_http_responses_by_code_total{code=~"4..|5.."}[5m])) + / + sum(rate(quicknotes_http_requests_total[5m])) + ) > 0.05 + for: 5m + labels: + severity: page # Task 2.1.2 + annotations: + summary: "QuickNotes error rate above 5% for 5m" + description: "Error ratio is {{ $value | humanizePercentage }} (threshold 5%)." + runbook_url: "docs/runbook/high-error-rate.md" # Task 2.1.3 diff --git a/submissions/lab1.md b/submissions/lab1.md new file mode 100644 index 000000000..33da79068 --- /dev/null +++ b/submissions/lab1.md @@ -0,0 +1,93 @@ +# Lab 1 submission + +## Task 1 +Request: +``` +curl -s http://localhost:8080/health | python3 -m json.tool +``` + +Answer: +``` +{ + "notes": 5, + "status": "ok" +} +``` + +Request: +``` +curl -s http://localhost:8080/notes | python3 -m json.tool +``` + +Answer: +``` +[ + { + "id": 2, + "title": "Read app/main.go first", + "body": "Start by understanding the entry point \u2014 env vars, signal handling, graceful shutdown.", + "created_at": "2026-01-15T10:05:00Z" + }, + { + "id": 3, + "title": "DevOps mantra", + "body": "If it hurts, do it more often.", + "created_at": "2026-01-15T10:10:00Z" + }, + { + "id": 4, + "title": "Endpoint cheat-sheet", + "body": "GET /notes GET /notes/{id} POST /notes DELETE /notes/{id} GET /health GET /metrics", + "created_at": "2026-01-15T10:15:00Z" + }, + { + "id": 1, + "title": "Welcome to QuickNotes", + "body": "This is the project you'll containerize, deploy, monitor, and harden across all 10 labs.", + "created_at": "2026-01-15T10:00:00Z" + } +] +``` + +Request: +``` +curl -s -X POST http://localhost:8080/notes \ + -H 'Content-Type: application/json' \ + -d '{"title":"hello","body":"first POST"}' | python3 -m json.tool +``` + +Answer: +``` +{ + "id": 5, + "title": "hello", + "body": "first POST", + "created_at": "2026-06-05T10:51:13.503497Z" +}, +``` + +``` +git log --show-signature -1 + +commit 843a27f3ade36ea41d723f168fb3f8c9c1f7b70c (HEAD -> feature/lab1, origin/feature/lab1) +Good "git" signature for 15dnau@gmail.com with ED25519 key SHA256:k0n7/mx/uRX52s/zu9pxaN+h/IKnBJzcnuybJgthVkM +Author: Dmitrii <15dnau@gmail.com> +Date: Fri Jun 5 14:03:50 2026 +0300 + + docs(lab1): start submission + + Signed-off-by: Dmitrii <15dnau@gmail.com> +``` + +### Verified commit + +![Verified commit](verified.png "Verified commit") + +When we work with Github we trust that commit made by Dmitrii was actually made by Dmitrii. However Git itself does not verify commit's author. Anyone can set any name and make a commit, therefore we want commits to be verified. + +### GitHub Community +Why starring repositories matters in open source +For a project, stars are a signal of trust and relevance. Moreover, starring is something like bookmarking a repository. + +How following developers helps in team projects and professional growth +Following your colleagues on GitHub gives you a low-noise feed of their activity \ No newline at end of file diff --git a/submissions/lab6.md b/submissions/lab6.md new file mode 100644 index 000000000..76cef9f08 --- /dev/null +++ b/submissions/lab6.md @@ -0,0 +1,250 @@ +# Lab 6 — Containers: Dockerize QuickNotes + +## Task 1 — Multi-Stage Dockerfile (≤ 25 MB) + +### Dockerfile (`app/Dockerfile`) + +```dockerfile +# ─── builder stage ─── +FROM golang:1.24-alpine AS builder +WORKDIR /src + +# Dependency manifest first → cached unless go.mod changes. +# (QuickNotes is stdlib-only, so there is no go.sum.) +COPY go.mod ./ +RUN go mod download + +# Then the source. +COPY . . + +# Static (CGO off), stripped (-s -w), reproducible (-trimpath) binary. +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags='-s -w' -o /quicknotes . + +# Pre-create /data owned by nonroot (65532) so the named volume inherits it. +RUN mkdir -p /data && chown 65532:65532 /data + +# ─── runtime stage ─── +FROM gcr.io/distroless/static:nonroot + +COPY --from=builder /quicknotes /quicknotes +COPY --from=builder --chown=65532:65532 /data /data +COPY --chown=65532:65532 seed.json /seed.json + +ENV ADDR=":8080" \ + DATA_PATH="/data/notes.json" \ + SEED_PATH="/seed.json" + +USER nonroot +EXPOSE 8080 +ENTRYPOINT ["/quicknotes"] +``` + +> The distroless image has no shell or curl, so the healthcheck is a subcommand +> of the app binary itself (`/quicknotes healthcheck`) — added in `main.go`. It +> does an HTTP GET to `/health` on loopback and exits 0 (healthy) or 1. + +### Image size + +``` +$ docker images quicknotes:lab6 +REPOSITORY TAG IMAGE ID CREATED SIZE +quicknotes lab6 8f49caa3db13 3 minutes ago 14.9MB + +$ docker image ls | grep golang +golang 1.24 d2d2bc1c84f7 4 months ago 1.33GB +``` + +The final image is **14.9 MB** vs the **1.33 GB** full Go toolchain image. Multi-stage cut ~99% by keeping only the static binary in a distroless runtime. + +### `docker inspect` — User / ExposedPorts / Entrypoint + +``` +$ docker inspect quicknotes:lab6 | jq '.[0].Config | {User, ExposedPorts, Entrypoint, Env}' +{ + "User": "nonroot", + "ExposedPorts": { "8080/tcp": {} }, + "Entrypoint": [ "/quicknotes" ], + "Env": [ + "ADDR=:8080", + "DATA_PATH=/data/notes.json", + "SEED_PATH=/seed.json" + ] +} +``` + +### Functional test + +``` +$ docker run -d --name qn-test -p 8080:8080 quicknotes:lab6 +$ curl -s http://localhost:8080/health +{"notes":4,"status":"ok"} +$ curl -s http://localhost:8080/notes | head -c 120 +[{"id":1,"title":"Welcome to QuickNotes","body":"This is the project you'll containerize, ... +``` + +### Design Questions + +**a) Why does layer order matter?** + +Docker caches each instruction as a layer and reuses it until one of its inputs changes; once a layer is invalidated, every layer after it is rebuilt too. If I write `COPY . . && go mod download && go build`, then **any** change to a source file invalidates the `COPY` layer, so `go mod download` is forced to run again on every rebuild. If instead I write `COPY go.mod ./ && go mod download && COPY . . && go build`, the dependency layer only depends on `go.mod`, so editing source code keeps the cached dependencies and skips the download. QuickNotes is stdlib-only, so the download is almost instant here, but on a real project with many modules this is the difference between a fast rebuild and re-fetching every dependency each time. The rule from the lab — manifest before source — maximizes cache reuse. + +**b) Why `CGO_ENABLED=0`? What happens in distroless-static if you forget it?** + +`CGO_ENABLED=0` tells Go to build a fully **static** binary with no dependency on the system C library (libc) and to use the pure-Go DNS resolver instead of the C one. The `distroless/static` base is intentionally empty — it has no libc and no dynamic linker. If I forget the flag, Go may produce a **dynamically linked** binary, and when the container starts the kernel cannot find the linker/libc, so it fails with a confusing `no such file or directory` even though the binary is right there. Building static makes the binary self-contained, which is exactly what a `static` base expects. + +**c) What is `gcr.io/distroless/static:nonroot`?** + +It is a minimal base image that contains almost nothing: CA certificates, `/etc/passwd` with a `nonroot` user (UID 65532), timezone data, and a writable `/tmp` — and that is basically it. What it does **not** have is a shell, a package manager, libc, or any of the usual OS utilities. This matters for CVEs because vulnerability scanners report issues in installed packages, and there are almost no packages here, so the attack surface is tiny — Trivy typically reports **zero** HIGH/CRITICAL findings. There is also no shell for an attacker to use if they do get in, which removes a whole class of exploitation. + +**d) `-ldflags='-s -w'` and `-trimpath` — what does each do, and the cost?** + +`-ldflags='-s -w'` strips two things from the binary: `-s` removes the symbol table and `-w` removes the DWARF debug information. The benefit is a noticeably smaller binary; the cost is that debugging is harder — tools like `gdb`/`delve` lose symbol and debug data (Go panic stack traces still work). `-trimpath` removes the absolute build-machine paths (like `/Users/me/...`) from the binary and replaces them with clean module paths. The benefit is **reproducibility** — the same source builds byte-for-byte the same regardless of where it is built — and it avoids leaking local filesystem paths into the binary; the cost is again slightly less convenient debugging because the original paths are gone. + +--- + +## Task 2 — Compose + Healthcheck + Persistent Volume + +### compose.yaml (repo root) + +```yaml +services: + quicknotes: + build: + context: ./app + image: quicknotes:lab6 + ports: + - "8080:8080" + environment: + ADDR: ":8080" + DATA_PATH: "/data/notes.json" + SEED_PATH: "/seed.json" + volumes: + - quicknotes-data:/data + healthcheck: + test: ["CMD", "/quicknotes", "healthcheck"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 5s + restart: unless-stopped + + read_only: true + tmpfs: + - /tmp + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + +volumes: + quicknotes-data: +``` + +The healthcheck works (`docker compose ps` reports the container as `healthy`): + +``` +NAME IMAGE COMMAND SERVICE STATUS +devops-intro-quicknotes-1 quicknotes:lab6 "/quicknotes" quicknotes Up 9 seconds (healthy) +``` + +### Persistence test + +``` +# 1) create a note, confirm it is there +$ curl -X POST -H 'Content-Type: application/json' \ + -d '{"title":"durable","body":"survive a restart"}' http://localhost:8080/notes +{"id":5,"title":"durable","body":"survive a restart","created_at":"2026-06-23T12:51:40Z"} +$ curl -s http://localhost:8080/notes | grep durable +...,"id":5,"title":"durable",... # present ✅ + +# 2) down WITHOUT -v, then up again → note survives +$ docker compose down +$ docker compose up -d +$ curl -s http://localhost:8080/notes | grep durable +...,"id":5,"title":"durable",... # STILL present ✅ + +# 3) down -v (removes the volume), then up → note is gone +$ docker compose down -v + ✔ Volume devops-intro_quicknotes-data Removed +$ docker compose up -d +$ curl -s http://localhost:8080/notes | grep durable || echo "GONE (empty)" +GONE (empty) # destroyed ✅ +``` + +### Design Questions + +**e) Distroless has no shell. How do you healthcheck it?** + +I used the option **"a binary that's already in the image"** — the app binary itself. I added a tiny `healthcheck` subcommand to `main.go`, so `/quicknotes healthcheck` does an HTTP GET to `/health` on loopback and exits `0` (healthy) or `1`. The compose healthcheck is then `test: ["CMD", "/quicknotes", "healthcheck"]` in exec form (no shell needed). I rejected the alternatives: a **sidecar** container adds a whole extra service and does not even set the health *status* of `quicknotes` itself; a **`:debug` image with wget** pulls busybox back in and breaks the minimal/no-shell property that is the whole point; and **relying on process-liveness only** does not prove the HTTP server actually answers. Reusing the binary keeps the image minimal and gives a real HTTP check. + +**f) Why does `volumes: [quicknotes-data:/data]` survive `docker compose down`? What destroys it?** + +A **named volume** is a Docker object with its own lifecycle, separate from any container. The notes are written into that volume, not into the container's writable layer. `docker compose down` removes the containers and the network but **leaves named volumes alone**, so when I bring the stack back up the same volume is re-attached and the note is still there — which the test confirms. What destroys it is asking for it explicitly: `docker compose down -v` (or `docker volume rm devops-intro_quicknotes-data`). The test shows exactly this — after `down -v` the note is gone. + +**g) `depends_on` without `condition: service_healthy` — what does it wait for, and the bug?** + +Plain `depends_on: [quicknotes]` only waits for the container to be **started** (created and running), not for the application inside to be **ready**. The bug is a startup race: a dependent service can begin sending requests while QuickNotes is still booting/seeding and not yet listening, so it gets connection-refused or flaky failures that only happen on cold start. Adding `condition: service_healthy` makes Docker wait until the healthcheck passes before starting the dependent service, which removes the race. + +--- + +## Bonus — The 6 Security Defaults + +### Hardened `services.quicknotes` block + +```yaml + read_only: true + tmpfs: + - /tmp # writable scratch outside the /data volume + cap_drop: + - ALL # (3) drop every Linux capability (app needs none) + security_opt: + - no-new-privileges:true # (5) block setuid privilege escalation + # (1) USER nonroot + (2) distroless base come from the Dockerfile +``` + +### Verification of each default + +``` +1. USER nonroot + $ docker inspect quicknotes:lab6 --format '{{.Config.User}}' + nonroot + +2. No shell (distroless) — exec must FAIL + $ docker compose exec quicknotes sh + OCI runtime exec failed: exec: "sh": executable file not found in $PATH + exit=127 + +3. Capabilities dropped + $ docker inspect $CID --format '{{.HostConfig.CapDrop}}' + [ALL] + +4. Read-only root filesystem + $ docker inspect $CID --format '{{.HostConfig.ReadonlyRootfs}}' + true + +5. no-new-privileges + $ docker inspect $CID --format '{{.HostConfig.SecurityOpt}}' + [no-new-privileges:true] +``` + +### Trivy scan (default 6) + +``` +$ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \ + aquasec/trivy:0.59.1 image --severity HIGH,CRITICAL --no-progress quicknotes:lab6 + +quicknotes:lab6 (debian 13.5) +============================= +Total: 0 (HIGH: 0, CRITICAL: 0) ← distroless OS layer is clean + +quicknotes (gobinary) +===================== +Total: 13 (HIGH: 13, CRITICAL: 0) ← Go stdlib CVEs (net/url, crypto/x509, net/http …) +``` + +**Reading the result honestly:** the distroless **OS layer has 0 HIGH/CRITICAL**, which is exactly the value of a minimal base — there are no OS packages to be vulnerable. The 13 HIGH findings are all in `stdlib` (the Go runtime compiled into the binary, v1.24.13); their fixed versions are 1.25.x/1.26.x, so the only real remediation is bumping the Go toolchain — but Lab 6 pins the builder to Go **1.24**, so within that constraint they remain. The lesson: distroless removes the *operating system* attack surface entirely, but the application binary still carries the *language runtime's* CVEs, so a minimal base is necessary but not sufficient — you also have to keep the compiler patched. + +### Which default gives the most security per line of YAML? + +The **distroless base** (defaults 1+2, effectively one `FROM` line plus `USER nonroot`) gives the most: it removed the entire OS CVE surface (Trivy shows 0 OS findings) and removed the shell, so even a successful RCE has no `sh`, no package manager, and no tools to pivot with — that kills several exploitation classes at once. In the compose file, `cap_drop: ALL` is the best single line: one line strips every Linux capability, so the process cannot do privileged kernel operations even if compromised. `read_only: true` and `no-new-privileges:true` are each one cheap line that closes off tampering and setuid escalation respectively. Together they layer cleanly, but distroless is the highest-leverage choice. + diff --git a/submissions/lab8-alert-firing.png b/submissions/lab8-alert-firing.png new file mode 100644 index 000000000..ec920f4bc Binary files /dev/null and b/submissions/lab8-alert-firing.png differ diff --git a/submissions/lab8-checkly.png b/submissions/lab8-checkly.png new file mode 100644 index 000000000..7433da60f Binary files /dev/null and b/submissions/lab8-checkly.png differ diff --git a/submissions/lab8-dashboard.png b/submissions/lab8-dashboard.png new file mode 100644 index 000000000..8146f20ad Binary files /dev/null and b/submissions/lab8-dashboard.png differ diff --git a/submissions/lab8-saturation.png b/submissions/lab8-saturation.png new file mode 100644 index 000000000..e61eea615 Binary files /dev/null and b/submissions/lab8-saturation.png differ diff --git a/submissions/lab8.md b/submissions/lab8.md new file mode 100644 index 000000000..a54c42fbd --- /dev/null +++ b/submissions/lab8.md @@ -0,0 +1,365 @@ +# Lab 8 — SRE & Monitoring: Golden Signals Dashboard + One Good Alert + +**Author:** Dmitrii · **Branch:** `feature/lab8` + +QuickNotes (Lab 6) now runs alongside Prometheus and Grafana in the Compose stack. +Prometheus scrapes QuickNotes' `/metrics`, Grafana auto-provisions a four-panel +golden-signals dashboard and a Prometheus datasource, and a Prometheus rule pages on a +sustained >5% error rate. Everything is provisioned from files — a fresh +`docker compose up` reproduces the whole observability stack with zero manual clicks. + +--- + +## Task 1 — Prometheus + Grafana with a Provisioned Dashboard + +### Layout + +``` +monitoring/ +├── prometheus/ +│ ├── prometheus.yml +│ └── rules/ +│ └── alerts.yml +└── grafana/ + ├── dashboards/ + │ └── golden-signals.json # the dashboard (mounted at /var/lib/grafana/dashboards) + └── provisioning/ + ├── datasources/ + │ └── datasource.yml # Prometheus datasource, set default + └── dashboards/ + └── dashboard.yml # provider: load JSON from /var/lib/grafana/dashboards +``` + +### Config files + +**`monitoring/prometheus/prometheus.yml`** — 15s scrape interval, one job targeting the +QuickNotes Compose service by name + container port: + +```yaml +global: + scrape_interval: 15s + evaluation_interval: 15s + +rule_files: + - /etc/prometheus/rules/*.yml + +scrape_configs: + - job_name: quicknotes + metrics_path: /metrics + static_configs: + - targets: ["quicknotes:8080"] # Compose DNS resolves this inside the network +``` + +**`monitoring/grafana/provisioning/datasources/datasource.yml`** — Prometheus datasource, +default, pointing at the Compose service: + +```yaml +apiVersion: 1 +datasources: + - name: Prometheus + type: prometheus + uid: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: false +``` + +**`monitoring/grafana/provisioning/dashboards/dashboard.yml`** — file provider pointing at +the mount path: + +```yaml +apiVersion: 1 +providers: + - name: golden-signals + orgId: 1 + type: file + options: + path: /var/lib/grafana/dashboards +``` + +**`monitoring/grafana/dashboards/golden-signals.json`** — the dashboard. Four panels, one +per golden signal (full JSON in the repo): + +| Panel | PromQL | +|-------|--------| +| **Latency** (proxy — no histogram exposed) | `sum(rate(quicknotes_http_requests_total[5m]))` | +| **Traffic** | `sum(rate(quicknotes_http_requests_total[5m]))` | +| **Errors** | `sum(rate(quicknotes_http_responses_by_code_total{code=~"4..\|5.."}[5m])) / sum(rate(quicknotes_http_requests_total[5m]))` | +| **Saturation** | `quicknotes_notes_total` | + +> QuickNotes exposes only counters/gauges (no request-duration histogram), so the Latency +> panel uses the request-rate proxy the spec permits. + +**`compose.yaml`** extension — pinned images, read-only config mounts, healthcheck gate, +no default Grafana creds: + +```yaml + prometheus: + image: prom/prometheus:v3.5.0 + volumes: + - ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ./monitoring/prometheus/rules:/etc/prometheus/rules:ro + command: [--config.file=/etc/prometheus/prometheus.yml] + ports: ["9090:9090"] + depends_on: + quicknotes: { condition: service_healthy } + restart: unless-stopped + + grafana: + image: grafana/grafana:13.0.2 + environment: + GF_SECURITY_ADMIN_USER: admin + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-changeme-please} + volumes: + - ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro + - ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro + ports: ["3000:3000"] + depends_on: [prometheus] + restart: unless-stopped +``` + +> Lab-6 fix found while wiring this up: the QuickNotes healthcheck must invoke the binary's +> `healthcheck` **subcommand** (`["CMD", "/quicknotes", "healthcheck"]`), not a `-health` +> flag — otherwise the container never reports healthy and `depends_on: +> condition: service_healthy` blocks Prometheus from starting. + +### Verification + +Prometheus target is `up`: + +``` +$ curl -s http://localhost:9090/api/v1/targets | jq '.data.activeTargets[].health' +"up" +``` + +Grafana auto-loaded the dashboard ( `GET /api/search?type=dash-db` ): + +```json +{ "title": "QuickNotes — Golden Signals", "uid": "quicknotes-golden" } +``` + +After ~200 mixed requests + sustained traffic, all four panels show non-trivial graphs +(Traffic/Latency ≈ 0.8 req/s, Errors crossing the 5% threshold line, Saturation = 24 notes). + +![QuickNotes golden-signals dashboard with traffic](lab8-dashboard.png) + +### Design questions + +**a) Pull vs push.** Prometheus *pulls*, so **QuickNotes must be reachable from Prometheus** +at `quicknotes:8080` on the Compose network — QuickNotes never needs to know Prometheus +exists. Failure mode: if Prometheus can't reach QuickNotes, the target goes `up == 0` and +every panel goes "No data"; we lose *visibility*, but QuickNotes keeps serving users +normally. (We are blind, not down.) + +**b) `scrape_interval`.** `5s` triples sample volume → 3× storage and TSDB cardinality +pressure plus extra load on the scraped target, for little added signal. `5m` is cheap but +coarse: outages or spikes shorter than 5 minutes fall between scrapes and are invisible, and +`rate()` over short windows breaks because it needs several samples inside the window. `15s` +balances resolution against cost. + +**c) `rate()` vs `irate()` vs `delta()`.** Use **`rate()`** for Traffic. It's the +per-second average increase of a monotonic **counter** over the window, which smooths scrape +jitter and counter resets — ideal for a dashboard. `irate()` uses only the last two samples, +so it's spiky and better for fast-moving alerting expressions, not trend panels. `delta()` is +for **gauges** (non-monotonic), so it's wrong for a counter like +`quicknotes_http_requests_total`. + +**d) Why provision from files.** Reproducibility and review: the datasource and dashboard are +versioned in git, recreated identically on every `docker compose up`, survive container +re-creation, and change through PRs instead of undocumented UI clicks. No "works on my +Grafana" drift. + +--- + +## Task 2 — One Good Alert + Runbook + +### Alert rule — `monitoring/prometheus/rules/alerts.yml` + +```yaml +groups: + - name: quicknotes-golden-signals + rules: + - alert: QuickNotesHighErrorRate + expr: | + ( + sum(rate(quicknotes_http_responses_by_code_total{code=~"4..|5.."}[5m])) + / + sum(rate(quicknotes_http_requests_total[5m])) + ) > 0.05 + for: 5m + labels: + severity: page + annotations: + summary: "QuickNotes error rate above 5% for 5m" + description: "Error ratio is {{ $value | humanizePercentage }} (threshold 5%)." + runbook_url: "docs/runbook/high-error-rate.md" +``` + +Why this satisfies "sustained, not single-event": the `rate(...[5m])` window already smooths +a single 4xx burst, and `for: 5m` then requires the breach to *persist* before the alert +leaves `Pending`. A single malformed request can't page anyone. If traffic is zero the +division is absent, so the rule stays Inactive instead of firing on NaN. + +### Observed firing + +Drove ~14% errors (6 healthy GETs + 1 malformed `POST /notes` per second) sustained for +more than 5 minutes and watched the transition: + +``` +[17:43:42] state=pending +[17:43:57] state=pending +[17:44:12] state=pending +[17:44:27] state=firing ← exactly 5m after activeAt 17:39:17 +``` + +Firing payload: + +```json +{ + "labels": { "alertname": "QuickNotesHighErrorRate", "severity": "page" }, + "annotations": { + "description": "Error ratio is 12.39% (threshold 5%).", + "runbook_url": "docs/runbook/high-error-rate.md" + }, + "state": "firing", + "value": "0.1239" +} +``` + +![QuickNotesHighErrorRate firing in Prometheus](lab8-alert-firing.png) + +### Runbook + +Lives next to the code at [`docs/runbook/high-error-rate.md`](../docs/runbook/high-error-rate.md) +and is auto-linked from the alert's `runbook_url` annotation. Full text: + +> # Runbook — QuickNotes High Error Rate +> +> **Alert:** `QuickNotesHighErrorRate` · **Severity:** page +> +> ## What this alert means +> More than 5% of HTTP responses from QuickNotes have been 4xx/5xx for at least +> 5 minutes — users are seeing real failures right now, not a one-off blip. +> +> ## Triage steps (in order) +> 1. **Confirm it's real, not a probe artifact.** Open Grafana → +> *QuickNotes — Golden Signals* (http://localhost:3000) and check the **Errors** +> panel is still above the red 5% line, and **Traffic** is non-zero (a divide on +> near-zero traffic can spike the ratio). +> 2. **Find which status code dominates.** In Prometheus (http://localhost:9090), +> run `topk(5, sum by (code) (rate(quicknotes_http_responses_by_code_total[5m])))`. +> `5xx` ⇒ server/app fault; mostly `4xx` ⇒ bad client traffic or a broken caller. +> 3. **Read the logs around the spike.** `docker compose logs --since=15m quicknotes`. +> Look for panics, `failed to persist note`, or a flood of `invalid JSON body`. +> 4. **Check saturation & the host.** Glance at the **Saturation** panel and +> `docker compose ps` / `docker stats quicknotes` — is the container restarting, +> OOM-killed, or out of disk for `/data`? +> +> ## Mitigations (stop the bleeding) +> - **Roll back** to the last known-good image: redeploy the previous +> `quicknotes` tag and `docker compose up -d quicknotes`. +> - **Restart the service** to clear a wedged process: `docker compose restart quicknotes`. +> - **Shed bad traffic** if the errors are an abusive/broken client: block the +> source upstream (reverse proxy / firewall) until the caller is fixed. +> +> ## Post-incident +> Once errors are back below 5%, mark the alert resolved and write a blameless +> postmortem using the Lecture 1 template (`lectures/` → postmortem template): +> timeline, root cause, what detected it (this alert), and the follow-up actions +> to prevent recurrence. + +A 3 AM on-call who has never seen QuickNotes can act from this: every step names the exact +URL or command to run. + +### Design questions + +**e) Why sustained 5 minutes.** A single bad request (one client sending malformed JSON) +isn't an incident — paging on it trains on-call to ignore pages. Requiring the breach to hold +for 5 minutes filters transient blips and only pages on a *real, ongoing, user-facing* +problem. It trades a few minutes of detection latency for far fewer false pages. + +**f) Symptom vs cause.** Our alert is a **symptom** alert — it fires on what users actually +experience (failed responses). A **cause** alert would be e.g. "QuickNotes CPU > 90%" or +"`/data` disk > 80%". Cause alerts are worse on both sides: high CPU may never hurt a user +(false page on a proxy metric), *and* errors can happen at normal CPU (missed incident). You +end up paging on a guess about *why* instead of the *what users feel*. + +**g) Alert fatigue threshold.** If more than ~**20–30%** of pages from this alert fire when +**no user was actually harmed** (no SLO burn, traffic was near-zero, or it self-resolved +before anyone acted), the alert is too noisy and must be retuned — raise the threshold, +lengthen `for:`, or switch to an SLO burn-rate alert. Past roughly one-in-three false pages, +on-call starts reflexively dismissing it, which is worse than not having the alert. + +--- + +## Bonus — Synthetic Monitoring from the Outside + +Exposed QuickNotes publicly with `cloudflared tunnel --url http://localhost:8080` +(`https://poems-bob-statute-enhancement.trycloudflare.com`) and configured a **Checkly API +check** hitting `/health` **every 1 minute from 3 regions** (Germany 🇩🇪, UK 🇬🇧, +Japan 🇯🇵 — ≥ 2 required), asserting `status == 200` and `response time < 2000 ms`. Left it +running > 30 minutes: **100% availability, 0 failures.** + +![Checkly API check — 100% availability across 3 regions](lab8-checkly.png) + +### Internal vs external, same window + +Prometheus (last 30 min, queried directly): + +``` +$ curl -s 'http://localhost:9090/api/v1/query' \ + --data-urlencode 'query=sum(rate(quicknotes_http_requests_total[30m]))' | jq -r '.data.result[0].value[1]' +0.2168 # ≈ 0.22 req/s + +$ curl -s 'http://localhost:9090/api/v1/query' \ + --data-urlencode 'query=sum(rate(quicknotes_http_responses_by_code_total{code=~"4..|5.."}[30m])) / sum(rate(quicknotes_http_requests_total[30m]))' | jq -r '.data.result[0].value[1]' +0 # 0% errors this window +``` + +| | Prometheus (inside the Compose net) | Checkly (3 regions, external) | +|--|---|---| +| Avg latency **p50** | **N/A** — no request-duration histogram exposed | **261 ms** | +| Avg latency **p95** | **N/A** — no histogram | **1.07 s** | +| Errors observed | **0%** error ratio over 30 m (traffic ≈ 0.22 req/s) | **100%** availability; **1** latency-assertion breach (London run = 2168 ms > 2000 ms), recovered on retry | + +The most telling row is latency: **Prometheus can't report it at all**, because QuickNotes +only exposes counters/gauges. Checkly measures real end-to-end latency (261 ms p50 / +1.07 s p95) from the outside without any app instrumentation — and that p95 already includes +the Cloudflare-tunnel + cross-region network hop, which is exactly the user-perceived path +Prometheus never sees. The per-region spread makes the point concrete: **Tokyo** runs land +~325–340 ms (and occasionally > 1 s) while **Frankfurt/London** sit at ~160–275 ms — purely a +function of distance from the tunnel origin, a dimension invisible to an in-network scraper. + +One run from **London actually breached the 2000 ms assertion (2168 ms)** and opened a +Checkly Error Group; the linear retry then passed, so availability stayed 100% (retry ratio +0.23%). That single event is the whole argument in miniature — a real user-facing latency +regression that QuickNotes' internal metrics, having no latency signal at all, would never +have surfaced. + +### Failure-mode analysis + +- **What Checkly catches that Prometheus cannot:** anything *between the user and the app* — + the cloudflared tunnel or ingress going down, DNS/TLS failures, cross-region network + problems, or the whole host dying. In all those cases Prometheus, scraping from *inside* the + same Compose network, still sees a perfectly healthy app and `up == 1` while real users + outside get nothing. Checkly also catches latency/SLA regressions Prometheus is blind to + here (no histogram). +- **What Prometheus catches that Checkly cannot:** fine-grained internal signal — per-status + -code error breakdown (`quicknotes_http_responses_by_code_total`), saturation + (`quicknotes_notes_total`), and the *full* request stream. A once-a-minute external probe + on a single path statistically misses low-rate or endpoint-specific errors: our 30-min + error injection earlier was plainly visible to Prometheus (12%+ ratio) yet a 1/min Checkly + probe hitting only `/health` would likely have stayed green throughout. + +--- + +## Reproduce + +```bash +echo "GRAFANA_ADMIN_PASSWORD=$(openssl rand -base64 18)" > .env +docker compose up -d --build +curl -s http://localhost:9090/api/v1/targets | jq '.data.activeTargets[].health' # "up" +# Grafana: http://localhost:3000 (admin / $GRAFANA_ADMIN_PASSWORD) +# Prometheus: http://localhost:9090/targets and /alerts +``` diff --git a/submissions/lab9.md b/submissions/lab9.md new file mode 100644 index 000000000..7ba6e5cfd --- /dev/null +++ b/submissions/lab9.md @@ -0,0 +1,260 @@ +# Lab 9 — DevSecOps: Trivy + ZAP + govulncheck + +Tooling (pinned, never `:latest`): Trivy `aquasec/trivy:0.59.1`, ZAP `ghcr.io/zaproxy/zaproxy:2.16.1`. +All scan artifacts live in [`submissions/lab9/`](lab9/). Fix commit: `eaa6907`. +Bonus task not attempted. + +--- + +## Task 1 — Trivy: Image + Filesystem + Config + SBOM + +### 1.1 Scan outputs (tops) + +**Image scan** (`trivy image --severity HIGH,CRITICAL quicknotes:lab6`) — full output: [`lab9/trivy-image.txt`](lab9/trivy-image.txt) + +``` +quicknotes:lab6 (debian 13.5) +============================= +Total: 0 (HIGH: 0, CRITICAL: 0) + +quicknotes (gobinary) +===================== +Total: 10 (HIGH: 10, CRITICAL: 0) + +┌─────────┬────────────────┬──────────┬────────┬───────────────────┬─────────────────┬───────────────────────────────────────────────┐ +│ Library │ Vulnerability │ Severity │ Status │ Installed Version │ Fixed Version │ Title │ +├─────────┼────────────────┼──────────┼────────┼───────────────────┼─────────────────┼───────────────────────────────────────────────┤ +│ stdlib │ CVE-2026-25679 │ HIGH │ fixed │ v1.24.13 │ 1.25.8, 1.26.1 │ net/url: Incorrect parsing of IPv6 host ... │ +│ │ CVE-2026-27145 │ │ │ │ 1.25.11, 1.26.4 │ crypto/x509: DoS via excessive DNS ... │ +│ │ ... (10 HIGH total, all Go stdlib v1.24.13) │ +``` + +The distroless base (`gcr.io/distroless/static:nonroot`) is clean — **all** 10 HIGH +findings are in the **Go standard library** compiled into the binary: Go 1.24 left its +support window, fixes only land in 1.25.8+/1.26.x. One root cause → one fix: builder +image bumped `golang:1.24-alpine` → `golang:1.26-alpine` (commit `eaa6907`). + +**Re-scan after rebuild** — [`lab9/trivy-image-after.txt`](lab9/trivy-image-after.txt): + +``` +quicknotes:lab6 (debian 13.5) +============================= +Total: 0 (HIGH: 0, CRITICAL: 0) +``` +(gobinary section: no findings — 10 HIGH → **0**.) + +**Filesystem scan** (`trivy fs --severity HIGH,CRITICAL /repo`) — [`lab9/trivy-fs.txt`](lab9/trivy-fs.txt) + +``` +.vagrant/machines/default/virtualbox/private_key (secrets) +========================================================== +Total: 1 (HIGH: 1, CRITICAL: 0) + +HIGH: AsymmetricPrivateKey (private-key) +``` + +**Config scan** (`trivy config /repo`) — before the fix (first run, 2026-07-07): + +``` +app/Dockerfile (dockerfile) +=========================== +Tests: 28 (SUCCESSES: 27, FAILURES: 1) +Failures: 1 (UNKNOWN: 0, LOW: 1, MEDIUM: 0, HIGH: 0, CRITICAL: 0) + +AVD-DS-0026 (LOW): Add HEALTHCHECK instruction in your Dockerfile +``` + +After adding `HEALTHCHECK` to the Dockerfile the re-scan is clean — +[`lab9/trivy-config.txt`](lab9/trivy-config.txt) (captured post-fix) reports no failures +for `app/Dockerfile`. + +### 1.2 Triage — every HIGH/CRITICAL + +| # | Scan | ID | Component | Sev | Disposition | Reason | +|---|------|----|-----------|-----|-------------|--------| +| 1 | image | CVE-2026-25679 | stdlib `net/url` (IPv6 host parsing) | HIGH | **FIX** | Reachable — `net/http` parses request URLs on every call. Fixed by builder bump to Go 1.26, commit `eaa6907`; after-scan: 0 findings | +| 2 | image | CVE-2026-27145 | stdlib `crypto/x509` (DNS names DoS) | HIGH | **FIX** | Not reachable in practice (server is plain HTTP, no TLS), swept up by the same rebuild (`eaa6907`) | +| 3 | image | CVE-2026-32280 | stdlib `crypto/x509`/`tls` (chain building DoS) | HIGH | **FIX** | Same as #2 — unreachable (no TLS), fixed by rebuild | +| 4 | image | CVE-2026-32281 | stdlib `crypto/x509` (chain validation DoS) | HIGH | **FIX** | Same as #2 | +| 5 | image | CVE-2026-32283 | stdlib `crypto/tls` (TLS 1.3 key DoS) | HIGH | **FIX** | Same as #2 | +| 6 | image | CVE-2026-33811 | stdlib `net` (long CNAME DoS) | HIGH | **FIX** | Barely reachable — no outbound DNS except the loopback healthcheck; rebuilt anyway | +| 7 | image | CVE-2026-33814 | stdlib `net/http` HTTP/2 (SETTINGS frame DoS) | HIGH | **FIX** | HTTP/2 requires TLS (no h2c configured) → unreachable, but rebuilt | +| 8 | image | CVE-2026-39820 | stdlib `net/mail` (DoS) | HIGH | **FIX** | `net/mail` never imported → unreachable; rebuilt | +| 9 | image | CVE-2026-39836 | stdlib (ELSA-2026-22121 umbrella) | HIGH | **FIX** | Umbrella advisory for the same stdlib set; rebuilt | +| 10 | image | CVE-2026-42499 | stdlib `net/mail` (address parsing DoS) | HIGH | **FIX** | Same as #8 | +| 11 | fs | AsymmetricPrivateKey (secret) | `.vagrant/.../private_key` | HIGH | **FALSE POSITIVE** | Not a leaked secret: Vagrant auto-generates this key for local VM SSH. Evidence: `git check-ignore -v` → `.gitignore:27:.vagrant/`; `git log --all -- .vagrant` → empty. Never committed, never shipped | +| 12 | config | AVD-DS-0026 | `app/Dockerfile` — no `HEALTHCHECK` | LOW | **FIX** | Below the HIGH/CRIT bar but trivially fixable: exec-form `HEALTHCHECK CMD ["/quicknotes", "healthcheck"]` (the binary self-probes — works in distroless without a shell). Commit `eaa6907`; re-scan clean | + +The reachability column on rows 1–10 is deliberate: severity says HIGH for all ten, +reachability says only `net/url` truly matters here. All got FIX anyway because one +Dockerfile line fixes all ten at once — when the fix is cheaper than the analysis, fix. + +### SBOM — CycloneDX + +Generated with `trivy image --format cyclonedx` → [`lab9/sbom.cdx.json`](lab9/sbom.cdx.json). First 30 lines: + +```json +{ + "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "serialNumber": "urn:uuid:8fb1e95c-5c52-4c86-bbd6-af5ef27cec11", + "version": 1, + "metadata": { + "timestamp": "2026-07-07T19:07:47+00:00", + "tools": { + "components": [ + { + "type": "application", + "group": "aquasecurity", + "name": "trivy", + "version": "0.59.1" + } + ] + }, + "component": { + "bom-ref": "pkg:oci/quicknotes@sha256%3A81847428a82d84573bd5f12a9f7781dbda6cd615810a90dd06e9d699f5727e55?arch=arm64&repository_url=index.docker.io%2Flibrary%2Fquicknotes", + "type": "container", + "name": "quicknotes:lab6", + "purl": "pkg:oci/quicknotes@sha256%3A81847428a82d84573bd5f12a9f7781dbda6cd615810a90dd06e9d699f5727e55?arch=arm64&repository_url=index.docker.io%2Flibrary%2Fquicknotes", + "properties": [ + { + "name": "aquasecurity:trivy:DiffID", + "value": "sha256:187cfc6d1e3e8a40a5e64653bcd3239c140807dcf1c09e48021178705a5a6139" + }, + { + "name": "aquasecurity:trivy:DiffID", +``` + +### 1.3 Design questions + +**a) CVE severity is one input.** CVSS scores worst-case exploitability in the abstract; triage needs context: **reachability** (do we ever execute the vulnerable code path? — govulncheck's whole premise), **exploit availability** (a PoC in the wild / KEV-listed beats a theoretical 9.8), **deployment context** (a CVE in a network parser matters less if the port is never exposed; QuickNotes runs as nonroot in distroless, shrinking post-exploit blast radius), and **data at risk** (notes API vs. payment system). A reachable, exploited-in-the-wild MEDIUM outranks an unreachable CRITICAL. Our own image scan illustrates it: 10 identical-severity HIGHs, but only the `net/url` one sits on a code path this app actually executes. + +**b) Why distroless is the strongest single control.** Vulnerability count scales with what's installed. Distroless-static ships no shell, no package manager, no libc, no OS packages — whole vulnerability *classes* are absent, not merely patched: nothing for the scanner to flag and nothing for an attacker to live off after initial compromise (no `/bin/sh` to pop). Our scan shows it concretely: the OS layer contributed **zero** findings; every finding came from the one thing distroless can't remove — our own binary and its stdlib. It removes both scanner noise (fewer findings to triage forever) and real attack surface, and it can't regress silently the way "we patch promptly" can. + +**c) `.trivyignore` — right vs. theater.** Right: a *documented* FALSE POSITIVE (like our Vagrant key — with the `git check-ignore` evidence attached) or a dated ACCEPT/WATCH where each entry carries a reason, an owner, and a re-evaluation date in review-tracked form. Theater: ignoring findings to make CI green with no reason and no expiry — that's identical to disabling the scanner for those CVEs, permanently and invisibly. Test: if an entry has no explanation and no date, it's theater. + +**d) What the SBOM buys you later.** It converts "are we affected by CVE-X?" from a re-scan-everything fire drill into a database lookup. Log4Shell day: orgs with SBOMs grepped their inventory for `log4j-core` and had an affected-list in minutes — including for images built years ago whose build environments no longer exist; orgs without spent weeks rediscovering their own dependency trees. The SBOM is a point-in-time inventory of what actually shipped, queryable against *future* vulnerability knowledge. + +--- + +## Task 2 — OWASP ZAP baseline + fix + +### 2.1 Runs + +Baseline (passive only, never the active scan), pinned `zaproxy:2.16.1`, two targets — +the API root and `/notes` (a 200 endpoint) — because the first run exposed a DAST blind +spot: the spider only found 404s (`/`, `/robots.txt`, `/sitemap.xml` — a JSON API has no +root page and no links to crawl), so all header rules **PASSed vacuously**. Only the +`/notes`-targeted scan made the passive rules see a real API response. + +Reports: [`zap-before.html`](lab9/zap-before.html) / [`.json`](lab9/zap-before.json), +[`zap-before-notes.html`](lab9/zap-before-notes.html) / [`.json`](lab9/zap-before-notes.json), +and the `-after` counterparts. + +### 2.2 Triage — every finding + +| ID | Name | Risk | Affected URL | Disposition | Reason | +|----|------|------|--------------|-------------|--------| +| 10021 | X-Content-Type-Options Header Missing | Low | `/notes` (200 OK) | **FIX** | JSON served without `nosniff` invites MIME-sniffing if a response is ever coerced into a document context. Fixed: `X-Content-Type-Options: nosniff` (middleware, `eaa6907`) | +| 10049 | Storable and Cacheable Content | Informational | `/notes` (200), `/`, `/robots.txt`, `/sitemap.xml` (404s) | **FIX** | No `Cache-Control` → shared caches may store responses; on `/notes` that's user data. Fixed: `Cache-Control: no-store` (middleware, `eaa6907`) | +| 90004 | Insufficient Site Isolation Against Spectre | Low | `/notes` (200 OK) | **FIX** | Missing `Cross-Origin-Resource-Policy` lets cross-origin pages pull API responses into their process (Spectre-class reads). Fixed: `Cross-Origin-Resource-Policy: same-origin` (middleware, `eaa6907`) | +| 10116 | ZAP is Out of Date | Informational | n/a (scanner self-check) | **FALSE POSITIVE** | The alert concerns the *scanner*, not the app — ZAP is deliberately pinned to `2.16.1` per the lab's pinning requirement. Appears flakily across runs | +| 10049 (after) | Non-Storable Content | Informational | all URLs | **ACCEPT** (intended) | Appears only in the after-scans: plugin 10049 now reports the *opposite* condition — responses cannot be cached, which is exactly what `no-store` is for. Confirmation of the fix, not a defect. Re-evaluate if caching is ever wanted (by 2027-01-07) | +| — | Spider warning: `/` returned 404, expected 200 | n/a | `/` | **FALSE POSITIVE** | Not a vulnerability — QuickNotes intentionally has no `/` route; the API surface is `/health`, `/metrics`, `/notes` | + +### 2.3 The fix — middleware + guarded test (commit `eaa6907`) + +`app/middleware.go` — one middleware wrapping the whole router, applied to **all** +routes (including the mux-generated 404/405 paths no handler owns): + +```go +func securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'") + h.Set("X-Content-Type-Options", "nosniff") + h.Set("X-Frame-Options", "DENY") + h.Set("Referrer-Policy", "no-referrer") + h.Set("Cache-Control", "no-store") + h.Set("Cross-Origin-Resource-Policy", "same-origin") + next.ServeHTTP(w, r) + }) +} + +// Handler is the full HTTP stack: router wrapped in security middleware. +// main() and the tests must both use this, so the middleware can't be +// silently dropped from production wiring without a test failing. +func (s *Server) Handler() http.Handler { + return securityHeaders(s.Routes()) +} +``` + +`main.go` serves `server.Handler()` (was `server.Routes()`). The unit test +`TestSecurityHeaders_PresentOnAllRoutes` (`app/middleware_test.go`) asserts all six +headers on `/health`, `/metrics`, `/notes`, `/notes/{id}` **and** an unknown route +(404), going through the same `Handler()` the server uses. + +**Proof the test guards the fix** — middleware removed from `Handler()`, test fails: + +``` +--- FAIL: TestSecurityHeaders_PresentOnAllRoutes (0.00s) + middleware_test.go:42: GET /health: header X-Frame-Options = "", want "DENY" + middleware_test.go:42: GET /health: header Cache-Control = "", want "no-store" + middleware_test.go:42: GET /notes: header Content-Security-Policy = "", want "default-src 'none'; frame-ancestors 'none'" + middleware_test.go:42: GET /no-such-route: header X-Frame-Options = "", want "DENY" + ... (24 assertions fail across 5 routes) +FAIL quicknotes 0.503s +``` +Middleware restored → `ok quicknotes 0.380s`. + +### 2.4 Before / after + +Live check after rebuild (`curl -si http://localhost:8080/health`): + +``` +HTTP/1.1 200 OK +Cache-Control: no-store +Content-Security-Policy: default-src 'none'; frame-ancestors 'none' +Content-Type: application/json +Cross-Origin-Resource-Policy: same-origin +Referrer-Policy: no-referrer +X-Content-Type-Options: nosniff +X-Frame-Options: DENY +``` + +ZAP `/notes` target, **before** (4 WARNs): + +``` +WARN-NEW: X-Content-Type-Options Header Missing [10021] x 1 +WARN-NEW: Storable and Cacheable Content [10049] x 4 +WARN-NEW: ZAP is Out of Date [10116] x 1 +WARN-NEW: Insufficient Site Isolation Against Spectre Vulnerability [90004] x 1 +``` + +ZAP `/notes` target, **after** (fixed findings gone; 10021 and 90004 in the PASS list): + +``` +PASS: X-Content-Type-Options Header Missing [10021] +PASS: Insufficient Site Isolation Against Spectre Vulnerability [90004] +WARN-NEW: Non-Storable Content [10049] x 3 <- opposite condition: fix confirmed +WARN-NEW: ZAP is Out of Date [10116] x 1 <- scanner self-check, documented FP +``` + +### 2.5 Design questions + +**e) Why middleware, not per-handler headers.** One enforcement point vs. n copies of a convention. Per-handler `Header().Set` calls fail open: the next handler someone adds — or the 404/405 responses the router generates, which no handler owns — silently ships without headers, and no diff review catches an *absence*. Middleware wraps the whole router, so error paths and future routes are covered by construction, policy lives in one place, and a single test can guard it (our test asserts headers on an unknown route precisely to pin the no-handler path). + +**f) `default-src 'none'`.** It forbids the document from loading *any* sub-resource: scripts, styles, images, fonts, XHR/fetch, frames, media. A real website would render as broken unstyled text. QuickNotes only returns JSON — its responses are data consumed by clients, never documents that load sub-resources — so the strictest CSP costs nothing while guaranteeing that if a response were ever coerced into rendering as HTML (content-type confusion, reflected input), the browser would execute none of it. A website instead needs an allowlist of what it actually uses (`default-src 'self'; ...`) — that's why "CSP too strict" is a real pitfall there and a non-issue here. + +**g) Cost of rubber-stamping informational findings.** Two costs. (1) A real signal drowns: some "informational" items are context-dependent real issues — our own 10049 is informational, yet on `/notes` it meant user data could sit in shared caches; blanket-accepting means nobody ever made that judgment. (2) It corrodes the process: "accepted" is supposed to mean *a human read this and decided, with a date*; once it means "clicked through", every future triage table is untrustworthy and others must redo the work. Reading them is cheap exactly because they're few — the discipline is the deliverable. The flip side is also real: our 10116 genuinely is noise, and saying *why* (it's about the scanner, which we pin deliberately) is what separates a decision from a shrug. + +--- + +## Summary of changes + +| Change | Finding(s) closed | Evidence | +|--------|-------------------|----------| +| Security-headers middleware + guarded test (`app/middleware.go`, `app/middleware_test.go`, `app/main.go`) | ZAP 10021, 10049, 90004 | before/after scans, failing-test proof | +| Builder image `golang:1.24-alpine` → `1.26-alpine` | 10 HIGH stdlib CVEs | `trivy-image.txt` (10 HIGH) → `trivy-image-after.txt` (0) | +| `HEALTHCHECK` instruction in Dockerfile | Trivy AVD-DS-0026 | config scan before (1 LOW) → after (clean) | diff --git a/submissions/lab9/sbom.cdx.json b/submissions/lab9/sbom.cdx.json new file mode 100644 index 000000000..fcf845d40 --- /dev/null +++ b/submissions/lab9/sbom.cdx.json @@ -0,0 +1,474 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "serialNumber": "urn:uuid:8fb1e95c-5c52-4c86-bbd6-af5ef27cec11", + "version": 1, + "metadata": { + "timestamp": "2026-07-07T19:07:47+00:00", + "tools": { + "components": [ + { + "type": "application", + "group": "aquasecurity", + "name": "trivy", + "version": "0.59.1" + } + ] + }, + "component": { + "bom-ref": "pkg:oci/quicknotes@sha256%3A81847428a82d84573bd5f12a9f7781dbda6cd615810a90dd06e9d699f5727e55?arch=arm64&repository_url=index.docker.io%2Flibrary%2Fquicknotes", + "type": "container", + "name": "quicknotes:lab6", + "purl": "pkg:oci/quicknotes@sha256%3A81847428a82d84573bd5f12a9f7781dbda6cd615810a90dd06e9d699f5727e55?arch=arm64&repository_url=index.docker.io%2Flibrary%2Fquicknotes", + "properties": [ + { + "name": "aquasecurity:trivy:DiffID", + "value": "sha256:187cfc6d1e3e8a40a5e64653bcd3239c140807dcf1c09e48021178705a5a6139" + }, + { + "name": "aquasecurity:trivy:DiffID", + "value": "sha256:275a30dd8ce958b21daa9ad962c6fbc09f98306ee2f486b65c9075dc257b1412" + }, + { + "name": "aquasecurity:trivy:DiffID", + "value": "sha256:4cde6b0bb6f50a5f255eef7b2a42162c661cf776b803225dcac9a659e396bb6b" + }, + { + "name": "aquasecurity:trivy:DiffID", + "value": "sha256:4d049f83d9cf21d1f5cc0e11deaf36df02790d0e60c1a3829538fb4b61685368" + }, + { + "name": "aquasecurity:trivy:DiffID", + "value": "sha256:51a426917f2517abf1c6179bb2b82dcae802730342fcfaa5b4044dac4871aa0f" + }, + { + "name": "aquasecurity:trivy:DiffID", + "value": "sha256:5eff961794c0b705dcd7c7afa5c1f794143761f5b1ed8b6cf04af194fd1dbbd2" + }, + { + "name": "aquasecurity:trivy:DiffID", + "value": "sha256:5fd2536c39c0700be8b7b4344e375196da2f126842fd8ede66996a18860a3890" + }, + { + "name": "aquasecurity:trivy:DiffID", + "value": "sha256:621c35e751a51a9a9dc3e80aa0b7fe8be2a93402ea6ccd307d30852cd7776cda" + }, + { + "name": "aquasecurity:trivy:DiffID", + "value": "sha256:6f1cdceb6a3146f0ccb986521156bef8a422cdbb0863396f7f751f575ba308f4" + }, + { + "name": "aquasecurity:trivy:DiffID", + "value": "sha256:8c1a7c4365a2a1ae8d7f361675388c8094a9f30514d9dfc0e06682f9367e759f" + }, + { + "name": "aquasecurity:trivy:DiffID", + "value": "sha256:ad51d0769d16ba578106a177987dfe3d2e02c1668c852b795b2f6b024068242a" + }, + { + "name": "aquasecurity:trivy:DiffID", + "value": "sha256:af5aa97ebe6ce1604747ec1e21af7136ded391bcabe4acef882e718a87c86bcc" + }, + { + "name": "aquasecurity:trivy:DiffID", + "value": "sha256:bd3cdfae1d3fdd83a2231d608969b38b82349777c2fff9a7c12d54f8ac5c9b38" + }, + { + "name": "aquasecurity:trivy:DiffID", + "value": "sha256:bec7e6bb35e05d1284f28b10d2150c259717d91c658c4c10c08424bb9466caba" + }, + { + "name": "aquasecurity:trivy:DiffID", + "value": "sha256:c8b007d0206e4b10ed4d3b3d99dfeab47c2648e82011989fd78a5731baf33fc3" + }, + { + "name": "aquasecurity:trivy:DiffID", + "value": "sha256:cdc86dc1f5faa271071008105f61fbbbebaf921eb9a9f85a867eb7aee0964c3e" + }, + { + "name": "aquasecurity:trivy:ImageID", + "value": "sha256:81847428a82d84573bd5f12a9f7781dbda6cd615810a90dd06e9d699f5727e55" + }, + { + "name": "aquasecurity:trivy:RepoDigest", + "value": "quicknotes@sha256:81847428a82d84573bd5f12a9f7781dbda6cd615810a90dd06e9d699f5727e55" + }, + { + "name": "aquasecurity:trivy:RepoTag", + "value": "quicknotes:lab6" + }, + { + "name": "aquasecurity:trivy:SchemaVersion", + "value": "2" + } + ] + } + }, + "components": [ + { + "bom-ref": "b05d0e50-e166-48f3-b4a7-4b5a28e03910", + "type": "application", + "name": "quicknotes", + "properties": [ + { + "name": "aquasecurity:trivy:Class", + "value": "lang-pkgs" + }, + { + "name": "aquasecurity:trivy:Type", + "value": "gobinary" + } + ] + }, + { + "bom-ref": "cb13a64d-4d93-4a29-86b0-df82dbcae2b4", + "type": "operating-system", + "name": "debian", + "version": "13.5", + "properties": [ + { + "name": "aquasecurity:trivy:Class", + "value": "os-pkgs" + }, + { + "name": "aquasecurity:trivy:Type", + "value": "debian" + } + ] + }, + { + "bom-ref": "pkg:deb/debian/base-files@13.8%2Bdeb13u5?arch=arm64&distro=debian-13.5", + "type": "library", + "supplier": { + "name": "Santiago Vila " + }, + "name": "base-files", + "version": "13.8+deb13u5", + "licenses": [ + { + "license": { + "name": "GPL-2.0-or-later" + } + }, + { + "license": { + "name": "verbatim" + } + } + ], + "purl": "pkg:deb/debian/base-files@13.8%2Bdeb13u5?arch=arm64&distro=debian-13.5", + "properties": [ + { + "name": "aquasecurity:trivy:LayerDiffID", + "value": "sha256:8c1a7c4365a2a1ae8d7f361675388c8094a9f30514d9dfc0e06682f9367e759f" + }, + { + "name": "aquasecurity:trivy:LayerDigest", + "value": "sha256:115c774471ecdf6d6bf2f7f3ff02075abc7092bca65df86b8b013699ecb2eb0a" + }, + { + "name": "aquasecurity:trivy:PkgID", + "value": "base-files@13.8+deb13u5" + }, + { + "name": "aquasecurity:trivy:PkgType", + "value": "debian" + }, + { + "name": "aquasecurity:trivy:SrcName", + "value": "base-files" + }, + { + "name": "aquasecurity:trivy:SrcVersion", + "value": "13.8+deb13u5" + } + ] + }, + { + "bom-ref": "pkg:deb/debian/media-types@13.0.0?arch=all&distro=debian-13.5", + "type": "library", + "supplier": { + "name": "Mime-Support Packagers " + }, + "name": "media-types", + "version": "13.0.0", + "licenses": [ + { + "license": { + "name": "ad-hoc" + } + } + ], + "purl": "pkg:deb/debian/media-types@13.0.0?arch=all&distro=debian-13.5", + "properties": [ + { + "name": "aquasecurity:trivy:LayerDiffID", + "value": "sha256:275a30dd8ce958b21daa9ad962c6fbc09f98306ee2f486b65c9075dc257b1412" + }, + { + "name": "aquasecurity:trivy:LayerDigest", + "value": "sha256:d6b1b89eccacc15c2420b2776d72c1dae334a00805ed9af54bf2f71e4d536f28" + }, + { + "name": "aquasecurity:trivy:PkgID", + "value": "media-types@13.0.0" + }, + { + "name": "aquasecurity:trivy:PkgType", + "value": "debian" + }, + { + "name": "aquasecurity:trivy:SrcName", + "value": "media-types" + }, + { + "name": "aquasecurity:trivy:SrcVersion", + "value": "13.0.0" + } + ] + }, + { + "bom-ref": "pkg:deb/debian/netbase@6.5?arch=all&distro=debian-13.5", + "type": "library", + "supplier": { + "name": "Marco d'Itri " + }, + "name": "netbase", + "version": "6.5", + "licenses": [ + { + "license": { + "name": "GPL-2.0-only" + } + } + ], + "purl": "pkg:deb/debian/netbase@6.5?arch=all&distro=debian-13.5", + "properties": [ + { + "name": "aquasecurity:trivy:LayerDiffID", + "value": "sha256:621c35e751a51a9a9dc3e80aa0b7fe8be2a93402ea6ccd307d30852cd7776cda" + }, + { + "name": "aquasecurity:trivy:LayerDigest", + "value": "sha256:c172f21841dff4c8cf45cde46589c1c2616cefe7e819965e92e6d3475c428aa0" + }, + { + "name": "aquasecurity:trivy:PkgID", + "value": "netbase@6.5" + }, + { + "name": "aquasecurity:trivy:PkgType", + "value": "debian" + }, + { + "name": "aquasecurity:trivy:SrcName", + "value": "netbase" + }, + { + "name": "aquasecurity:trivy:SrcVersion", + "value": "6.5" + } + ] + }, + { + "bom-ref": "pkg:deb/debian/tzdata-legacy@2026b-0%2Bdeb13u1?arch=all&distro=debian-13.5", + "type": "library", + "supplier": { + "name": "GNU Libc Maintainers " + }, + "name": "tzdata-legacy", + "version": "2026b-0+deb13u1", + "licenses": [ + { + "license": { + "name": "public-domain" + } + } + ], + "purl": "pkg:deb/debian/tzdata-legacy@2026b-0%2Bdeb13u1?arch=all&distro=debian-13.5", + "properties": [ + { + "name": "aquasecurity:trivy:LayerDiffID", + "value": "sha256:bec7e6bb35e05d1284f28b10d2150c259717d91c658c4c10c08424bb9466caba" + }, + { + "name": "aquasecurity:trivy:LayerDigest", + "value": "sha256:99ba982a9142213c751a1709dcf088e63d8601f03b3f211bae037be698fef270" + }, + { + "name": "aquasecurity:trivy:PkgID", + "value": "tzdata-legacy@2026b-0+deb13u1" + }, + { + "name": "aquasecurity:trivy:PkgType", + "value": "debian" + }, + { + "name": "aquasecurity:trivy:SrcName", + "value": "tzdata" + }, + { + "name": "aquasecurity:trivy:SrcRelease", + "value": "0+deb13u1" + }, + { + "name": "aquasecurity:trivy:SrcVersion", + "value": "2026b" + } + ] + }, + { + "bom-ref": "pkg:deb/debian/tzdata@2026b-0%2Bdeb13u1?arch=all&distro=debian-13.5", + "type": "library", + "supplier": { + "name": "GNU Libc Maintainers " + }, + "name": "tzdata", + "version": "2026b-0+deb13u1", + "licenses": [ + { + "license": { + "name": "public-domain" + } + } + ], + "purl": "pkg:deb/debian/tzdata@2026b-0%2Bdeb13u1?arch=all&distro=debian-13.5", + "properties": [ + { + "name": "aquasecurity:trivy:LayerDiffID", + "value": "sha256:c8b007d0206e4b10ed4d3b3d99dfeab47c2648e82011989fd78a5731baf33fc3" + }, + { + "name": "aquasecurity:trivy:LayerDigest", + "value": "sha256:99515e7b4d35e0652d3b0fde571b6ec269222ecacc506f026e1758d6261e9109" + }, + { + "name": "aquasecurity:trivy:PkgID", + "value": "tzdata@2026b-0+deb13u1" + }, + { + "name": "aquasecurity:trivy:PkgType", + "value": "debian" + }, + { + "name": "aquasecurity:trivy:SrcName", + "value": "tzdata" + }, + { + "name": "aquasecurity:trivy:SrcRelease", + "value": "0+deb13u1" + }, + { + "name": "aquasecurity:trivy:SrcVersion", + "value": "2026b" + } + ] + }, + { + "bom-ref": "pkg:golang/quicknotes", + "type": "library", + "name": "quicknotes", + "purl": "pkg:golang/quicknotes", + "properties": [ + { + "name": "aquasecurity:trivy:LayerDiffID", + "value": "sha256:5eff961794c0b705dcd7c7afa5c1f794143761f5b1ed8b6cf04af194fd1dbbd2" + }, + { + "name": "aquasecurity:trivy:LayerDigest", + "value": "sha256:398156325fd8447921e67b7e384444a12a3234be8f3f26396ddd9b757277df7f" + }, + { + "name": "aquasecurity:trivy:PkgID", + "value": "quicknotes" + }, + { + "name": "aquasecurity:trivy:PkgType", + "value": "gobinary" + } + ] + }, + { + "bom-ref": "pkg:golang/stdlib@v1.24.13", + "type": "library", + "name": "stdlib", + "version": "v1.24.13", + "purl": "pkg:golang/stdlib@v1.24.13", + "properties": [ + { + "name": "aquasecurity:trivy:LayerDiffID", + "value": "sha256:5eff961794c0b705dcd7c7afa5c1f794143761f5b1ed8b6cf04af194fd1dbbd2" + }, + { + "name": "aquasecurity:trivy:LayerDigest", + "value": "sha256:398156325fd8447921e67b7e384444a12a3234be8f3f26396ddd9b757277df7f" + }, + { + "name": "aquasecurity:trivy:PkgID", + "value": "stdlib@v1.24.13" + }, + { + "name": "aquasecurity:trivy:PkgType", + "value": "gobinary" + } + ] + } + ], + "dependencies": [ + { + "ref": "b05d0e50-e166-48f3-b4a7-4b5a28e03910", + "dependsOn": [ + "pkg:golang/quicknotes" + ] + }, + { + "ref": "cb13a64d-4d93-4a29-86b0-df82dbcae2b4", + "dependsOn": [ + "pkg:deb/debian/base-files@13.8%2Bdeb13u5?arch=arm64&distro=debian-13.5", + "pkg:deb/debian/media-types@13.0.0?arch=all&distro=debian-13.5", + "pkg:deb/debian/netbase@6.5?arch=all&distro=debian-13.5", + "pkg:deb/debian/tzdata-legacy@2026b-0%2Bdeb13u1?arch=all&distro=debian-13.5", + "pkg:deb/debian/tzdata@2026b-0%2Bdeb13u1?arch=all&distro=debian-13.5" + ] + }, + { + "ref": "pkg:deb/debian/base-files@13.8%2Bdeb13u5?arch=arm64&distro=debian-13.5", + "dependsOn": [] + }, + { + "ref": "pkg:deb/debian/media-types@13.0.0?arch=all&distro=debian-13.5", + "dependsOn": [] + }, + { + "ref": "pkg:deb/debian/netbase@6.5?arch=all&distro=debian-13.5", + "dependsOn": [] + }, + { + "ref": "pkg:deb/debian/tzdata-legacy@2026b-0%2Bdeb13u1?arch=all&distro=debian-13.5", + "dependsOn": [] + }, + { + "ref": "pkg:deb/debian/tzdata@2026b-0%2Bdeb13u1?arch=all&distro=debian-13.5", + "dependsOn": [] + }, + { + "ref": "pkg:golang/quicknotes", + "dependsOn": [ + "pkg:golang/stdlib@v1.24.13" + ] + }, + { + "ref": "pkg:golang/stdlib@v1.24.13", + "dependsOn": [] + }, + { + "ref": "pkg:oci/quicknotes@sha256%3A81847428a82d84573bd5f12a9f7781dbda6cd615810a90dd06e9d699f5727e55?arch=arm64&repository_url=index.docker.io%2Flibrary%2Fquicknotes", + "dependsOn": [ + "b05d0e50-e166-48f3-b4a7-4b5a28e03910", + "cb13a64d-4d93-4a29-86b0-df82dbcae2b4" + ] + } + ], + "vulnerabilities": [] +} diff --git a/submissions/lab9/trivy-config.txt b/submissions/lab9/trivy-config.txt new file mode 100644 index 000000000..e69de29bb diff --git a/submissions/lab9/trivy-fs.txt b/submissions/lab9/trivy-fs.txt new file mode 100644 index 000000000..1d6041191 --- /dev/null +++ b/submissions/lab9/trivy-fs.txt @@ -0,0 +1,16 @@ + +.vagrant/machines/default/virtualbox/private_key (secrets) +========================================================== +Total: 1 (HIGH: 1, CRITICAL: 0) + +HIGH: AsymmetricPrivateKey (private-key) +════════════════════════════════════════ +Asymmetric Private Key +──────────────────────────────────────── + .vagrant/machines/default/virtualbox/private_key:1 +──────────────────────────────────────── + 1 [ BEGIN OPENSSH PRIVATE KEY-----*******************************************************************************************************************************************************************************************************************************************************************************************************************************************-----END OPENSSH PRI + 2 +──────────────────────────────────────── + + diff --git a/submissions/lab9/trivy-image-after.txt b/submissions/lab9/trivy-image-after.txt new file mode 100644 index 000000000..880863e9e --- /dev/null +++ b/submissions/lab9/trivy-image-after.txt @@ -0,0 +1,5 @@ + +quicknotes:lab6 (debian 13.5) +============================= +Total: 0 (HIGH: 0, CRITICAL: 0) + diff --git a/submissions/lab9/trivy-image.txt b/submissions/lab9/trivy-image.txt new file mode 100644 index 000000000..e8fb212f5 --- /dev/null +++ b/submissions/lab9/trivy-image.txt @@ -0,0 +1,52 @@ + +quicknotes:lab6 (debian 13.5) +============================= +Total: 0 (HIGH: 0, CRITICAL: 0) + + +quicknotes (gobinary) +===================== +Total: 10 (HIGH: 10, CRITICAL: 0) + +┌─────────┬────────────────┬──────────┬────────┬───────────────────┬─────────────────┬──────────────────────────────────────────────────────────────┐ +│ Library │ Vulnerability │ Severity │ Status │ Installed Version │ Fixed Version │ Title │ +├─────────┼────────────────┼──────────┼────────┼───────────────────┼─────────────────┼──────────────────────────────────────────────────────────────┤ +│ stdlib │ CVE-2026-25679 │ HIGH │ fixed │ v1.24.13 │ 1.25.8, 1.26.1 │ net/url: Incorrect parsing of IPv6 host literals in net/url │ +│ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2026-25679 │ +│ ├────────────────┤ │ │ ├─────────────────┼──────────────────────────────────────────────────────────────┤ +│ │ CVE-2026-27145 │ │ │ │ 1.25.11, 1.26.4 │ crypto/x509: golang: golang crypto/x509: Denial of Service │ +│ │ │ │ │ │ │ via excessive processing of DNS... │ +│ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2026-27145 │ +│ ├────────────────┤ │ │ ├─────────────────┼──────────────────────────────────────────────────────────────┤ +│ │ CVE-2026-32280 │ │ │ │ 1.25.9, 1.26.2 │ crypto/x509: crypto/tls: golang: Go: Denial of Service │ +│ │ │ │ │ │ │ vulnerability in certificate chain building... │ +│ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2026-32280 │ +│ ├────────────────┤ │ │ │ ├──────────────────────────────────────────────────────────────┤ +│ │ CVE-2026-32281 │ │ │ │ │ crypto/x509: golang: Go crypto/x509: Denial of Service via │ +│ │ │ │ │ │ │ inefficient certificate chain validation... │ +│ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2026-32281 │ +│ ├────────────────┤ │ │ │ ├──────────────────────────────────────────────────────────────┤ +│ │ CVE-2026-32283 │ │ │ │ │ crypto/tls: golang: Go crypto/tls: Denial of Service via │ +│ │ │ │ │ │ │ multiple TLS 1.3 key... │ +│ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2026-32283 │ +│ ├────────────────┤ │ │ ├─────────────────┼──────────────────────────────────────────────────────────────┤ +│ │ CVE-2026-33811 │ │ │ │ 1.25.10, 1.26.3 │ net: golang: Go net package: Denial of Service via long │ +│ │ │ │ │ │ │ CNAME response... │ +│ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2026-33811 │ +│ ├────────────────┤ │ │ │ ├──────────────────────────────────────────────────────────────┤ +│ │ CVE-2026-33814 │ │ │ │ │ net/http/internal/http2: golang: golang.org/x/net: Go │ +│ │ │ │ │ │ │ HTTP/2: Denial of Service via malformed │ +│ │ │ │ │ │ │ SETTINGS_MAX_FRAME_SIZE frame... │ +│ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2026-33814 │ +│ ├────────────────┤ │ │ │ ├──────────────────────────────────────────────────────────────┤ +│ │ CVE-2026-39820 │ │ │ │ │ net/mail: golang: Go net/mail: Denial of Service via crafted │ +│ │ │ │ │ │ │ email inputs │ +│ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2026-39820 │ +│ ├────────────────┤ │ │ │ ├──────────────────────────────────────────────────────────────┤ +│ │ CVE-2026-39836 │ │ │ │ │ ELSA-2026-22121: golang security update (IMPORTANT) │ +│ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2026-39836 │ +│ ├────────────────┤ │ │ │ ├──────────────────────────────────────────────────────────────┤ +│ │ CVE-2026-42499 │ │ │ │ │ net/mail: golang: net/mail: Denial of Service via │ +│ │ │ │ │ │ │ pathological email address parsing │ +│ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2026-42499 │ +└─────────┴────────────────┴──────────┴────────┴───────────────────┴─────────────────┴──────────────────────────────────────────────────────────────┘ diff --git a/submissions/lab9/zap-after-notes.html b/submissions/lab9/zap-after-notes.html new file mode 100644 index 000000000..47ff62e81 --- /dev/null +++ b/submissions/lab9/zap-after-notes.html @@ -0,0 +1,619 @@ + + + + +ZAP Scanning Report + + + +

+ + + ZAP Scanning Report +

+

+ + +

+ + Site: http://host.docker.internal:8080 + +

+ +

+ Generated on Tue, 7 Jul 2026 20:02:47 +

+ +

+ ZAP Version: 2.16.1 +

+ +

+ ZAP by Checkmarx +

+ + +

Summary of Alerts

+ + + + + + + + + + + + + + + + + + + + + + + + + +
Risk LevelNumber of Alerts
+
High
+
+
0
+
+
Medium
+
+
0
+
+
Low
+
+
1
+
+
Informational
+
+
1
+
+
False Positives:
+
+
0
+
+
+ + + + +

Summary of Sequences

+

For each step: result (Pass/Fail) - risk (of highest alert(s) for the step, if any).

+ + + + + + +

Alerts

+ + + + + + + + + + + + + + + + + + + + + + +
NameRisk LevelNumber of Instances
ZAP is Out of DateLow1
Non-Storable ContentInformational3
+
+ + + +

Alert Detail

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
Low
ZAP is Out of Date
Description +
The version of ZAP you are using to test your app is out of date and is no longer being updated.
+
+ +
The risk level is set based on how out of date your ZAP version is.
+ +
URLhttp://host.docker.internal:8080/sitemap.xml
MethodGET
Parameter
Attack
Evidence
Other InfoThe latest version of ZAP is 2.17.0
Instances1
Solution +
Download the latest version of ZAP from https://www.zaproxy.org/download/ and install it.
+ +
Reference + https://www.zaproxy.org/download/ + +
CWE Id1104
WASC Id45
Plugin Id10116
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
Informational
Non-Storable Content
Description +
The response contents are not storable by caching components such as proxy servers. If the response does not contain sensitive, personal or user-specific information, it may benefit from being stored and cached, to improve performance.
+ +
URLhttp://host.docker.internal:8080/
MethodGET
Parameter
Attack
Evidenceno-store
Other Info
URLhttp://host.docker.internal:8080/notes
MethodGET
Parameter
Attack
Evidenceno-store
Other Info
URLhttp://host.docker.internal:8080/sitemap.xml
MethodGET
Parameter
Attack
Evidenceno-store
Other Info
Instances3
Solution +
The content may be marked as storable by ensuring that the following conditions are satisfied:
+
+ +
The request method must be understood by the cache and defined as being cacheable ("GET", "HEAD", and "POST" are currently defined as cacheable)
+
+ +
The response status code must be understood by the cache (one of the 1XX, 2XX, 3XX, 4XX, or 5XX response classes are generally understood)
+
+ +
The "no-store" cache directive must not appear in the request or response header fields
+
+ +
For caching by "shared" caches such as "proxy" caches, the "private" response directive must not appear in the response
+
+ +
For caching by "shared" caches such as "proxy" caches, the "Authorization" header field must not appear in the request, unless the response explicitly allows it (using one of the "must-revalidate", "public", or "s-maxage" Cache-Control response directives)
+
+ +
In addition to the conditions above, at least one of the following conditions must also be satisfied by the response:
+
+ +
It must contain an "Expires" header field
+
+ +
It must contain a "max-age" response directive
+
+ +
For "shared" caches such as "proxy" caches, it must contain a "s-maxage" response directive
+
+ +
It must contain a "Cache Control Extension" that allows it to be cached
+
+ +
It must have a status code that is defined as cacheable by default (200, 203, 204, 206, 300, 301, 404, 405, 410, 414, 501).
+ +
Reference + https://datatracker.ietf.org/doc/html/rfc7234 +
+ + https://datatracker.ietf.org/doc/html/rfc7231 +
+ + https://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html + +
CWE Id524
WASC Id13
Plugin Id10049
+
+ + + + + +

Sequence Details

+ With the associated active scan results. + + + +
+ + + + + + + diff --git a/submissions/lab9/zap-after-notes.json b/submissions/lab9/zap-after-notes.json new file mode 100644 index 000000000..33d8250ad --- /dev/null +++ b/submissions/lab9/zap-after-notes.json @@ -0,0 +1,99 @@ +{ + "@programName": "ZAP", + "@version": "2.16.1", + "@generated": "Tue, 7 Jul 2026 20:02:47", + "created": "2026-07-07T20:02:47.453042010Z", + "site":[ + { + "@name": "http://host.docker.internal:8080", + "@host": "host.docker.internal", + "@port": "8080", + "@ssl": "false", + "alerts": [ + { + "pluginid": "10116", + "alertRef": "10116", + "alert": "ZAP is Out of Date", + "name": "ZAP is Out of Date", + "riskcode": "1", + "confidence": "3", + "riskdesc": "Low (High)", + "desc": "

The version of ZAP you are using to test your app is out of date and is no longer being updated.

The risk level is set based on how out of date your ZAP version is.

", + "instances":[ + { + "id": "3", + "uri": "http://host.docker.internal:8080/sitemap.xml", + "nodeName": null, + "method": "GET", + "param": "", + "attack": "", + "evidence": "", + "otherinfo": "The latest version of ZAP is 2.17.0" + } + ], + "count": "1", + "systemic": false, + "solution": "

Download the latest version of ZAP from https://www.zaproxy.org/download/ and install it.

", + "otherinfo": "

The latest version of ZAP is 2.17.0

", + "reference": "

https://www.zaproxy.org/download/

", + "cweid": "1104", + "wascid": "45", + "sourceid": "10" + }, + { + "pluginid": "10049", + "alertRef": "10049-1", + "alert": "Non-Storable Content", + "name": "Non-Storable Content", + "riskcode": "0", + "confidence": "2", + "riskdesc": "Informational (Medium)", + "desc": "

The response contents are not storable by caching components such as proxy servers. If the response does not contain sensitive, personal or user-specific information, it may benefit from being stored and cached, to improve performance.

", + "instances":[ + { + "id": "2", + "uri": "http://host.docker.internal:8080/", + "nodeName": null, + "method": "GET", + "param": "", + "attack": "", + "evidence": "no-store", + "otherinfo": "" + }, + { + "id": "5", + "uri": "http://host.docker.internal:8080/notes", + "nodeName": null, + "method": "GET", + "param": "", + "attack": "", + "evidence": "no-store", + "otherinfo": "" + }, + { + "id": "4", + "uri": "http://host.docker.internal:8080/sitemap.xml", + "nodeName": null, + "method": "GET", + "param": "", + "attack": "", + "evidence": "no-store", + "otherinfo": "" + } + ], + "count": "3", + "systemic": false, + "solution": "

The content may be marked as storable by ensuring that the following conditions are satisfied:

The request method must be understood by the cache and defined as being cacheable (\"GET\", \"HEAD\", and \"POST\" are currently defined as cacheable)

The response status code must be understood by the cache (one of the 1XX, 2XX, 3XX, 4XX, or 5XX response classes are generally understood)

The \"no-store\" cache directive must not appear in the request or response header fields

For caching by \"shared\" caches such as \"proxy\" caches, the \"private\" response directive must not appear in the response

For caching by \"shared\" caches such as \"proxy\" caches, the \"Authorization\" header field must not appear in the request, unless the response explicitly allows it (using one of the \"must-revalidate\", \"public\", or \"s-maxage\" Cache-Control response directives)

In addition to the conditions above, at least one of the following conditions must also be satisfied by the response:

It must contain an \"Expires\" header field

It must contain a \"max-age\" response directive

For \"shared\" caches such as \"proxy\" caches, it must contain a \"s-maxage\" response directive

It must contain a \"Cache Control Extension\" that allows it to be cached

It must have a status code that is defined as cacheable by default (200, 203, 204, 206, 300, 301, 404, 405, 410, 414, 501).

", + "otherinfo": "", + "reference": "

https://datatracker.ietf.org/doc/html/rfc7234

https://datatracker.ietf.org/doc/html/rfc7231

https://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html

", + "cweid": "524", + "wascid": "13", + "sourceid": "9" + } + ] + } + ], + "sequences":[ + ] + +} diff --git a/submissions/lab9/zap-after.html b/submissions/lab9/zap-after.html new file mode 100644 index 000000000..8867d7003 --- /dev/null +++ b/submissions/lab9/zap-after.html @@ -0,0 +1,587 @@ + + + + +ZAP Scanning Report + + + +

+ + + ZAP Scanning Report +

+

+ + +

+ + Site: http://host.docker.internal:8080 + +

+ +

+ Generated on Tue, 7 Jul 2026 20:02:22 +

+ +

+ ZAP Version: 2.16.1 +

+ +

+ ZAP by Checkmarx +

+ + +

Summary of Alerts

+ + + + + + + + + + + + + + + + + + + + + + + + + +
Risk LevelNumber of Alerts
+
High
+
+
0
+
+
Medium
+
+
0
+
+
Low
+
+
1
+
+
Informational
+
+
1
+
+
False Positives:
+
+
0
+
+
+ + + + +

Summary of Sequences

+

For each step: result (Pass/Fail) - risk (of highest alert(s) for the step, if any).

+ + + + + + +

Alerts

+ + + + + + + + + + + + + + + + + + + + + + +
NameRisk LevelNumber of Instances
ZAP is Out of DateLow1
Non-Storable ContentInformational2
+
+ + + +

Alert Detail

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
Low
ZAP is Out of Date
Description +
The version of ZAP you are using to test your app is out of date and is no longer being updated.
+
+ +
The risk level is set based on how out of date your ZAP version is.
+ +
URLhttp://host.docker.internal:8080/robots.txt
MethodGET
Parameter
Attack
Evidence
Other InfoThe latest version of ZAP is 2.17.0
Instances1
Solution +
Download the latest version of ZAP from https://www.zaproxy.org/download/ and install it.
+ +
Reference + https://www.zaproxy.org/download/ + +
CWE Id1104
WASC Id45
Plugin Id10116
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
Informational
Non-Storable Content
Description +
The response contents are not storable by caching components such as proxy servers. If the response does not contain sensitive, personal or user-specific information, it may benefit from being stored and cached, to improve performance.
+ +
URLhttp://host.docker.internal:8080/robots.txt
MethodGET
Parameter
Attack
Evidenceno-store
Other Info
URLhttp://host.docker.internal:8080/sitemap.xml
MethodGET
Parameter
Attack
Evidenceno-store
Other Info
Instances2
Solution +
The content may be marked as storable by ensuring that the following conditions are satisfied:
+
+ +
The request method must be understood by the cache and defined as being cacheable ("GET", "HEAD", and "POST" are currently defined as cacheable)
+
+ +
The response status code must be understood by the cache (one of the 1XX, 2XX, 3XX, 4XX, or 5XX response classes are generally understood)
+
+ +
The "no-store" cache directive must not appear in the request or response header fields
+
+ +
For caching by "shared" caches such as "proxy" caches, the "private" response directive must not appear in the response
+
+ +
For caching by "shared" caches such as "proxy" caches, the "Authorization" header field must not appear in the request, unless the response explicitly allows it (using one of the "must-revalidate", "public", or "s-maxage" Cache-Control response directives)
+
+ +
In addition to the conditions above, at least one of the following conditions must also be satisfied by the response:
+
+ +
It must contain an "Expires" header field
+
+ +
It must contain a "max-age" response directive
+
+ +
For "shared" caches such as "proxy" caches, it must contain a "s-maxage" response directive
+
+ +
It must contain a "Cache Control Extension" that allows it to be cached
+
+ +
It must have a status code that is defined as cacheable by default (200, 203, 204, 206, 300, 301, 404, 405, 410, 414, 501).
+ +
Reference + https://datatracker.ietf.org/doc/html/rfc7234 +
+ + https://datatracker.ietf.org/doc/html/rfc7231 +
+ + https://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html + +
CWE Id524
WASC Id13
Plugin Id10049
+
+ + + + + +

Sequence Details

+ With the associated active scan results. + + + +
+ + + + + + + diff --git a/submissions/lab9/zap-after.json b/submissions/lab9/zap-after.json new file mode 100644 index 000000000..2358c10c2 --- /dev/null +++ b/submissions/lab9/zap-after.json @@ -0,0 +1,89 @@ +{ + "@programName": "ZAP", + "@version": "2.16.1", + "@generated": "Tue, 7 Jul 2026 20:02:22", + "created": "2026-07-07T20:02:22.483672721Z", + "site":[ + { + "@name": "http://host.docker.internal:8080", + "@host": "host.docker.internal", + "@port": "8080", + "@ssl": "false", + "alerts": [ + { + "pluginid": "10116", + "alertRef": "10116", + "alert": "ZAP is Out of Date", + "name": "ZAP is Out of Date", + "riskcode": "1", + "confidence": "3", + "riskdesc": "Low (High)", + "desc": "

The version of ZAP you are using to test your app is out of date and is no longer being updated.

The risk level is set based on how out of date your ZAP version is.

", + "instances":[ + { + "id": "3", + "uri": "http://host.docker.internal:8080/robots.txt", + "nodeName": null, + "method": "GET", + "param": "", + "attack": "", + "evidence": "", + "otherinfo": "The latest version of ZAP is 2.17.0" + } + ], + "count": "1", + "systemic": false, + "solution": "

Download the latest version of ZAP from https://www.zaproxy.org/download/ and install it.

", + "otherinfo": "

The latest version of ZAP is 2.17.0

", + "reference": "

https://www.zaproxy.org/download/

", + "cweid": "1104", + "wascid": "45", + "sourceid": "6" + }, + { + "pluginid": "10049", + "alertRef": "10049-1", + "alert": "Non-Storable Content", + "name": "Non-Storable Content", + "riskcode": "0", + "confidence": "2", + "riskdesc": "Informational (Medium)", + "desc": "

The response contents are not storable by caching components such as proxy servers. If the response does not contain sensitive, personal or user-specific information, it may benefit from being stored and cached, to improve performance.

", + "instances":[ + { + "id": "4", + "uri": "http://host.docker.internal:8080/robots.txt", + "nodeName": null, + "method": "GET", + "param": "", + "attack": "", + "evidence": "no-store", + "otherinfo": "" + }, + { + "id": "1", + "uri": "http://host.docker.internal:8080/sitemap.xml", + "nodeName": null, + "method": "GET", + "param": "", + "attack": "", + "evidence": "no-store", + "otherinfo": "" + } + ], + "count": "2", + "systemic": false, + "solution": "

The content may be marked as storable by ensuring that the following conditions are satisfied:

The request method must be understood by the cache and defined as being cacheable (\"GET\", \"HEAD\", and \"POST\" are currently defined as cacheable)

The response status code must be understood by the cache (one of the 1XX, 2XX, 3XX, 4XX, or 5XX response classes are generally understood)

The \"no-store\" cache directive must not appear in the request or response header fields

For caching by \"shared\" caches such as \"proxy\" caches, the \"private\" response directive must not appear in the response

For caching by \"shared\" caches such as \"proxy\" caches, the \"Authorization\" header field must not appear in the request, unless the response explicitly allows it (using one of the \"must-revalidate\", \"public\", or \"s-maxage\" Cache-Control response directives)

In addition to the conditions above, at least one of the following conditions must also be satisfied by the response:

It must contain an \"Expires\" header field

It must contain a \"max-age\" response directive

For \"shared\" caches such as \"proxy\" caches, it must contain a \"s-maxage\" response directive

It must contain a \"Cache Control Extension\" that allows it to be cached

It must have a status code that is defined as cacheable by default (200, 203, 204, 206, 300, 301, 404, 405, 410, 414, 501).

", + "otherinfo": "", + "reference": "

https://datatracker.ietf.org/doc/html/rfc7234

https://datatracker.ietf.org/doc/html/rfc7231

https://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html

", + "cweid": "524", + "wascid": "13", + "sourceid": "6" + } + ] + } + ], + "sequences":[ + ] + +} diff --git a/submissions/lab9/zap-before-notes.html b/submissions/lab9/zap-before-notes.html new file mode 100644 index 000000000..76d1ef2b5 --- /dev/null +++ b/submissions/lab9/zap-before-notes.html @@ -0,0 +1,838 @@ + + + + +ZAP Scanning Report + + + +

+ + + ZAP Scanning Report +

+

+ + +

+ + Site: http://host.docker.internal:8080 + +

+ +

+ Generated on Tue, 7 Jul 2026 19:56:01 +

+ +

+ ZAP Version: 2.16.1 +

+ +

+ ZAP by Checkmarx +

+ + +

Summary of Alerts

+ + + + + + + + + + + + + + + + + + + + + + + + + +
Risk LevelNumber of Alerts
+
High
+
+
0
+
+
Medium
+
+
0
+
+
Low
+
+
3
+
+
Informational
+
+
1
+
+
False Positives:
+
+
0
+
+
+ + + + +

Summary of Sequences

+

For each step: result (Pass/Fail) - risk (of highest alert(s) for the step, if any).

+ + + + + + +

Alerts

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameRisk LevelNumber of Instances
Insufficient Site Isolation Against Spectre VulnerabilityLow1
X-Content-Type-Options Header MissingLow1
ZAP is Out of DateLow1
Storable and Cacheable ContentInformational4
+
+ + + +

Alert Detail

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
Low
Insufficient Site Isolation Against Spectre Vulnerability
Description +
Cross-Origin-Resource-Policy header is an opt-in header designed to counter side-channels attacks like Spectre. Resource should be specifically set as shareable amongst different origins.
+ +
URLhttp://host.docker.internal:8080/notes
MethodGET
ParameterCross-Origin-Resource-Policy
Attack
Evidence
Other Info
Instances1
Solution +
Ensure that the application/web server sets the Cross-Origin-Resource-Policy header appropriately, and that it sets the Cross-Origin-Resource-Policy header to 'same-origin' for all web pages.
+
+ +
'same-site' is considered as less secured and should be avoided.
+
+ +
If resources must be shared, set the header to 'cross-origin'.
+
+ +
If possible, ensure that the end user uses a standards-compliant and modern web browser that supports the Cross-Origin-Resource-Policy header (https://caniuse.com/mdn-http_headers_cross-origin-resource-policy).
+ +
Reference + https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cross-Origin-Embedder-Policy + +
CWE Id693
WASC Id14
Plugin Id90004
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
Low
X-Content-Type-Options Header Missing
Description +
The Anti-MIME-Sniffing header X-Content-Type-Options was not set to 'nosniff'. This allows older versions of Internet Explorer and Chrome to perform MIME-sniffing on the response body, potentially causing the response body to be interpreted and displayed as a content type other than the declared content type. Current (early 2014) and legacy versions of Firefox will use the declared content type (if one is set), rather than performing MIME-sniffing.
+ +
URLhttp://host.docker.internal:8080/notes
MethodGET
Parameterx-content-type-options
Attack
Evidence
Other InfoThis issue still applies to error type pages (401, 403, 500, etc.) as those pages are often still affected by injection issues, in which case there is still concern for browsers sniffing pages away from their actual content type. +At "High" threshold this scan rule will not alert on client or server error responses.
Instances1
Solution +
Ensure that the application/web server sets the Content-Type header appropriately, and that it sets the X-Content-Type-Options header to 'nosniff' for all web pages.
+
+ +
If possible, ensure that the end user uses a standards-compliant and modern web browser that does not perform MIME-sniffing at all, or that can be directed by the web application/web server to not perform MIME-sniffing.
+ +
Reference + https://learn.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/compatibility/gg622941(v=vs.85) +
+ + https://owasp.org/www-community/Security_Headers + +
CWE Id693
WASC Id15
Plugin Id10021
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
Low
ZAP is Out of Date
Description +
The version of ZAP you are using to test your app is out of date and is no longer being updated.
+
+ +
The risk level is set based on how out of date your ZAP version is.
+ +
URLhttp://host.docker.internal:8080/robots.txt
MethodGET
Parameter
Attack
Evidence
Other InfoThe latest version of ZAP is 2.17.0
Instances1
Solution +
Download the latest version of ZAP from https://www.zaproxy.org/download/ and install it.
+ +
Reference + https://www.zaproxy.org/download/ + +
CWE Id1104
WASC Id45
Plugin Id10116
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
Informational
Storable and Cacheable Content
Description +
The response contents are storable by caching components such as proxy servers, and may be retrieved directly from the cache, rather than from the origin server by the caching servers, in response to similar requests from other users. If the response data is sensitive, personal or user-specific, this may result in sensitive information being leaked. In some cases, this may even result in a user gaining complete control of the session of another user, depending on the configuration of the caching components in use in their environment. This is primarily an issue where "shared" caching servers such as "proxy" caches are configured on the local network. This configuration is typically found in corporate or educational environments, for instance.
+ +
URLhttp://host.docker.internal:8080/
MethodGET
Parameter
Attack
Evidence
Other InfoIn the absence of an explicitly specified caching lifetime directive in the response, a liberal lifetime heuristic of 1 year was assumed. This is permitted by rfc7234.
URLhttp://host.docker.internal:8080/notes
MethodGET
Parameter
Attack
Evidence
Other InfoIn the absence of an explicitly specified caching lifetime directive in the response, a liberal lifetime heuristic of 1 year was assumed. This is permitted by rfc7234.
URLhttp://host.docker.internal:8080/robots.txt
MethodGET
Parameter
Attack
Evidence
Other InfoIn the absence of an explicitly specified caching lifetime directive in the response, a liberal lifetime heuristic of 1 year was assumed. This is permitted by rfc7234.
URLhttp://host.docker.internal:8080/sitemap.xml
MethodGET
Parameter
Attack
Evidence
Other InfoIn the absence of an explicitly specified caching lifetime directive in the response, a liberal lifetime heuristic of 1 year was assumed. This is permitted by rfc7234.
Instances4
Solution +
Validate that the response does not contain sensitive, personal or user-specific information. If it does, consider the use of the following HTTP response headers, to limit, or prevent the content being stored and retrieved from the cache by another user:
+
+ +
Cache-Control: no-cache, no-store, must-revalidate, private
+
+ +
Pragma: no-cache
+
+ +
Expires: 0
+
+ +
This configuration directs both HTTP 1.0 and HTTP 1.1 compliant caching servers to not store the response, and to not retrieve the response (without validation) from the cache, in response to a similar request.
+ +
Reference + https://datatracker.ietf.org/doc/html/rfc7234 +
+ + https://datatracker.ietf.org/doc/html/rfc7231 +
+ + https://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html + +
CWE Id524
WASC Id13
Plugin Id10049
+
+ + + + + +

Sequence Details

+ With the associated active scan results. + + + +
+ + + + + + + diff --git a/submissions/lab9/zap-before-notes.json b/submissions/lab9/zap-before-notes.json new file mode 100644 index 000000000..cb083692c --- /dev/null +++ b/submissions/lab9/zap-before-notes.json @@ -0,0 +1,169 @@ +{ + "@programName": "ZAP", + "@version": "2.16.1", + "@generated": "Tue, 7 Jul 2026 19:56:01", + "created": "2026-07-07T19:56:01.672066628Z", + "site":[ + { + "@name": "http://host.docker.internal:8080", + "@host": "host.docker.internal", + "@port": "8080", + "@ssl": "false", + "alerts": [ + { + "pluginid": "90004", + "alertRef": "90004-1", + "alert": "Insufficient Site Isolation Against Spectre Vulnerability", + "name": "Insufficient Site Isolation Against Spectre Vulnerability", + "riskcode": "1", + "confidence": "2", + "riskdesc": "Low (Medium)", + "desc": "

Cross-Origin-Resource-Policy header is an opt-in header designed to counter side-channels attacks like Spectre. Resource should be specifically set as shareable amongst different origins.

", + "instances":[ + { + "id": "10", + "uri": "http://host.docker.internal:8080/notes", + "nodeName": null, + "method": "GET", + "param": "Cross-Origin-Resource-Policy", + "attack": "", + "evidence": "", + "otherinfo": "" + } + ], + "count": "1", + "systemic": false, + "solution": "

Ensure that the application/web server sets the Cross-Origin-Resource-Policy header appropriately, and that it sets the Cross-Origin-Resource-Policy header to 'same-origin' for all web pages.

'same-site' is considered as less secured and should be avoided.

If resources must be shared, set the header to 'cross-origin'.

If possible, ensure that the end user uses a standards-compliant and modern web browser that supports the Cross-Origin-Resource-Policy header (https://caniuse.com/mdn-http_headers_cross-origin-resource-policy).

", + "otherinfo": "", + "reference": "

https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cross-Origin-Embedder-Policy

", + "cweid": "693", + "wascid": "14", + "sourceid": "1" + }, + { + "pluginid": "10021", + "alertRef": "10021", + "alert": "X-Content-Type-Options Header Missing", + "name": "X-Content-Type-Options Header Missing", + "riskcode": "1", + "confidence": "2", + "riskdesc": "Low (Medium)", + "desc": "

The Anti-MIME-Sniffing header X-Content-Type-Options was not set to 'nosniff'. This allows older versions of Internet Explorer and Chrome to perform MIME-sniffing on the response body, potentially causing the response body to be interpreted and displayed as a content type other than the declared content type. Current (early 2014) and legacy versions of Firefox will use the declared content type (if one is set), rather than performing MIME-sniffing.

", + "instances":[ + { + "id": "3", + "uri": "http://host.docker.internal:8080/notes", + "nodeName": null, + "method": "GET", + "param": "x-content-type-options", + "attack": "", + "evidence": "", + "otherinfo": "This issue still applies to error type pages (401, 403, 500, etc.) as those pages are often still affected by injection issues, in which case there is still concern for browsers sniffing pages away from their actual content type.\nAt \"High\" threshold this scan rule will not alert on client or server error responses." + } + ], + "count": "1", + "systemic": false, + "solution": "

Ensure that the application/web server sets the Content-Type header appropriately, and that it sets the X-Content-Type-Options header to 'nosniff' for all web pages.

If possible, ensure that the end user uses a standards-compliant and modern web browser that does not perform MIME-sniffing at all, or that can be directed by the web application/web server to not perform MIME-sniffing.

", + "otherinfo": "

This issue still applies to error type pages (401, 403, 500, etc.) as those pages are often still affected by injection issues, in which case there is still concern for browsers sniffing pages away from their actual content type.

At \"High\" threshold this scan rule will not alert on client or server error responses.

", + "reference": "

https://learn.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/compatibility/gg622941(v=vs.85)

https://owasp.org/www-community/Security_Headers

", + "cweid": "693", + "wascid": "15", + "sourceid": "9" + }, + { + "pluginid": "10116", + "alertRef": "10116", + "alert": "ZAP is Out of Date", + "name": "ZAP is Out of Date", + "riskcode": "1", + "confidence": "3", + "riskdesc": "Low (High)", + "desc": "

The version of ZAP you are using to test your app is out of date and is no longer being updated.

The risk level is set based on how out of date your ZAP version is.

", + "instances":[ + { + "id": "5", + "uri": "http://host.docker.internal:8080/robots.txt", + "nodeName": null, + "method": "GET", + "param": "", + "attack": "", + "evidence": "", + "otherinfo": "The latest version of ZAP is 2.17.0" + } + ], + "count": "1", + "systemic": false, + "solution": "

Download the latest version of ZAP from https://www.zaproxy.org/download/ and install it.

", + "otherinfo": "

The latest version of ZAP is 2.17.0

", + "reference": "

https://www.zaproxy.org/download/

", + "cweid": "1104", + "wascid": "45", + "sourceid": "8" + }, + { + "pluginid": "10049", + "alertRef": "10049-3", + "alert": "Storable and Cacheable Content", + "name": "Storable and Cacheable Content", + "riskcode": "0", + "confidence": "2", + "riskdesc": "Informational (Medium)", + "desc": "

The response contents are storable by caching components such as proxy servers, and may be retrieved directly from the cache, rather than from the origin server by the caching servers, in response to similar requests from other users. If the response data is sensitive, personal or user-specific, this may result in sensitive information being leaked. In some cases, this may even result in a user gaining complete control of the session of another user, depending on the configuration of the caching components in use in their environment. This is primarily an issue where \"shared\" caching servers such as \"proxy\" caches are configured on the local network. This configuration is typically found in corporate or educational environments, for instance.

", + "instances":[ + { + "id": "1", + "uri": "http://host.docker.internal:8080/", + "nodeName": null, + "method": "GET", + "param": "", + "attack": "", + "evidence": "", + "otherinfo": "In the absence of an explicitly specified caching lifetime directive in the response, a liberal lifetime heuristic of 1 year was assumed. This is permitted by rfc7234." + }, + { + "id": "7", + "uri": "http://host.docker.internal:8080/notes", + "nodeName": null, + "method": "GET", + "param": "", + "attack": "", + "evidence": "", + "otherinfo": "In the absence of an explicitly specified caching lifetime directive in the response, a liberal lifetime heuristic of 1 year was assumed. This is permitted by rfc7234." + }, + { + "id": "6", + "uri": "http://host.docker.internal:8080/robots.txt", + "nodeName": null, + "method": "GET", + "param": "", + "attack": "", + "evidence": "", + "otherinfo": "In the absence of an explicitly specified caching lifetime directive in the response, a liberal lifetime heuristic of 1 year was assumed. This is permitted by rfc7234." + }, + { + "id": "0", + "uri": "http://host.docker.internal:8080/sitemap.xml", + "nodeName": null, + "method": "GET", + "param": "", + "attack": "", + "evidence": "", + "otherinfo": "In the absence of an explicitly specified caching lifetime directive in the response, a liberal lifetime heuristic of 1 year was assumed. This is permitted by rfc7234." + } + ], + "count": "4", + "systemic": false, + "solution": "

Validate that the response does not contain sensitive, personal or user-specific information. If it does, consider the use of the following HTTP response headers, to limit, or prevent the content being stored and retrieved from the cache by another user:

Cache-Control: no-cache, no-store, must-revalidate, private

Pragma: no-cache

Expires: 0

This configuration directs both HTTP 1.0 and HTTP 1.1 compliant caching servers to not store the response, and to not retrieve the response (without validation) from the cache, in response to a similar request.

", + "otherinfo": "

In the absence of an explicitly specified caching lifetime directive in the response, a liberal lifetime heuristic of 1 year was assumed. This is permitted by rfc7234.

", + "reference": "

https://datatracker.ietf.org/doc/html/rfc7234

https://datatracker.ietf.org/doc/html/rfc7231

https://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html

", + "cweid": "524", + "wascid": "13", + "sourceid": "10" + } + ] + } + ], + "sequences":[ + ] + +} diff --git a/submissions/lab9/zap-before.html b/submissions/lab9/zap-before.html new file mode 100644 index 000000000..a9d3782a2 --- /dev/null +++ b/submissions/lab9/zap-before.html @@ -0,0 +1,566 @@ + + + + +ZAP Scanning Report + + + +

+ + + ZAP Scanning Report +

+

+ + +

+ + Site: http://host.docker.internal:8080 + +

+ +

+ Generated on Tue, 7 Jul 2026 19:25:41 +

+ +

+ ZAP Version: 2.16.1 +

+ +

+ ZAP by Checkmarx +

+ + +

Summary of Alerts

+ + + + + + + + + + + + + + + + + + + + + + + + + +
Risk LevelNumber of Alerts
+
High
+
+
0
+
+
Medium
+
+
0
+
+
Low
+
+
1
+
+
Informational
+
+
1
+
+
False Positives:
+
+
0
+
+
+ + + + +

Summary of Sequences

+

For each step: result (Pass/Fail) - risk (of highest alert(s) for the step, if any).

+ + + + + + +

Alerts

+ + + + + + + + + + + + + + + + + + + + + + +
NameRisk LevelNumber of Instances
ZAP is Out of DateLow1
Storable and Cacheable ContentInformational2
+
+ + + +

Alert Detail

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
Low
ZAP is Out of Date
Description +
The version of ZAP you are using to test your app is out of date and is no longer being updated.
+
+ +
The risk level is set based on how out of date your ZAP version is.
+ +
URLhttp://host.docker.internal:8080/sitemap.xml
MethodGET
Parameter
Attack
Evidence
Other InfoThe latest version of ZAP is 2.17.0
Instances1
Solution +
Download the latest version of ZAP from https://www.zaproxy.org/download/ and install it.
+ +
Reference + https://www.zaproxy.org/download/ + +
CWE Id1104
WASC Id45
Plugin Id10116
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
Informational
Storable and Cacheable Content
Description +
The response contents are storable by caching components such as proxy servers, and may be retrieved directly from the cache, rather than from the origin server by the caching servers, in response to similar requests from other users. If the response data is sensitive, personal or user-specific, this may result in sensitive information being leaked. In some cases, this may even result in a user gaining complete control of the session of another user, depending on the configuration of the caching components in use in their environment. This is primarily an issue where "shared" caching servers such as "proxy" caches are configured on the local network. This configuration is typically found in corporate or educational environments, for instance.
+ +
URLhttp://host.docker.internal:8080
MethodGET
Parameter
Attack
Evidence
Other InfoIn the absence of an explicitly specified caching lifetime directive in the response, a liberal lifetime heuristic of 1 year was assumed. This is permitted by rfc7234.
URLhttp://host.docker.internal:8080/sitemap.xml
MethodGET
Parameter
Attack
Evidence
Other InfoIn the absence of an explicitly specified caching lifetime directive in the response, a liberal lifetime heuristic of 1 year was assumed. This is permitted by rfc7234.
Instances2
Solution +
Validate that the response does not contain sensitive, personal or user-specific information. If it does, consider the use of the following HTTP response headers, to limit, or prevent the content being stored and retrieved from the cache by another user:
+
+ +
Cache-Control: no-cache, no-store, must-revalidate, private
+
+ +
Pragma: no-cache
+
+ +
Expires: 0
+
+ +
This configuration directs both HTTP 1.0 and HTTP 1.1 compliant caching servers to not store the response, and to not retrieve the response (without validation) from the cache, in response to a similar request.
+ +
Reference + https://datatracker.ietf.org/doc/html/rfc7234 +
+ + https://datatracker.ietf.org/doc/html/rfc7231 +
+ + https://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html + +
CWE Id524
WASC Id13
Plugin Id10049
+
+ + + + + +

Sequence Details

+ With the associated active scan results. + + + +
+ + + + + + + diff --git a/submissions/lab9/zap-before.json b/submissions/lab9/zap-before.json new file mode 100644 index 000000000..f8b9080d2 --- /dev/null +++ b/submissions/lab9/zap-before.json @@ -0,0 +1,89 @@ +{ + "@programName": "ZAP", + "@version": "2.16.1", + "@generated": "Tue, 7 Jul 2026 19:25:41", + "created": "2026-07-07T19:25:41.828181216Z", + "site":[ + { + "@name": "http://host.docker.internal:8080", + "@host": "host.docker.internal", + "@port": "8080", + "@ssl": "false", + "alerts": [ + { + "pluginid": "10116", + "alertRef": "10116", + "alert": "ZAP is Out of Date", + "name": "ZAP is Out of Date", + "riskcode": "1", + "confidence": "3", + "riskdesc": "Low (High)", + "desc": "

The version of ZAP you are using to test your app is out of date and is no longer being updated.

The risk level is set based on how out of date your ZAP version is.

", + "instances":[ + { + "id": "3", + "uri": "http://host.docker.internal:8080/sitemap.xml", + "nodeName": null, + "method": "GET", + "param": "", + "attack": "", + "evidence": "", + "otherinfo": "The latest version of ZAP is 2.17.0" + } + ], + "count": "1", + "systemic": false, + "solution": "

Download the latest version of ZAP from https://www.zaproxy.org/download/ and install it.

", + "otherinfo": "

The latest version of ZAP is 2.17.0

", + "reference": "

https://www.zaproxy.org/download/

", + "cweid": "1104", + "wascid": "45", + "sourceid": "6" + }, + { + "pluginid": "10049", + "alertRef": "10049-3", + "alert": "Storable and Cacheable Content", + "name": "Storable and Cacheable Content", + "riskcode": "0", + "confidence": "2", + "riskdesc": "Informational (Medium)", + "desc": "

The response contents are storable by caching components such as proxy servers, and may be retrieved directly from the cache, rather than from the origin server by the caching servers, in response to similar requests from other users. If the response data is sensitive, personal or user-specific, this may result in sensitive information being leaked. In some cases, this may even result in a user gaining complete control of the session of another user, depending on the configuration of the caching components in use in their environment. This is primarily an issue where \"shared\" caching servers such as \"proxy\" caches are configured on the local network. This configuration is typically found in corporate or educational environments, for instance.

", + "instances":[ + { + "id": "2", + "uri": "http://host.docker.internal:8080", + "nodeName": null, + "method": "GET", + "param": "", + "attack": "", + "evidence": "", + "otherinfo": "In the absence of an explicitly specified caching lifetime directive in the response, a liberal lifetime heuristic of 1 year was assumed. This is permitted by rfc7234." + }, + { + "id": "4", + "uri": "http://host.docker.internal:8080/sitemap.xml", + "nodeName": null, + "method": "GET", + "param": "", + "attack": "", + "evidence": "", + "otherinfo": "In the absence of an explicitly specified caching lifetime directive in the response, a liberal lifetime heuristic of 1 year was assumed. This is permitted by rfc7234." + } + ], + "count": "2", + "systemic": false, + "solution": "

Validate that the response does not contain sensitive, personal or user-specific information. If it does, consider the use of the following HTTP response headers, to limit, or prevent the content being stored and retrieved from the cache by another user:

Cache-Control: no-cache, no-store, must-revalidate, private

Pragma: no-cache

Expires: 0

This configuration directs both HTTP 1.0 and HTTP 1.1 compliant caching servers to not store the response, and to not retrieve the response (without validation) from the cache, in response to a similar request.

", + "otherinfo": "

In the absence of an explicitly specified caching lifetime directive in the response, a liberal lifetime heuristic of 1 year was assumed. This is permitted by rfc7234.

", + "reference": "

https://datatracker.ietf.org/doc/html/rfc7234

https://datatracker.ietf.org/doc/html/rfc7231

https://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html

", + "cweid": "524", + "wascid": "13", + "sourceid": "7" + } + ] + } + ], + "sequences":[ + ] + +} diff --git a/submissions/lab9/zap.yaml b/submissions/lab9/zap.yaml new file mode 100644 index 000000000..c26058926 --- /dev/null +++ b/submissions/lab9/zap.yaml @@ -0,0 +1,41 @@ +env: + contexts: + - excludePaths: [] + name: baseline + urls: + - http://host.docker.internal:8080/notes + - http://host.docker.internal:8080/ + parameters: + failOnError: true + progressToStdout: false +jobs: +- parameters: + enableTags: false + maxAlertsPerRule: 10 + type: passiveScan-config +- parameters: + maxDuration: 1 + url: http://host.docker.internal:8080/ + type: spider +- parameters: + maxDuration: 0 + type: passiveScan-wait +- parameters: + format: Long + summaryFile: /home/zap/zap_out.json + rules: [] + type: outputSummary +- parameters: + reportDescription: '' + reportDir: /zap/wrk/ + reportFile: zap-after-notes.html + reportTitle: ZAP Scanning Report + template: traditional-html + type: report +- parameters: + reportDescription: '' + reportDir: /zap/wrk/ + reportFile: zap-after-notes.json + reportTitle: ZAP Scanning Report + template: traditional-json + type: report diff --git a/submissions/verified.png b/submissions/verified.png new file mode 100644 index 000000000..e7f1e43dd Binary files /dev/null and b/submissions/verified.png differ