From 878af7590006fd32af573eb56b976acbc4d424be Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 9 Aug 2026 18:56:22 +0000 Subject: [PATCH] feat(release): complete milestone 10 hardening --- .dockerignore | 8 ++ Dockerfile | 27 ++++ Makefile | 17 +++ README.md | 6 +- cmd/dogama-agent/main.go | 7 +- cmd/dogama/main.go | 8 +- compose.yaml | 9 ++ docs/PROJECT-STATE.md | 15 +- docs/contributing/testing.md | 35 +++++ docs/operations/deployment-and-release.md | 77 ++++++++++ internal/web/server.go | 4 +- internal/web/server_test.go | 2 +- tests/e2e/bootstrap_test.go | 164 ++++++++++++++++++++++ tools/release.sh | 33 +++++ tools/sbom/main.go | 97 +++++++++++++ 15 files changed, 497 insertions(+), 12 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 docs/contributing/testing.md create mode 100644 docs/operations/deployment-and-release.md create mode 100644 tests/e2e/bootstrap_test.go create mode 100755 tools/release.sh create mode 100644 tools/sbom/main.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0d27dc8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.cache +dist +data +secrets +*.db +*.db-shm +*.db-wal diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cf70f6f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +# syntax=docker/dockerfile:1@sha256:87999aa3d42bdc6bea60565083ee17e86d1f3339802f543c0d03998580f9cb89 +FROM golang:1.25-bookworm@sha256:908f8ff2ec296df2f349563072c7925775cd28b50361a52ed834a8a37399b9bf AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod go mod download +COPY . . +ARG TARGETOS=linux +ARG TARGETARCH +ARG VERSION=dev +ARG COMMIT=unknown +RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -buildvcs=false \ + -ldflags="-s -w -X main.version=$VERSION -X main.commit=$COMMIT" -o /out/dogama ./cmd/dogama && \ + CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -buildvcs=false \ + -ldflags="-s -w -X main.version=$VERSION -X main.commit=$COMMIT" -o /out/dogama-agent ./cmd/dogama-agent + +FROM gcr.io/distroless/static-debian12:nonroot@sha256:f5b485ea962d9bd1186b2f6b3a061191539b905b82ec395de78cbfae51f20e35 AS dogama +WORKDIR /var/lib/dogama +COPY --from=build /out/dogama /usr/local/bin/dogama +EXPOSE 8080 +ENTRYPOINT ["/usr/local/bin/dogama"] + +FROM gcr.io/distroless/static-debian12:latest@sha256:a9fcaedd4c9b59e12dd65d954f0b5044f19b0647a8a3712e77205df9e7b102cd AS dogama-agent +WORKDIR /var/lib/dogama-agent +COPY --from=build /out/dogama-agent /usr/local/bin/dogama-agent +EXPOSE 8081 +ENTRYPOINT ["/usr/local/bin/dogama-agent"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..78246e9 --- /dev/null +++ b/Makefile @@ -0,0 +1,17 @@ +.PHONY: test e2e images release + +VERSION ?= dev +COMMIT ?= $(shell git rev-parse --short=12 HEAD) + +test: + go test ./... + +e2e: + go test ./tests/e2e -v + +images: + docker build --target dogama --build-arg VERSION=$(VERSION) --build-arg COMMIT=$(COMMIT) -t dogama:$(VERSION) . + docker build --target dogama-agent --build-arg VERSION=$(VERSION) --build-arg COMMIT=$(COMMIT) -t dogama-agent:$(VERSION) . + +release: + ./tools/release.sh $(VERSION) diff --git a/README.md b/README.md index a72c1a4..940e73f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ DoGaMa is a lightweight, self-hosted manager for private game servers running as Docker containers. It is designed for families and small groups of friends, not for commercial hosting or general Docker administration. -This repository currently contains the normative product and engineering specification. Implementation must follow the documents and machine-readable contracts linked below. +This repository contains the deployable DoGaMa V1 implementation and its normative product and engineering contracts. ## Product invariants @@ -41,6 +41,7 @@ This repository currently contains the normative product and engineering specifi - [Backups, import, restore and export](docs/operations/backups-import-export.md) - [Resources, ports, storage, mods and updates](docs/operations/instance-operations.md) - [Notifications and audit](docs/operations/notifications-and-audit.md) +- [Deployment and release](docs/operations/deployment-and-release.md) - [Security and threat model](docs/security/security-and-threat-model.md) - [Administration and manager interfaces](docs/ux/interfaces.md) @@ -48,6 +49,7 @@ This repository currently contains the normative product and engineering specifi - [Current operational project state](docs/PROJECT-STATE.md) - [Development conventions](docs/contributing/development.md) +- [Contributor testing](docs/contributing/testing.md) - [AI and Codex contributor guide](docs/contributing/ai-codex-guide.md) - [Template schema](specs/template.schema.json) - [Module manifest schema](specs/module-manifest.schema.json) @@ -75,7 +77,7 @@ Only the main application's HTTP port is published. The agent and game-managemen ## Status -The first seven roadmap foundations are implemented: application/authentication, the restricted agent boundary, the validated catalog, registered instance lifecycle, per-instance authorization, recoverable game-data backups, and the WebAssembly integration runtime. DoGaMa creates atomic `tar.zst` archives with manifests and SHA-256 metadata, selectively retains scheduled backups, supports five-field cron policies with IANA timezones, stages hostile imports under strict limits, and restores through validated staging with a default `pre_restore` safety backup. The module runtime executes typed, capability-checked adapters with bounded resources and instance-pinned networking; the bundled Palworld REST reference adapter is compiled reproducibly and covered by sandbox integration tests. Updates remain later roadmap work. +The V1 roadmap is implemented. See the current operational baseline and known limitations in [PROJECT-STATE.md](docs/PROJECT-STATE.md), and use the deployment guide for production installation and release verification. ## Validate the specification diff --git a/cmd/dogama-agent/main.go b/cmd/dogama-agent/main.go index 78d11f3..7ba03f8 100644 --- a/cmd/dogama-agent/main.go +++ b/cmd/dogama-agent/main.go @@ -16,6 +16,11 @@ import ( "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog" ) +var ( + version = "dev" + commit = "unknown" +) + func main() { logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) if err := run(logger); err != nil { @@ -67,7 +72,7 @@ func run(logger *slog.Logger) error { defer stop() errCh := make(chan error, 1) go func() { - logger.Info("agent listening", "event", "agent.started", "address", config.ListenAddress, "allowed_root_count", paths.RootCount()) + logger.Info("agent listening", "event", "agent.started", "address", config.ListenAddress, "allowed_root_count", paths.RootCount(), "version", version, "commit", commit) errCh <- server.ListenAndServe() }() select { diff --git a/cmd/dogama/main.go b/cmd/dogama/main.go index f0a0fa1..fd9ce6f 100644 --- a/cmd/dogama/main.go +++ b/cmd/dogama/main.go @@ -26,6 +26,11 @@ import ( "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/web" ) +var ( + version = "dev" + commit = "unknown" +) + func main() { logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) if err := run(logger); err != nil { @@ -127,10 +132,11 @@ func run(logger *slog.Logger) error { ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Minute, IdleTimeout: 60 * time.Second, + MaxHeaderBytes: 16 << 10, } errCh := make(chan error, 1) go func() { - logger.Info("application listening", "event", "application.started", "address", listenAddress) + logger.Info("application listening", "event", "application.started", "address", listenAddress, "version", version, "commit", commit) errCh <- server.ListenAndServe() }() select { diff --git a/compose.yaml b/compose.yaml index 4f1864c..ad81fe1 100644 --- a/compose.yaml +++ b/compose.yaml @@ -2,6 +2,7 @@ services: dogama: image: ghcr.io/dogama/dogama:${DOGAMA_VERSION:-latest} restart: unless-stopped + read_only: true ports: - "${DOGAMA_HTTP_PORT:-8080}:8080" environment: @@ -19,6 +20,12 @@ services: - ./data:/var/lib/dogama - ${DOGAMA_SERVERS_ROOT:-/srv/game-servers}:/srv/game-servers - ${DOGAMA_BACKUPS_ROOT:-/srv/game-backups}:/srv/game-backups + tmpfs: + - /tmp:rw,noexec,nosuid,nodev,size=32m + security_opt: + - no-new-privileges:true + cap_drop: + - ALL networks: - frontend - control @@ -50,6 +57,8 @@ services: - /tmp:rw,noexec,nosuid,nodev,size=16m security_opt: - no-new-privileges:true + cap_drop: + - ALL networks: - control - games diff --git a/docs/PROJECT-STATE.md b/docs/PROJECT-STATE.md index f8ce251..485bd2b 100644 --- a/docs/PROJECT-STATE.md +++ b/docs/PROJECT-STATE.md @@ -4,9 +4,9 @@ Read this compact operational baseline before starting a milestone. Open detaile ## Baseline -- Current reference: pre-milestone-10 UI redesign branch from merged milestone 9 baseline `d68d97d`. +- Current reference: milestone 10 working branch from the UI-redesign baseline `cd65414`. - Released SQLite migrations: `0001` through `0009`; never rewrite them. -- Roadmap milestones 1-9 are implemented. +- Roadmap milestones 1-10 are implemented; this is the V1 feature baseline. ## Architecture @@ -33,6 +33,10 @@ Read this compact operational baseline before starting a milestone. Open detaile - Compact allow-listed audit events for authentication and significant mutations, administrator filtering, bounded manual purge, daily retention and maximum-count enforcement. - Responsive server-rendered application shell with the official square DoGaMa logo, synthwave-derived design tokens, aligned permission-aware navigation, searchable real instance cards and state summaries above the server grid. - Dedicated administrator Audit and Settings pages; notification channels, audit retention/purge and game-container labels retain their existing backend contracts outside the Dashboard. +- Restrictive browser headers and bounded public HTTP headers. +- Hardened read-only Compose services, capability dropping, private agent networking and distinct minimal OCI image targets. +- Linux black-box bootstrap/authentication E2E coverage plus a documented disposable-Docker V1 release verification matrix. +- Deterministic Linux `amd64`/`arm64` archives with embedded build identity, SPDX module SBOM and SHA-256 checksums. ## Durable decisions @@ -56,7 +60,6 @@ Read this compact operational baseline before starting a milestone. Open detaile ## Known limitations and debt -- Release hardening remains roadmap work. - Scheduled backup outcomes and repeated authentication blocks are audited/logged, but broader scheduler-origin notification coverage remains intentionally limited to events emitted by implemented workflows. - Instance detail, catalog and backup management remain API-first; their sidebar entries are deliberately disabled until corresponding web pages exist, so navigation does not imply unavailable routes. - Linux is the deployment target. Native Windows execution of the full Go suite is blocked by Unix `Statfs` code; use Linux/WSL/CI for complete execution. @@ -64,12 +67,12 @@ Read this compact operational baseline before starting a milestone. Open detaile ## Validation and CI -- No repository-hosted Gitea/GitHub workflow files are currently present. +- No repository-hosted Gitea/GitHub workflow files are present; release validation is host-agnostic and documented through `Makefile`, `AGENTS.md` and the contributor testing guide. - Normal completion gate for Go changes is the validation set in `AGENTS.md` on Linux. - Specification validation is `python tools/validate_spec.py` with `tools/requirements-validation.txt` available. - Start with package/file-specific tests, then run global tests, build, race detection, vet, static analysis and schema validation as applicable. ## Next known work -- Roadmap milestone 10: security hardening, end-to-end tests, contributor documentation and release packaging. -- Update this file at the end of every merged milestone or durable architectural change; keep it compact and remove stale statements. +- No V1 milestone remains. Do not begin V1.x or V2 work without an explicit accepted scope. +- Update this file after every merged milestone or durable architectural change; keep it compact and remove stale statements. diff --git a/docs/contributing/testing.md b/docs/contributing/testing.md new file mode 100644 index 0000000..61f20df --- /dev/null +++ b/docs/contributing/testing.md @@ -0,0 +1,35 @@ +# Contributor testing + +Run the smallest affected package first, then the complete gate from +`AGENTS.md`. `make e2e` runs the Linux black-box bootstrap test against the real +`dogama` binary and a temporary SQLite database; it verifies first-run closure, +CSRF denial, secure cookies, security headers, authentication and protected +catalog access. + +The following V1 critical paths are intentionally split between deterministic +integration tests and disposable-Docker release verification: + +| Boundary or workflow | Automated coverage | +|---|---| +| Bootstrap, login, logout, CSRF, session rotation and throttling | `internal/web` and `tests/e2e` | +| Admin/user/manager membership and explicit-deny behavior | `internal/web` and `internal/authorization` | +| Agent authentication, replay, request bounds and unrelated-container denial | `internal/agent` | +| Path, symlink, plan, image, port and label restrictions | `internal/agent` | +| Archive traversal, links, extraction limits, backup integrity and restore safety | `internal/importexport` and `internal/backup` | +| WASM capability, network, fuel, memory, response and concurrency bounds | `internal/module` | +| Digest update success, failed readiness and rollback | `internal/instance` and `internal/web` | +| Empty and prior-schema migrations | `internal/persistence/sqlite` | + +Before publishing a release, additionally use an isolated Docker daemon with +disposable host roots. Run `docker compose config --quiet`, build both image +targets, confirm that the main container has no socket and the agent has no +published port, then exercise Palworld draft/install/start/stop, backup/restore, +an intentionally failing digest update, and container-only deletion. Confirm +that unrelated containers cannot be inspected or mutated and that player and +backup roots remain after deletion and interruption. Never point this test at a +host containing valuable containers or player data. + +Tests must use generated secrets and fixtures. Do not place real credentials, +host paths, saves, database copies or registry tokens in logs or commits. A +failure report should name the operation and stable error code without copying +secret-bearing request bodies. diff --git a/docs/operations/deployment-and-release.md b/docs/operations/deployment-and-release.md new file mode 100644 index 0000000..1f7ae25 --- /dev/null +++ b/docs/operations/deployment-and-release.md @@ -0,0 +1,77 @@ +# Deployment and release + +## Production prerequisites + +DoGaMa V1 targets one Linux Docker host with the Compose plugin. Put the public +application behind a trusted TLS reverse proxy; never publish the agent port. +Create the server and backup roots on the host and restrict them to the +administrator responsible for DoGaMa. Back up `data/dogama.db`, the server +roots, and the backup roots using host-level tooling. + +Create secrets before the first start. Both files must be readable only by the +deployment administrator; the master key is exactly 32 bytes and the agent +token is at least 32 bytes. + +```sh +install -d -m 0700 secrets +install -d -m 0750 -o 65532 -g 65532 data +umask 077 +head -c 32 /dev/urandom > secrets/master_key +head -c 32 /dev/urandom > secrets/agent_token +docker compose config --quiet +DOGAMA_VERSION=v1.0.0 docker compose up -d +``` + +Pin `DOGAMA_VERSION` to an immutable released version in production. The main +application runs as a non-root user with a read-only root filesystem, no Linux +capabilities and no Docker socket. The root-running restricted agent is isolated +on the private control network, has a read-only root filesystem and no added +capabilities; only it receives the Docker socket. The game and backup bind roots +remain writable because lifecycle and recovery workflows require them. + +TLS termination must retain DoGaMa's CSP, HSTS, frame, MIME and referrer +headers. Do not make forwarded client addresses authoritative for login rate +limiting. After startup, verify that only the configured application port is +published and that normal use redirects to `/setup` until the first +administrator is created. + +The application image uses numeric UID/GID `65532:65532`. Grant that identity +write access to the configured server and backup roots (with ACLs or matching +ownership) while keeping access unavailable to unrelated host users. + +## Release procedure + +From a clean, signed-off release commit, run the complete validation gate in +`AGENTS.md`, then create deterministic Linux archives: + +```sh +make release VERSION=v1.0.0 +(cd dist/dogama-v1.0.0 && sha256sum -c SHA256SUMS) +``` + +The release command refuses to overwrite an existing release directory. It +produces static `amd64` and `arm64` archives, embedded Go build information, an +SPDX 2.3 module SBOM and SHA-256 checksums. `SOURCE_DATE_EPOCH` defaults to the +release commit timestamp and may be supplied explicitly for reproduction. + +Build the two OCI images from the same commit and version: + +```sh +make images VERSION=v1.0.0 +``` + +The Dockerfile has distinct `dogama` and `dogama-agent` targets. Publish both +images under the same immutable version and record their registry digests in +the release notes. A release is complete only after a fresh-host Compose smoke +test, bootstrap, Palworld draft/install against a disposable Docker daemon, +backup/restore, failed-update rollback, and the security-denial checks described +in the contributor testing guide. + +## Upgrade and rollback + +Stop the application, take a filesystem-consistent copy of the SQLite database, +then change only `DOGAMA_VERSION` and start Compose. Startup applies append-only +migrations before serving requests. Preserve the pre-upgrade database copy and +all player/backup roots. If startup or validation fails, stop the new containers, +restore the database copy, select the prior image version and start again. Never +roll back only the database while a newer application is writing to it. diff --git a/internal/web/server.go b/internal/web/server.go index d65ed3f..f9e53c4 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -1650,11 +1650,13 @@ func (s *server) problem(w http.ResponseWriter, status int, message string) { func (s *server) securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'") + w.Header().Set("Content-Security-Policy", "default-src 'none'; base-uri 'none'; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self'; script-src 'self'; style-src 'self'") w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + w.Header().Set("Cross-Origin-Opener-Policy", "same-origin") + w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") next.ServeHTTP(w, r) }) } diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 28672a1..ee4be03 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -586,7 +586,7 @@ func TestBootstrapAuthenticationAndLogoutFlow(t *testing.T) { if !strings.Contains(home.Body.String(), "Signed in as admin") { t.Fatalf("protected page did not identify user: %s", home.Body.String()) } - if home.Header().Get("Content-Security-Policy") == "" || home.Header().Get("X-Content-Type-Options") != "nosniff" { + if home.Header().Get("Content-Security-Policy") == "" || home.Header().Get("X-Content-Type-Options") != "nosniff" || home.Header().Get("Strict-Transport-Security") == "" || home.Header().Get("Cross-Origin-Opener-Policy") != "same-origin" { t.Fatal("security headers missing") } diff --git a/tests/e2e/bootstrap_test.go b/tests/e2e/bootstrap_test.go new file mode 100644 index 0000000..458b08b --- /dev/null +++ b/tests/e2e/bootstrap_test.go @@ -0,0 +1,164 @@ +//go:build linux + +package e2e_test + +import ( + "bytes" + "context" + "io" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestV1BootstrapAuthenticationAndHTTPBoundary(t *testing.T) { + repositoryRoot := filepath.Clean(filepath.Join("..", "..")) + binary := filepath.Join(t.TempDir(), "dogama") + build := exec.Command("go", "build", "-trimpath", "-o", binary, "./cmd/dogama") + build.Dir = repositoryRoot + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build dogama: %v\n%s", err, output) + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + address := listener.Addr().String() + _ = listener.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var logs bytes.Buffer + command := exec.CommandContext(ctx, binary) + command.Env = append(os.Environ(), + "DOGAMA_LISTEN_ADDRESS="+address, + "DOGAMA_DATABASE_PATH="+filepath.Join(t.TempDir(), "dogama.db"), + "DOGAMA_IMPORTS_ROOT="+filepath.Join(t.TempDir(), "imports"), + "DOGAMA_SERVERS_ROOT="+filepath.Join(t.TempDir(), "servers"), + ) + command.Stdout, command.Stderr = &logs, &logs + if err := command.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + cancel() + _ = command.Wait() + }) + + client := &http.Client{Timeout: 2 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }} + baseURL := "http://" + address + setup := waitFor(t, client, baseURL+"/setup", &logs) + assertStatus(t, setup, http.StatusOK) + assertSecurityHeaders(t, setup) + csrf := cookieValue(t, setup, "dogama_csrf") + + badCSRF := form(t, client, baseURL+"/setup", url.Values{"csrf_token": {"forged"}, "username": {"admin"}, "password": {"correct horse battery staple"}}, csrf) + assertStatus(t, badCSRF, http.StatusForbidden) + created := form(t, client, baseURL+"/setup", url.Values{"csrf_token": {csrf}, "username": {"admin"}, "password": {"correct horse battery staple"}}, csrf) + assertStatus(t, created, http.StatusSeeOther) + + closed := get(t, client, baseURL+"/setup", "") + assertStatus(t, closed, http.StatusSeeOther) + loginPage := get(t, client, baseURL+"/login", "") + csrf = cookieValue(t, loginPage, "dogama_csrf") + login := form(t, client, baseURL+"/login", url.Values{"csrf_token": {csrf}, "username": {"admin"}, "password": {"correct horse battery staple"}}, csrf) + assertStatus(t, login, http.StatusSeeOther) + session := cookieValue(t, login, "dogama_session") + csrf = cookieValue(t, login, "dogama_csrf") + + unauthenticated := get(t, client, baseURL+"/api/v1/catalog", "") + assertStatus(t, unauthenticated, http.StatusUnauthorized) + authenticated := get(t, client, baseURL+"/api/v1/catalog", "dogama_session="+session+"; dogama_csrf="+csrf) + assertStatus(t, authenticated, http.StatusOK) + if !strings.Contains(readBody(t, authenticated), "palworld-official") { + t.Fatal("authenticated catalog omitted the Palworld reference template") + } +} + +func waitFor(t *testing.T, client *http.Client, target string, logs *bytes.Buffer) *http.Response { + t.Helper() + deadline := time.Now().Add(15 * time.Second) + for time.Now().Before(deadline) { + response, err := client.Get(target) + if err == nil { + return response + } + time.Sleep(50 * time.Millisecond) + } + t.Fatalf("application did not start:\n%s", logs.String()) + return nil +} + +func get(t *testing.T, client *http.Client, target, cookie string) *http.Response { + t.Helper() + request, _ := http.NewRequest(http.MethodGet, target, nil) + if cookie != "" { + request.Header.Set("Cookie", cookie) + } + response, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + return response +} + +func form(t *testing.T, client *http.Client, target string, values url.Values, csrf string) *http.Response { + t.Helper() + request, _ := http.NewRequest(http.MethodPost, target, strings.NewReader(values.Encode())) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.Header.Set("Cookie", "dogama_csrf="+csrf) + response, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + return response +} + +func cookieValue(t *testing.T, response *http.Response, name string) string { + t.Helper() + for _, cookie := range response.Cookies() { + if cookie.Name == name { + if !cookie.Secure || !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode { + t.Fatalf("insecure %s cookie: %#v", name, cookie) + } + return cookie.Value + } + } + t.Fatalf("cookie %s not found", name) + return "" +} + +func assertSecurityHeaders(t *testing.T, response *http.Response) { + t.Helper() + for name, expected := range map[string]string{"X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", "Cross-Origin-Opener-Policy": "same-origin"} { + if got := response.Header.Get(name); got != expected { + t.Fatalf("%s = %q, want %q", name, got, expected) + } + } + if !strings.Contains(response.Header.Get("Content-Security-Policy"), "default-src 'none'") || response.Header.Get("Strict-Transport-Security") == "" { + t.Fatal("strict browser security headers are incomplete") + } +} + +func assertStatus(t *testing.T, response *http.Response, expected int) { + t.Helper() + if response.StatusCode != expected { + t.Fatalf("status = %d, want %d; body=%s", response.StatusCode, expected, readBody(t, response)) + } +} + +func readBody(t *testing.T, response *http.Response) string { + t.Helper() + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + return string(body) +} diff --git a/tools/release.sh b/tools/release.sh new file mode 100755 index 0000000..8f4558a --- /dev/null +++ b/tools/release.sh @@ -0,0 +1,33 @@ +#!/bin/sh +set -eu + +version=${1:-} +case "$version" in + v[0-9]*.[0-9]*.[0-9]*) ;; + *) echo "usage: $0 vMAJOR.MINOR.PATCH" >&2; exit 2 ;; +esac + +commit=$(git rev-parse HEAD) +epoch=${SOURCE_DATE_EPOCH:-$(git show -s --format=%ct HEAD)} +release_dir="dist/dogama-${version}" +if [ -e "$release_dir" ]; then + echo "$release_dir already exists; refusing to overwrite it" >&2 + exit 1 +fi +mkdir -p "$release_dir" + +for arch in amd64 arm64; do + stage="$release_dir/linux-$arch" + mkdir -p "$stage" + CGO_ENABLED=0 GOOS=linux GOARCH=$arch go build -trimpath -buildvcs=false \ + -ldflags="-s -w -X main.version=$version -X main.commit=$commit" -o "$stage/dogama" ./cmd/dogama + CGO_ENABLED=0 GOOS=linux GOARCH=$arch go build -trimpath -buildvcs=false \ + -ldflags="-s -w -X main.version=$version -X main.commit=$commit" -o "$stage/dogama-agent" ./cmd/dogama-agent + go version -m "$stage/dogama" > "$stage/build-info.txt" + tar --sort=name --owner=0 --group=0 --numeric-owner --mtime="@$epoch" \ + -C "$release_dir" -czf "$release_dir/dogama-${version}-linux-$arch.tar.gz" "linux-$arch" +done + +go list -m -json all | SOURCE_DATE_EPOCH=$epoch go run ./tools/sbom > "$release_dir/dogama-${version}.spdx.json" +(cd "$release_dir" && sha256sum ./*.tar.gz ./*.spdx.json > SHA256SUMS) +echo "release artifacts written to $release_dir" diff --git a/tools/sbom/main.go b/tools/sbom/main.go new file mode 100644 index 0000000..2b4c359 --- /dev/null +++ b/tools/sbom/main.go @@ -0,0 +1,97 @@ +// Command sbom converts `go list -m -json all` output into a deterministic SPDX 2.3 inventory. +package main + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strconv" + "strings" + "time" +) + +type module struct { + Path string + Version string + Sum string +} + +type document struct { + SPDXVersion string `json:"spdxVersion"` + DataLicense string `json:"dataLicense"` + SPDXID string `json:"SPDXID"` + Name string `json:"name"` + DocumentNamespace string `json:"documentNamespace"` + CreationInfo creationInfo `json:"creationInfo"` + Packages []packageInfo `json:"packages"` +} + +type creationInfo struct { + Created string `json:"created"` + Creators []string `json:"creators"` +} + +type packageInfo struct { + Name string `json:"name"` + SPDXID string `json:"SPDXID"` + VersionInfo string `json:"versionInfo,omitempty"` + DownloadLocation string `json:"downloadLocation"` + FilesAnalyzed bool `json:"filesAnalyzed"` + Checksums []checksum `json:"checksums,omitempty"` +} + +type checksum struct { + Algorithm string `json:"algorithm"` + ChecksumValue string `json:"checksumValue"` +} + +func main() { + decoder := json.NewDecoder(os.Stdin) + var modules []module + for { + var value module + if err := decoder.Decode(&value); err != nil { + if errors.Is(err, io.EOF) { + break + } + fatal(err) + } + modules = append(modules, value) + } + epoch, err := strconv.ParseInt(os.Getenv("SOURCE_DATE_EPOCH"), 10, 64) + if err != nil { + fatal(fmt.Errorf("SOURCE_DATE_EPOCH: %w", err)) + } + digest := sha256.Sum256([]byte(fmt.Sprint(modules))) + doc := document{ + SPDXVersion: "SPDX-2.3", DataLicense: "CC0-1.0", SPDXID: "SPDXRef-DOCUMENT", + Name: "DoGaMa Go module inventory", DocumentNamespace: "https://dogama.invalid/spdx/" + hex.EncodeToString(digest[:]), + CreationInfo: creationInfo{Created: time.Unix(epoch, 0).UTC().Format(time.RFC3339), Creators: []string{"Tool: dogama-release"}}, + } + for index, value := range modules { + pkg := packageInfo{Name: value.Path, SPDXID: fmt.Sprintf("SPDXRef-Package-%d", index), VersionInfo: value.Version, DownloadLocation: "https://proxy.golang.org/" + strings.ToLower(value.Path), FilesAnalyzed: false} + if strings.HasPrefix(value.Sum, "h1:") { + sum, decodeErr := base64.StdEncoding.DecodeString(strings.TrimPrefix(value.Sum, "h1:")) + if decodeErr != nil || len(sum) != sha256.Size { + fatal(fmt.Errorf("invalid module checksum for %s", value.Path)) + } + pkg.Checksums = []checksum{{Algorithm: "SHA256", ChecksumValue: hex.EncodeToString(sum)}} + } + doc.Packages = append(doc.Packages, pkg) + } + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + if err := encoder.Encode(doc); err != nil { + fatal(err) + } +} + +func fatal(err error) { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) +}