Fetch Alpine.js at build time under a verified hash (closes #145) #164

Merged
clawbot merged 1 commits from issue-145-unvendor-alpine into next 2026-08-17 23:12:17 +02:00
Collaborator

Closes #145.

static/js/alpine.min.js was a committed minified bundle: unreviewable, referenced by no hash, and forbidden by REPO_POLICIES.md on both counts (no build artifacts in version control; every external reference pinned by content hash).

Option taken: 1 (build-time fetch, verified sha256)

No blocker was hit, so option 2 was not needed. Option 3 (drop Alpine) is left as #163 per the direction on the issue.

Alpine 3.14.9, from https://registry.npmjs.org/alpinejs/-/alpinejs-3.14.9.tgz — the publisher of record; jsDelivr and unpkg serve mirrors of this same tarball, verified identical while picking the source.

artifact sha256
alpinejs-3.14.9.tgz 97dad7c0c81e659cfc8e7700055da9770f8186187cb9a8a76efb57e00d5ce52a
package/dist/cdn.min.js (shipped) 3ed1eed252488921df65e363d6715deb04d7f92aaedb9e52199fdf73cb1e0ad3

The shipped hash is byte-identical to the blob that was committed, so the asset served does not change — this is a supply-chain change, not a behavioral one.

How the pin is enforced, not merely recorded

  • script/fetch-assets downloads, checks the tarball sha256, extracts, checks the extracted file's sha256, and only then installs. Both hashes are hardcoded. Nothing is installed on mismatch.
  • static/vendor.sha256 records the sha256 of every installed asset and is the single source of truth: the fetch script verifies against it, so the hash cannot drift into a second copy nothing reads.
  • static/vendor_test.go re-hashes the bytes go:embed actually put in the binary against that manifest. A build that skipped, cached around, or subverted the fetch cannot produce a green make test.
  • internal/server/static_assets_test.go fetches every /s/ script templates/base.html loads through the real router, so the page is proven to still get its JavaScript.
  • .gitignore keeps the artifact out of the repo; .dockerignore keeps a host copy out of the build context, so the image can only obtain it by fetching and verifying.

make bootstrap runs the fetch for local development, make assets runs it on demand, and the Dockerfile builder stage runs it before make test/make build.

Verification failure modes, demonstrated

All three run against a throwaway copy of the tree, since a live verification step is the whole point of the issue.

Expected hash corrupted by one hex digit:

fetch-assets: fetching Alpine.js 3.14.9 from https://registry.npmjs.org/alpinejs/-/alpinejs-3.14.9.tgz
fetch-assets: sha256 mismatch for package/dist/cdn.min.js from alpinejs-3.14.9.tgz
  expected: 3ed1eed252488921df65e363d6715deb04d7f92aaedb9e52199fdf73cb1e0ad4
  actual:   3ed1eed252488921df65e363d6715deb04d7f92aaedb9e52199fdf73cb1e0ad3
=== exit: 1 ===

URL repointed at a different version (3.14.8), hashes untouched:

fetch-assets: fetching Alpine.js 3.14.8 from https://registry.npmjs.org/alpinejs/-/alpinejs-3.14.8.tgz
fetch-assets: sha256 mismatch for alpinejs-3.14.8.tgz
  expected: 97dad7c0c81e659cfc8e7700055da9770f8186187cb9a8a76efb57e00d5ce52a
  actual:   f55d868c385452454b17ebf72d4873a4635791209d82efb07c23c2a98c38a1fc
=== exit: 1 ===

In both cases static/js/ still held only app.js afterwards: the bad bytes were never installed.

Asset tampered with after a good fetch, caught by make test on the embedded bytes:

vendor_test.go:49: embedded js/alpine.min.js does not match its pinned sha256 in
vendor.sha256; run `script/fetch-assets` (or `make assets`) to install the
pinned third-party assets
--- FAIL: TestVendoredAssetsMatchManifest/js/alpine.min.js (0.00s)

Asset absent entirely (fresh clone) fails both new tests with actionable messages — js/alpine.min.js is listed in vendor.sha256 but is not embedded and base.html loads /s/js/alpine.min.js but the server does not serve it. This fired for real during the rebase of this branch, which dropped the untracked artifact from the working tree; make assets fixed it.

The asset is present in the runtime image and served at the same URL

curl against the built runtime image (started --rm, removed afterwards):

HTTP/1.1 200 OK
Content-Length: 44758
Content-Type: text/javascript; charset=utf-8
served sha256: 3ed1eed252488921df65e363d6715deb04d7f92aaedb9e52199fdf73cb1e0ad3
version:"3.14.9"

and /pages/login from that same container still emits <script defer src="/s/js/alpine.min.js". Same URL, same bytes, hash equal to the pin.

Gate evidence

make check (with an isolated lint cache, see below): exit 0, 0 issues.

docker build --no-cache-filter=lint --no-cache-filter=builder .: exit 0, and the checks demonstrably executed rather than replaying:

  • #21 [lint 8/8] RUN make lint0 issues., DONE 56.9s
  • make test — 11 packages with real per-package durations (internal/delivery 4.404s, internal/handlers 3.670s, static 1.027s, ...), zero (cached) markers in the whole log
  • the asset fetch genuinely ran in-image rather than being served from a cached layer or the host: #29 [builder 8/11] RUN script/fetch-assetsfetching Alpine.js 3.14.9 from https://registry.npmjs.org/...installed static/js/alpine.min.js (3ed1eed...)

Note for reviewers: a plain host make lint on this machine reports ~18 phantom issues under ../rev-130-clawbot-merge/..., a path that does not exist and is outside the module — stale entries in the shared ~/.cache/golangci-lint, the skew described in #106 and #109. With an isolated lint cache the host reports 0 issues., matching the Docker run exactly. None of the phantom findings touch this branch.

Also observed, out of scope and not filed: make lint warns that the gomodguard linter is deprecated since v2.12.0 in favour of gomodguard_v2. The fix is a .golangci.yml edit, which is yours alone to make.

TODO.md untouched, as instructed.

Closes https://git.eeqj.de/sneak/webhooker/issues/145. `static/js/alpine.min.js` was a committed minified bundle: unreviewable, referenced by no hash, and forbidden by `REPO_POLICIES.md` on both counts (no build artifacts in version control; every external reference pinned by content hash). ## Option taken: 1 (build-time fetch, verified sha256) No blocker was hit, so option 2 was not needed. Option 3 (drop Alpine) is left as https://git.eeqj.de/sneak/webhooker/issues/163 per the direction on the issue. Alpine **3.14.9**, from `https://registry.npmjs.org/alpinejs/-/alpinejs-3.14.9.tgz` — the publisher of record; jsDelivr and unpkg serve mirrors of this same tarball, verified identical while picking the source. | artifact | sha256 | | --- | --- | | `alpinejs-3.14.9.tgz` | `97dad7c0c81e659cfc8e7700055da9770f8186187cb9a8a76efb57e00d5ce52a` | | `package/dist/cdn.min.js` (shipped) | `3ed1eed252488921df65e363d6715deb04d7f92aaedb9e52199fdf73cb1e0ad3` | The shipped hash is byte-identical to the blob that was committed, so **the asset served does not change** — this is a supply-chain change, not a behavioral one. ## How the pin is enforced, not merely recorded - `script/fetch-assets` downloads, checks the tarball sha256, extracts, checks the extracted file's sha256, and only then installs. Both hashes are hardcoded. Nothing is installed on mismatch. - `static/vendor.sha256` records the sha256 of every installed asset and is the single source of truth: the fetch script verifies against it, so the hash cannot drift into a second copy nothing reads. - `static/vendor_test.go` re-hashes the bytes `go:embed` actually put in the binary against that manifest. A build that skipped, cached around, or subverted the fetch cannot produce a green `make test`. - `internal/server/static_assets_test.go` fetches every `/s/` script `templates/base.html` loads through the real router, so the page is proven to still get its JavaScript. - `.gitignore` keeps the artifact out of the repo; `.dockerignore` keeps a host copy out of the build context, so the image can only obtain it by fetching and verifying. `make bootstrap` runs the fetch for local development, `make assets` runs it on demand, and the Dockerfile builder stage runs it before `make test`/`make build`. ## Verification failure modes, demonstrated All three run against a throwaway copy of the tree, since a live verification step is the whole point of the issue. Expected hash corrupted by one hex digit: ``` fetch-assets: fetching Alpine.js 3.14.9 from https://registry.npmjs.org/alpinejs/-/alpinejs-3.14.9.tgz fetch-assets: sha256 mismatch for package/dist/cdn.min.js from alpinejs-3.14.9.tgz expected: 3ed1eed252488921df65e363d6715deb04d7f92aaedb9e52199fdf73cb1e0ad4 actual: 3ed1eed252488921df65e363d6715deb04d7f92aaedb9e52199fdf73cb1e0ad3 === exit: 1 === ``` URL repointed at a different version (3.14.8), hashes untouched: ``` fetch-assets: fetching Alpine.js 3.14.8 from https://registry.npmjs.org/alpinejs/-/alpinejs-3.14.8.tgz fetch-assets: sha256 mismatch for alpinejs-3.14.8.tgz expected: 97dad7c0c81e659cfc8e7700055da9770f8186187cb9a8a76efb57e00d5ce52a actual: f55d868c385452454b17ebf72d4873a4635791209d82efb07c23c2a98c38a1fc === exit: 1 === ``` In both cases `static/js/` still held only `app.js` afterwards: the bad bytes were never installed. Asset tampered with after a good fetch, caught by `make test` on the embedded bytes: ``` vendor_test.go:49: embedded js/alpine.min.js does not match its pinned sha256 in vendor.sha256; run `script/fetch-assets` (or `make assets`) to install the pinned third-party assets --- FAIL: TestVendoredAssetsMatchManifest/js/alpine.min.js (0.00s) ``` Asset absent entirely (fresh clone) fails both new tests with actionable messages — `js/alpine.min.js is listed in vendor.sha256 but is not embedded` and `base.html loads /s/js/alpine.min.js but the server does not serve it`. This fired for real during the rebase of this branch, which dropped the untracked artifact from the working tree; `make assets` fixed it. ## The asset is present in the runtime image and served at the same URL `curl` against the built runtime image (started `--rm`, removed afterwards): ``` HTTP/1.1 200 OK Content-Length: 44758 Content-Type: text/javascript; charset=utf-8 served sha256: 3ed1eed252488921df65e363d6715deb04d7f92aaedb9e52199fdf73cb1e0ad3 version:"3.14.9" ``` and `/pages/login` from that same container still emits `<script defer src="/s/js/alpine.min.js"`. Same URL, same bytes, hash equal to the pin. ## Gate evidence `make check` (with an isolated lint cache, see below): **exit 0**, `0 issues.` `docker build --no-cache-filter=lint --no-cache-filter=builder .`: **exit 0**, and the checks demonstrably executed rather than replaying: - `#21 [lint 8/8] RUN make lint` — `0 issues.`, `DONE 56.9s` - `make test` — 11 packages with real per-package durations (`internal/delivery 4.404s`, `internal/handlers 3.670s`, `static 1.027s`, ...), **zero** `(cached)` markers in the whole log - the asset fetch genuinely ran in-image rather than being served from a cached layer or the host: `#29 [builder 8/11] RUN script/fetch-assets` → `fetching Alpine.js 3.14.9 from https://registry.npmjs.org/...` → `installed static/js/alpine.min.js (3ed1eed...)` Note for reviewers: a plain host `make lint` on this machine reports ~18 phantom issues under `../rev-130-clawbot-merge/...`, a path that does not exist and is outside the module — stale entries in the shared `~/.cache/golangci-lint`, the skew described in https://git.eeqj.de/sneak/webhooker/issues/106 and https://git.eeqj.de/sneak/webhooker/issues/109. With an isolated lint cache the host reports `0 issues.`, matching the Docker run exactly. None of the phantom findings touch this branch. Also observed, out of scope and not filed: `make lint` warns that the `gomodguard` linter is deprecated since v2.12.0 in favour of `gomodguard_v2`. The fix is a `.golangci.yml` edit, which is yours alone to make. `TODO.md` untouched, as instructed.
clawbot added the needs-review label 2026-08-17 23:02:37 +02:00
clawbot added 1 commit 2026-08-17 23:02:38 +02:00
Fetch Alpine.js at build time under a verified hash (closes #145)
All checks were successful
check / check (push) Successful in 3m29s
b8272e158c
static/js/alpine.min.js was a committed minified bundle: unreviewable,
referenced by no hash, and forbidden by REPO_POLICIES.md on both counts.

It is now fetched by script/fetch-assets from a pinned npm registry
tarball, with the tarball sha256 and the extracted file's sha256 both
hardcoded and checked before anything is installed. The hash of every
installed asset lives in static/vendor.sha256, and static/vendor_test.go
re-hashes the bytes go:embed actually put in the binary against that
manifest, so the pin is enforced on what ships rather than merely
recorded. .gitignore keeps the artifact out of the repo and
.dockerignore keeps a host copy out of the build context, so the image
can only get it by fetching and verifying it.

Alpine 3.14.9 is byte-identical to the blob that was committed
(3ed1eed252488921df65e363d6715deb04d7f92aaedb9e52199fdf73cb1e0ad3), so
the served asset does not change; internal/server/static_assets_test.go
fetches every /s/ script base.html loads through the real router to
prove the page still gets it.
clawbot self-assigned this 2026-08-17 23:02:47 +02:00
Author
Collaborator

PASS

Independent review. DoD of #145 met: blob gone from the tree, pin enforced (not merely recorded), page still served.

Pin verified independently — I re-downloaded alpinejs-3.14.9.tgz from registry.npmjs.org: sha256 97dad7c0...5ce52a matches; package/dist/cdn.min.js sha256 3ed1eed2...1e0ad3, 44758 bytes, matches static/vendor.sha256 and is byte-identical to the blob at next:static/js/alpine.min.js. Served asset unchanged.

Negative controls I reproduced myself (not taken on report):

  • expected hash flipped one digit → script/fetch-assets exit 1, static/js/ left holding only app.js
  • URL repointed to 3.14.8, hashes untouched → exit 1 at the tarball check, static/js/ clean
  • asset tampered after a good fetch → make test FAIL, vendor_test.go:49, real durations, zero (cached)
  • asset absent → both new tests FAIL (is listed in vendor.sha256 but is not embedded, base.html loads /s/js/alpine.min.js but the server does not serve it)
  • manifest entry nothing installs (js/ghost.js) → exit 1; manifest with no entry for the asset → exit 1
  • asset present + registry unreachable → exit 0, no network; asset absent + unreachable → nonzero, fails closed

.dockerignore verified with a host copy actually present in the context (the author's own build ran before the host copy existed, so it did not prove this): COPY . . lands static/js/ with app.js only. A stale or tampered host copy cannot be baked in.

Runtime: image built and run --rm, /s/js/alpine.min.js → 200, 44758 bytes, served sha256 equals the pin, version:"3.14.9"; /pages/login emits <script defer src="/s/js/alpine.min.js">. Container and images removed.

Gate: docker build --no-cache-filter=lint --no-cache-filter=builder . exit 0. make fmt-check DONE 2.0s, make lint DONE 60.8s → 0 issues., script/fetch-assets genuinely fetched in-image (#29, not cached), make test 11 packages with real durations, zero (cached) in the whole log. CI green on b8272e1 (Successful in 3m29s). Merges clean against next @ 279effb. Single commit, title ends (closes #145), base next, TODO.md untouched, no attribution trailers, shellcheck --shell=sh clean on script/fetch-assets and script/bootstrap.


One non-blocking gap (not grounds to hold this, but worth closing):

static/static.go uses //go:embed css js — directory patterns. A missing static/js/alpine.min.js is therefore not a compile error, so on a tree that never ran the fetch, make build exits 0 and produces a binary whose admin UI silently has no Alpine (I ran it: make build exit 0, binary emitted, /s/js/alpine.min.js absent). Same for make run, make dev, and a bare go build. Every gated path is safe — the Dockerfile fetches before make test/make build, and make check/CI fail loudly — so this is local-dev ergonomics only, and the failure is a dead UI rather than an unverified asset. Acceptable fix: name the files in the embed (//go:embed css js/app.js js/alpine.min.js), which turns absence into a compile error on every build path, or make build depend on assets in the Makefile.

Disclosure: I did not run make lint on the host. Lint evidence is the uncached Docker lint stage only, per the shared-host cache-bleed rule (#106, #109). make check was verified as script/test (host, exit 0) + script/fmt-check (host, exit 0) + make lint in that container.

PASS Independent review. DoD of https://git.eeqj.de/sneak/webhooker/issues/145 met: blob gone from the tree, pin enforced (not merely recorded), page still served. **Pin verified independently** — I re-downloaded `alpinejs-3.14.9.tgz` from `registry.npmjs.org`: sha256 `97dad7c0...5ce52a` matches; `package/dist/cdn.min.js` sha256 `3ed1eed2...1e0ad3`, 44758 bytes, matches `static/vendor.sha256` and is byte-identical to the blob at `next:static/js/alpine.min.js`. Served asset unchanged. **Negative controls I reproduced myself** (not taken on report): - expected hash flipped one digit → `script/fetch-assets` exit 1, `static/js/` left holding only `app.js` - URL repointed to 3.14.8, hashes untouched → exit 1 at the tarball check, `static/js/` clean - asset tampered after a good fetch → `make test` FAIL, `vendor_test.go:49`, real durations, zero `(cached)` - asset absent → both new tests FAIL (`is listed in vendor.sha256 but is not embedded`, `base.html loads /s/js/alpine.min.js but the server does not serve it`) - manifest entry nothing installs (`js/ghost.js`) → exit 1; manifest with no entry for the asset → exit 1 - asset present + registry unreachable → exit 0, no network; asset absent + unreachable → nonzero, fails closed **`.dockerignore`** verified with a host copy actually present in the context (the author's own build ran before the host copy existed, so it did not prove this): `COPY . .` lands `static/js/` with `app.js` only. A stale or tampered host copy cannot be baked in. **Runtime**: image built and run `--rm`, `/s/js/alpine.min.js` → 200, 44758 bytes, served sha256 equals the pin, `version:"3.14.9"`; `/pages/login` emits `<script defer src="/s/js/alpine.min.js">`. Container and images removed. **Gate**: `docker build --no-cache-filter=lint --no-cache-filter=builder .` exit 0. `make fmt-check` DONE 2.0s, `make lint` DONE 60.8s → `0 issues.`, `script/fetch-assets` genuinely fetched in-image (`#29`, not cached), `make test` 11 packages with real durations, **zero** `(cached)` in the whole log. CI green on `b8272e1` (`Successful in 3m29s`). Merges clean against `next` @ `279effb`. Single commit, title ends ` (closes #145)`, base `next`, `TODO.md` untouched, no attribution trailers, shellcheck `--shell=sh` clean on `script/fetch-assets` and `script/bootstrap`. --- **One non-blocking gap** (not grounds to hold this, but worth closing): `static/static.go` uses `//go:embed css js` — directory patterns. A missing `static/js/alpine.min.js` is therefore not a compile error, so on a tree that never ran the fetch, `make build` exits 0 and produces a binary whose admin UI silently has no Alpine (I ran it: `make build` exit 0, binary emitted, `/s/js/alpine.min.js` absent). Same for `make run`, `make dev`, and a bare `go build`. Every *gated* path is safe — the Dockerfile fetches before `make test`/`make build`, and `make check`/CI fail loudly — so this is local-dev ergonomics only, and the failure is a dead UI rather than an unverified asset. Acceptable fix: name the files in the embed (`//go:embed css js/app.js js/alpine.min.js`), which turns absence into a compile error on every build path, or make `build` depend on `assets` in the `Makefile`. Disclosure: I did not run `make lint` on the host. Lint evidence is the uncached Docker `lint` stage only, per the shared-host cache-bleed rule (https://git.eeqj.de/sneak/webhooker/issues/106, https://git.eeqj.de/sneak/webhooker/issues/109). `make check` was verified as `script/test` (host, exit 0) + `script/fmt-check` (host, exit 0) + `make lint` in that container.
clawbot merged commit c378690977 into next 2026-08-17 23:12:17 +02:00
clawbot deleted branch issue-145-unvendor-alpine 2026-08-17 23:12:18 +02:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/webhooker#164