From c378690977e7ed7144c214cbbb432dee07548545 Mon Sep 17 00:00:00 2001 From: clawbot Date: Mon, 17 Aug 2026 23:12:17 +0200 Subject: [PATCH] Fetch and verify Alpine at build time instead of committing it (closes #145) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit static/js/alpine.min.js was a committed minified bundle, which REPO_POLICIES forbids, referenced by no content hash at all. A minified blob is unreviewable, which is the shape a supply-chain compromise takes. script/fetch-assets now downloads Alpine 3.14.9 from the npm registry and verifies sha256 on both the tarball and the extracted file, and static/vendor_test.go re-hashes the bytes go:embed actually placed in the binary. The shipped bytes are byte-identical to the blob that was committed, so the served asset does not change. Independently reviewed. Five negative controls reproduced by the reviewer: flipped expected hash, repointed URL, post-fetch tampering, asset absent, and manifest inconsistencies — each fails closed with static/js/ left clean. Registry hashes confirmed against the pins, and the runtime image was built, run and curled to confirm the asset is still served and the login page still loads it. Known gap, filed separately: static/static.go embeds the js directory rather than named files, so a missing fetched asset is not a compile error on ungated local build paths. Every gated path fails loudly, so the release artifact is unaffected. --- .dockerignore | 5 ++ .gitignore | 7 +- Dockerfile | 10 ++- Makefile | 5 +- README.md | 24 ++++++ internal/server/static_assets_test.go | 50 +++++++++++++ script/bootstrap | 8 +- script/fetch-assets | 104 ++++++++++++++++++++++++++ static/js/alpine.min.js | 5 -- static/vendor.sha256 | 1 + static/vendor_test.go | 92 +++++++++++++++++++++++ 11 files changed, 302 insertions(+), 9 deletions(-) create mode 100644 internal/server/static_assets_test.go create mode 100755 script/fetch-assets delete mode 100644 static/js/alpine.min.js create mode 100644 static/vendor.sha256 create mode 100644 static/vendor_test.go diff --git a/.dockerignore b/.dockerignore index 66a74a5..5565f6c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,11 @@ # stage of the Dockerfile. .git/ bin/ +# Third-party browser assets are fetched and hash-verified inside the build by +# script/fetch-assets. Excluding any host copy keeps a developer's working tree +# from supplying the bytes that get shipped. The script and its +# static/vendor.sha256 manifest stay in the context. +static/js/alpine.min.js *.md LICENSE .editorconfig diff --git a/.gitignore b/.gitignore index 16f68ad..50cd133 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,9 @@ tmp/ temp/ # CI cache barrier, written into the build context by the check workflow -.ci-fingerprint \ No newline at end of file +.ci-fingerprint + +# Third-party browser assets, fetched and hash-verified by +# script/fetch-assets against static/vendor.sha256. Not committed: +# REPO_POLICIES.md forbids minified bundles in version control. +/static/js/alpine.min.js \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index f43c43a..2c7be0e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,7 +32,7 @@ FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a349228 # Depend on lint stage passing COPY --from=lint /src/go.sum /dev/null -RUN apt-get update && apt-get install -y --no-install-recommends make && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates && rm -rf /var/lib/apt/lists/* WORKDIR /build @@ -44,6 +44,14 @@ RUN go mod download # the lint stage above. COPY . . +# Fetch the third-party browser assets the UI serves. They are not committed +# (REPO_POLICIES.md forbids minified bundles in version control) and +# .dockerignore keeps any host copy out of the build context, so this step is +# the only way they enter the image. Each download is checked against a +# hardcoded sha256 and the build fails on mismatch; make test re-checks the +# hashes against the bytes go:embed actually put in the binary. +RUN script/fetch-assets + # Run tests and build RUN make test RUN make build diff --git a/Makefile b/Makefile index 81ee568..58935b2 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: bootstrap setup test lint fmt fmt-check check build run dev deps docker clean hooks css +.PHONY: bootstrap setup assets test lint fmt fmt-check check build run dev deps docker clean hooks css # Default target .DEFAULT_GOAL := check @@ -9,6 +9,9 @@ bootstrap: setup: @script/setup +assets: + @script/fetch-assets + test: @script/test diff --git a/README.md b/README.md index 570f087..6c940f9 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ make docker ```bash make bootstrap # Install all dependencies (idempotent) make setup # Bootstrap + install git pre-commit hook +make assets # Fetch + verify third-party browser assets make fmt # Format code (gofmt + goimports) make lint # Run golangci-lint make test # Run tests with race detection @@ -247,6 +248,8 @@ them. We provide: - `script/setup` — make a fresh clone ready for development (bootstrap, then install-precommit) - `script/projectname` — output the project name ("webhooker") +- `script/fetch-assets` — download the third-party browser assets into + `static/`, verifying each against its pinned sha256 - `script/test` — run the test suite - `script/lint` — run golangci-lint - `script/fmt` — format all code (writes) @@ -260,6 +263,27 @@ them. We provide: - `script/install-precommit` — install the git pre-commit hook that runs `script/precommit` +## Third-party browser assets + +The web UI serves one third-party script, Alpine.js. It is **not** committed: +a minified bundle in the tree is unreviewable, and `REPO_POLICIES.md` bars +both committed build artifacts and unpinned external references. + +Instead `script/fetch-assets` downloads it from a pinned URL, checks the +download against a hardcoded sha256, and installs it under `static/`. The +sha256 of every installed asset is recorded in `static/vendor.sha256`, and +`static/vendor_test.go` re-hashes the bytes `go:embed` put in the binary +against that manifest — so the pin is enforced on what actually ships, not +merely written down. Any mismatch fails the build. + +`make bootstrap` runs the fetch for local development, and the Dockerfile +runs it in the build stage; `.gitignore` and `.dockerignore` keep the +artifact out of both the repo and the build context. + +To move to a new version: update the version, URL, and tarball sha256 in +`script/fetch-assets` and the asset sha256 in `static/vendor.sha256`, then +run `make assets && make check`. + ## Rationale Webhook integrations between services are inherently fragile. The diff --git a/internal/server/static_assets_test.go b/internal/server/static_assets_test.go new file mode 100644 index 0000000..c7d389f --- /dev/null +++ b/internal/server/static_assets_test.go @@ -0,0 +1,50 @@ +package server_test + +import ( + "net/http" + "regexp" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "sneak.berlin/go/webhooker/templates" +) + +// TestBaseTemplateScriptsAreServed walks every /s/ script the base +// template loads on each page and fetches it through the real router. +// Alpine.js is fetched at build time rather than committed, so nothing +// in the repo guarantees it is present: this is the check that the page +// still gets the JavaScript it asks for. +func TestBaseTemplateScriptsAreServed(t *testing.T) { + t.Parallel() + + // scriptSrc matches the src of every