diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..1a68db5e5 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,13 @@ +## Goal + + +## Changes +- + +## Testing + + +## Checklist +- [ ] Title is a clear sentence (≤ 70 chars) +- [ ] Commits are signed (`git log --show-signature`) +- [ ] `submissions/labN.md` updated diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..7aa3c563c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,59 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + paths: + - 'app/**' + - '.github/workflows/ci.yml' + +permissions: + contents: read + +defaults: + run: + working-directory: app + +jobs: + vet: + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + go: ['1.23', '1.24'] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: actions/setup-go@3041bf56c941b39c61721a86cd11f3bb1338122a + with: + go-version: ${{ matrix.go }} + cache-dependency-path: app/go.sum + - run: go vet ./... + + test: + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + go: ['1.23', '1.24'] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: actions/setup-go@3041bf56c941b39c61721a86cd11f3bb1338122a + with: + go-version: ${{ matrix.go }} + cache-dependency-path: app/go.sum + - run: go test -race -count=1 ./... + + lint: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: actions/setup-go@3041bf56c941b39c61721a86cd11f3bb1338122a + with: + go-version: '1.24' + cache-dependency-path: app/go.sum + - uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee + with: + version: v2.12.0 + working-directory: app \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..c5a78b27b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,49 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: read + packages: write + +jobs: + build-push: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + + - name: Set image name + id: img + run: echo "name=ghcr.io/${GITHUB_REPOSITORY,,}/quicknotes" >> "$GITHUB_OUTPUT" + + - name: Log in to ghcr.io + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 + with: + images: ${{ steps.img.outputs.name }} + tags: | + type=semver,pattern={{version}} + type=raw,value=latest + + - name: Set up Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c + + - name: Build and push + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a + with: + context: app + file: app/Dockerfile + platforms: linux/amd64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file diff --git a/Vagrantfile b/Vagrantfile new file mode 100644 index 000000000..7ab97f6e5 --- /dev/null +++ b/Vagrantfile @@ -0,0 +1,51 @@ +Vagrant.configure("2") do |config| + + config.vm.box = "bento/ubuntu-24.04" + + config.vm.hostname = "quicknotes" + + config.vm.network "forwarded_port", guest: 8080, host: 18080, host_ip: "127.0.0.1" + + config.vm.synced_folder "./app", "/opt/quicknotes/app" + + config.vm.provider "virtualbox" do |vb| + vb.name = "quicknotes-lab5" + vb.cpus = 2 + vb.memory = 1024 + vb.customize ["modifyvm", :id, "--paravirtprovider", "legacy"] + end + + config.vm.provision "shell", inline: <<-SHELL + if ! /usr/local/go/bin/go version 2>/dev/null | grep -q "go1.24.5"; then + curl -fsSL "https://go.dev/dl/go1.24.5.linux-amd64.tar.gz" -o /tmp/go.tgz + rm -rf /usr/local/go + tar -C /usr/local -xzf /tmp/go.tgz + fi + export PATH=$PATH:/usr/local/go/bin + ln -sf /usr/local/go/bin/go /usr/local/bin/go + ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt + mkdir -p /opt/quicknotes/bin /var/lib/quicknotes + cd /opt/quicknotes/app + go build -o /opt/quicknotes/bin/qn . + cat > /etc/systemd/system/quicknotes.service <<'UNIT' +[Unit] +Description=QuickNotes +After=network.target + +[Service] +WorkingDirectory=/opt/quicknotes/app +Environment=ADDR=:8080 +Environment=DATA_PATH=/var/lib/quicknotes/notes.json +Environment=SEED_PATH=/opt/quicknotes/app/seed.json +ExecStart=/opt/quicknotes/bin/qn +Restart=on-failure + +[Install] +WantedBy=multi-user.target +UNIT + systemctl daemon-reload + systemctl enable --now quicknotes + sleep 2 + systemctl --no-pager status quicknotes | head -n 5 + SHELL +end \ No newline at end of file diff --git a/ansible/files/quicknotes b/ansible/files/quicknotes new file mode 100755 index 000000000..307a597a3 Binary files /dev/null and b/ansible/files/quicknotes differ diff --git a/ansible/inventory.ini b/ansible/inventory.ini new file mode 100644 index 000000000..ea08448a8 --- /dev/null +++ b/ansible/inventory.ini @@ -0,0 +1,5 @@ +[lab5] +quicknotes ansible_host=127.0.0.1 ansible_port=2222 ansible_user=vagrant ansible_ssh_private_key_file=/home/moze/Documents/DevOps/DevOps-Intro/.vagrant/machines/default/virtualbox/private_key + +[lab5:vars] +ansible_ssh_common_args='-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null' \ No newline at end of file diff --git a/ansible/playbook.yaml b/ansible/playbook.yaml new file mode 100644 index 000000000..c7b3e3e1c --- /dev/null +++ b/ansible/playbook.yaml @@ -0,0 +1,62 @@ +- name: Deploy QuickNotes + hosts: lab5 + become: true + gather_facts: false + + vars: + app_user: quicknotes + data_dir: /var/lib/quicknotes + bin_path: /usr/local/bin/quicknotes + listen_addr: ":7070" + + tasks: + - name: Create system user + ansible.builtin.user: + name: "{{ app_user }}" + system: true + shell: /usr/sbin/nologin + create_home: false + + - name: Ensure data directory + ansible.builtin.file: + path: "{{ data_dir }}" + state: directory + owner: "{{ app_user }}" + group: "{{ app_user }}" + mode: "0750" + + - name: Copy QuickNotes binary + ansible.builtin.copy: + src: files/quicknotes + dest: "{{ bin_path }}" + mode: "0755" + notify: restart quicknotes + + - name: Copy seed file + ansible.builtin.copy: + src: ../app/seed.json + dest: "{{ data_dir }}/seed.json" + owner: "{{ app_user }}" + group: "{{ app_user }}" + mode: "0640" + + - name: Render systemd unit + ansible.builtin.template: + src: templates/quicknotes.service.j2 + dest: /etc/systemd/system/quicknotes.service + mode: "0644" + notify: restart quicknotes + + - name: Enable and start service + ansible.builtin.systemd: + name: quicknotes + enabled: true + state: started + daemon_reload: true + + handlers: + - name: restart quicknotes + ansible.builtin.systemd: + name: quicknotes + state: restarted + daemon_reload: true \ No newline at end of file diff --git a/ansible/templates/quicknotes.service.j2 b/ansible/templates/quicknotes.service.j2 new file mode 100644 index 000000000..0edd4a9dd --- /dev/null +++ b/ansible/templates/quicknotes.service.j2 @@ -0,0 +1,17 @@ +[Unit] +Description=QuickNotes +After=network-online.target +Wants=network-online.target + +[Service] +User={{ app_user }} +WorkingDirectory={{ data_dir }} +Environment=ADDR={{ listen_addr }} +Environment=DATA_PATH={{ data_dir }}/notes.json +Environment=SEED_PATH={{ data_dir }}/seed.json +ExecStart={{ bin_path }} +Restart=on-failure +RestartSec=2 + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 000000000..99ddb8db6 --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,26 @@ +# Stage 1: builder stage +FROM golang:1.24 AS builder + +WORKDIR /src + +COPY go.mod ./ +RUN go mod download + +RUN mkdir -p /data + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux \ + go build -trimpath -ldflags='-s -w' -o /quicknotes . + +# Stage 2: runtime stage +FROM gcr.io/distroless/static:nonroot + +USER 65532:65532 + +COPY --from=builder /quicknotes /quicknotes +COPY --from=builder --chown=65532:65532 /data /data + +EXPOSE 8080 + +ENTRYPOINT ["/quicknotes"] \ No newline at end of file diff --git a/app/handlers.go b/app/handlers.go index c534979c5..187c418b0 100644 --- a/app/handlers.go +++ b/app/handlers.go @@ -26,7 +26,7 @@ func NewServer(store *Store) *Server { return &Server{store: store, requestsByCode: by} } -func (s *Server) Routes() *http.ServeMux { +func (s *Server) Routes() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /health", s.wrap(s.handleHealth)) mux.HandleFunc("GET /metrics", s.wrap(s.handleMetrics)) @@ -34,7 +34,7 @@ func (s *Server) Routes() *http.ServeMux { mux.HandleFunc("POST /notes", s.wrap(s.handleCreateNote)) mux.HandleFunc("GET /notes/{id}", s.wrap(s.handleGetNote)) mux.HandleFunc("DELETE /notes/{id}", s.wrap(s.handleDeleteNote)) - return mux + return securityHeaders(mux) } type statusWriter struct { diff --git a/app/handlers_test.go b/app/handlers_test.go index 9dff2e3e5..a46893b01 100644 --- a/app/handlers_test.go +++ b/app/handlers_test.go @@ -112,6 +112,16 @@ func TestDeleteNote_RemovesAndReturns204(t *testing.T) { } } +func TestSecurityHeaders_PresentOnAllRoutes(t *testing.T) { + srv := newTestServer(t) + for _, target := range []string{"/health", "/notes", "/metrics"} { + rec := do(t, srv, http.MethodGet, target, nil) + if got := rec.Header().Get("Cache-Control"); got != "no-store" { + t.Errorf("%s: Cache-Control = %q, want no-store", target, got) + } + } +} + func TestMetrics_ExposesPrometheusFormat(t *testing.T) { srv := newTestServer(t) _ = do(t, srv, http.MethodPost, "/notes", map[string]string{"title": "x"}) diff --git a/app/main.go b/app/main.go index e258ffcfe..74c65d06d 100644 --- a/app/main.go +++ b/app/main.go @@ -16,6 +16,14 @@ func main() { dataPath := envOrDefault("DATA_PATH", "data/notes.json") seedPath := envOrDefault("SEED_PATH", "seed.json") + if len(os.Args) > 1 && os.Args[1] == "healthcheck" { + resp, err := http.Get("http://localhost:8080/health") + if err != nil || resp.StatusCode != http.StatusOK { + os.Exit(1) + } + os.Exit(0) + } + if err := ensureSeeded(dataPath, seedPath); err != nil { log.Fatalf("seed: %v", err) } diff --git a/app/middleware.go b/app/middleware.go new file mode 100644 index 000000000..ff76bd206 --- /dev/null +++ b/app/middleware.go @@ -0,0 +1,10 @@ +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") + next.ServeHTTP(w, r) + }) +} \ No newline at end of file diff --git a/cloud/Dockerfile b/cloud/Dockerfile new file mode 100644 index 000000000..b77a58ee1 --- /dev/null +++ b/cloud/Dockerfile @@ -0,0 +1 @@ +FROM ghcr.io/sparrow12345/devops-intro/quicknotes:0.1.0 \ No newline at end of file diff --git a/cloud/README.md b/cloud/README.md new file mode 100644 index 000000000..5aae2d060 --- /dev/null +++ b/cloud/README.md @@ -0,0 +1,13 @@ +--- +title: QuickNotes +colorFrom: blue +colorTo: indigo +sdk: docker +app_port: 8080 +pinned: false +--- + +# QuickNotes on HF Spaces + +Runs the QuickNotes container from `ghcr.io/sparrow12345/devops-intro/quicknotes`. +Endpoints: `/health`, `/notes`. \ No newline at end of file diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 000000000..062288466 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,49 @@ +services: + quicknotes: + build: + context: ./app + image: quicknotes:lab6 + ports: + - "8080:8080" + volumes: + - quicknotes-data:/data + healthcheck: + test: ["CMD", "/quicknotes", "healthcheck"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 5s + environment: + ADDR: ":8080" + DATA_PATH: /data/notes.json + SEED_PATH: /data/seed.json + restart: unless-stopped + + prometheus: + image: prom/prometheus:v3.1.0 + ports: + - "9090:9090" + volumes: + - ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ./monitoring/prometheus/alerts.yml:/etc/prometheus/alerts.yml:ro + depends_on: + quicknotes: + condition: service_healthy + restart: unless-stopped + + grafana: + image: grafana/grafana:13.0.3 + ports: + - "3000:3000" + environment: + GF_SECURITY_ADMIN_USER: admin + GF_SECURITY_ADMIN_PASSWORD: ${GF_PASSWORD} + volumes: + - ./monitoring/grafana/provisioning:/etc/grafana/provisioning + - ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards + depends_on: + - prometheus + restart: unless-stopped + +volumes: + quicknotes-data: \ No newline at end of file diff --git a/docs/runbook/high-error-rate.md b/docs/runbook/high-error-rate.md new file mode 100644 index 000000000..c3bf31bb3 --- /dev/null +++ b/docs/runbook/high-error-rate.md @@ -0,0 +1,28 @@ +# Runbook — HighErrorRate (QuickNotes) + +## What this alert means + +More than 5% of QuickNotes HTTP responses have been 4xx/5xx for 5+ minutes, users are +seeing failures right now. + +## Triage steps + +1. Confirm scope in Grafana → "QuickNotes — Golden Signals" → Error-ratio panel. Note whether + it's climbing, flat, or recovering, and the absolute req/s (low traffic = fewer affected users). +2. Break down by status code in Prometheus: + `sum by (code) (rate(quicknotes_http_responses_by_code_total{code=~"4..|5.."}[5m]))`. + - 5xx = server fault (our bug/crash). + - 4xx = clients/bad input or a broken caller. +3. Check the container is healthy: `docker compose ps` and `docker compose logs --tail=100 quicknotes`. + Look for panics, restart loops, or disk/permission errors on `/data/notes.json`. + +## Mitigations + +- **Restart the service** to clear the broken process: `docker compose restart quicknotes`. +- **Roll back** to the last known-good image tag if a recent deploy caused it +- If 4xx is driven by one abusive client, rate-limit or block that source upstream. + +## Post-incident + +After the error ratio is back under 5% and stable, write a blameless postmortem (what happened, why, and what changes) with timeline, root cause, what detected it and +follow-up actions. diff --git a/monitoring/grafana/dashboards/golden-signals.json b/monitoring/grafana/dashboards/golden-signals.json new file mode 100644 index 000000000..6b6c32391 --- /dev/null +++ b/monitoring/grafana/dashboards/golden-signals.json @@ -0,0 +1,621 @@ +{ + "apiVersion": "dashboard.grafana.app/v2", + "kind": "Dashboard", + "metadata": { + "name": "add2tdl", + "namespace": "default", + "uid": "76609882-b1d4-4f87-a593-b1c47714f663", + "resourceVersion": "1782825135931018", + "generation": 11, + "creationTimestamp": "2026-06-30T12:42:20Z", + "labels": { + "grafana.app/deprecatedInternalID": "281550602752000" + }, + "annotations": { + "grafana.app/createdBy": "user:cfqp435ic7j0ge", + "grafana.app/folder": "", + "grafana.app/saved-from-ui": "Grafana v13.0.3 (3ca0684c7c)", + "grafana.app/updatedBy": "user:cfqp435ic7j0ge", + "grafana.app/updatedTimestamp": "2026-06-30T13:12:15Z" + } + }, + "spec": { + "annotations": [ + { + "kind": "AnnotationQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "grafana", + "version": "v0", + "datasource": { + "name": "-- Grafana --" + }, + "spec": {} + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "builtIn": true + } + } + ], + "cursorSync": "Off", + "editable": true, + "elements": { + "panel-1": { + "kind": "Panel", + "spec": { + "id": 1, + "title": "Latency", + "description": "", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "datasource": { + "name": "PBFA97CFB590B2093" + }, + "spec": { + "editorMode": "code", + "expr": "rate(quicknotes_http_requests_total[1m])", + "legendFormat": "__auto", + "range": true + } + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "timeseries", + "version": "13.0.3", + "spec": { + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + }, + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + } + } + } + } + }, + "panel-2": { + "kind": "Panel", + "spec": { + "id": 2, + "title": "Rate", + "description": "", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "datasource": { + "name": "PBFA97CFB590B2093" + }, + "spec": { + "editorMode": "code", + "expr": "rate(quicknotes_http_requests_total[1m])", + "legendFormat": "__auto", + "range": true + } + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "timeseries", + "version": "13.0.3", + "spec": { + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + }, + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + } + } + } + } + }, + "panel-3": { + "kind": "Panel", + "spec": { + "id": 3, + "title": "Total Notes", + "description": "", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "datasource": { + "name": "PBFA97CFB590B2093" + }, + "spec": { + "editorMode": "code", + "expr": "quicknotes_notes_total", + "legendFormat": "__auto", + "range": true + } + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "gauge", + "version": "13.0.3", + "spec": { + "options": { + "barShape": "flat", + "barWidthFactor": 0.3, + "effects": { + "barGlow": false, + "centerGlow": false, + "gradient": true + }, + "endpointMarker": "point", + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "segmentCount": 1, + "segmentSpacing": 0.3, + "shape": "gauge", + "showThresholdLabels": false, + "showThresholdMarkers": false, + "sizing": "auto", + "sparkline": true, + "textMode": "auto" + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + }, + "color": { + "mode": "palette-classic" + } + }, + "overrides": [] + } + } + } + } + }, + "panel-4": { + "kind": "Panel", + "spec": { + "id": 4, + "title": "Errors", + "description": "", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "datasource": { + "name": "PBFA97CFB590B2093" + }, + "spec": { + "editorMode": "code", + "expr": "sum(rate(quicknotes_http_responses_by_code_total{code=~\"4..|5..\"}[5m])) / clamp_min(sum(rate(quicknotes_http_responses_by_code_total[5m])), 1e-9)", + "legendFormat": "__auto", + "range": true + } + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "timeseries", + "version": "13.0.3", + "spec": { + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + }, + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "bars", + "fillOpacity": 100, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + } + } + } + } + } + }, + "layout": { + "kind": "GridLayout", + "spec": { + "items": [ + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 0, + "width": 24, + "height": 7, + "element": { + "kind": "ElementReference", + "name": "panel-3" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 7, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-1" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 12, + "y": 7, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-2" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 15, + "width": 24, + "height": 9, + "element": { + "kind": "ElementReference", + "name": "panel-4" + } + } + } + ] + } + }, + "links": [], + "liveNow": false, + "preload": false, + "tags": [], + "timeSettings": { + "timezone": "browser", + "from": "now-6h", + "to": "now", + "autoRefresh": "", + "autoRefreshIntervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "hideTimepicker": false, + "fiscalYearStartMonth": 0 + }, + "title": "QuickNotes", + "variables": [ + { + "kind": "QueryVariable", + "spec": { + "name": "query0", + "current": { + "text": "", + "value": "" + }, + "hide": "dontHide", + "refresh": "onDashboardLoad", + "skipUrlSync": false, + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "datasource": { + "name": "PBFA97CFB590B2093" + }, + "spec": {} + }, + "regex": "", + "regexApplyTo": "value", + "sort": "disabled", + "options": [], + "multi": false, + "includeAll": false, + "allowCustomValue": true + } + } + ], + "preferences": { + "layout": { + "kind": "GridLayout", + "spec": { + "items": [] + } + } + } + } +} \ No newline at end of file diff --git a/monitoring/grafana/provisioning/dashboards/dashboard.yml b/monitoring/grafana/provisioning/dashboards/dashboard.yml new file mode 100644 index 000000000..4ce1e77ee --- /dev/null +++ b/monitoring/grafana/provisioning/dashboards/dashboard.yml @@ -0,0 +1,12 @@ +apiVersion: 1 + +providers: + - name: golden-signals + orgId: 1 + folder: "" + type: file + disableDeletion: false + updateIntervalSeconds: 30 + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards diff --git a/monitoring/grafana/provisioning/datasources/datasource.yml b/monitoring/grafana/provisioning/datasources/datasource.yml new file mode 100644 index 000000000..86fd3465e --- /dev/null +++ b/monitoring/grafana/provisioning/datasources/datasource.yml @@ -0,0 +1,8 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true diff --git a/monitoring/prometheus/alerts.yml b/monitoring/prometheus/alerts.yml new file mode 100644 index 000000000..afcb3455c --- /dev/null +++ b/monitoring/prometheus/alerts.yml @@ -0,0 +1,15 @@ +groups: + - name: quicknotes-golden-signals + rules: + - alert: HighErrorRate + expr: | + sum(rate(quicknotes_http_responses_by_code_total{code=~"4..|5.."}[5m])) + / + clamp_min(sum(rate(quicknotes_http_responses_by_code_total[5m])), 1e-9) + > 0.05 + for: 5m + labels: + severity: page + annotations: + summary: "QuickNotes error ratio above 5% for 5 minutes" + runbook_url: "docs/runbook/high-error-rate.md" \ No newline at end of file diff --git a/monitoring/prometheus/prometheus.yml b/monitoring/prometheus/prometheus.yml new file mode 100644 index 000000000..7c5bccf7a --- /dev/null +++ b/monitoring/prometheus/prometheus.yml @@ -0,0 +1,12 @@ +global: + scrape_interval: 15s + +rule_files: + - /etc/prometheus/alerts.yml + +scrape_configs: + - job_name: quicknotes + metrics_path: /metrics + static_configs: + - targets: ["quicknotes:8080"] + diff --git a/reports/sbom.json b/reports/sbom.json new file mode 100644 index 000000000..5e811e942 --- /dev/null +++ b/reports/sbom.json @@ -0,0 +1,449 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "serialNumber": "urn:uuid:4c15b36e-71f7-47d4-8fc0-56b074a8211e", + "version": 1, + "metadata": { + "timestamp": "2026-07-06T19:13:11+00:00", + "tools": { + "components": [ + { + "type": "application", + "group": "aquasecurity", + "name": "trivy", + "version": "0.59.1" + } + ] + }, + "component": { + "bom-ref": "ab8f8462-d31b-426c-8614-88d530c350a2", + "type": "container", + "name": "quicknotes:lab6", + "properties": [ + { + "name": "aquasecurity:trivy:DiffID", + "value": "sha256:187cfc6d1e3e8a40a5e64653bcd3239c140807dcf1c09e48021178705a5a6139" + }, + { + "name": "aquasecurity:trivy:DiffID", + "value": "sha256:275a30dd8ce958b21daa9ad962c6fbc09f98306ee2f486b65c9075dc257b1412" + }, + { + "name": "aquasecurity:trivy:DiffID", + "value": "sha256:2d23756844b206e13e56bd289485af23770bf17da66e8615ed5bf0114d89f2ec" + }, + { + "name": "aquasecurity:trivy:DiffID", + "value": "sha256:4cde6b0bb6f50a5f255eef7b2a42162c661cf776b803225dcac9a659e396bb6b" + }, + { + "name": "aquasecurity:trivy:DiffID", + "value": "sha256:4d049f83d9cf21d1f5cc0e11deaf36df02790d0e60c1a3829538fb4b61685368" + }, + { + "name": "aquasecurity:trivy:DiffID", + "value": "sha256:575804c9528c7e2f6116006dee77aeb6d35d6cef5f75ea22436b8eeb1ce17336" + }, + { + "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:92cb9c37b7d3957ac56645a979418f65e6c5bdba00eb99622affae5fc124ac07" + }, + { + "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:dee08bd2a433f8043a433a805abcd8d8173e11bdfd882699c3d880f84d77ea69" + }, + { + "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:RepoTag", + "value": "quicknotes:lab6" + }, + { + "name": "aquasecurity:trivy:SchemaVersion", + "value": "2" + } + ] + } + }, + "components": [ + { + "bom-ref": "68a3ef10-bbc5-4c5b-b424-baae3f8eeb35", + "type": "application", + "name": "quicknotes", + "properties": [ + { + "name": "aquasecurity:trivy:Class", + "value": "lang-pkgs" + }, + { + "name": "aquasecurity:trivy:Type", + "value": "gobinary" + } + ] + }, + { + "bom-ref": "a6ec0768-2f0a-4e59-8e52-765dc44d8612", + "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: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: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: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: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: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:2d23756844b206e13e56bd289485af23770bf17da66e8615ed5bf0114d89f2ec" + }, + { + "name": "aquasecurity:trivy:PkgID", + "value": "quicknotes" + }, + { + "name": "aquasecurity:trivy:PkgType", + "value": "gobinary" + } + ] + }, + { + "bom-ref": "pkg:golang/stdlib@v1.24.13", + "type": "library", + "name": "stdlib", + "version": "v1.24.13", + "purl": "pkg:golang/stdlib@v1.24.13", + "properties": [ + { + "name": "aquasecurity:trivy:LayerDiffID", + "value": "sha256:2d23756844b206e13e56bd289485af23770bf17da66e8615ed5bf0114d89f2ec" + }, + { + "name": "aquasecurity:trivy:PkgID", + "value": "stdlib@v1.24.13" + }, + { + "name": "aquasecurity:trivy:PkgType", + "value": "gobinary" + } + ] + } + ], + "dependencies": [ + { + "ref": "68a3ef10-bbc5-4c5b-b424-baae3f8eeb35", + "dependsOn": [ + "pkg:golang/quicknotes" + ] + }, + { + "ref": "a6ec0768-2f0a-4e59-8e52-765dc44d8612", + "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": "ab8f8462-d31b-426c-8614-88d530c350a2", + "dependsOn": [ + "68a3ef10-bbc5-4c5b-b424-baae3f8eeb35", + "a6ec0768-2f0a-4e59-8e52-765dc44d8612" + ] + }, + { + "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": [ + "pkg:golang/stdlib@v1.24.13" + ] + }, + { + "ref": "pkg:golang/stdlib@v1.24.13", + "dependsOn": [] + } + ], + "vulnerabilities": [] +} diff --git a/reports/zap-after.html b/reports/zap-after.html new file mode 100644 index 000000000..e5deafc39 --- /dev/null +++ b/reports/zap-after.html @@ -0,0 +1,594 @@ + + + + +ZAP Scanning Report + + + +

+ + + ZAP Scanning Report +

+

+ + +

+ + Site: http://localhost:8080 + +

+ +

+ Generated on Mon, 6 Jul 2026 21:35:29 +

+ +

+ ZAP Version: 2.17.0 +

+ +

+ ZAP by Checkmarx +

+ + +

Summary of Alerts

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

Insights

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
LevelReasonSiteDescriptionStatistic
+
Info
+
+
Informational
+
+
http://localhost:8080
+
+
Percentage of responses with status code 4xx
+
+
100 %
+
+
Info
+
+
Informational
+
+
http://localhost:8080
+
+
Percentage of endpoints with content type text/plain
+
+
100 %
+
+
Info
+
+
Informational
+
+
http://localhost:8080
+
+
Percentage of endpoints with method GET
+
+
100 %
+
+
Info
+
+
Informational
+
+
http://localhost:8080
+
+
Count of total endpoints
+
+
2
+
+
+ + + + + + +

Summary of Sequences

+

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

+ + + + + + + + +

Alerts

+ + + + + + + + + + + + + + +
NameRisk LevelNumber of Instances
Non-Storable ContentInformational2
+
+ + + +

Alert Detail

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
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://localhost:8080
Node Namehttp://localhost:8080
MethodGET
Parameter
Attack
Evidenceno-store
Other Info
URLhttp://localhost:8080/robots.txt
Node Namehttp://localhost:8080/robots.txt
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-after.json b/reports/zap-after.json new file mode 100644 index 000000000..be16a3dca --- /dev/null +++ b/reports/zap-after.json @@ -0,0 +1,93 @@ +{ + "@programName": "ZAP", + "@version": "2.17.0", + "@generated": "Mon, 6 Jul 2026 21:35:29", + "created": "2026-07-06T21:35:29.116915814Z", + "insights":[ + { + "level": "Info", + "reason": "Informational", + "site": "http://localhost:8080", + "key": "insight.code.4xx", + "description": "Percentage of responses with status code 4xx", + "statistic": "100" + }, + { + "level": "Info", + "reason": "Informational", + "site": "http://localhost:8080", + "key": "insight.endpoint.ctype.text/plain", + "description": "Percentage of endpoints with content type text/plain", + "statistic": "100" + }, + { + "level": "Info", + "reason": "Informational", + "site": "http://localhost:8080", + "key": "insight.endpoint.method.GET", + "description": "Percentage of endpoints with method GET", + "statistic": "100" + }, + { + "level": "Info", + "reason": "Informational", + "site": "http://localhost:8080", + "key": "insight.endpoint.total", + "description": "Count of total endpoints", + "statistic": "2" + } + ], + "site":[ + { + "@name": "http://localhost:8080", + "@host": "localhost", + "@port": "8080", + "@ssl": "false", + "alerts": [ + { + "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": "3", + "uri": "http://localhost:8080", + "nodeName": "http:\/\/localhost:8080", + "method": "GET", + "param": "", + "attack": "", + "evidence": "no-store", + "otherinfo": "" + }, + { + "id": "1", + "uri": "http://localhost:8080/robots.txt", + "nodeName": "http:\/\/localhost:8080\/robots.txt", + "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": "1" + } + ] + } + ], + "sequences":[ + ] + +} diff --git a/reports/zap-before.html b/reports/zap-before.html new file mode 100644 index 000000000..d2127b415 --- /dev/null +++ b/reports/zap-before.html @@ -0,0 +1,579 @@ + + + + +ZAP Scanning Report + + + +

+ + + ZAP Scanning Report +

+

+ + +

+ + Site: http://localhost:8080 + +

+ +

+ Generated on Mon, 6 Jul 2026 20:38:28 +

+ +

+ ZAP Version: 2.17.0 +

+ +

+ ZAP by Checkmarx +

+ + +

Summary of Alerts

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

Insights

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
LevelReasonSiteDescriptionStatistic
+
Info
+
+
Informational
+
+
http://localhost:8080
+
+
Percentage of responses with status code 4xx
+
+
100 %
+
+
Info
+
+
Informational
+
+
http://localhost:8080
+
+
Percentage of endpoints with content type text/plain
+
+
100 %
+
+
Info
+
+
Informational
+
+
http://localhost:8080
+
+
Percentage of endpoints with method GET
+
+
100 %
+
+
Info
+
+
Informational
+
+
http://localhost:8080
+
+
Count of total endpoints
+
+
2
+
+
+ + + + + + +

Summary of Sequences

+

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

+ + + + + + + + +

Alerts

+ + + + + + + + + + + + + + +
NameRisk LevelNumber of Instances
Storable and Cacheable ContentInformational2
+
+ + + +

Alert Detail

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
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://localhost:8080
Node Namehttp://localhost:8080
MethodGET
Parameter
Attack
Evidence
Other Info +
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.
+ +
URLhttp://localhost:8080/sitemap.xml
Node Namehttp://localhost:8080/sitemap.xml
MethodGET
Parameter
Attack
Evidence
Other Info +
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.
+ +
Instances2
Solution +
Validate that the response does not contain sensitive, personal or user-specific information. If it does, consider the use of the following HTTP response headers, to limit, or prevent the content being stored and retrieved from the cache by another user:
+
+ +
Cache-Control: no-cache, no-store, must-revalidate, private
+
+ +
Pragma: no-cache
+
+ +
Expires: 0
+
+ +
This configuration directs both HTTP 1.0 and HTTP 1.1 compliant caching servers to not store the response, and to not retrieve the response (without validation) from the cache, in response to a similar request.
+ +
Reference + https://datatracker.ietf.org/doc/html/rfc7234 +
+ + https://datatracker.ietf.org/doc/html/rfc7231 +
+ + https://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html + +
CWE Id524
WASC Id13
Plugin Id10049
+
+ + + + + +

Sequence Details

+ With the associated active scan results. + + + +
+ + + + + + + diff --git a/reports/zap-before.json b/reports/zap-before.json new file mode 100644 index 000000000..1cae01723 --- /dev/null +++ b/reports/zap-before.json @@ -0,0 +1,93 @@ +{ + "@programName": "ZAP", + "@version": "2.17.0", + "@generated": "Mon, 6 Jul 2026 20:38:28", + "created": "2026-07-06T20:38:28.432784562Z", + "insights":[ + { + "level": "Info", + "reason": "Informational", + "site": "http://localhost:8080", + "key": "insight.code.4xx", + "description": "Percentage of responses with status code 4xx", + "statistic": "100" + }, + { + "level": "Info", + "reason": "Informational", + "site": "http://localhost:8080", + "key": "insight.endpoint.ctype.text/plain", + "description": "Percentage of endpoints with content type text/plain", + "statistic": "100" + }, + { + "level": "Info", + "reason": "Informational", + "site": "http://localhost:8080", + "key": "insight.endpoint.method.GET", + "description": "Percentage of endpoints with method GET", + "statistic": "100" + }, + { + "level": "Info", + "reason": "Informational", + "site": "http://localhost:8080", + "key": "insight.endpoint.total", + "description": "Count of total endpoints", + "statistic": "2" + } + ], + "site":[ + { + "@name": "http://localhost:8080", + "@host": "localhost", + "@port": "8080", + "@ssl": "false", + "alerts": [ + { + "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": "3", + "uri": "http://localhost:8080", + "nodeName": "http:\/\/localhost:8080", + "method": "GET", + "param": "", + "attack": "", + "evidence": "", + "otherinfo": "In the absence of an explicitly specified caching lifetime directive in the response, a liberal lifetime heuristic of 1 year was assumed. This is permitted by rfc7234." + }, + { + "id": "1", + "uri": "http://localhost:8080/sitemap.xml", + "nodeName": "http:\/\/localhost:8080\/sitemap.xml", + "method": "GET", + "param": "", + "attack": "", + "evidence": "", + "otherinfo": "In the absence of an explicitly specified caching lifetime directive in the response, a liberal lifetime heuristic of 1 year was assumed. This is permitted by rfc7234." + } + ], + "count": "2", + "systemic": false, + "solution": "

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

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

Pragma: no-cache

Expires: 0

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

", + "otherinfo": "

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

", + "reference": "

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

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

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

", + "cweid": "524", + "wascid": "13", + "sourceid": "1" + } + ] + } + ], + "sequences":[ + ] + +} diff --git a/submissions/lab1.md b/submissions/lab1.md new file mode 100644 index 000000000..22b40d35b --- /dev/null +++ b/submissions/lab1.md @@ -0,0 +1,35 @@ +# Lab 1 submission + +## Task 1: SSH Commit Signing & First Signed Commit + +### `curl` output + +![curl output](src/lab01_curl.png) + +### `git log` output + +![git log output](src/lab01_sign.png) + +### Verification output + +![verification output](src/lab01_verified.png) + +### *why signed commits matter* + +Signing a commit cryptographically ties it to an identity, so a reviewer can check that a change really came from the person it claims to, not from someone who just set user.name and user.email to impersonate them. Git makes this easy to fake. +By default, anyone can author a commit. Looking at the xz-utils backdoor: an attacker using the name "Jia Tan" spent months building maintainer trust, then buried a backdoor in a compression library that ships in most Linux distros. +Require signing, keep a record of who signed what, and an unexpected or unverifiable commit sticks out instead of blending into the history. That alone won't stop a determined attacker, but it raises the cost. + +## Task 2: Pull Request Template & First PR + +![PR Template](src/lab01_PR_template.png) + +## Task 3: GitHub Community Engagement + +- **Why starring repositories matters in open source:** + + Starring is both a bookmark and a signal: it saves a project to your profile for later and publicly endorses it, and aggregate star counts act as a rough trust/popularity signal that helps others discover worthwhile tools and motivates maintainers who mostly work for free. + +- **How following developers helps in team projects and professional growth:** + + Following developers turns GitHub into a feed of what your teammates and the wider community are building, you see their new projects and activity, which makes it easier to coordinate on team work, learn from how others structure code, and build the professional network that carries past a single course. diff --git a/submissions/lab10.md b/submissions/lab10.md new file mode 100644 index 000000000..b70fb12aa --- /dev/null +++ b/submissions/lab10.md @@ -0,0 +1,71 @@ +# Lab 10 submission + +## Task 1: CI-Automated Push to `ghcr.io` + +### Workflow + +[**Release workflow**](https://github.com/sparrow12345/DevOps-Intro/blob/feature/lab10/.github/workflows/release.yml) + +### Registry URL + successful pull + +[**URL**](https://github.com/sparrow12345/DevOps-Intro/pkgs/container/devops-intro%2Fquicknotes) + +![Successful pull](src/lab10_pull.png) + +### Green CI release + +[**Green CI**](https://github.com/sparrow12345/DevOps-Intro/actions/runs/28874946885/job/85647391280) + +### Design questions + +- **OIDC vs `GITHUB_TOKEN` — for pushing to ghcr.io from the same repo, `GITHUB_TOKEN` with `packages: write` is enough. When would you reach for OIDC instead, and what does it give you that `GITHUB_TOKEN` doesn't?** + + For this lab `GITHUB_TOKEN` is enough, since ghcr.io is on GitHub and the token's already trusted there. We'd switch to OIDC when pushing to another cloud like AWS ECR, so we don't have to store a long-lived password in the repo. OIDC lets the workflow prove who it is and get a short-lived credential back instead. + +- **`:latest` tag vs `:v0.1.0` immutable tag — Lab 6 covered why `:latest` is mutable. So why do you still ship a `:latest` tag alongside the immutable one in production releases?** + + `:latest` is mutable, so it's not safe to pin a real deploy to it. But it's convenient, people can pull without knowing the version number. So we ship both: `:v0.1.0` for actual deploys, `:latest` as the "newest" shortcut. + +- **`packages:` write scope only — what's the principle, and what concrete attack does the narrow scope prevent vs `write: all`?** + + Least privilege, give the job only what it needs. With `write: all`, a malicious action or dependency could push commits or edit issues/releases. Locked to `packages: write`, the worst it can do is push an image, not touch the code. + +## Task 2: Deploy to Hugging Face Spaces + +### Space URL + `curl -v` health check + +[**HF Space**](https://huggingface.co/spaces/sparrow12345/quicknotes) + +![curl -v health](src/lab10_HF_curl_health.png) + +![curl -v notes](src/lab10_HF_curl_notes.png) + +## Space repo's `Dockerile` & `README.md` + +- [**Dockerfile**](https://github.com/sparrow12345/DevOps-Intro/blob/feature/lab10/cloud/Dockerfile) + +- [**README**](https://github.com/sparrow12345/DevOps-Intro/blob/feature/lab10/cloud/README.md) + +## Warm p50 latency + +![Warm p50 latency](src/lab10_warm.png) + +## Cold latencies + +![Cold latencies](src/lab10_cold.png) + +## Design questions + +- **HF Spaces "sleep" vs Cloud Run "scale to zero" — same idea, different orders of magnitude. Why is HF's wake so much slower? What does the platform optimize for differently?** + + Same idea: shut down to zero when idle, start back up on the next request. HF wakes slowly because it re-pulls the whole image and boots a full container on free shared hardware, they're optimizing for cheap, not fast. Cloud Run is a paid product that keeps the image cached and uses a fast sandbox, so it starts in under a second. + +- **Why does the Space need `app_port: 8080`? What's HF's default and why do they default to that?** + + HF defaults to port 7860, but QuickNotes listens on 8080. Without setting it, HF's proxy hits 7860 where nothing runs and the Space looks broken. `app_port: 8080` points the proxy at the right port. + +- **You pulled the image from ghcr.io into the Space. What's the trade-off vs building the Dockerfile inside the Space?** + + I pull the image CI already built instead of rebuilding in the Space. + - **Good:** it's the exact image that passed CI, and no build step means a fast deploy. + - **Bad:** I can't debug the build inside HF, and I depend on ghcr staying reachable. Building in-Space would let me debug and use HF's cache, but it could drift from CI. I picked pulling for the consistency. diff --git a/submissions/lab2.md b/submissions/lab2.md new file mode 100644 index 000000000..991c12a6a --- /dev/null +++ b/submissions/lab2.md @@ -0,0 +1,66 @@ +# Lab 2 submission + +## Task 1: Git Object Model + Reflog Recovery + +### 1.1: Explore your repo's plumbing + +- **`HEAD`:** + + ![head](src/lab02_head.png) + +- **tree:** + + ![tree](src/lab02_tree.png) + +- **blob:** + + ![blob](src/lab02_blob.png) + +- **file contents:** + + ![file contents](src/lab02_file.png) + +### 1.2 Inside `.git/` + +### 1.2 — .git/ interpretation + +- `cat .git/HEAD` → `ref: refs/heads/feature/lab1`: it stores the current branch name, not a commit SHA. +- `ls .git/refs/heads/` → `feature main`: `feature` is a directory, not a branch. Slashes in branch names (`feature/lab1`, `feature/lab2`) become real folders, so a branch is just a file holding a commit SHA. +- `.git/objects/`: loose objects sharded into dirs by the first 2 chars of their SHA. +- `find .git/objects -type f | wc -l` counted 34 files + + ![git folder](src/lab02_git_folder.png) + +### 1.3: Simulate disaster + recover + +- **`git reflog` output:** + + ![reflog](src/lab02_reflog.png) + +- **`git reset --hard` output:** + + ![reset](src/lab02_reset.png) + +- ***what would happen if git gc had run between the bad reset and your recovery?*** + + `git gc` prunes unreachable objects, but it respects the reflog and a grace period: by default it only prunes objects older than two weeks and keeps reflog entries for 90 days. Because the reflog still referenced my two commits, an ordinary git gc would not have deleted them, they were seconds old and still reachable via the reflog. The real danger is an aggressive prune that ignores the grace window — `git gc --prune=now` or `git reflog expire --expire=now --all` followed by `git prune` which removes unreachable objects immediately, if that had run, the commits would be unrecoverable. + +## Task 2: Tag a Release & Rebase a Feature + +### 2.1: Signed release tag + +![release tag verification](src/lab02_tags_verification.png) + +![release tag verification remote](src/lab02_tags_verification2.png) + +### 2.2: Rebase + +- **`git log --oneline` output:** + + ![rebase log before](src/lab02_before_rebase.png) + + ![rebase log after](src/lab02_after_rebase.png) + +- ***When you'd choose merge vs rebase?*** + + We can use rebase for personal projects or when having a linear history is a priority, but for large teams with many contributors, merge is safer to avoid the risk of losing commits or causing confusion by rewriting history. diff --git a/submissions/lab3.md b/submissions/lab3.md new file mode 100644 index 000000000..44697b340 --- /dev/null +++ b/submissions/lab3.md @@ -0,0 +1,77 @@ +# Lab 3 submission + +## Task 1: Write the PR Gate + +### Why GitHub? + +Because I can sign in to github.com and my fork already lives there. + +### Green CI run + +[Clear CI run](https://github.com/sparrow12345/DevOps-Intro/actions/runs/27569104479) + +### Failed run and fix + +![Failed run](src/lab03_broken_ci.png) + +![Fix](src/lab03_fix_commit.png) + +### Branch protection + +![Branch rule](src/lab03_branch_rule.png) + +![Rule applied](src/lab03_rules_PR.png) + +### Questions + +**a) Why pin the runner version (`ubuntu-24.04`) instead of ubuntu-latest? What breaks otherwise?** + +`ubuntu-latest` is a moving alias. GitHub repoints it to the next LTS on their schedule, and when they do, the preinstalled toolchain change. Which can cause pipelines that were working fine to break. By pinning `ubuntu-24.04` we make sure that the pipeline won't break unexpectedly when GitHub updates their runners. And we can handle the update by ourselves. + +**b) Why split vet + test + lint into separate units? What would happen with one combined job?** + +- Parallelism: separate jobs run on separate runners simultaneously, since they're not dependent on each other. + +- Signal: separate jobs can tell us exactly where our problem is, ex: (vet -> code error/bugs, test -> wrong code logic/broken test, lint -> style issues). + +**c) GH path: what real attack does SHA pinning prevent?** + +A tag-hijack / supply-chain injection against a third-party action. A Git tag like @v4 is a mutable pointer, whoever controls the action's repo can move it to point at new, malicious code, and every workflow referencing @v4 silently pulls it on the next run. This is exactly the tj-actions/changed-files compromise of March 2025: attackers gained write access and repointed the action's tags to a malicious version, leaking a loot of secrets from multiple runners. By pinning to a specific SHA, we ensure that we're always running the exact code we reviewed and tested, and not a moving target that can be hijacked. + +**d) GH path: what is `permissions:` and what's the principle behind it?** + +Control the access level of the GITHUB_TOKEN. We can restrict it to only the permissions we need for our workflow. This follows the principle of least privilege, which states that we should only grant the minimum permissions necessary for a task, reducing the attack surface and potential damage if the token is compromised. + +## Task 2: Make It Fast and Smart + +### Measurements table + +![Measurements](src/lab03_CI_timers.png) + +| Scenario | Wall-clock | +|----------|-----------| +| Baseline (no cache, single Go version, no path filter) | 41 s | +| With cache | 37 s | +| With cache + matrix | 43 s | + +### Description of each optimization + +**Caching:** `setup-go` caches the Go module and build caches for us automatically once we point it at the lockfile with `cache-dependency-path: app/go.sum`. The key comes from the `go.sum` hash, so it stays valid until a dependency actually changes. + +**Go matrix (1.23 + 1.24):** `vet` and `test` run against both Go versions in parallel, which catches bugs that only show up on one toolchain. Lint stays on 1.24 since it only needs to run once. The cells run on separate runners, so the matrix barely adds wall-clock. + +**Path filter:** The `pull_request` trigger is scoped to `app/**` and the workflow file, so a docs-only PR (like a README edit) doesn't trigger any runs and wastes no CI minutes. + +### Questions + +**f) Why cache `go.sum`-keyed inputs and not build outputs?** + +`go.sum` pins the exact version of every module, so a cache keyed on its hash is safe: if the hash matches, the dependencies are the same, and as soon as one changes the key changes with it. Build outputs don't have that property. Compiled archives and binaries bake in the toolchain version, build tags, and paths, so restoring an old one can give a green build that doesn't match your actual source. + +**g) What does `fail-fast: false` change in a matrix run, and when do you actually want `fail-fast: true`?** + +In a matrix, `fail-fast: true` cancels every still-running cell the moment one cell fails. With `fail-fast: false`, all cells run to completion regardless. We want `false` here because the whole reason for the matrix is to compare go `1.23` and `1.24` and `fail-fast` might kill one of the cells. We'd choose `fail-fast: true` when cells we want all cells to pass like in a large sharded test matrix where any single failure already means "block the merge" and we'd rather save CI minutes and give fast feedback than collect every failure. + +**h) What's the risk of an attacker writing a cache from a malicious PR that protected branches later read?** + +The risk is cache poisoning. A PR from a fork runs CI and can write to the shared cache, and if a protected branch later reads that cache, it runs whatever the attacker stuffed in there. A poisoned module or build cache basically becomes code execution in a trusted context, with access to real secrets. diff --git a/submissions/lab4.md b/submissions/lab4.md new file mode 100644 index 000000000..3ebff4846 --- /dev/null +++ b/submissions/lab4.md @@ -0,0 +1,165 @@ +# Lab 4 submission + +## Task 1: Trace a Request End-to-End + +### Capture + +- **Handshake:** + + ```bash + 14:37:45.615329 IP6 ::1.41280 > ::1.8080: Flags [S], seq 3032480471 ..... # SYN + 14:37:45.615344 IP6 ::1.8080 > ::1.41280: Flags [S.], seq 2891834856, ack ..... # SYN/ACK + 14:37:45.615352 IP6 ::1.41280 > ::1.8080: Flags [.], ack 1 ..... # ACK + ``` + +- **Request:** + + ```bash + 14:37:45.615442 IP6 ::1.41280 > ::1.8080: Flags [P.], seq 1:175, ack 1 ..... + .POST /notes HTTP/1.1 + Host: localhost:8080 + User-Agent: curl/8.5.0 + Accept: */* + Content-Type: application/json + Content-Length: 39 + + {"title":"trace me","body":"in flight"} + 14:37:45.615448 IP6 ::1.8080 > ::1.41280: Flags [.], ack 175 ..... + ``` + +- **Response:** + + ```bash + 14:37:45.615866 IP6 ::1.8080 > ::1.41280: Flags [P.], seq 1:207, ack 175 ..... + HTTP/1.1 201 Created + Content-Type: application/json + Date: Tue, 16 Jun 2026 11:37:45 GMT + Content-Length: 93 + + {"id":8,"title":"trace me","body":"in flight","created_at":"2026-06-16T11:37:45.615622707Z"} + + 14:37:45.615873 IP6 ::1.41280 > ::1.8080: Flags [.], ack 207 ..... + ``` + +- **Connection close:** + + ```bash + 14:37:45.616064 IP6 ::1.41280 > ::1.8080: Flags [F.], seq 175, ack 207 ..... + 14:37:45.616119 IP6 ::1.8080 > ::1.41280: Flags [F.], seq 207, ack 176 ..... + 14:37:45.616135 IP6 ::1.41280 > ::1.8080: Flags [.], ack 208 ..... + ``` + +### Commands output + +![The 5 commands output](src/lab04_5commands.png) + +![Running quicknotes as a service](src/lab04_running_service.png) + +### What would you check first if QuickNotes returned 502? + +A 502 means a gateway/proxy in front of QuickNotes got an invalid or no response from the upstream, so the proxy is up but the app behind it isn't answering correctly. I'd debug outside-in: first confirm the app process is actually running and listening on its port (`ss -tlnp | grep 8080`), then curl the app directly bypassing the proxy (`curl -v localhost:8080/health`) to decide whether the fault is in the app or between the app and proxy. +If the app answers fine directly, the proxy is pointing at the wrong port/host or timing out; if it doesn't, I check the app's logs for a crash. + +## Task 2: Outside-In Debugging on a Broken Deploy + +### Run a broken instance + +![Broken instance](src/lab04_command_fail.png) + +### Outside-in chain + +- **Is the app running?** + + command: + + ```bash + ps -ef | grep "go run" | grep -v grep + ``` + + output: + + ```bash + moze 64664 54359 0 16:04 pts/8 00:00:00 go run . + ``` + +- **Is it listening on the expected port?** + + command: + + ```bash + ss -tlnp | grep 8080 + ``` + + output: + + ```bash + LISTEN 0 4096 *:8080 *:* users:(("quicknotes",pid=64699,fd=3)) + ``` + +- **Is it reachable from host?** + + command: + + ```bash + curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/health + ``` + + output: + + ```bash + 200 + ``` + +- **Is firewall blocking?** + + command: + + ```bash + sudo iptables -L -n -v 2>/dev/null || sudo nft list ruleset 2>/dev/null || true + ``` + + output: + + ```bash + Chain INPUT (policy ACCEPT 0 packets, 0 bytes) + pkts bytes target prot opt in out source destination + + Chain FORWARD (policy DROP 0 packets, 0 bytes) + pkts bytes target prot opt in out source destination + 118 129K DOCKER-USER 0 -- * * 0.0.0.0/0 0.0.0.0/0 + 118 129K DOCKER-FORWARD 0 -- * * 0.0.0.0/0 0.0.0.0/0 + + Chain OUTPUT (policy ACCEPT 0 packets, 0 bytes) + pkts bytes target prot opt in out source destination + ...... + ``` + +- **Is DNS working?** + + command: + + ```bash + dig +short localhost + ``` + + output: + + ```bash + 127.0.0.1 + ``` + +### Root cause + +From the command `ps -ef | grep "go run" | grep -v grep` we see that there is already a process running `go run .` with PID 64664. And from `ss -tlnp | grep 8080` we see that there is a process using port 8080, which is a process for the app `go run .` created. +When we try to start another instance of the app, it fails to bind to port 8080 because it's already in use by the first instance. + +**Repair:** +We kill the child processes of the first instance (64664) then we kill it itself: + +![Repairing](src/lab04_repair.png) + +### What's systemic about this kind of failure, and what tooling could prevent it? + +The second instance died with `bind: address already in use`. That isn't a bug. The kernel only allows one listener per port, so it rejected the duplicate. +This is something that might cause major problems in production environments. A deploy or restart often starts the new instance before the old one has released the port. Maybe the old process is still alive, maybe it crashed and nobody reaped it, maybe the socket is sitting in `TIME_WAIT`. The new instance can't bind, and the service goes down. +A supervisor like `systemd` avoids most of this: it stops the old unit completely before starting the new one, and it tracks the whole process group so an orphan can't keep holding the port. diff --git a/submissions/lab5.md b/submissions/lab5.md new file mode 100644 index 000000000..6b01b84ee --- /dev/null +++ b/submissions/lab5.md @@ -0,0 +1,66 @@ +# Lab 5 submission + +## Task 1: Vagrant Up + Run QuickNotes Inside + +- [**Vagrant File**](https://github.com/sparrow12345/DevOps-Intro/blob/feature/lab5/Vagrantfile) + +- **First 10 output lines:** + + ![first 10 logs lines](src/lab05_vagrangt_log.png) + +- **`curl` output:** + + ![curl verify QuickNotes is running](src/lab05_verify.png) + +- **Design questions:** + + - **Vagrant supports nfs, rsync, virtualbox, and smb mount types. Which did you pick and why? What's the trade-off?** + + I used the default `virtualbox` shared folder. It's bidirectional and host edits appear in the guest instantly, needs no extra host tooling. The trade-off: VirtualBox shared folders have slower I/O than native disk and depend on Guest Additions matching the kernel. `rsync` would be faster to read inside the guest but is a one-way, point-in-time copy and host changes don't propagate until `vagrant rsync`, which is wrong for an edit-rebuild loop. `nfs`/`smb` are faster for large trees but add a daemon and a `sudo` host dependency, overkill for one small `app/` folder. + + - **Which network mode are you using (it's the default, but say which it is)? Why is 127.0.0.1-bound port forwarding safer than a Bridged interface for a course exercise?** + + The default is `NAT` with port forwarding. `127.0.0.1`-bound forwarding is safer than a Bridged interface because the guest port is reachable only from the host, nothing on the LAN/Wi-Fi can reach it. A Bridged interface puts the VM directly on the physical network with its own IP, exposing QuickNotes (which has no auth) to every device on the network. + + - **Vagrant supports shell, ansible, ansible_local, puppet, chef, … which did you pick for installing Go and why?** + + I used the `shell` provisioner. Installing go and writing one systemd unit is a handful of commands but using Ansible/Puppet/Chef would be an over kill for this simple app. + + - **Why pin Go to a specific point release (1.24.5) instead of 1.24?** + + `1.24` is a floating minor that resolves to whatever the latest patch is *the day we provision* two students (or the same student next month) could get different compilers, so builds aren't reproducible and a regression in a new patch + could silently break the VM. Pinning the exact point release `1.24.5` makes `vagrant up` deterministic. + +## Task 2: Save, Break, Restore + +- **Save:** + + ![Save snapshot](src/lab05_snapshot_save.png) + +- **Break + Verify:** + + ![Break VM](src/lab05_snapshot_break.png) + +- **Restore:** + + ![Restore snapshot](src/lab05_snapshot_restore.png) + +- **Verify:** + + ![Verify snapshot restore](src/lab05_snapshot_verify.png) + +- **Design questions:** + + - **What failure modes is a snapshot useless for?** + + A snapshot lives on the same disk with the VM, so it's useless if the disk of the hosting device breaks, get infected with a ransomware or get stolen ... + A snapshot is just a rollback point on the live volume, to go back to certain timestamp in the VM's life. + + - **Vagrant snapshots are copy-on-write under VirtualBox. What does that mean for disk usage when you take 10 snapshots vs 1?** + + Under VirtualBox a snapshot freezes the current disk and writes only subsequent changes to a differencing image. So 10 snapshots don't cost 10× the VM size, each costs only the delta written between it and the next, plus a little metadata. + Also taking a snapshot is near-instant and cheap, the cost grows with how much you change afterward, not with how many snapshots you hold. + + - **When is snapshotting an antipattern?** + + Long snapshot chains. Every read has to walk the chain of differencing images, so deep chains slow disk I/O and balloon storage as deltas accumulate, a corrupt link can invalidate everything after it. Snapshots are for short-lived "checkpoint before a risky change, then commit or roll back" use, not as a backup strategy, a version-control substitute, or something you let pile up for weeks. diff --git a/submissions/lab6.md b/submissions/lab6.md new file mode 100644 index 000000000..ea22ec441 --- /dev/null +++ b/submissions/lab6.md @@ -0,0 +1,69 @@ +# Lab 6 submission + +## Task 1: Multi-Stage Dockerfile + +- [**Docker Image**](https://github.com/sparrow12345/DevOps-Intro/blob/feature/lab6/app/Dockerfile) + +- **`docker images quicknotes:lab6` output and image size:** + + ![docker image](src/lab06_image_size.png) + +- **`docker inspect` output:** + + ![docker inspect](src/lab06_inspect.png) + +- **Design questions:** + + - **Why does layer-order matter?** + + Docker caches each instruction's layer, keyed on the instruction plus the files it touches. If a layer's inputs change, that layer and every layer after it are rebuilt. + + - With the bad ordering `COPY . .` touches all source. + Any code edit invalidates that layer, so `go mod download` re-runs and re-downloads modules on every rebuild. + ![Bad ordering](src/lab06_bad_dockerfile.png) + + - With well ordered version the download layer depends only on `go.mod`/`go.sum`. Editing source invalidates only the layers from `COPY . .`. + ![Good ordering](src/lab06_good_dockerfile.png) + + - **Why `CGO_ENABLED=0`? What happens in distroless-static if you forget it?** + + With cgo enabled, go may link against the system C library. That produces a dynamically linked binary. `distroless/static` doesn't have such dependencies, and the binary will fail to start because it's missing C libraries that the system doesn't have. + + - **What is `gcr.io/distroless/static:nonroot`? What's in it, what isn't, and why does that matter for CVEs?** + + A minimal docker image that contains the bare minimum of what we need to run our app. It doesn't contain a shell, package manager or any other program that we might find in other distros. This reduces the attack surface on our service and the number of possible CVEs we might get on our service. + + - **`-ldflags='-s -w'` and `-trimpath`: what does each flag do, and what's the cost?** + + - `-s` strips the symbol table and debug info, `-w` strips DWARF table, together they reduce the binary size. + - `-trimpath` remove all file system paths from the resulting executable, so builds are reproducible and don't leak your directory layout. + + These flags are useful for production builds, but they make debugging harder. + +## Task 2: Compose + Healthcheck + Persistent Volume + +- [**Docker Compose**](https://github.com/sparrow12345/DevOps-Intro/blob/feature/lab6/compose.yaml) + +- **3-Steps Persistence:** + + - Note present: + ![Firing the app](src/lab06_note_present.png) + + - Note present after `docker compose down`: + ![compose down](src/lab06_down.png) + + - Note absent after `docker compose down -v`: + ![compose down -v](src/lab06_down_v.png) + +- **Design questions:** + - **Distroless has no shell. How do you healthcheck it?** + + You can't use shell commands like `curl` or `wget` because distroless images don't have them. We can add a subcommand to our binary that performs an internal HTTP request to `/health` and with that we can check the health of our application without needing any external tools to do so. + + - **Why does `volumes: [quicknotes-data:/data]` survive `docker compose down`?** + + A named volume is a docker object managed by docker that doesn't get affected by the existence or absence of a container. It lives on the host machine and is not deleted when the container is removed unless we explicitly do so. This allows us to have persistent data that survives container restarts and recreations. + + - **`depends_on` without `condition: service_healthy` — what does it actually wait for? What's the bug it can cause?** + + `depends_on` without `condition: service_healthy` only waits for the container to start, not for the service to be ready. This can cause a bug where the dependent service tries to connect to the service before it's ready, leading to connection errors or failed requests. diff --git a/submissions/lab7.md b/submissions/lab7.md new file mode 100644 index 000000000..83ebe8975 --- /dev/null +++ b/submissions/lab7.md @@ -0,0 +1,79 @@ +# Lab 7 submission + +## Task 1: Idempotent Deploy to the Lab 5 VM + +### `playbook.yml` & `inventory.ini` + +- [**playbook.yml**](https://github.com/sparrow12345/DevOps-Intro/blob/feature/lab7/ansible/playbook.yaml) +- [**inventory.ini**](https://github.com/sparrow12345/DevOps-Intro/blob/feature/lab7/ansible/inventory.ini) + +### First run + +![First Run](src/lab07_first_run.png) + +### Curl output + +![curl command](src/lab07_curl.png) + +### Design questions + +- **What's the difference between command: and the dedicated modules (`apt`, `file`, `copy`, `systemd`)? Which is idempotent, and why does it matter?** + + `command`: just execute a literal string on the host. Ansible has no model of what the `command` means, so it can't tell whether the desired state already holds. + The dedicated modules are declarative, we describe the target state and the module first inspects the current state, then acts only if there's drift. That makes them idempotent, running them N times leaves the same result as running once, and they report changed only on the run that actually altered something. + It matters because idempotency is what lets us re-run the whole playbook safely. With `command`: everything looks changed every run. + +- **`notify`: and handlers: when does a handler fire? When does it not fire? Why is that the right default?** + + A handler fires only when a task that notify it reports changed, and it runs once, no matter how many tasks notified it. It does not fire when the notifying task is ok (no change), nor in `--check` mode, nor if the play aborts before the handler flush. + It's the right default because handlers represent reactions to change, we don't want to bounce the service on every run; we only want the disruptive action when something it depends on actually moved. + +- **Variable hierarchy: Ansible has at least 22 levels of variable precedence. List the top 3 places you'd put a variable for this lab (defaults, group_vars, playbook vars, …) and why?** + + - Playbook `vars`: Where we put `app_user`, `data_dir`, `bin_path`, `listen_addr`. Highest-visibility, single-file, and easy to tweak. Right for our lab. + - `group_vars/lab5.yml`: Values that belong to the target group rather than the play. If we later targeted staging vs prod with the same playbook, the per-environment `listen_addr` or paths would live here, keeping the playbook generic. + - `defaults`: They sit at the bottom of precedence so anything above (group_vars, play vars) can override them. + +- **`gather_facts: true` is the default. Do you need it for this playbook? What does turning it off save you per run?** + + No, the playbook references no ansible_* facts, every value is an explicit variable, and module behavior (`user`, `file`, `copy`, `template`, `systemd`) doesn't depend on gathered facts. So we set `gather_facts: false`. + Turning it off skips the setup task that runs at the start against the host, an extra SSH round-trip plus the cost of enumerating hardware/network/OS facts on the VM every single run. + +## Task 2: Prove Idempotency + Selective Re-run + +### Second run + +![Second run](src/lab07_change0.png) + +### Changes made + +![Changes made](src/lab07_change1.png) + +### `--check --diff` + +![diff & change](src/lab07_diff.png) + +### Design questions + +- **Why does the second run report `changed=0`? What specifically does the `file` / `template` module check to decide?** + + Because nothing changed, so every module finds the host already in the desired state and reports ok. + + - `file` compares the requested attributes against the inode: does the path exist, is it the right state (directory), and do `owner`/`group`/`mode` match? On run 2 the dir already exists as `quicknotes:quicknotes 0750`, so `ok`, no change. + - `template` renders the Jinja source in memory, computes its checksum (SHA-1), and compares against the checksum of the file already on the host; it also resolves owner/group/mode. Identical render + identical metadata, no write `ok`. + - `copy` does the same checksum comparison between the local source and the remote file. + + Since no task reports changed, the `restart quicknotes` handler is never notified, no restart. That's the whole `changed=0`result. + +- **What would happen if you used `shell: 'echo "ADDR=..." > /etc/systemd/system/quicknotes.service'` instead of the `template`: module? Trace the failure modes** + + - **Never idempotent:** `shell` runs unconditionally and reports `changed` every run, the handler restarts the service on every play, no `changed=0`. + - **No drift detection / no diff:** Can't compare desired vs actual, so `--check` and `--diff` are meaningless. + - **Non-atomic write:** `>` truncates the file then writes. If the command is interrupted, systemd is left with a corrupt unit. + - **No ownership/mode management:** We'd separately need `chmod`/`chown`. + - **Quoting:** Embedding a multi-line unit with `Environment`, ports, and variables inside a shell string is a lot harder to work with, and might cause a lot of problems. + +- **`ansible-playbook --check` is dry-run. `--diff` shows changes. What's the bug you'd catch by running `--check --diff` before a production deploy that you'd miss with plain `--check`?** + + Plain `--check` tells us that a file would change (`changed=1`), but not what would change. `--diff` prints the actual change that happened. + The bug we catch: a wrong value or a typo in one of the lines. diff --git a/submissions/lab8.md b/submissions/lab8.md new file mode 100644 index 000000000..a2e13fb54 --- /dev/null +++ b/submissions/lab8.md @@ -0,0 +1,67 @@ +# Lab 8 submission + +## Task 1: Prometheus + Grafana with a Provisioned Dashboard + +### Configuration files + +- [**Docker Compose**](https://github.com/sparrow12345/DevOps-Intro/blob/feature/lab8/compose.yaml) +- [**Prometheus**](https://github.com/sparrow12345/DevOps-Intro/blob/feature/lab8/monitoring/prometheus/prometheus.yml) +- **Grafana:** + - [datasource.yml](https://github.com/sparrow12345/DevOps-Intro/blob/feature/lab8/monitoring/grafana/provisioning/datasources/datasource.yml) + - [dashboard.yml](https://github.com/sparrow12345/DevOps-Intro/blob/feature/lab8/monitoring/grafana/provisioning/dashboards/dashboard.yml) + +### Grafana dashboard + +![Grafana dashboard](src/lab08_dashboard.png) + +### Curl output + +![curl](src/lab08_curl.png) + +### Design questions + +- **Pull vs push: Prometheus pulls. What does that mean for which side (Prometheus or QuickNotes) needs to be reachable? What's the failure mode if Prometheus can't reach QuickNotes?** + + Pull means prometheus initiates the connection, so QuickNotes must be reachable by prometheus and QuickNotes never needs to reach Prometheus. + Failure mode: if Prometheus can't reach QuickNotes the target goes `up == 0` and we get old data. + +- **`scrape_interval: 15s` is a default. What query problems do you create by setting it to 5s? To 5m?** + + With `scrape_interval: 5s` we get more data: higher storage + memory + cost and rate() windows must still be ≥ 4× the interval so very short ranges get noisy/empty. + `scrape_interval: 5m` we miss short spikes entirely and alerts react slowly. + +- **PromQL `rate()` vs `irate()` vs `delta()` — which one is right for the Traffic panel and why?** + + We use `rate()` for Traffic. `rate()` is the average per-second rate over the window good for dashboards/alerts. `irate()` uses only the last two points not very good for a traffic graph. `delta()` is for gauges, not counters, and isn't reset-aware. + +- **Why provision Grafana from files instead of clicking through the UI on every fresh stack?** + + For reproducibility and version control, a fresh docker compose up on any machine will get us the identical datasource + dashboard, without manual setup. + +## Task 2: One Good Alert + Runbook + +### Alert definition + +[Prometheus alert](https://github.com/sparrow12345/DevOps-Intro/blob/feature/lab8/monitoring/prometheus/alerts.yml) + +### Firing alert + +![Firing alert](src/lab08_alert_firing.png) + +### Runbook + +[Runbook](https://github.com/sparrow12345/DevOps-Intro/blob/feature/lab8/docs/runbook/high-error-rate.md) + +### Design questions + +- **Why "sustained for 5 minutes" instead of "fire immediately on first bad request"?** + + A single bad request is noise, not an incident. Requiring the ratio to hold for 5 min will lead to paging for ongoing problems while reducing false pages and having less alert fatigue. + +- **Symptom alerts vs cause alerts: the alert above is a symptom alert. What's an example of a cause alert someone might write for QuickNotes? Why is it worse?** + + A cause alert would be e.g. "CPU > 90%" or "disk usage > 95%". Cause alerts are worse because high CPU may not hurt users at all, but might be a sign of internal damage or an unknown intrusion while a symptom alert catches any root cause that actually degrades users. + +- **Alert fatigue: Lecture 8 cited it as the bigger danger than too few alerts. What's a quantitative threshold ("page X% of the time the user wasn't actually affected") that would mean your alert is too noisy?** + + If more than ~5–10% of pages fire while users were not actually affected, the alert is too noisy and should be changed and tuned. diff --git a/submissions/lab9.md b/submissions/lab9.md new file mode 100644 index 000000000..711ef3b3e --- /dev/null +++ b/submissions/lab9.md @@ -0,0 +1,200 @@ +# Lab 9 submission + +## Task 1: Trivy Image + Filesystem + Config + SBOM + +### Scans + +1. **Image Scan:** + + ```bash + docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:0.59.1 image --severity HIGH,CRITICAL quicknotes:lab6 + ``` + + ![Image Scan](src/lab09_image_scan.png) + +2. **Filesystem Scan:** + + ```bash + docker run --rm -v ./:/DevOps-Intro aquasec/trivy:0.59.1 fs --severity HIGH,CRITICAL /DevOps-Intro + ``` + + ![Filesystem scan](src/lab09_filesystem_scan.png) + +3. **Config Scan:** + + ```bash + docker run --rm -v ./:/DevOps-Intro aquasec/trivy:0.59.1 config /DevOps-Intro + ``` + + ![Config scan](src/lab09_config_scan.png) + +4. **SBOM:** + + ```bash + docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:0.59.1 image --format cyclonedx quicknotes:lab6 > sbom.json + ``` + + ![SBOM](src/lab09_sbom.png) + +### Triage table + +- **Image scan** + + | Finding | Disposition | Means | Reason | + |---------|:-----------:|-------|--------| + | CVE-2026-25679 (`net/url`) | **FIX** | Patch / upgrade now | Go stdlib DoS bug, rebuild on Go 1.25.11 fixes it | + | CVE-2026-27145 (`crypto/x509`) | **FIX** | Patch / upgrade now | Go stdlib DoS bug, rebuild on Go 1.25.11 fixes it | + | CVE-2026-32280 (`crypto/x509`) | **FIX** | Patch / upgrade now | Go stdlib DoS bug, rebuild on Go 1.25.11 fixes it | + | CVE-2026-32281 (`crypto/x509`) | **FIX** | Patch / upgrade now | Go stdlib DoS bug, rebuild on Go 1.25.11 fixes it | + | CVE-2026-32283 (`crypto/tls`) | **FIX** | Patch / upgrade now | Go stdlib DoS bug, rebuild on Go 1.25.11 fixes it | + | CVE-2026-33811 (`net`) | **FIX** | Patch / upgrade now | Go stdlib DoS bug, rebuild on Go 1.25.11 fixes it | + | CVE-2026-33814 (`net/http`) | **FIX** | Patch / upgrade now | Go stdlib DoS bug, rebuild on Go 1.25.11 fixes it | + | CVE-2026-39820 (`net/mail`) | **FIX** | Patch / upgrade now | Go stdlib DoS bug, rebuild on Go 1.25.11 fixes it | + | CVE-2026-39836 (golang update) | **FIX** | Patch / upgrade now | Go stdlib DoS bug, rebuild on Go 1.25.11 fixes it | + | CVE-2026-42499 (`net/mail`) | **FIX** | Patch / upgrade now | Go stdlib DoS bug, rebuild on Go 1.25.11 fixes it | + | CVE-2026-42504 (`mime`) | **FIX** | Patch / upgrade now | Go stdlib DoS bug, rebuild on Go 1.25.11 fixes it | + +- **Filesystem scan** + + | Finding | Disposition | Means | Reason | + |---------|:-----------:|-------|--------| + | Private key in `.vagrant/.../private_key` | **FALSE POSITIVE** | Scanner is wrong | Local Vagrant VM key, gitignored and never committed | + +### SBOM + +First 30 lines of the CycloneDX SBOM (`sbom.json`): + +```json +{ + "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "serialNumber": "urn:uuid:4c15b36e-71f7-47d4-8fc0-56b074a8211e", + "version": 1, + "metadata": { + "timestamp": "2026-07-06T19:13:11+00:00", + "tools": { + "components": [ + { + "type": "application", + "group": "aquasecurity", + "name": "trivy", + "version": "0.59.1" + } + ] + }, + "component": { + "bom-ref": "ab8f8462-d31b-426c-8614-88d530c350a2", + "type": "container", + "name": "quicknotes:lab6", + "properties": [ + { + "name": "aquasecurity:trivy:DiffID", + "value": "sha256:187cfc6d1e3e8a40a5e64653bcd3239c140807dcf1c09e48021178705a5a6139" + }, + { + "name": "aquasecurity:trivy:DiffID", + "value": "sha256:275a30dd8ce958b21daa9ad962c6fbc09f98306ee2f486b65c9075dc257b1412" + } +``` + +### Design questions + +- **CVE severity is one input, not the answer. What else (reachability, exploit availability, deployment context) matters when triaging?** + + Severity only tells us how bad it is if exploited. We also need: reachability (does our code actually call the vulnerable function?), exploit availability (is there a public exploit), and deployment context. A CRITICAL in code we never call, on an internal service, is less urgent than a MEDIUM that's deployed for users with a working exploit. + +- **Distroless images often show zero HIGH/CRITICAL. Why is the minimal base the strongest single security control?** + + A minimal base has almost no packages (distroless has no shell, no package manager, no OS libraries). This reduce the surface of attack and remove the need to patch system vulnerabilities our app never use. + +- **`.trivyignore` lets you suppress findings. When is that the right move, and when is it security theater?** + + It's right when we make a documented, dated decision, ex: a false positive, or a finding with no upstream fix yet and we write down why. It's security theater when we ignore a finding just to make the scan green without understanding it. + +- **The SBOM is a list of components. What concrete future problem does having it today solve?** + + When the next Log4Shell drops, the SBOM lets us instantly answer "do we ship this component, and which version?" by searching one file, instead of manually auditing every image under pressure. It turns "are we affected?" from days of investigation into a lookup. + +## Task 2: OWASP ZAP Baseline + Fix at Least One Finding + +### Baseline scan + +```bash +docker run --rm --network host -v $(pwd):/zap/wrk/:rw ghcr.io/zaproxy/zaproxy:2.17.0 zap-baseline.py -t http://localhost:8080 -r zap-before.html -J zap-before.json +``` + +The scan returned a single finding: + +```bash +FAIL-NEW: 0 FAIL-INPROG: 0 WARN-NEW: 1 WARN-INPROG: 0 INFO: 0 IGNORE: 0 PASS: 66 +``` + +### Triage table + +| ID | Name | Risk | Affected URL | Disposition | Reason | +|----|------|:----:|--------------|:-----------:|--------| +| 10049 | Storable and Cacheable Content | Informational | `http://localhost:8080`, `/sitemap.xml` | **FIX** | Responses have no `Cache-Control`, so a shared proxy could cache them. Fixed in code by adding `Cache-Control: no-store` in middleware. | + +### Code fix + +Added a `securityHeaders` middleware that wraps the router (`app/middleware.go`) and applied it to all routes in `Routes()` (`app/handlers.go`). + +```go +func securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + next.ServeHTTP(w, r) + }) +} +``` + +```go +func (s *Server) Routes() http.Handler { + mux := http.NewServeMux() + ... + return securityHeaders(mux) +} +``` + +Guard test (`app/handlers_test.go`) fails if the middleware is removed: + +```go +func TestSecurityHeaders_PresentOnAllRoutes(t *testing.T) { + srv := newTestServer(t) + for _, target := range []string{"/health", "/notes", "/metrics"} { + rec := do(t, srv, http.MethodGet, target, nil) + if got := rec.Header().Get("Cache-Control"); got != "no-store" { + t.Errorf("%s: Cache-Control = %q, want no-store", target, got) + } + } +} +``` + +### Before / after + +**Before:** + +```bash +WARN-NEW: Storable and Cacheable Content [10049] x 2 +``` + +**After:** + +```bash +WARN-NEW: Non-Storable Content [10049] x 2 +``` + +### Design questions + +- **Why a middleware and not per-handler header sets?** + + A middleware sets the headers in one place for every route, and new routes get them automatically. Per-handler `Header().Set` calls are duplicated everywhere, and the guarantee breaks the moment someone adds a handler and forgets to copy them. + +- **`Content-Security-Policy: default-src 'none'` is the strictest CSP. What does it break? Why is it OK for QuickNotes (an API) but not for a website?** + + It blocks all scripts, styles, images, fonts and frames, so a browser can't load any resource. That's fine for QuickNotes because it's a JSON API with no web page to render. On a real website it would show a blank/broken page. + +- **False positives vs accepted findings: what's the cost of marking informational issues all "accepted" without reading them?** + + If we accept everything we stop reading the findings, so a real issue hiding in the informational noise gets bypassed along with the harmless ones. Accept-all is the same as not triaging at all. diff --git a/submissions/src/lab01_PR_template.png b/submissions/src/lab01_PR_template.png new file mode 100644 index 000000000..6112dc518 Binary files /dev/null and b/submissions/src/lab01_PR_template.png differ diff --git a/submissions/src/lab01_curl.png b/submissions/src/lab01_curl.png new file mode 100644 index 000000000..682fdc0c4 Binary files /dev/null and b/submissions/src/lab01_curl.png differ diff --git a/submissions/src/lab01_sign.png b/submissions/src/lab01_sign.png new file mode 100644 index 000000000..902c418a5 Binary files /dev/null and b/submissions/src/lab01_sign.png differ diff --git a/submissions/src/lab01_verified.png b/submissions/src/lab01_verified.png new file mode 100644 index 000000000..ce5efce1a Binary files /dev/null and b/submissions/src/lab01_verified.png differ diff --git a/submissions/src/lab02_after_rebase.png b/submissions/src/lab02_after_rebase.png new file mode 100644 index 000000000..3cd257e3d Binary files /dev/null and b/submissions/src/lab02_after_rebase.png differ diff --git a/submissions/src/lab02_before_rebase.png b/submissions/src/lab02_before_rebase.png new file mode 100644 index 000000000..73f101cb5 Binary files /dev/null and b/submissions/src/lab02_before_rebase.png differ diff --git a/submissions/src/lab02_blob.png b/submissions/src/lab02_blob.png new file mode 100644 index 000000000..a27f524bd Binary files /dev/null and b/submissions/src/lab02_blob.png differ diff --git a/submissions/src/lab02_file.png b/submissions/src/lab02_file.png new file mode 100644 index 000000000..aa63b44d9 Binary files /dev/null and b/submissions/src/lab02_file.png differ diff --git a/submissions/src/lab02_git_folder.png b/submissions/src/lab02_git_folder.png new file mode 100644 index 000000000..abbe00a36 Binary files /dev/null and b/submissions/src/lab02_git_folder.png differ diff --git a/submissions/src/lab02_head.png b/submissions/src/lab02_head.png new file mode 100644 index 000000000..8c45adb37 Binary files /dev/null and b/submissions/src/lab02_head.png differ diff --git a/submissions/src/lab02_reflog.png b/submissions/src/lab02_reflog.png new file mode 100644 index 000000000..4d86622fb Binary files /dev/null and b/submissions/src/lab02_reflog.png differ diff --git a/submissions/src/lab02_reset.png b/submissions/src/lab02_reset.png new file mode 100644 index 000000000..010714a50 Binary files /dev/null and b/submissions/src/lab02_reset.png differ diff --git a/submissions/src/lab02_tags_verification.png b/submissions/src/lab02_tags_verification.png new file mode 100644 index 000000000..73aac9b30 Binary files /dev/null and b/submissions/src/lab02_tags_verification.png differ diff --git a/submissions/src/lab02_tags_verification2.png b/submissions/src/lab02_tags_verification2.png new file mode 100644 index 000000000..32363854b Binary files /dev/null and b/submissions/src/lab02_tags_verification2.png differ diff --git a/submissions/src/lab02_tree.png b/submissions/src/lab02_tree.png new file mode 100644 index 000000000..3bfc0cf6f Binary files /dev/null and b/submissions/src/lab02_tree.png differ diff --git a/submissions/src/lab03_CI_timers.png b/submissions/src/lab03_CI_timers.png new file mode 100644 index 000000000..6c3b5aa93 Binary files /dev/null and b/submissions/src/lab03_CI_timers.png differ diff --git a/submissions/src/lab03_branch_rule.png b/submissions/src/lab03_branch_rule.png new file mode 100644 index 000000000..5550b9f04 Binary files /dev/null and b/submissions/src/lab03_branch_rule.png differ diff --git a/submissions/src/lab03_broken_ci.png b/submissions/src/lab03_broken_ci.png new file mode 100644 index 000000000..aea5d8349 Binary files /dev/null and b/submissions/src/lab03_broken_ci.png differ diff --git a/submissions/src/lab03_fix_commit.png b/submissions/src/lab03_fix_commit.png new file mode 100644 index 000000000..f4b29b059 Binary files /dev/null and b/submissions/src/lab03_fix_commit.png differ diff --git a/submissions/src/lab03_rules_PR.png b/submissions/src/lab03_rules_PR.png new file mode 100644 index 000000000..d2b017491 Binary files /dev/null and b/submissions/src/lab03_rules_PR.png differ diff --git a/submissions/src/lab04_5commands.png b/submissions/src/lab04_5commands.png new file mode 100644 index 000000000..6cd02ce28 Binary files /dev/null and b/submissions/src/lab04_5commands.png differ diff --git a/submissions/src/lab04_command_fail.png b/submissions/src/lab04_command_fail.png new file mode 100644 index 000000000..4767bc9d7 Binary files /dev/null and b/submissions/src/lab04_command_fail.png differ diff --git a/submissions/src/lab04_repair.png b/submissions/src/lab04_repair.png new file mode 100644 index 000000000..a711b0b77 Binary files /dev/null and b/submissions/src/lab04_repair.png differ diff --git a/submissions/src/lab04_running_service.png b/submissions/src/lab04_running_service.png new file mode 100644 index 000000000..66d55187b Binary files /dev/null and b/submissions/src/lab04_running_service.png differ diff --git a/submissions/src/lab05_snapshot_break.png b/submissions/src/lab05_snapshot_break.png new file mode 100644 index 000000000..b2cf05c53 Binary files /dev/null and b/submissions/src/lab05_snapshot_break.png differ diff --git a/submissions/src/lab05_snapshot_restore.png b/submissions/src/lab05_snapshot_restore.png new file mode 100644 index 000000000..e8a7acf31 Binary files /dev/null and b/submissions/src/lab05_snapshot_restore.png differ diff --git a/submissions/src/lab05_snapshot_save.png b/submissions/src/lab05_snapshot_save.png new file mode 100644 index 000000000..3fdbbb5d0 Binary files /dev/null and b/submissions/src/lab05_snapshot_save.png differ diff --git a/submissions/src/lab05_snapshot_verify.png b/submissions/src/lab05_snapshot_verify.png new file mode 100644 index 000000000..1be399246 Binary files /dev/null and b/submissions/src/lab05_snapshot_verify.png differ diff --git a/submissions/src/lab05_vagrangt_log.png b/submissions/src/lab05_vagrangt_log.png new file mode 100644 index 000000000..41433eeea Binary files /dev/null and b/submissions/src/lab05_vagrangt_log.png differ diff --git a/submissions/src/lab05_verify.png b/submissions/src/lab05_verify.png new file mode 100644 index 000000000..2e2dee2ae Binary files /dev/null and b/submissions/src/lab05_verify.png differ diff --git a/submissions/src/lab06_bad_dockerfile.png b/submissions/src/lab06_bad_dockerfile.png new file mode 100644 index 000000000..cf5fe86bd Binary files /dev/null and b/submissions/src/lab06_bad_dockerfile.png differ diff --git a/submissions/src/lab06_down.png b/submissions/src/lab06_down.png new file mode 100644 index 000000000..02299d214 Binary files /dev/null and b/submissions/src/lab06_down.png differ diff --git a/submissions/src/lab06_down_v.png b/submissions/src/lab06_down_v.png new file mode 100644 index 000000000..0f2104f9e Binary files /dev/null and b/submissions/src/lab06_down_v.png differ diff --git a/submissions/src/lab06_good_dockerfile.png b/submissions/src/lab06_good_dockerfile.png new file mode 100644 index 000000000..8cbf4eea5 Binary files /dev/null and b/submissions/src/lab06_good_dockerfile.png differ diff --git a/submissions/src/lab06_image_size.png b/submissions/src/lab06_image_size.png new file mode 100644 index 000000000..1da27d866 Binary files /dev/null and b/submissions/src/lab06_image_size.png differ diff --git a/submissions/src/lab06_inspect.png b/submissions/src/lab06_inspect.png new file mode 100644 index 000000000..6efee8121 Binary files /dev/null and b/submissions/src/lab06_inspect.png differ diff --git a/submissions/src/lab06_note_present.png b/submissions/src/lab06_note_present.png new file mode 100644 index 000000000..6b285f209 Binary files /dev/null and b/submissions/src/lab06_note_present.png differ diff --git a/submissions/src/lab07_change0.png b/submissions/src/lab07_change0.png new file mode 100644 index 000000000..b594ff68c Binary files /dev/null and b/submissions/src/lab07_change0.png differ diff --git a/submissions/src/lab07_change1.png b/submissions/src/lab07_change1.png new file mode 100644 index 000000000..cf4fce3e6 Binary files /dev/null and b/submissions/src/lab07_change1.png differ diff --git a/submissions/src/lab07_curl.png b/submissions/src/lab07_curl.png new file mode 100644 index 000000000..13339fbf4 Binary files /dev/null and b/submissions/src/lab07_curl.png differ diff --git a/submissions/src/lab07_diff.png b/submissions/src/lab07_diff.png new file mode 100644 index 000000000..e523a2927 Binary files /dev/null and b/submissions/src/lab07_diff.png differ diff --git a/submissions/src/lab07_first_run.png b/submissions/src/lab07_first_run.png new file mode 100644 index 000000000..b355b9e73 Binary files /dev/null and b/submissions/src/lab07_first_run.png differ diff --git a/submissions/src/lab08_alert_firing.png b/submissions/src/lab08_alert_firing.png new file mode 100644 index 000000000..68ba369e5 Binary files /dev/null and b/submissions/src/lab08_alert_firing.png differ diff --git a/submissions/src/lab08_curl.png b/submissions/src/lab08_curl.png new file mode 100644 index 000000000..f9f2125b4 Binary files /dev/null and b/submissions/src/lab08_curl.png differ diff --git a/submissions/src/lab08_dashboard.png b/submissions/src/lab08_dashboard.png new file mode 100644 index 000000000..d76bd710a Binary files /dev/null and b/submissions/src/lab08_dashboard.png differ diff --git a/submissions/src/lab09_config_scan.png b/submissions/src/lab09_config_scan.png new file mode 100644 index 000000000..405a32bf3 Binary files /dev/null and b/submissions/src/lab09_config_scan.png differ diff --git a/submissions/src/lab09_filesystem_scan.png b/submissions/src/lab09_filesystem_scan.png new file mode 100644 index 000000000..4ab95ccd2 Binary files /dev/null and b/submissions/src/lab09_filesystem_scan.png differ diff --git a/submissions/src/lab09_image_scan.png b/submissions/src/lab09_image_scan.png new file mode 100644 index 000000000..e15d65445 Binary files /dev/null and b/submissions/src/lab09_image_scan.png differ diff --git a/submissions/src/lab09_sbom.png b/submissions/src/lab09_sbom.png new file mode 100644 index 000000000..777cca921 Binary files /dev/null and b/submissions/src/lab09_sbom.png differ diff --git a/submissions/src/lab10_HF_curl_health.png b/submissions/src/lab10_HF_curl_health.png new file mode 100644 index 000000000..4192fe44a Binary files /dev/null and b/submissions/src/lab10_HF_curl_health.png differ diff --git a/submissions/src/lab10_HF_curl_notes.png b/submissions/src/lab10_HF_curl_notes.png new file mode 100644 index 000000000..19283c39a Binary files /dev/null and b/submissions/src/lab10_HF_curl_notes.png differ diff --git a/submissions/src/lab10_cold.png b/submissions/src/lab10_cold.png new file mode 100644 index 000000000..5fbcbb45b Binary files /dev/null and b/submissions/src/lab10_cold.png differ diff --git a/submissions/src/lab10_pull.png b/submissions/src/lab10_pull.png new file mode 100644 index 000000000..0edee3445 Binary files /dev/null and b/submissions/src/lab10_pull.png differ diff --git a/submissions/src/lab10_warm.png b/submissions/src/lab10_warm.png new file mode 100644 index 000000000..cd275fb97 Binary files /dev/null and b/submissions/src/lab10_warm.png differ