diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..423f05acd --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,51 @@ +name: Release QuickNotes + +on: + push: + tags: + - "v*" + +permissions: + contents: read + packages: write + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository_owner }}/devops-intro/quicknotes + +jobs: + build-and-push: + name: Build and push QuickNotes image + runs-on: ubuntu-24.04 + + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f + + - name: Log in to GitHub Container Registry + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker metadata + id: meta + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=tag + type=raw,value=latest + + - name: Build and push + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 + with: + context: ./app + file: ./app/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore index 1c0a1e94b..8ef91c244 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,6 @@ # ONLY contain: # (a) instructor-only paths (refs/), and # (b) machine-generated junk that NOBODY should ever commit. -# # Do NOT add lab DELIVERABLES here (scan reports, SBOMs, go.sum, k8s # manifests, CI workflows, Dockerfiles, playbooks, dashboards, …). Students # are told to commit those in their submission PRs — ignoring them upstream @@ -15,43 +14,34 @@ # Reference submissions (dry-run worked examples). Never pushed upstream; # students never see these. This is the one path that is intentionally hidden. refs/ - # ── Machine-generated junk (no one commits these) ─────────────── # Compiled binaries / local runtime state app/quicknotes app/data/ /quicknotes *.exe - # Vagrant runtime state (Lab 5) — the Vagrantfile IS committed; .vagrant/ is not .vagrant/ - # Nix build symlinks (Lab 11) — flake.nix + flake.lock ARE committed; result is not result result-* - # Terraform state — MUST never be committed (can contain secrets) *.tfstate *.tfstate.backup .terraform/ - # Python virtualenvs / caches .venv/ __pycache__/ *.pyc - # Editor / IDE .vscode/ .idea/ *.swp - # OS noise .DS_Store Thumbs.db - # Local agent config (not part of the course) .claude/ - # NOTE: deliberately NOT ignored, because students commit them as lab evidence: # submissions/labN.md (lab reports) # .github/workflows/*.yml (Lab 3 CI) diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 000000000..928eb9eca --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,58 @@ +# syntax=docker/dockerfile:1 + +FROM golang:1.26.4-alpine AS builder + +WORKDIR /src + +COPY go.mod ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags="-s -w" -o /out/quicknotes . + +RUN cat > /tmp/healthcheck.go <<'EOF' +package main + +import ( + "net/http" + "os" + "time" +) + +func main() { + client := http.Client{Timeout: 2 * time.Second} + resp, err := client.Get("http://127.0.0.1:8080/health") + if err != nil { + os.Exit(1) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + os.Exit(1) + } +} +EOF + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags="-s -w" -o /out/healthcheck /tmp/healthcheck.go + +# Create an empty /data directory that will be copied into the runtime image +RUN mkdir -p /out/data + +FROM gcr.io/distroless/static:nonroot + +WORKDIR / + +COPY --from=builder /out/quicknotes /quicknotes +COPY --from=builder /out/healthcheck /healthcheck + +# Make /data exist in the image and be owned by distroless nonroot UID/GID 65532. +# When Docker creates a fresh named volume, it initializes it from this directory. +COPY --from=builder --chown=65532:65532 /out/data /data + +USER nonroot:nonroot + +EXPOSE 8080 + +ENTRYPOINT ["/quicknotes"] diff --git a/app/main.go b/app/main.go index e258ffcfe..331bc8450 100644 --- a/app/main.go +++ b/app/main.go @@ -28,7 +28,7 @@ func main() { server := NewServer(store) srv := &http.Server{ Addr: addr, - Handler: server.Routes(), + Handler: securityHeaders(server.Routes()), ReadHeaderTimeout: 5 * time.Second, } diff --git a/app/security.go b/app/security.go new file mode 100644 index 000000000..bd585f973 --- /dev/null +++ b/app/security.go @@ -0,0 +1,15 @@ +package main + +import "net/http" + +func securityHeaders(next http.Handler) http.Handler { +return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +w.Header().Set("Cache-Control", "no-store") +w.Header().Set("Content-Security-Policy", "default-src 'none'") +w.Header().Set("X-Content-Type-Options", "nosniff") +w.Header().Set("Referrer-Policy", "no-referrer") +w.Header().Set("X-Frame-Options", "DENY") + +next.ServeHTTP(w, r) +}) +} diff --git a/app/security_test.go b/app/security_test.go new file mode 100644 index 000000000..39487fa6c --- /dev/null +++ b/app/security_test.go @@ -0,0 +1,34 @@ +package main + +import ( +"net/http" +"net/http/httptest" +"testing" +) + +func TestSecurityHeadersMiddleware(t *testing.T) { +next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +w.WriteHeader(http.StatusOK) +}) + +handler := securityHeaders(next) + +req := httptest.NewRequest(http.MethodGet, "/health", nil) +rr := httptest.NewRecorder() + +handler.ServeHTTP(rr, req) + +tests := map[string]string{ +"Cache-Control": "no-store", +"Content-Security-Policy": "default-src 'none'", +"X-Content-Type-Options": "nosniff", +"Referrer-Policy": "no-referrer", +"X-Frame-Options": "DENY", +} + +for header, want := range tests { +if got := rr.Header().Get(header); got != want { +t.Fatalf("%s = %q, want %q", header, got, want) +} +} +} diff --git a/cloud/hf-space/Dockerfile b/cloud/hf-space/Dockerfile new file mode 100644 index 000000000..f7982648f --- /dev/null +++ b/cloud/hf-space/Dockerfile @@ -0,0 +1,3 @@ +FROM ghcr.io/minimaxc/devops-intro/quicknotes:v0.1.0 + +EXPOSE 8080 diff --git a/cloud/hf-space/README.md b/cloud/hf-space/README.md new file mode 100644 index 000000000..a8a0dae04 --- /dev/null +++ b/cloud/hf-space/README.md @@ -0,0 +1,23 @@ +--- +title: QuickNotes +emoji: 📝 +sdk: docker +app_port: 8080 +pinned: false +--- + +# QuickNotes + +This Hugging Face Space runs the QuickNotes Docker image published by the Lab 10 GitHub Actions release workflow. + +Image: + +ghcr.io/minimaxc/devops-intro/quicknotes:v0.1.0 + +Health endpoint: + +/health + +Notes endpoint: + +/notes diff --git a/cloud/teardown.md b/cloud/teardown.md new file mode 100644 index 000000000..4d82ed060 --- /dev/null +++ b/cloud/teardown.md @@ -0,0 +1,15 @@ +# Lab 10 teardown + +## Hugging Face Space + +Delete the Space from the Hugging Face Space settings page after the lab if it is no longer needed. + +## Local containers + +Run: + +docker compose down + +## Cloudflare quick tunnel + +Stop the cloudflared process with Ctrl+C. The trycloudflare URL is ephemeral and stops working when the process exits. \ No newline at end of file diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 000000000..c7e3e9557 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,33 @@ +services: + quicknotes: + build: + context: ./app + dockerfile: Dockerfile + image: quicknotes:lab6 + ports: + - "8080:8080" + environment: + ADDR: ":8080" + DATA_PATH: "/data/notes.json" + SEED_PATH: "/app/seed.json" + volumes: + - quicknotes-data:/data + restart: unless-stopped + healthcheck: + test: ["CMD", "/healthcheck"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 5s + + # Bonus hardening defaults + cap_drop: + - ALL + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + +volumes: + quicknotes-data: \ No newline at end of file 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/lab11.md b/labs/lab11.md index 7095f9939..1a470c31a 100644 --- a/labs/lab11.md +++ b/labs/lab11.md @@ -36,6 +36,7 @@ By the end: - Read [Reading 11](../lectures/reading11.md) - Install Nix with Flakes enabled: - [Determinate Nix Installer](https://determinate.systems/posts/determinate-nix-installer/) (recommended) + - If `install.determinate.systems` is unreachable or times out from your network, use the [official installer](https://nixos.org/download/) (`sh <(curl -L https://nixos.org/nix/install) --daemon`) — it is served from a different CDN. Enable flakes afterwards: add `experimental-features = nix-command flakes` to `~/.config/nix/nix.conf` - ≥ 8 GB free disk - A second machine, fresh Docker container (`docker run -it nixos/nix bash`), or a colleague — for verifying reproducibility @@ -47,7 +48,7 @@ By the end: Your `flake.nix` at the **repo root** MUST: -1. Pin **nixpkgs** to a specific channel revision in `inputs:` (e.g. `nixos-24.11`) +1. Pin **nixpkgs** to a specific channel revision in `inputs:` (e.g. `nixos-25.11`) — note that `app/go.mod` requires **Go ≥ 1.24**, so the channel's default `buildGoModule` must ship at least that (see Common Pitfalls) 2. Expose a package `quicknotes` (and `default`) that **builds the QuickNotes Go source from `app/`** 3. Use `buildGoModule` (or `buildGoApplication`, etc. — your choice; document why) 4. Set **`CGO_ENABLED = 0`** so the binary is static @@ -248,6 +249,8 @@ In `submissions/lab11.md`: - 🪤 **Different hashes on two machines** — usually means `flake.lock` is not committed. The lockfile pins nixpkgs to a specific revision - 🪤 **Out of disk** — Nix store grows. `nix store gc` reclaims unreferenced paths - 🪤 **`nix build` requires internet on first run** — downloads pre-built artifacts from cache.nixos.org. Subsequent builds are mostly local +- 🪤 **`go.mod requires go >= 1.24` from `buildGoModule`** — your pinned nixpkgs ships an older default Go (e.g. `nixos-24.11` → Go 1.23). Fix it **in the flake**: pin `nixos-25.11` or newer, or use `buildGo124Module` / `buildGoModule.override { go = pkgs.go_1_24; }`. Don't downgrade `app/go.mod` — the app source is not yours to edit +- 🪤 **Installer or build times out on `install.determinate.systems`** — the host may be unreachable from your network even when a plain `curl -I` returns 200. Check from the *same terminal* where you run nix (a browser VPN does not cover WSL2 traffic): `curl -I https://install.determinate.systems` vs `curl -I https://cache.nixos.org/nix-cache-info`. Fall back to the official nixos.org installer (different CDN); builds themselves only need cache.nixos.org and github.com. If cache.nixos.org is also blocked, use a mirror substituter: `--option substituters "https://mirrors.tuna.tsinghua.edu.cn/nix-channels/store"` - 🪤 **WSL2 multi-user Nix is finicky** — use the Determinate installer; or single-user on WSL2 --- 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/lectures/reading11.md b/lectures/reading11.md index 7ce223ee0..3d460f0df 100644 --- a/lectures/reading11.md +++ b/lectures/reading11.md @@ -94,7 +94,7 @@ Flakes (Nix 2.4+, standard since ~2024) lock **all** external dependencies — i # flake.nix { description = "QuickNotes — DevOps-Intro project"; - inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; outputs = { self, nixpkgs }: let pkgs = nixpkgs.legacyPackages.x86_64-linux; diff --git a/reports/trivy/quicknotes-cyclonedx.json b/reports/trivy/quicknotes-cyclonedx.json new file mode 100644 index 000000000..3af2bfe0b --- /dev/null +++ b/reports/trivy/quicknotes-cyclonedx.json @@ -0,0 +1,537 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "serialNumber": "urn:uuid:a782fc85-1c0f-4680-9234-12fa4df1117d", + "version": 1, + "metadata": { + "timestamp": "2026-07-07T16:22:55+00:00", + "tools": { + "components": [ + { + "type": "application", + "group": "aquasecurity", + "name": "trivy", + "version": "0.59.1" + } + ] + }, + "component": { + "bom-ref": "pkg:oci/quicknotes@sha256%3A72e01af9d2867f6074a1f3f2d2ff2428a4734d5eec2bf2c2898c271f4e268812?arch=amd64&repository_url=index.docker.io%2Flibrary%2Fquicknotes", + "type": "container", + "name": "quicknotes:lab6", + "purl": "pkg:oci/quicknotes@sha256%3A72e01af9d2867f6074a1f3f2d2ff2428a4734d5eec2bf2c2898c271f4e268812?arch=amd64&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:5b82705488fff6364ff9ca9ee456a74c618ba4f42a4ad5ca2610b635bb5ec1c0" + }, + { + "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:844e16d965858cde1e5321870e1ee9bf8a634196cfb78b71b2fb1bad3d9b6ed6" + }, + { + "name": "aquasecurity:trivy:DiffID", + "value": "sha256:92cb9c37b7d3957ac56645a979418f65e6c5bdba00eb99622affae5fc124ac07" + }, + { + "name": "aquasecurity:trivy:DiffID", + "value": "sha256:a820f4151cda9512bcc0674c91ac64321d702ff9e5b76fa884c41bdac8bd19cc" + }, + { + "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:ImageID", + "value": "sha256:72e01af9d2867f6074a1f3f2d2ff2428a4734d5eec2bf2c2898c271f4e268812" + }, + { + "name": "aquasecurity:trivy:Labels:com.docker.compose.project", + "value": "devops-intro" + }, + { + "name": "aquasecurity:trivy:Labels:com.docker.compose.service", + "value": "quicknotes" + }, + { + "name": "aquasecurity:trivy:Labels:com.docker.compose.version", + "value": "5.1.4" + }, + { + "name": "aquasecurity:trivy:RepoDigest", + "value": "quicknotes@sha256:72e01af9d2867f6074a1f3f2d2ff2428a4734d5eec2bf2c2898c271f4e268812" + }, + { + "name": "aquasecurity:trivy:RepoTag", + "value": "quicknotes:lab6" + }, + { + "name": "aquasecurity:trivy:SchemaVersion", + "value": "2" + } + ] + } + }, + "components": [ + { + "bom-ref": "17ab9852-b9c0-4e50-aabb-64fee78a650f", + "type": "library", + "name": "stdlib", + "version": "v1.26.4", + "purl": "pkg:golang/stdlib@v1.26.4", + "properties": [ + { + "name": "aquasecurity:trivy:LayerDiffID", + "value": "sha256:5b82705488fff6364ff9ca9ee456a74c618ba4f42a4ad5ca2610b635bb5ec1c0" + }, + { + "name": "aquasecurity:trivy:LayerDigest", + "value": "sha256:7aa68902a5451125be85d0f3da27f7d61255bce83526bc45b98d80e800ce018e" + }, + { + "name": "aquasecurity:trivy:PkgID", + "value": "stdlib@v1.26.4" + }, + { + "name": "aquasecurity:trivy:PkgType", + "value": "gobinary" + } + ] + }, + { + "bom-ref": "2b48a749-6333-43c3-ac7a-8e2fcb96630d", + "type": "application", + "name": "healthcheck", + "properties": [ + { + "name": "aquasecurity:trivy:Class", + "value": "lang-pkgs" + }, + { + "name": "aquasecurity:trivy:Type", + "value": "gobinary" + } + ] + }, + { + "bom-ref": "78e39592-1ff1-41f2-9f4b-dbdcf4549ebd", + "type": "application", + "name": "quicknotes", + "properties": [ + { + "name": "aquasecurity:trivy:Class", + "value": "lang-pkgs" + }, + { + "name": "aquasecurity:trivy:Type", + "value": "gobinary" + } + ] + }, + { + "bom-ref": "aa7061ed-c7ea-43c4-a2ea-edd46cd7ab33", + "type": "library", + "name": "stdlib", + "version": "v1.26.4", + "purl": "pkg:golang/stdlib@v1.26.4", + "properties": [ + { + "name": "aquasecurity:trivy:LayerDiffID", + "value": "sha256:a820f4151cda9512bcc0674c91ac64321d702ff9e5b76fa884c41bdac8bd19cc" + }, + { + "name": "aquasecurity:trivy:LayerDigest", + "value": "sha256:a8d93f9623d522f8a2533ab8feda3d7938e0c7f144b7bf6abd7184dfb4f27ce7" + }, + { + "name": "aquasecurity:trivy:PkgID", + "value": "stdlib@v1.26.4" + }, + { + "name": "aquasecurity:trivy:PkgType", + "value": "gobinary" + } + ] + }, + { + "bom-ref": "e03a036a-6baf-4530-ba2a-208309b849bd", + "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=amd64&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=amd64&distro=debian-13.5", + "properties": [ + { + "name": "aquasecurity:trivy:LayerDiffID", + "value": "sha256:92cb9c37b7d3957ac56645a979418f65e6c5bdba00eb99622affae5fc124ac07" + }, + { + "name": "aquasecurity:trivy:LayerDigest", + "value": "sha256:47de5dd0b812c573630914955e26abda537e09b5286a824c96e22e3854d4dd53" + }, + { + "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:5b82705488fff6364ff9ca9ee456a74c618ba4f42a4ad5ca2610b635bb5ec1c0" + }, + { + "name": "aquasecurity:trivy:LayerDigest", + "value": "sha256:7aa68902a5451125be85d0f3da27f7d61255bce83526bc45b98d80e800ce018e" + }, + { + "name": "aquasecurity:trivy:PkgID", + "value": "quicknotes" + }, + { + "name": "aquasecurity:trivy:PkgType", + "value": "gobinary" + } + ] + } + ], + "dependencies": [ + { + "ref": "17ab9852-b9c0-4e50-aabb-64fee78a650f", + "dependsOn": [] + }, + { + "ref": "2b48a749-6333-43c3-ac7a-8e2fcb96630d", + "dependsOn": [ + "aa7061ed-c7ea-43c4-a2ea-edd46cd7ab33" + ] + }, + { + "ref": "78e39592-1ff1-41f2-9f4b-dbdcf4549ebd", + "dependsOn": [ + "pkg:golang/quicknotes" + ] + }, + { + "ref": "aa7061ed-c7ea-43c4-a2ea-edd46cd7ab33", + "dependsOn": [] + }, + { + "ref": "e03a036a-6baf-4530-ba2a-208309b849bd", + "dependsOn": [ + "pkg:deb/debian/base-files@13.8%2Bdeb13u5?arch=amd64&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=amd64&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": [ + "17ab9852-b9c0-4e50-aabb-64fee78a650f" + ] + }, + { + "ref": "pkg:oci/quicknotes@sha256%3A72e01af9d2867f6074a1f3f2d2ff2428a4734d5eec2bf2c2898c271f4e268812?arch=amd64&repository_url=index.docker.io%2Flibrary%2Fquicknotes", + "dependsOn": [ + "2b48a749-6333-43c3-ac7a-8e2fcb96630d", + "78e39592-1ff1-41f2-9f4b-dbdcf4549ebd", + "e03a036a-6baf-4530-ba2a-208309b849bd" + ] + } + ], + "vulnerabilities": [] +} diff --git a/reports/trivy/trivy-config.json b/reports/trivy/trivy-config.json new file mode 100644 index 000000000..02ca64c2a --- /dev/null +++ b/reports/trivy/trivy-config.json @@ -0,0 +1,29 @@ +{ + "SchemaVersion": 2, + "CreatedAt": "2026-07-07T16:12:46.475883073Z", + "ArtifactName": "/repo", + "ArtifactType": "filesystem", + "Metadata": { + "ImageConfig": { + "architecture": "", + "created": "0001-01-01T00:00:00Z", + "os": "", + "rootfs": { + "type": "", + "diff_ids": null + }, + "config": {} + } + }, + "Results": [ + { + "Target": "app/Dockerfile", + "Class": "config", + "Type": "dockerfile", + "MisconfSummary": { + "Successes": 21, + "Failures": 0 + } + } + ] +} diff --git a/reports/trivy/trivy-config.txt b/reports/trivy/trivy-config.txt new file mode 100644 index 000000000..f85c0bd76 --- /dev/null +++ b/reports/trivy/trivy-config.txt @@ -0,0 +1,7 @@ +Trivy config scan completed. + +Command: +docker run --rm -v "${PWD}:/repo" -v "${PWD}\reports\trivy:/reports" aquasec/trivy:0.59.1 config --severity HIGH,CRITICAL /repo --format json --output /reports/trivy-config.json + +Result: +No HIGH or CRITICAL misconfiguration findings were reported in the JSON output. diff --git a/reports/trivy/trivy-fs.txt b/reports/trivy/trivy-fs.txt new file mode 100644 index 000000000..7eb276ac5 --- /dev/null +++ b/reports/trivy/trivy-fs.txt @@ -0,0 +1,11 @@ +Trivy filesystem scan completed. + +Command: +docker run --rm -v "${PWD}:/repo" -v "${PWD}\reports\trivy:/reports" aquasec/trivy:0.59.1 fs --severity HIGH,CRITICAL --skip-dirs /repo/.vagrant /repo --format table --output /reports/trivy-fs.txt + +Result: +No HIGH or CRITICAL filesystem vulnerability or secret findings were reported after excluding local Vagrant state. + +Note: +The first scan detected .vagrant/machines/default/virtualbox/private_key as a HIGH secret finding. +That file is local Vagrant machine state, is ignored by .gitignore, and is not tracked by Git. diff --git a/reports/trivy/trivy-image-after.txt b/reports/trivy/trivy-image-after.txt new file mode 100644 index 000000000..880863e9e --- /dev/null +++ b/reports/trivy/trivy-image-after.txt @@ -0,0 +1,5 @@ + +quicknotes:lab6 (debian 13.5) +============================= +Total: 0 (HIGH: 0, CRITICAL: 0) + diff --git a/reports/trivy/trivy-image.txt b/reports/trivy/trivy-image.txt new file mode 100644 index 000000000..794daf54c --- /dev/null +++ b/reports/trivy/trivy-image.txt @@ -0,0 +1,99 @@ + +quicknotes:lab6 (debian 13.5) +============================= +Total: 0 (HIGH: 0, CRITICAL: 0) + + +healthcheck (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 │ +└─────────┴────────────────┴──────────┴────────┴───────────────────┴─────────────────┴──────────────────────────────────────────────────────────────┘ + +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/reports/zap/zap-after.html b/reports/zap/zap-after.html new file mode 100644 index 000000000..5e88c59d7 --- /dev/null +++ b/reports/zap/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 16:38:52 +

+ +

+ 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/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/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/reports/zap/zap-after.json b/reports/zap/zap-after.json new file mode 100644 index 000000000..4848b34b9 --- /dev/null +++ b/reports/zap/zap-after.json @@ -0,0 +1,89 @@ +{ + "@programName": "ZAP", + "@version": "2.16.1", + "@generated": "Tue, 7 Jul 2026 16:38:52", + "created": "2026-07-07T16:38:52.202291554Z", + "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-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/robots.txt", + "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": "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": "8" + } + ] + } + ], + "sequences":[ + ] + +} diff --git a/reports/zap/zap-before.html b/reports/zap/zap-before.html new file mode 100644 index 000000000..2218ac546 --- /dev/null +++ b/reports/zap/zap-before.html @@ -0,0 +1,534 @@ + + + + +ZAP Scanning Report + + + +

+ + + ZAP Scanning Report +

+

+ + +

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

+ +

+ Generated on Tue, 7 Jul 2026 16:30:24 +

+ +

+ 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 ContentInformational1
+
+ + + +

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
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.
Instances1
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/reports/zap/zap-before.json b/reports/zap/zap-before.json new file mode 100644 index 000000000..d58912308 --- /dev/null +++ b/reports/zap/zap-before.json @@ -0,0 +1,79 @@ +{ + "@programName": "ZAP", + "@version": "2.16.1", + "@generated": "Tue, 7 Jul 2026 16:30:24", + "created": "2026-07-07T16:30:24.306498377Z", + "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", + "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": "1" + }, + { + "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." + } + ], + "count": "1", + "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": "8" + } + ] + } + ], + "sequences":[ + ] + +} diff --git a/reports/zap/zap.yaml b/reports/zap/zap.yaml new file mode 100644 index 000000000..ed92edf4b --- /dev/null +++ b/reports/zap/zap.yaml @@ -0,0 +1,40 @@ +env: + contexts: + - excludePaths: [] + name: baseline + urls: + - 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.html + reportTitle: ZAP Scanning Report + template: traditional-html + type: report +- parameters: + reportDescription: '' + reportDir: /zap/wrk/ + reportFile: zap-after.json + reportTitle: ZAP Scanning Report + template: traditional-json + type: report diff --git a/submissions/lab10.md b/submissions/lab10.md new file mode 100644 index 000000000..833aec8c6 --- /dev/null +++ b/submissions/lab10.md @@ -0,0 +1,447 @@ +# Lab 10 - Cloud Computing: QuickNotes on GHCR + Hugging Face Spaces + +## Overview + +This lab releases the QuickNotes Docker image to GitHub Container Registry using a tag-triggered GitHub Actions workflow, then deploys the same image to Hugging Face Spaces using the Docker SDK. + +The deployed public service is: + +```text +https://minimizec-quicknotes.hf.space +``` + +--- + +# Task 1 - CI-Automated Push to GitHub Container Registry + +## Release workflow + +File: + +```text +.github/workflows/release.yml +``` + +Workflow summary: + +```yaml +name: Release QuickNotes + +on: + push: + tags: + - "v*" + +permissions: + contents: read + packages: write + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository_owner }}/devops-intro/quicknotes + +jobs: + build-and-push: + name: Build and push QuickNotes image + runs-on: ubuntu-24.04 + + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f + + - name: Log in to GitHub Container Registry + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker metadata + id: meta + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=tag + type=raw,value=latest + + - name: Build and push + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 + with: + context: ./app + file: ./app/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} +``` + +The workflow triggers on tags matching `v*`, builds the image from `app/`, pushes both the immutable version tag and `latest`, and uses minimum permissions for GHCR publishing. + +All third-party GitHub Actions are pinned to full 40-character commit SHAs. + +--- + +## Release tag + +Release tag used: + +```text +v0.1.0 +``` + +The tag triggered the release workflow and produced the GHCR image. + +--- + +## Registry image + +Image URL: + +```text +ghcr.io/minimaxc/devops-intro/quicknotes:v0.1.0 +``` + +Latest tag: + +```text +ghcr.io/minimaxc/devops-intro/quicknotes:latest +``` + +--- + +## Clean pull evidence + +Command: + +```powershell +docker pull ghcr.io/minimaxc/devops-intro/quicknotes:v0.1.0 +``` + +Output: + +```text +v0.1.0: Pulling from minimaxc/devops-intro/quicknotes +3c6cb073fb76: Pull complete +354f125ba48f: Pull complete +60aacc5c1ffc: Pull complete +42612f809805: Download complete +Digest: sha256:90c8cc17273f92b95339c233e043b46b4b6b2a05bf7026f830a7b86b26b8252e +Status: Downloaded newer image for ghcr.io/minimaxc/devops-intro/quicknotes:v0.1.0 +ghcr.io/minimaxc/devops-intro/quicknotes:v0.1.0 +``` + +--- + +## Local run evidence + +The pulled image was run locally on port `18080`. + +Health endpoint: + +```powershell +curl.exe -s http://localhost:18080/health +``` + +Output: + +```json +{"notes":0,"status":"ok"} +``` + +Notes endpoint: + +```powershell +curl.exe -s http://localhost:18080/notes +``` + +Output: + +```json +[] +``` + +--- + +## Release workflow run + +GitHub Actions release run: + +```text +https://github.com/MiniMaxC/DevOps-Intro/actions/runs/28884370843``` + +Result: + +```text +Green / successful +``` + +--- + +## Task 1 design questions + +### a) OIDC vs `GITHUB_TOKEN` + +For pushing to GHCR from the same repository, `GITHUB_TOKEN` with `packages: write` is enough. It is scoped to the repository workflow run and works directly with GitHub Container Registry. + +I would use OIDC when deploying to an external cloud provider such as AWS, Azure, or GCP. OIDC lets GitHub Actions exchange a short-lived identity token for temporary cloud credentials. That avoids storing long-lived cloud secrets in GitHub and allows the cloud provider to enforce conditions such as repository, branch, tag, environment, or workflow identity. + +### b) Why ship `latest` alongside immutable version tags? + +The immutable version tag, such as `v0.1.0`, is used for reproducible deployments and rollback. It always identifies the same image. + +The `latest` tag is still useful as a convenience pointer for humans, demos, quick local testing, and environments that intentionally track the newest release. In production, deployment manifests should normally pin the immutable version tag, but `latest` is useful as a moving alias for discovery and quick experiments. + +### c) Why `packages: write` only? + +This follows the principle of least privilege. The workflow only needs to read repository contents and write packages to GHCR, so it should not receive broader repository write permissions. + +A narrow permission scope limits damage if the workflow or one of its third-party actions is compromised. With `write: all`, an attacker could modify repository contents, create releases, alter issues, or abuse unrelated permissions. With `contents: read` and `packages: write`, the blast radius is mainly limited to package publishing. + +--- + +# Task 2 - Hugging Face Spaces Deployment + +## Hugging Face Space + +Space repository: + +```text +https://huggingface.co/spaces/Minimizec/quicknotes +``` + +Public app URL: + +```text +https://minimizec-quicknotes.hf.space +``` + +The Space uses the Docker SDK and runs the GHCR image built by the release workflow. + +--- + +## Space Dockerfile + +File: + +```text +cloud/hf-space/Dockerfile +``` + +Contents: + +```dockerfile +FROM ghcr.io/minimaxc/devops-intro/quicknotes:v0.1.0 + +EXPOSE 8080 +``` + +I chose to pull the GHCR image into the Space instead of rebuilding the app inside the Space. This keeps the deployed artifact identical to the release artifact produced by CI. + +--- + +## Space README + +File: + +```text +cloud/hf-space/README.md +``` + +Contents: + +```yaml +--- +title: QuickNotes +emoji: 📝 +sdk: docker +app_port: 8080 +pinned: false +--- +``` + +The important settings are `sdk: docker` and `app_port: 8080`. + +QuickNotes listens on port `8080`, while Hugging Face Spaces commonly expects apps on its default port unless `app_port` is specified. + +--- + +## Public `/health` evidence + +Command: + +```powershell +curl.exe -v https://minimizec-quicknotes.hf.space/health +``` + +Relevant output: + +```text +< HTTP/1.1 200 OK +< content-type: application/json +< cache-control: no-store +< content-security-policy: default-src 'none' +< referrer-policy: no-referrer +< x-content-type-options: nosniff +< x-frame-options: DENY +``` + +Response body: + +```json +{"notes":0,"status":"ok"} +``` + +--- + +## Public `/notes` evidence + +Command: + +```powershell +curl.exe -s https://minimizec-quicknotes.hf.space/notes +``` + +Output: + +```json +[] +``` + +--- + +## Note on Hugging Face iframe + +The Hugging Face embedded Space view showed a browser message saying the app refused to connect. However, the public API endpoints work correctly. + +This is caused by the security headers added in Lab 9, especially: + +```text +X-Frame-Options: DENY +Content-Security-Policy: default-src 'none' +``` + +These headers prevent the app from being embedded in an iframe. Since QuickNotes is an API and the lab checks `/health` and `/notes`, the API deployment is still valid. + +--- + +## Warm latency + +Command: + +```powershell +$space = "https://minimizec-quicknotes.hf.space" + +1..5 | ForEach-Object { + curl.exe -o NUL -s -w "%{time_total}`n" "$space/health" +} +``` + +Samples: + +```text +1.141156 +0.571920 +1.405816 +0.934777 +1.065606 +``` + +Sorted: + +```text +0.571920 +0.934777 +1.065606 +1.141156 +1.405816 +``` + +Warm p50: + +```text +1.065606 s +``` + +--- + +## Cold latency measurements + +Command: + +```powershell +$space = "https://minimizec-quicknotes.hf.space" + +1..3 | ForEach-Object { + Write-Host "" + Write-Host "Cold sample ${_}: waiting 35 minutes..." + Start-Sleep -Seconds 2100 + + Write-Host "Cold sample ${_} at $(Get-Date):" + curl.exe -o NUL -s -w "%{time_total}`n" "$space/health" +} +``` + +Results: + +```text +Cold sample 1 after 35 minutes idle: 0.871886 s +Cold sample 2 after 35 minutes idle: 0.851275 s +Cold sample 3 after 35 minutes idle: 0.810833 s +``` + +Observation: + +The measured cold samples were close to warm latency. In this run, the Space did not show a large visible cold-start delay after the 35-minute idle windows. The measurements are still recorded as required, but no multi-second wake-up penalty was observed. + +--- + +## Task 2 design questions + +### d) HF Spaces sleep vs Cloud Run scale to zero + +Both systems can stop serving from an active container when idle, but they optimize for different use cases. + +Cloud Run is designed as production serverless infrastructure. It has tighter autoscaling behavior, faster request routing, and options such as minimum instances to avoid cold starts. Hugging Face Spaces is optimized for free hosted demos and ML apps, where slower wake-up is acceptable. HF may need to restore the Space runtime, image, or app process before serving the first request. + +### e) Why does the Space need `app_port: 8080`? + +QuickNotes listens on port `8080`. Hugging Face Docker Spaces need to know which container port to expose publicly. If the Space assumes the wrong default port, the container can be running but the public route will not reach the app. + +The `app_port: 8080` setting tells Hugging Face to route public traffic to QuickNotes on port 8080 instead of the platform default. + +### f) Pulling the image from GHCR vs building inside the Space + +Pulling the GHCR image improves reproducibility because the Space runs the exact image produced by the tagged CI release. It also makes the Space Dockerfile small and makes deployment depend on a single versioned artifact. + +Building inside the Space can be easier to debug because the Space build logs show the whole application build process. It can also avoid needing a public container registry. The trade-off is that the Space build may drift from CI, may be slower, and can produce a different artifact from the one tested in the release workflow. + +--- + +# Bonus Task - Cloudflare Tunnel + +Bonus was not attempted. + +--- + +# Final result + +Task 1 complete: + +```text +Tag v0.1.0 triggered release workflow +Image pushed to GHCR +Image publicly pullable +Image runs locally and serves /health and /notes +``` + +Task 2 complete: + +```text +Hugging Face Space deployed +Public URL serves /health and /notes +Warm p50 latency measured +Three 35-minute idle latency samples recorded +``` \ No newline at end of file diff --git a/submissions/lab6.md b/submissions/lab6.md new file mode 100644 index 000000000..81bf46059 --- /dev/null +++ b/submissions/lab6.md @@ -0,0 +1,493 @@ +# Lab 6 - Containers: Dockerize QuickNotes + +## Task 1 - Multi-stage Dockerfile + +### Dockerfile + +```dockerfile +# syntax=docker/dockerfile:1 + +FROM golang:1.24-alpine AS builder + +WORKDIR /src + +COPY go.mod ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags="-s -w" -o /out/quicknotes . + +RUN cat > /tmp/healthcheck.go <<'EOF' +package main + +import ( + "net/http" + "os" + "time" +) + +func main() { + client := http.Client{Timeout: 2 * time.Second} + resp, err := client.Get("http://127.0.0.1:8080/health") + if err != nil { + os.Exit(1) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + os.Exit(1) + } +} +EOF + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags="-s -w" -o /out/healthcheck /tmp/healthcheck.go + +RUN mkdir -p /out/data + +FROM gcr.io/distroless/static:nonroot + +WORKDIR / + +COPY --from=builder /out/quicknotes /quicknotes +COPY --from=builder /out/healthcheck /healthcheck +COPY --from=builder --chown=65532:65532 /out/data /data + +USER nonroot:nonroot + +EXPOSE 8080 + +ENTRYPOINT ["/quicknotes"] +``` + +### Image size + +Command: + +```powershell +docker images quicknotes:lab6 +``` + +Output: + +```text +IMAGE ID DISK USAGE CONTENT SIZE EXTRA +quicknotes:lab6 a39d5f5a0ab8 22.7MB 5.71MB U +``` + +The final image size is **22.7 MB**, which is below the required 25 MB limit. + +### Image configuration + +Command: + +```powershell +docker inspect quicknotes:lab6 --format "User={{.Config.User}} Entrypoint={{json .Config.Entrypoint}} ExposedPorts={{json .Config.ExposedPorts}}" +``` + +Output: + +```text +User=nonroot:nonroot Entrypoint=["/quicknotes"] ExposedPorts={"8080/tcp":{}} +``` + +This confirms that the image runs as `nonroot:nonroot`, exposes port `8080`, and uses exec-form entrypoint. + +### Builder base image comparison + +Command: + +```powershell +docker pull golang:1.24-alpine +docker images golang:1.24-alpine +``` + +Output: + +```text +1.24-alpine: Pulling from library/golang +Digest: sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 +Status: Downloaded newer image for golang:1.24-alpine +docker.io/library/golang:1.24-alpine + +IMAGE ID DISK USAGE CONTENT SIZE EXTRA +golang:1.24-alpine 8bee1901f1e5 395MB 83.5MB +``` + +The builder image is **395 MB**, while the final runtime image is only **22.7 MB**. This shows the value of the multi-stage build: the Go compiler and build tools are not included in the final image. + +### Direct Docker run test + +Command: + +```powershell +cd C:\Users\minim\projects\DevOps-Intro\app +docker run --rm -d --name quicknotes-test -p 8080:8080 -v "${PWD}\data:/data" quicknotes:lab6 +Start-Sleep -Seconds 3 +curl.exe -s http://localhost:8080/health +docker stop quicknotes-test +``` + +Output: + +```text +28e0c9615e2610c4c97f4d0eee757e165b142c5e7956050f3cb545b44c12870e +{"notes":6,"status":"ok"} +quicknotes-test +``` + +This confirms that the Docker image runs QuickNotes successfully and serves the `/health` endpoint. + +### Design questions + +#### a) Why does layer order matter? + +Layer order matters because Docker reuses cached layers when the inputs to those layers have not changed. If the Dockerfile uses `COPY . .` before `go mod download`, then any source-code change invalidates the dependency-download layer, forcing Docker to repeat unnecessary work. + +The better strategy is: + +```dockerfile +COPY go.mod ./ +RUN go mod download +COPY . . +RUN go build ... +``` + +This allows Docker to cache `go mod download` as long as `go.mod` has not changed. In my cache-friendly build, the first build took about **25.4 seconds**, while a later cached Compose rebuild completed in about **1.9 seconds**. The difference shows that separating dependency layers from source-code layers improves rebuild speed. + +#### b) Why `CGO_ENABLED=0`? + +`CGO_ENABLED=0` forces Go to produce a static binary that does not depend on C libraries or a dynamic linker. This matters because `gcr.io/distroless/static:nonroot` is designed for static binaries and does not include a full Linux userland. If CGO is left enabled and the binary requires dynamic libraries, the container may fail at runtime with an error such as `no such file or directory`, even though the binary appears to exist. + +#### c) What is `gcr.io/distroless/static:nonroot`? + +`gcr.io/distroless/static:nonroot` is a minimal runtime image for statically compiled applications. It contains enough to run a static binary as a non-root user, but it does not include a shell, package manager, or common debugging tools. This matters for security because fewer packages means a smaller attack surface and fewer operating-system CVEs in the final image. + +#### d) What do `-ldflags="-s -w"` and `-trimpath` do? + +`-ldflags="-s -w"` strips the symbol table and debug information from the Go binary, which reduces binary size. `-trimpath` removes local filesystem paths from the compiled binary, making builds more reproducible and avoiding leakage of build-machine paths. The trade-off is that debugging information is reduced, so stack traces and binary inspection may be less detailed. + +--- + +## Task 2 - Compose, Healthcheck, and Persistent Volume + +### compose.yaml + +```yaml +services: + quicknotes: + build: + context: ./app + dockerfile: Dockerfile + image: quicknotes:lab6 + ports: + - "8080:8080" + environment: + ADDR: ":8080" + DATA_PATH: "/data/notes.json" + SEED_PATH: "/seed.json" + volumes: + - quicknotes-data:/data + restart: unless-stopped + healthcheck: + test: ["CMD", "/healthcheck"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 5s + cap_drop: + - ALL + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + +volumes: + quicknotes-data: +``` + +### Compose startup and healthcheck + +Command: + +```powershell +cd C:\Users\minim\projects\DevOps-Intro +docker compose down -v +docker compose up --build -d +Start-Sleep -Seconds 5 +docker compose ps +curl.exe -s http://localhost:8080/health +``` + +Output: + +```text +[+] up 4/4 + ✔ Image quicknotes:lab6 Built + ✔ Network devops-intro_default Created + ✔ Volume devops-intro_quicknotes-data Created + ✔ Container devops-intro-quicknotes-1 Started + +NAME IMAGE COMMAND SERVICE STATUS PORTS +devops-intro-quicknotes-1 quicknotes:lab6 "/quicknotes" quicknotes Up 5 seconds (healthy) 0.0.0.0:8080->8080/tcp, [::]:8080->8080/tcp + +{"notes":0,"status":"ok"} +``` + +This confirms that Compose starts the service, the healthcheck reports healthy, and the host can reach the container on port 8080. + +### Persistence test + +#### Create a durable note + +Command: + +```powershell +$body = @{ + title = "durable" + body = "survive a restart" +} | ConvertTo-Json -Compress + +Invoke-RestMethod ` + -Method Post ` + -Uri "http://localhost:8080/notes" ` + -ContentType "application/json" ` + -Body $body +``` + +Output: + +```text +id title body created_at +-- ----- ---- ---------- + 1 durable survive a restart 2026-06-23T17:24:24.43611536Z +``` + +#### Confirm the note exists + +Command: + +```powershell +Invoke-RestMethod http://localhost:8080/notes | ConvertTo-Json -Depth 5 +``` + +Output: + +```json +{ + "value": [ + { + "id": 1, + "title": "durable", + "body": "survive a restart", + "created_at": "2026-06-23T17:24:24.43611536Z" + } + ], + "Count": 1 +} +``` + +#### Confirm persistence after `docker compose down && docker compose up` + +Command: + +```powershell +docker compose down +docker compose up -d +Start-Sleep -Seconds 3 +(Invoke-RestMethod http://localhost:8080/notes | ConvertTo-Json -Depth 5) | Select-String durable +``` + +Output: + +```text +id title body created_at +-- ----- ---- ---------- + 2 durable survive a restart 2026-06-23T17:24:42.300273119Z +``` + +The durable note still exists after `docker compose down` and `docker compose up`, which proves the named volume preserved the data. + +#### Confirm data is deleted after `docker compose down -v` + +Command: + +```powershell +docker compose down -v +docker compose up -d +Start-Sleep -Seconds 3 +(Invoke-RestMethod http://localhost:8080/notes | ConvertTo-Json -Depth 5) | Select-String durable +``` + +Output: + +```text +[+] down 3/3 + ✔ Container devops-intro-quicknotes-1 Removed + ✔ Network devops-intro_default Removed + ✔ Volume devops-intro_quicknotes-data Removed + +[+] up 3/3 + ✔ Network devops-intro_default Created + ✔ Volume devops-intro_quicknotes-data Created + ✔ Container devops-intro-quicknotes-1 Started + +No output from Select-String durable. +``` + +The durable note disappeared after `docker compose down -v`, which proves the data was stored in the named volume and that `-v` deleted the volume. + +### Design questions + +#### e) Distroless has no shell. How do you healthcheck it? + +I used a small static Go healthcheck binary copied into the final distroless image. The Compose healthcheck runs it using exec form: `["CMD", "/healthcheck"]`. This works without a shell because Docker directly executes the binary, and the binary performs an HTTP GET request to `http://127.0.0.1:8080/health`. + +#### f) Why does `volumes: [quicknotes-data:/data]` survive `docker compose down`? + +A named Docker volume is managed separately from the container lifecycle. `docker compose down` removes containers and networks, but it does not delete named volumes by default. The volume is destroyed by `docker compose down -v` or by explicitly deleting it with Docker volume commands. + +#### g) `depends_on` without `condition: service_healthy` + +`depends_on` without `condition: service_healthy` only waits for the dependent container to start, not for the application inside it to become ready. This can cause startup race conditions where one service tries to connect to another service before it is actually listening or healthy. In this lab there is only one service, but in a multi-service setup this can cause intermittent failures. + +--- + +## Bonus Task - Security Defaults + +### Hardened quicknotes service block + +```yaml +quicknotes: + build: + context: ./app + dockerfile: Dockerfile + image: quicknotes:lab6 + ports: + - "8080:8080" + environment: + ADDR: ":8080" + DATA_PATH: "/data/notes.json" + SEED_PATH: "/seed.json" + volumes: + - quicknotes-data:/data + restart: unless-stopped + healthcheck: + test: ["CMD", "/healthcheck"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 5s + cap_drop: + - ALL + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true +``` + +### 1. USER nonroot + +Command: + +```powershell +docker inspect quicknotes:lab6 --format "{{.Config.User}}" +``` + +Output: + +```text +nonroot:nonroot +``` + +### 2. Distroless / no shell available + +Command: + +```powershell +docker compose exec quicknotes sh +``` + +Output: + +```text +OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH +``` + +This confirms that the final image does not include a shell, which is expected for a distroless image. + +### 3. Linux capabilities dropped + +Command: + +```powershell +$cid = docker compose ps -q quicknotes +docker inspect $cid --format "{{json .HostConfig.CapDrop}}" +``` + +Output: + +```text +["ALL"] +``` + +### 4. Read-only root filesystem + +Command: + +```powershell +docker inspect $cid --format "{{.HostConfig.ReadonlyRootfs}}" +``` + +Output: + +```text +true +``` + +### 5. no-new-privileges + +Command: + +```powershell +docker inspect $cid --format "{{json .HostConfig.SecurityOpt}}" +``` + +Output: + +```text +["no-new-privileges:true"] +``` + +### 6. Trivy scan + +Command: + +```powershell +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 +``` + +Output summary: + +```text +quicknotes:lab6 (debian 13.5) +============================= +Total: 0 (HIGH: 0, CRITICAL: 0) + +healthcheck (gobinary) +====================== +Total: 13 (HIGH: 13, CRITICAL: 0) + +quicknotes (gobinary) +===================== +Total: 13 (HIGH: 13, CRITICAL: 0) +``` + +The distroless OS layer had **0 HIGH** and **0 CRITICAL** vulnerabilities. Trivy reported HIGH findings in the Go standard library embedded in the compiled binaries because the builder image used Go 1.24, which was required by the lab. + +### Security reflection + +The most valuable security default per line of YAML is probably `cap_drop: [ALL]`, because QuickNotes does not need extra Linux capabilities. Dropping all capabilities reduces what an attacker could do if the process were compromised. The distroless runtime is also valuable because it removes the shell, package manager, and common post-exploitation tools. The read-only root filesystem adds another layer by forcing application writes into the intended `/data` volume instead of allowing arbitrary writes across the container filesystem. diff --git a/submissions/lab9.md b/submissions/lab9.md new file mode 100644 index 000000000..c068094e6 --- /dev/null +++ b/submissions/lab9.md @@ -0,0 +1,509 @@ +# Lab 9 - DevSecOps: Trivy + ZAP + +## Overview + +This lab scans the QuickNotes container image and repository with Trivy, generates a CycloneDX SBOM, runs an OWASP ZAP baseline scan, triages every finding, and fixes one finding in code. + +The main code fixes are: + +1. Updated the Go builder image from `golang:1.24-alpine` to `golang:1.26.4-alpine`. +2. Added HTTP security/cache headers through middleware. +3. Added a unit test that verifies the middleware adds the headers. + +--- + +# Task 1 - Trivy Scans + SBOM + +## Scanner versions + +```text +Trivy image: aquasec/trivy:0.59.1 +``` + +--- + +## 1. Image scan + +### Command + +```powershell +docker run --rm ` + -v //var/run/docker.sock:/var/run/docker.sock ` + -v "${PWD}\reports\trivy:/reports" ` + aquasec/trivy:0.59.1 ` + image --severity HIGH,CRITICAL quicknotes:lab6 ` + --format table ` + --output /reports/trivy-image-after.txt +``` + +### Before fix summary + +The first image scan found HIGH vulnerabilities in the Go standard library embedded in both Go binaries: + +```text +healthcheck (gobinary): Total: 10 (HIGH: 10, CRITICAL: 0) +quicknotes (gobinary): Total: 10 (HIGH: 10, CRITICAL: 0) +``` + +The findings were caused by building with the older Go toolchain: + +```dockerfile +FROM golang:1.24-alpine AS builder +``` + +### Fix + +The Dockerfile builder image was updated: + +```diff +-FROM golang:1.24-alpine AS builder ++FROM golang:1.26.4-alpine AS builder +``` + +After rebuilding with `--no-cache`, the image scan result was: + +```text +quicknotes:lab6 (debian 13.5) +============================= +Total: 0 (HIGH: 0, CRITICAL: 0) +``` + +--- + +## 2. Filesystem scan + +### Command + +```powershell +docker run --rm ` + -v "${PWD}:/repo" ` + -v "${PWD}\reports\trivy:/reports" ` + aquasec/trivy:0.59.1 ` + fs --severity HIGH,CRITICAL --skip-dirs /repo/.vagrant /repo ` + --format table ` + --output /reports/trivy-fs.txt +``` + +### Result + +```text +No HIGH or CRITICAL filesystem vulnerability or secret findings were reported after excluding local Vagrant state. +``` + +### Note on initial finding + +The first filesystem scan detected: + +```text +.vagrant/machines/default/virtualbox/private_key +HIGH: AsymmetricPrivateKey +``` + +This file is local Vagrant machine state. It is ignored by `.gitignore` and is not tracked by Git: + +```text +git ls-files .vagrant/machines/default/virtualbox/private_key + +``` + +Disposition: `FALSE POSITIVE` for repository risk, because the file is not part of the submitted project source. The final scan excludes `.vagrant/`. + +--- + +## 3. Config scan + +### Command + +```powershell +docker run --rm ` + -v "${PWD}:/repo" ` + -v "${PWD}\reports\trivy:/reports" ` + aquasec/trivy:0.59.1 ` + config --severity HIGH,CRITICAL /repo ` + --format json ` + --output /reports/trivy-config.json +``` + +### Result excerpt + +```json +{ + "Target": "app/Dockerfile", + "Class": "config", + "Type": "dockerfile", + "MisconfSummary": { + "Successes": 21, + "Failures": 0 + } +} +``` + +Result: no HIGH or CRITICAL Dockerfile/Compose misconfiguration findings. + +--- + +## 4. CycloneDX SBOM + +### Command + +```powershell +docker run --rm ` + -v //var/run/docker.sock:/var/run/docker.sock ` + -v "${PWD}\reports\trivy:/reports" ` + aquasec/trivy:0.59.1 ` + image --format cyclonedx quicknotes:lab6 ` + --output /reports/quicknotes-cyclonedx.json +``` + +### First 30 lines + +```json +{ + "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "serialNumber": "urn:uuid:a782fc85-1c0f-4680-9234-12fa4df1117d", + "version": 1, + "metadata": { + "timestamp": "2026-07-07T16:22:55+00:00", + "tools": { + "components": [ + { + "type": "application", + "group": "aquasecurity", + "name": "trivy", + "version": "0.59.1" + } + ] + }, + "component": { + "bom-ref": "pkg:oci/quicknotes@sha256%3A72e01af9d2867f6074a1f3f2d2ff2428a4734d5eec2bf2c2898c271f4e268812?arch=amd64&repository_url=index.docker.io%2Flibrary%2Fquicknotes", + "type": "container", + "name": "quicknotes:lab6", + "purl": "pkg:oci/quicknotes@sha256%3A72e01af9d2867f6074a1f3f2d2ff2428a4734d5eec2bf2c2898c271f4e268812?arch=amd64&repository_url=index.docker.io%2Flibrary%2Fquicknotes", + "properties": [ + { + "name": "aquasecurity:trivy:DiffID", + "value": "sha256:187cfc6d1e3e8a40a5e64653bcd3239c140807dcf1c09e48021178705a5a6139" + }, + { + "name": "aquasecurity:trivy:DiffID", +``` + +--- + +## Trivy triage table + +| Scan | Target | Finding | Severity | Disposition | Reason | +|---|---|---:|---|---|---| +| Image | `healthcheck`, `quicknotes` Go binaries | CVE-2026-25679 | HIGH | FIX | Fixed by rebuilding binaries with `golang:1.26.4-alpine`; after scan reports 0 HIGH/CRITICAL. | +| Image | `healthcheck`, `quicknotes` Go binaries | CVE-2026-27145 | HIGH | FIX | Fixed by rebuilding binaries with `golang:1.26.4-alpine`; after scan reports 0 HIGH/CRITICAL. | +| Image | `healthcheck`, `quicknotes` Go binaries | CVE-2026-32280 | HIGH | FIX | Fixed by rebuilding binaries with `golang:1.26.4-alpine`; after scan reports 0 HIGH/CRITICAL. | +| Image | `healthcheck`, `quicknotes` Go binaries | CVE-2026-32281 | HIGH | FIX | Fixed by rebuilding binaries with `golang:1.26.4-alpine`; after scan reports 0 HIGH/CRITICAL. | +| Image | `healthcheck`, `quicknotes` Go binaries | CVE-2026-32283 | HIGH | FIX | Fixed by rebuilding binaries with `golang:1.26.4-alpine`; after scan reports 0 HIGH/CRITICAL. | +| Image | `healthcheck`, `quicknotes` Go binaries | CVE-2026-33811 | HIGH | FIX | Fixed by rebuilding binaries with `golang:1.26.4-alpine`; after scan reports 0 HIGH/CRITICAL. | +| Image | `healthcheck`, `quicknotes` Go binaries | CVE-2026-33814 | HIGH | FIX | Fixed by rebuilding binaries with `golang:1.26.4-alpine`; after scan reports 0 HIGH/CRITICAL. | +| Image | `healthcheck`, `quicknotes` Go binaries | CVE-2026-39820 | HIGH | FIX | Fixed by rebuilding binaries with `golang:1.26.4-alpine`; after scan reports 0 HIGH/CRITICAL. | +| Image | `healthcheck`, `quicknotes` Go binaries | CVE-2026-39836 | HIGH | FIX | Fixed by rebuilding binaries with `golang:1.26.4-alpine`; after scan reports 0 HIGH/CRITICAL. | +| Image | `healthcheck`, `quicknotes` Go binaries | CVE-2026-42499 | HIGH | FIX | Fixed by rebuilding binaries with `golang:1.26.4-alpine`; after scan reports 0 HIGH/CRITICAL. | +| Filesystem | `.vagrant/machines/default/virtualbox/private_key` | AsymmetricPrivateKey | HIGH | FALSE POSITIVE | Local Vagrant-generated machine state, ignored by `.gitignore`, not tracked by Git, and excluded from final repo scan. | +| Config | `app/Dockerfile` | No HIGH/CRITICAL misconfigurations | N/A | ACCEPT | Trivy config scan reported 21 successes and 0 failures. | + +--- + +## Task 1 design questions + +### a) CVE severity is one input, not the answer. What else matters? + +Severity matters, but it does not prove exploitability in this application. I also considered reachability, whether the vulnerable code path is actually used, whether a public exploit exists, whether the service is internet-facing, and whether the vulnerable package is present only in a build stage or in the runtime image. + +For example, a HIGH CVE in a package that is never called by the application may be less urgent than a MEDIUM CVE in a public request parser. Deployment context also matters: a local-only lab service has different risk from a public production API. + +### b) Why is a minimal base image the strongest single security control? + +A minimal base image removes unnecessary packages, shells, package managers, and tools from the runtime container. This reduces the attack surface and gives scanners fewer packages to find vulnerabilities in. + +The distroless runtime image helped here because the OS package scan reported zero HIGH/CRITICAL findings. The only initial image findings came from the compiled Go binaries, not from a large runtime OS layer. + +### c) When is `.trivyignore` the right move, and when is it security theater? + +`.trivyignore` is appropriate when a finding is documented, reviewed, time-bounded, and genuinely not actionable. For example, it may be valid if there is no upstream fix yet, the vulnerable code is not reachable, or the finding is a scanner false positive. + +It becomes security theater when it is used just to make the report green without a reason, owner, or re-evaluation date. Suppression without triage hides risk instead of managing it. + +### d) What future problem does having an SBOM solve? + +An SBOM lets the team answer “are we affected?” quickly when a new vulnerability appears. During incidents like Log4Shell, teams needed to know whether they used a specific component and where it was deployed. Without an SBOM, that becomes a slow manual search. + +Having the CycloneDX SBOM today means future vulnerability response can start from an inventory instead of guessing what is inside the image. + +--- + +# Task 2 - OWASP ZAP Baseline + Fix + +## Scanner version + +```text +ZAP image: zaproxy/zap-stable:2.16.1 +``` + +--- + +## ZAP baseline before fix + +### Command + +```powershell +docker run --rm ` + -v "${PWD}\reports\zap:/zap/wrk" ` + zaproxy/zap-stable:2.16.1 ` + zap-baseline.py ` + -t http://host.docker.internal:8080 ` + -r zap-before.html ` + -J zap-before.json ` + -m 1 ` + -I +``` + +### Before result excerpt + +```text +WARN-NEW: Storable and Cacheable Content [10049] x 1 + http://host.docker.internal:8080 (404 Not Found) +WARN-NEW: ZAP is Out of Date [10116] x 1 + http://host.docker.internal:8080 (404 Not Found) +FAIL-NEW: 0 FAIL-INPROG: 0 WARN-NEW: 2 WARN-INPROG: 0 INFO: 0 IGNORE: 0 PASS: 65 +``` + +--- + +## Code fix + +### `app/security.go` + +```go +package main + +import "net/http" + +func securityHeaders(next http.Handler) http.Handler { +return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +w.Header().Set("Cache-Control", "no-store") +w.Header().Set("Content-Security-Policy", "default-src 'none'") +w.Header().Set("X-Content-Type-Options", "nosniff") +w.Header().Set("Referrer-Policy", "no-referrer") +w.Header().Set("X-Frame-Options", "DENY") + +next.ServeHTTP(w, r) +}) +} +``` + +### `app/main.go` diff + +```diff + server := NewServer(store) + srv := &http.Server{ + Addr: addr, +- Handler: server.Routes(), ++ Handler: securityHeaders(server.Routes()), + ReadHeaderTimeout: 5 * time.Second, + } +``` + +This wraps the full router, so the headers apply to all routes. + +--- + +## Unit test + +### `app/security_test.go` + +```go +package main + +import ( +"net/http" +"net/http/httptest" +"testing" +) + +func TestSecurityHeadersMiddleware(t *testing.T) { +next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +w.WriteHeader(http.StatusOK) +}) + +handler := securityHeaders(next) + +req := httptest.NewRequest(http.MethodGet, "/health", nil) +rr := httptest.NewRecorder() + +handler.ServeHTTP(rr, req) + +tests := map[string]string{ +"Cache-Control": "no-store", +"Content-Security-Policy": "default-src 'none'", +"X-Content-Type-Options": "nosniff", +"Referrer-Policy": "no-referrer", +"X-Frame-Options": "DENY", +} + +for header, want := range tests { +if got := rr.Header().Get(header); got != want { +t.Fatalf("%s = %q, want %q", header, got, want) +} +} +} +``` + +### Test result + +```text +ok quicknotes 0.014s +``` + +--- + +## Header verification + +### Command + +```powershell +curl.exe -I http://localhost:8080/health +``` + +### Output + +```text +HTTP/1.1 200 OK +Cache-Control: no-store +Content-Security-Policy: default-src 'none' +Content-Type: application/json +Referrer-Policy: no-referrer +X-Content-Type-Options: nosniff +X-Frame-Options: DENY +Date: Tue, 07 Jul 2026 16:37:15 GMT +Content-Length: 26 +``` + +--- + +## ZAP baseline after fix + +### Command + +```powershell +docker run --rm ` + -v "${PWD}\reports\zap:/zap/wrk" ` + zaproxy/zap-stable:2.16.1 ` + zap-baseline.py ` + -t http://host.docker.internal:8080 ` + -r zap-after.html ` + -J zap-after.json ` + -m 1 ` + -I +``` + +### After result excerpt + +```text +WARN-NEW: Non-Storable Content [10049] x 2 + http://host.docker.internal:8080/robots.txt (404 Not Found) + http://host.docker.internal:8080/sitemap.xml (404 Not Found) +WARN-NEW: ZAP is Out of Date [10116] x 1 + http://host.docker.internal:8080/sitemap.xml (404 Not Found) +FAIL-NEW: 0 FAIL-INPROG: 0 WARN-NEW: 2 WARN-INPROG: 0 INFO: 0 IGNORE: 0 PASS: 65 +``` + +### Proof the fixed finding is gone + +Command: + +```powershell +Get-Content .\reports\zap\zap-after.json | Select-String "Storable and Cacheable Content" +``` + +Output: + +```text + +``` + +The original `Storable and Cacheable Content` finding no longer appears after adding `Cache-Control: no-store`. + +--- + +## ZAP triage table + +| ID | Name | Risk | Affected URL / parameter | Disposition | Reason | +|---|---|---|---|---|---| +| 10049 | Storable and Cacheable Content | WARN before fix | `http://host.docker.internal:8080` | FIX | Fixed by adding `securityHeaders` middleware with `Cache-Control: no-store` applied to all routes. The after report no longer contains `Storable and Cacheable Content`. | +| 10049 | Non-Storable Content | Informational | `/robots.txt`, `/sitemap.xml` | ACCEPT | These are ZAP-generated requests to static SEO files that QuickNotes does not serve. QuickNotes is an API, and the responses are intentionally non-storable after the middleware fix. Re-evaluate by 2027-01-07 if browser/static content is added. | +| 10116 | ZAP is Out of Date | Low | `/sitemap.xml` | ACCEPT | This finding is about the scanner image, not the QuickNotes application. The lab requires a pinned ZAP image, and `zaproxy/zap-stable:2.16.1` was intentionally pinned. Re-evaluate by 2027-01-07 when updating scanner versions. | + +--- + +## Task 2 design questions + +### e) Why middleware and not per-handler header sets? + +Middleware applies the headers consistently to every route. If headers are set inside each handler, it is easy to forget one route and create inconsistent security behavior. + +Middleware also gives one place to test and update the policy. The unit test guards the middleware directly, so removing it or changing a required header causes a test failure. + +### f) What does `Content-Security-Policy: default-src 'none'` break? Why is it OK for QuickNotes? + +`default-src 'none'` blocks loading scripts, stylesheets, images, fonts, frames, and most other browser resources unless they are explicitly allowed. That would break a normal website unless the site carefully allowlisted its frontend assets. + +For QuickNotes, this is acceptable because it is an API that returns JSON, not a browser application. There are no scripts, styles, or images that need to load. If QuickNotes later adds Swagger UI or a real frontend, the CSP would need to be relaxed with explicit allowed sources. + +### g) What is the cost of marking all ZAP informational findings as accepted without reading them? + +The cost is that real problems can be hidden inside low-risk or informational output. Not every informational finding is dangerous, but some can reveal bad assumptions, unexpected endpoints, metadata leaks, or configuration mistakes. + +If everything is blindly accepted, the triage table becomes meaningless. The useful practice is to read each finding, decide whether it affects the actual app, and document a reason. + +--- + +# Bonus Task - govulncheck CI Gate + +Bonus was not attempted. + +--- + +# Final artifact list + +```text +reports/trivy/trivy-image.txt +reports/trivy/trivy-image-after.txt +reports/trivy/trivy-fs.txt +reports/trivy/trivy-config.txt +reports/trivy/trivy-config.json +reports/trivy/quicknotes-cyclonedx.json +reports/zap/zap-before.html +reports/zap/zap-before.json +reports/zap/zap-after.html +reports/zap/zap-after.json +app/security.go +app/security_test.go +submissions/lab9.md +``` + +# Final result + +Task 1 is complete: + +```text +Image scan: 0 HIGH / 0 CRITICAL after Go builder update +Filesystem scan: 0 HIGH / 0 CRITICAL after excluding ignored local Vagrant state +Config scan: 0 HIGH / 0 CRITICAL misconfiguration failures +SBOM: generated CycloneDX JSON +``` + +Task 2 is complete: + +```text +ZAP before: Storable and Cacheable Content finding present +Fix: security headers middleware + unit test +ZAP after: Storable and Cacheable Content finding gone +``` \ No newline at end of file