Compare commits
2 Commits
3925fce24a
...
0598f1dc04
| Author | SHA1 | Date | |
|---|---|---|---|
| 0598f1dc04 | |||
| 992b3c68f5 |
@@ -19,9 +19,14 @@ RUN go mod download
|
|||||||
# .dockerignore.
|
# .dockerignore.
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Run formatting check and linter
|
# Run formatting check and linter. golangci-lint is invoked directly rather
|
||||||
|
# than through `make lint`: this stage is already the pinned linter image, and
|
||||||
|
# script/lint is a wrapper that builds Dockerfile.lint, so calling it here
|
||||||
|
# would need a docker daemon inside the build. Keep these steps in step with
|
||||||
|
# Dockerfile.lint, including --network=none (see its header for why).
|
||||||
RUN make fmt-check
|
RUN make fmt-check
|
||||||
RUN make lint
|
RUN --network=none golangci-lint config verify --config .golangci.yml
|
||||||
|
RUN --network=none golangci-lint run --config .golangci.yml ./...
|
||||||
|
|
||||||
# Build stage
|
# Build stage
|
||||||
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
|
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
|
||||||
|
|||||||
37
Dockerfile.lint
Normal file
37
Dockerfile.lint
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Lint-only image, built by script/lint. golangci-lint is never installed on
|
||||||
|
# the host: the repo is COPYed into the pinned image and linted as a build
|
||||||
|
# step, so a successful build IS a clean lint. This works even when the docker
|
||||||
|
# daemon is remote and bind mounts are impossible.
|
||||||
|
#
|
||||||
|
# script/lint passes --no-cache-filter=lint. Without it an unchanged tree
|
||||||
|
# replays the lint stage from cache and the build succeeds in under a second
|
||||||
|
# having run no linter at all. Do not drop that flag.
|
||||||
|
#
|
||||||
|
# The lint steps run with --network=none. `golangci-lint config verify` is
|
||||||
|
# documented as fetching its JSON schema over HTTPS, which would make linting
|
||||||
|
# depend on an unpinned remote artifact; this pinned image resolves the schema
|
||||||
|
# without any network, and --network=none enforces that rather than trusting
|
||||||
|
# it. It also proves no linter reaches out at analysis time. If a future image
|
||||||
|
# bump makes either step need the network, this build fails loudly instead of
|
||||||
|
# quietly acquiring an unpinned dependency.
|
||||||
|
|
||||||
|
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
|
||||||
|
# Using Debian-based image because mattn/go-sqlite3 (CGO) does not
|
||||||
|
# compile on Alpine musl (off64_t is a glibc type).
|
||||||
|
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS deps
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Copy go mod files first for better layer caching. This stage is cacheable;
|
||||||
|
# only the lint stage below is forced to re-execute.
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
FROM deps AS lint
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# `run` silently ignores config keys it does not recognize, so a typo would
|
||||||
|
# disable a setting without a word. `config verify` is what catches that.
|
||||||
|
RUN --network=none golangci-lint config verify --config .golangci.yml
|
||||||
|
RUN --network=none golangci-lint run --config .golangci.yml ./...
|
||||||
112
README.md
112
README.md
@@ -12,14 +12,16 @@ with retry support, logging, and observability. Category: infrastructure
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Go 1.26.1+ (the version in `go.mod`)
|
- Go 1.26.1+ (the version in `go.mod`)
|
||||||
- golangci-lint v2.12.2 (the version pinned in `script/bootstrap` and
|
- Docker (for linting, for the test stage of the CI gate, and for
|
||||||
in the `Dockerfile`'s lint stage; `make bootstrap` installs it)
|
containerized deployment)
|
||||||
- Docker (for containerized deployment, and for the lint and test
|
|
||||||
stages of the CI gate)
|
|
||||||
- `curl`, used by `script/fetch-assets` to download the third-party
|
- `curl`, used by `script/fetch-assets` to download the third-party
|
||||||
browser assets, which are not committed (`make bootstrap` installs
|
browser assets, which are not committed (`make bootstrap` installs
|
||||||
it if missing)
|
it if missing)
|
||||||
|
|
||||||
|
golangci-lint is not a prerequisite and must not be installed on the
|
||||||
|
host: `script/bootstrap` does not install it, and `make lint` runs the
|
||||||
|
digest-pinned linter image via `Dockerfile.lint`.
|
||||||
|
|
||||||
### Quick Start
|
### Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -27,9 +29,9 @@ with retry support, logging, and observability. Category: infrastructure
|
|||||||
git clone https://git.eeqj.de/sneak/webhooker.git
|
git clone https://git.eeqj.de/sneak/webhooker.git
|
||||||
cd webhooker
|
cd webhooker
|
||||||
|
|
||||||
# Install Go dependencies, the pinned linter, and the third-party
|
# Install Go dependencies and the third-party browser assets.
|
||||||
# browser assets. `make deps` alone is not enough: it only runs
|
# `make deps` alone is not enough: it only runs go mod download/tidy,
|
||||||
# go mod download/tidy, and the checks below need the fetched assets.
|
# and the checks below need the fetched assets.
|
||||||
make bootstrap
|
make bootstrap
|
||||||
|
|
||||||
# Run all checks (test, lint, format check)
|
# Run all checks (test, lint, format check)
|
||||||
@@ -52,7 +54,7 @@ make setup # Bootstrap + install git pre-commit hook
|
|||||||
make assets # Fetch + verify third-party browser assets
|
make assets # Fetch + verify third-party browser assets
|
||||||
make fmt # Format code (gofmt + goimports)
|
make fmt # Format code (gofmt + goimports)
|
||||||
make fmt-check # Fail if gofmt would change anything (writes nothing)
|
make fmt-check # Fail if gofmt would change anything (writes nothing)
|
||||||
make lint # Run golangci-lint
|
make lint # Run golangci-lint in Docker (Dockerfile.lint)
|
||||||
make test # Run tests with race detection
|
make test # Run tests with race detection
|
||||||
make check # test + lint + fmt-check (CI gate)
|
make check # test + lint + fmt-check (CI gate)
|
||||||
make build # Build binary to bin/webhooker
|
make build # Build binary to bin/webhooker
|
||||||
@@ -275,7 +277,7 @@ are inline commands with no script behind them. We provide:
|
|||||||
- `script/fetch-assets` — download the third-party browser assets into
|
- `script/fetch-assets` — download the third-party browser assets into
|
||||||
`static/`, verifying each against its pinned sha256
|
`static/`, verifying each against its pinned sha256
|
||||||
- `script/test` — run the test suite
|
- `script/test` — run the test suite
|
||||||
- `script/lint` — run golangci-lint
|
- `script/lint` — run golangci-lint in Docker (see Linting below)
|
||||||
- `script/fmt` — format all code (writes)
|
- `script/fmt` — format all code (writes)
|
||||||
- `script/fmt-check` — check formatting (read-only)
|
- `script/fmt-check` — check formatting (read-only)
|
||||||
- `script/check` — run test, lint, and fmt-check
|
- `script/check` — run test, lint, and fmt-check
|
||||||
@@ -1020,14 +1022,32 @@ buy the same amplification as an invented path. Nothing debuggable is
|
|||||||
lost: `page`, on the authenticated pagination links, is the only query
|
lost: `page`, on the authenticated pagination links, is the only query
|
||||||
parameter this service reads.
|
parameter this service reads.
|
||||||
|
|
||||||
The query string does not leave the host by the other route either.
|
Client-supplied request content does not leave the host by the other
|
||||||
The Sentry SDK attaches the request to every event it captures and
|
route either. The Sentry SDK attaches the request to every event it
|
||||||
copies the raw query into it, independently of the access log, so a
|
captures, independently of the access log, and `SendDefaultPII=false`
|
||||||
`BeforeSend` hook clears that field before the event is sent. The event
|
does not cover all of what it copies: the raw query string and the
|
||||||
still carries the scheme, host, path and method, which is what names
|
first 10 KiB of the request body are both taken unconditionally, the
|
||||||
the failing route. Nothing in this service reads a form field from the
|
body precisely because these handlers call `ParseForm`. A `BeforeSend`
|
||||||
query: every handler uses `PostFormValue`, so a value on the request
|
hook therefore replaces the query string and the body with
|
||||||
line cannot configure anything.
|
`(redacted)`, drops cookies and the remote-address environment, and
|
||||||
|
reduces the headers to a fixed allowlist — `Accept`, `Content-Length`,
|
||||||
|
`Content-Type`, `Host`, `Origin`, `Referer`, `User-Agent` and
|
||||||
|
`X-Request-Id`.
|
||||||
|
|
||||||
|
The body is replaced rather than filtered because the hook cannot tell
|
||||||
|
which route it is on: the SDK hands `BeforeSend` no request, so a
|
||||||
|
route-conditional rule would have to guess, and an unrecognised route
|
||||||
|
must not leak. Nothing debuggable is lost by it. Every handler reads
|
||||||
|
its fields with `PostFormValue`, so the body is exactly where the
|
||||||
|
credentials are — the target destination URL, the login password, both
|
||||||
|
password-change fields — and on the receiver route, the one route
|
||||||
|
whose body is genuine signal, that body is already stored on the event
|
||||||
|
and served from the UI. The headers are an allowlist for the same
|
||||||
|
reason: the SDK's own filter removes four names and passes everything
|
||||||
|
else, which would ship `X-CSRF-Token` and the shared secrets senders
|
||||||
|
put on the receiver route. What survives still names the failing
|
||||||
|
route — scheme, host, path, method — and `X-Request-Id` ties the event
|
||||||
|
to the local access log line that holds the rest.
|
||||||
|
|
||||||
The remaining client-supplied fields are truncated rather than dropped,
|
The remaining client-supplied fields are truncated rather than dropped,
|
||||||
each to a fixed budget: 512 bytes for `url`, `useragent` and `referer`,
|
each to a fixed budget: 512 bytes for `url`, `useragent` and `referer`,
|
||||||
@@ -1256,6 +1276,7 @@ webhooker/
|
|||||||
├── templates/ # Go HTML templates (base, login, sources, etc.)
|
├── templates/ # Go HTML templates (base, login, sources, etc.)
|
||||||
├── script/ # Scripts to Rule Them All entrypoints
|
├── script/ # Scripts to Rule Them All entrypoints
|
||||||
├── Dockerfile # Three stages: lint, test+build, Alpine runtime
|
├── Dockerfile # Three stages: lint, test+build, Alpine runtime
|
||||||
|
├── Dockerfile.lint # Lint-only image built by script/lint
|
||||||
├── Makefile # 10 of 16 targets shim script/; 6 are inline
|
├── Makefile # 10 of 16 targets shim script/; 6 are inline
|
||||||
├── go.mod / go.sum
|
├── go.mod / go.sum
|
||||||
└── .golangci.yml # Linter configuration
|
└── .golangci.yml # Linter configuration
|
||||||
@@ -1461,6 +1482,37 @@ Two operational consequences follow from bounding the sequence:
|
|||||||
no shutdown diagnostics at all. Keep the deployment's grace above
|
no shutdown diagnostics at all. Keep the deployment's grace above
|
||||||
the stop timeout.
|
the stop timeout.
|
||||||
|
|
||||||
|
### Linting
|
||||||
|
|
||||||
|
golangci-lint never runs on the host. `script/lint` builds
|
||||||
|
`Dockerfile.lint`, which copies the repo into the digest-pinned
|
||||||
|
golangci-lint image and lints as a build step, so a successful build is
|
||||||
|
a clean lint. A host binary would share one cache and one lock with
|
||||||
|
every other checkout on the machine, which has produced both invented
|
||||||
|
findings attributed to other worktrees and unearned passes.
|
||||||
|
|
||||||
|
Three properties are load-bearing:
|
||||||
|
|
||||||
|
- `script/lint` passes `--no-cache-filter=lint`. Without it an unchanged
|
||||||
|
tree replays the lint layer from cache and the build exits 0 in under
|
||||||
|
a second having linted nothing. The `deps` stage stays cacheable, so
|
||||||
|
module downloads are not repeated. Invalidation is scoped to the one
|
||||||
|
stage; never prune the shared build cache.
|
||||||
|
- `script/lint` does not trust that flag. Docker silently ignores
|
||||||
|
`--no-cache-filter` for a stage name that does not match, so a stage
|
||||||
|
rename or a one-character typo would restore the cached false green
|
||||||
|
with no warning and a fast exit 0. The script therefore tees the
|
||||||
|
build output and treats a run as a pass only if golangci-lint's own
|
||||||
|
summary line (`N issues.` / `N issues:`) appears in it: no summary,
|
||||||
|
no lint, whatever the exit code says.
|
||||||
|
- Both lint steps use `RUN --network=none`. `golangci-lint config
|
||||||
|
verify` is documented as fetching its JSON schema over HTTPS, which
|
||||||
|
would be an unpinned remote dependency; the pinned image resolves the
|
||||||
|
schema without network access, and `--network=none` enforces that
|
||||||
|
instead of trusting it. Verify is worth keeping because
|
||||||
|
`golangci-lint run` silently ignores config keys it does not
|
||||||
|
recognize, so a typo would disable a setting with no warning.
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
The Dockerfile uses a three-stage build. Each stage is pinned by
|
The Dockerfile uses a three-stage build. Each stage is pinned by
|
||||||
@@ -1469,7 +1521,8 @@ version is fixed independently of the compiler's:
|
|||||||
|
|
||||||
1. **Lint stage** (`golangci/golangci-lint:v2.12.2`, Debian-based) —
|
1. **Lint stage** (`golangci/golangci-lint:v2.12.2`, Debian-based) —
|
||||||
installs `make`, downloads dependencies, copies the source, and runs
|
installs `make`, downloads dependencies, copies the source, and runs
|
||||||
`make fmt-check` then `make lint`.
|
`make fmt-check`, then `golangci-lint config verify` and
|
||||||
|
`golangci-lint run`, both with `--network=none`.
|
||||||
2. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint
|
2. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint
|
||||||
stage passing (it copies a file from it), runs `script/fetch-assets`
|
stage passing (it copies a file from it), runs `script/fetch-assets`
|
||||||
to download and verify the third-party browser assets, then runs
|
to download and verify the third-party browser assets, then runs
|
||||||
@@ -1480,20 +1533,21 @@ version is fixed independently of the compiler's:
|
|||||||
runs as the non-root `webhooker` user (UID 1000), exposes port 8080,
|
runs as the non-root `webhooker` user (UID 1000), exposes port 8080,
|
||||||
and includes a health check against `/.well-known/healthcheck`.
|
and includes a health check against `/.well-known/healthcheck`.
|
||||||
|
|
||||||
|
The lint stage invokes `golangci-lint` directly rather than `make lint`:
|
||||||
|
it is already the pinned linter image, and `make lint` builds
|
||||||
|
`Dockerfile.lint`, which would need a docker daemon inside this build.
|
||||||
|
|
||||||
Both check stages use Debian rather than Alpine because
|
Both check stages use Debian rather than Alpine because
|
||||||
`gorm.io/driver/sqlite` pulls in `mattn/go-sqlite3`, which needs CGO
|
`gorm.io/driver/sqlite` pulls in `mattn/go-sqlite3`, which needs CGO
|
||||||
and does not compile against musl. Only the final binary is statically
|
and does not compile against musl. Only the final binary is statically
|
||||||
linked, which is what lets it run on the Alpine runtime image.
|
linked, which is what lets it run on the Alpine runtime image.
|
||||||
|
|
||||||
`script/cibuild` — `docker build .` — is the CI gate: the four check
|
`script/cibuild` — `docker build .` — is the CI gate: the checks run
|
||||||
targets run inside the image, so a build that succeeds is a repo that
|
inside the image, so a build that succeeds is a repo that is formatted,
|
||||||
is formatted, linted, tested and compiled. Only `script/cibuild` and
|
linted, tested and compiled. `script/lint` also uses Docker
|
||||||
`script/docker` involve Docker. `script/lint`, and therefore
|
(`Dockerfile.lint`, see Linting above), so `make lint` and `make check`
|
||||||
`make lint` and `make check`, run whatever `golangci-lint` is on the
|
run the same pinned linter version the gate does; only `script/test`
|
||||||
host, which can be a different version from the pinned one — so the
|
and `script/fmt-check` run on the host.
|
||||||
container is the authoritative lint result
|
|
||||||
([issue #109](https://git.eeqj.de/sneak/webhooker/issues/109) tracks
|
|
||||||
routing local linting through it as well).
|
|
||||||
|
|
||||||
#### CI gate honesty
|
#### CI gate honesty
|
||||||
|
|
||||||
@@ -1506,8 +1560,8 @@ the hash of the last commit that touched the build context, so:
|
|||||||
- Any commit that changes code (including a squash merge whose tree
|
- Any commit that changes code (including a squash merge whose tree
|
||||||
matches an already-built branch) gets a new fingerprint, invalidates
|
matches an already-built branch) gets a new fingerprint, invalidates
|
||||||
the `COPY . .` layer of both check stages, and really runs
|
the `COPY . .` layer of both check stages, and really runs
|
||||||
`make fmt-check`, `make lint`, `make test`, and `make build`. A run
|
`make fmt-check`, `golangci-lint`, `make test`, and `make build`. A
|
||||||
that reports success ran them.
|
run that reports success ran them.
|
||||||
- A docs-only commit leaves the fingerprint unchanged — `.dockerignore`
|
- A docs-only commit leaves the fingerprint unchanged — `.dockerignore`
|
||||||
excludes `*.md`, `LICENSE` and `.editorconfig` from the context
|
excludes `*.md`, `LICENSE` and `.editorconfig` from the context
|
||||||
anyway — so the image replays from cache and costs seconds.
|
anyway — so the image replays from cache and costs seconds.
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ func ScrubSentryRequestForTest(
|
|||||||
return scrubSentryRequest(event, hint)
|
return scrubSentryRequest(event, hint)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SentryClientOptionsForTest exposes the exact options enableSentry
|
||||||
|
// initialises the SDK with, so a test can capture events through the
|
||||||
|
// production hook wiring rather than a hand-built equivalent.
|
||||||
|
func SentryClientOptionsForTest(
|
||||||
|
dsn, release string,
|
||||||
|
) sentry.ClientOptions {
|
||||||
|
return sentryClientOptions(dsn, release)
|
||||||
|
}
|
||||||
|
|
||||||
// NewRouterForTest builds the real route tree via SetupRoutes with
|
// NewRouterForTest builds the real route tree via SetupRoutes with
|
||||||
// the supplied middleware and handlers, bypassing the fx lifecycle
|
// the supplied middleware and handlers, bypassing the fx lifecycle
|
||||||
// and the HTTP listener. Tests use it so that route-group middleware
|
// and the HTTP listener. Tests use it so that route-group middleware
|
||||||
|
|||||||
@@ -1,29 +1,51 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import "github.com/getsentry/sentry-go"
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
// sentryRedactedQuery stands in for the query string on every event
|
"github.com/getsentry/sentry-go"
|
||||||
// shipped to Sentry.
|
)
|
||||||
const sentryRedactedQuery = "(redacted)"
|
|
||||||
|
|
||||||
// scrubSentryRequest drops the query string from an event's request
|
// sentryRedacted stands in for a withheld field on every event shipped
|
||||||
// context before it leaves the process.
|
// to Sentry. It is a marker rather than an empty string so a reader
|
||||||
|
// can tell a suppressed value from an absent one.
|
||||||
|
const sentryRedacted = "(redacted)"
|
||||||
|
|
||||||
|
// sentryClientOptions builds the options the SDK is initialised with.
|
||||||
|
// It is its own function so a test can stand up a client wired exactly
|
||||||
|
// as production is, with only the transport swapped.
|
||||||
|
func sentryClientOptions(dsn, release string) sentry.ClientOptions {
|
||||||
|
return sentry.ClientOptions{
|
||||||
|
Dsn: dsn,
|
||||||
|
Release: release,
|
||||||
|
// Both hooks, because the SDK runs one for error events
|
||||||
|
// and the other for transactions.
|
||||||
|
BeforeSend: scrubSentryRequest,
|
||||||
|
BeforeSendTransaction: scrubSentryRequest,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// scrubSentryRequest strips client-supplied content from an event's
|
||||||
|
// request context before it leaves the process.
|
||||||
//
|
//
|
||||||
// sentryhttp attaches the whole *http.Request to the scope, and
|
// sentryhttp attaches the whole *http.Request to the scope
|
||||||
// sentry.NewRequest copies r.URL.RawQuery verbatim into
|
// (sentryhttp.go:113), and Scope.ApplyToEvent fills the event's
|
||||||
// Request.QueryString. That path is independent of the access log: it
|
// Request from it inside prepareEvent, which runs before this hook.
|
||||||
// is populated from the request even though the log line for the same
|
// Two of the fields it fills are copied with no SendDefaultPII guard:
|
||||||
// request records only the route pattern or a redacted query. Any
|
|
||||||
// error or panic captured while serving a request would therefore ship
|
|
||||||
// the query string to a third-party service, and a query string is
|
|
||||||
// client-chosen text that a mistyped or hand-built request can put a
|
|
||||||
// credential into.
|
|
||||||
//
|
//
|
||||||
// The query is not debugging signal here. One route in the service
|
// - QueryString, verbatim from r.URL.RawQuery.
|
||||||
// reads a query parameter at all — `page`, on the authenticated
|
// - Data, the first 10 KiB of the request body, teed off r.Body by
|
||||||
// pagination links in internal/handlers/source_management.go — and
|
// SetRequest and filled precisely because the handlers call
|
||||||
// Request.URL still carries scheme, host and path, which is what
|
// ParseForm.
|
||||||
// identifies the failing route.
|
//
|
||||||
|
// Since every form field in this service is read with PostFormValue,
|
||||||
|
// the body is the only place a credential is submitted: a target's
|
||||||
|
// destination URL, whose path segments are the bearer token, plus the
|
||||||
|
// login password and both password-change fields. None of that may
|
||||||
|
// reach a third-party service.
|
||||||
|
//
|
||||||
|
// This hook is a floor, not a default: the fields it clears stay
|
||||||
|
// cleared even if SendDefaultPII is ever turned on.
|
||||||
func scrubSentryRequest(
|
func scrubSentryRequest(
|
||||||
event *sentry.Event,
|
event *sentry.Event,
|
||||||
_ *sentry.EventHint,
|
_ *sentry.EventHint,
|
||||||
@@ -32,9 +54,64 @@ func scrubSentryRequest(
|
|||||||
return event
|
return event
|
||||||
}
|
}
|
||||||
|
|
||||||
if event.Request.QueryString != "" {
|
req := event.Request
|
||||||
event.Request.QueryString = sentryRedactedQuery
|
|
||||||
|
if req.QueryString != "" {
|
||||||
|
req.QueryString = sentryRedacted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.Data != "" {
|
||||||
|
req.Data = sentryRedacted
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Cookies = ""
|
||||||
|
req.Env = nil
|
||||||
|
req.Headers = keptSentryHeaders(req.Headers)
|
||||||
|
|
||||||
return event
|
return event
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// keptSentryHeaders returns the subset of headers an event may carry
|
||||||
|
// off-host. Dropping by allowlist rather than by blocklist is what
|
||||||
|
// makes an unrecognised header safe: the SDK's own filter removes four
|
||||||
|
// names and passes everything else, so X-Csrf-Token — which
|
||||||
|
// gorilla/csrf accepts in place of the form field — and the shared
|
||||||
|
// secrets senders put on the receiver route (X-Gitlab-Token and the
|
||||||
|
// per-provider signature headers) would otherwise ship verbatim.
|
||||||
|
func keptSentryHeaders(headers map[string]string) map[string]string {
|
||||||
|
if len(headers) == 0 {
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
kept := make(map[string]string, len(headers))
|
||||||
|
|
||||||
|
for name, value := range headers {
|
||||||
|
if sentryKeepsHeader(name) {
|
||||||
|
kept[name] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|
||||||
|
// sentryKeepsHeader reports whether a request header is routing or
|
||||||
|
// content metadata rather than client-chosen payload. Referer is kept
|
||||||
|
// on the reasoning that it is browser-set, that this service emits
|
||||||
|
// only ?page= in its own links, and that Referrer-Policy is set to
|
||||||
|
// strict-origin-when-cross-origin. X-Request-Id ties the event to the
|
||||||
|
// local access log line, which holds the rest of the detail.
|
||||||
|
func sentryKeepsHeader(name string) bool {
|
||||||
|
switch http.CanonicalHeaderKey(name) {
|
||||||
|
case "Accept",
|
||||||
|
"Content-Length",
|
||||||
|
"Content-Type",
|
||||||
|
"Host",
|
||||||
|
"Origin",
|
||||||
|
"Referer",
|
||||||
|
"User-Agent",
|
||||||
|
"X-Request-Id":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,89 +5,211 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/getsentry/sentry-go"
|
"github.com/getsentry/sentry-go"
|
||||||
|
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/webhooker/internal/server"
|
"sneak.berlin/go/webhooker/internal/server"
|
||||||
)
|
)
|
||||||
|
|
||||||
// sentrySecretSegment is the path segment of a Slack incoming-webhook
|
// The three markers below are the credentials a captured event could
|
||||||
// URL — the part that is the bearer credential. No Sentry event may
|
// carry off-host, one per field of sentry.Request that the SDK fills
|
||||||
// carry it.
|
// from the request without a SendDefaultPII guard.
|
||||||
const sentrySecretSegment = "T00000000/B00000000/QQSENTRYSECRETQQ"
|
const (
|
||||||
|
// sentryBodyMarker is submitted as a form value. Since every
|
||||||
|
// handler reads its fields with PostFormValue, the body is the
|
||||||
|
// only place a password or a target URL is ever supplied.
|
||||||
|
sentryBodyMarker = "QQSENTRYBODYMARKERQQ"
|
||||||
|
|
||||||
// sentryEventFor builds the event Sentry would ship for a request
|
// sentryQueryMarker rides the request line.
|
||||||
// carrying the given raw query, using the SDK's own request
|
sentryQueryMarker = "T00000000/B00000000/QQSENTRYQUERYMARKERQQ"
|
||||||
// conversion rather than a hand-built Request, so the test tracks
|
|
||||||
// what the SDK actually collects.
|
// sentryHeaderMarker rides X-Csrf-Token, which gorilla/csrf
|
||||||
func sentryEventFor(t *testing.T, rawQuery string) *sentry.Event {
|
// accepts in place of the form field.
|
||||||
|
sentryHeaderMarker = "QQSENTRYHEADERMARKERQQ"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sentryKeptUserAgent is a non-secret header value planted so the
|
||||||
|
// assertions below cannot pass by the event carrying no headers at
|
||||||
|
// all.
|
||||||
|
const sentryKeptUserAgent = "webhooker-test-agent"
|
||||||
|
|
||||||
|
// captureTransport records events instead of shipping them, so a test
|
||||||
|
// sees exactly the payload the SDK would have put on the wire.
|
||||||
|
type captureTransport struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
events []*sentry.Event
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *captureTransport) Configure(sentry.ClientOptions) {}
|
||||||
|
|
||||||
|
func (c *captureTransport) Flush(time.Duration) bool { return true }
|
||||||
|
|
||||||
|
func (c *captureTransport) SendEvent(event *sentry.Event) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
c.events = append(c.events, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// captureThroughSentryHTTP panics inside a form handler wrapped in the
|
||||||
|
// real sentryhttp middleware and returns the event the SDK produced.
|
||||||
|
//
|
||||||
|
// This is the only construction path on which Request.Data appears:
|
||||||
|
// sentryhttp calls Scope.SetRequest, which tees r.Body into a 10 KiB
|
||||||
|
// buffer, ParseForm drains the tee, and Scope.ApplyToEvent copies the
|
||||||
|
// buffer into the event inside prepareEvent — before BeforeSend runs.
|
||||||
|
// A hand-built sentry.NewRequest never reads the body and so cannot
|
||||||
|
// regress-test any of it.
|
||||||
|
//
|
||||||
|
// scrub selects whether the production BeforeSend hooks are installed,
|
||||||
|
// so the same path shows both what the SDK collects and what survives.
|
||||||
|
func captureThroughSentryHTTP(t *testing.T, scrub bool) *sentry.Event {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
transport := &captureTransport{}
|
||||||
context.Background(),
|
|
||||||
http.MethodPost,
|
opts := server.SentryClientOptionsForTest(
|
||||||
"/source/abc/targets?"+rawQuery,
|
"https://public@sentry.invalid/1", "webhooker-test",
|
||||||
nil,
|
|
||||||
)
|
)
|
||||||
|
opts.Transport = transport
|
||||||
|
|
||||||
event := sentry.NewEvent()
|
if !scrub {
|
||||||
event.Request = sentry.NewRequest(req)
|
opts.BeforeSend = nil
|
||||||
|
opts.BeforeSendTransaction = nil
|
||||||
|
}
|
||||||
|
|
||||||
return event
|
client, err := sentry.NewClient(opts)
|
||||||
}
|
|
||||||
|
|
||||||
// TestSentryScrub_QueryStringIsCollectedUnscrubbed pins the reason the
|
|
||||||
// hook exists: the SDK copies the raw query into the event on its own,
|
|
||||||
// independently of the access log, which records only the route
|
|
||||||
// pattern or a redacted query for the same request.
|
|
||||||
func TestSentryScrub_QueryStringIsCollectedUnscrubbed(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
event := sentryEventFor(
|
|
||||||
t, "url=https://hooks.slack.com/services/"+sentrySecretSegment,
|
|
||||||
)
|
|
||||||
|
|
||||||
require.Contains(
|
|
||||||
t, event.Request.QueryString, sentrySecretSegment,
|
|
||||||
"the SDK is expected to collect the raw query; "+
|
|
||||||
"if it no longer does, the scrub hook's premise changed",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSentryScrub_RedactsQueryString is the regression test: the hook
|
|
||||||
// installed on both BeforeSend and BeforeSendTransaction must leave no
|
|
||||||
// byte of the query in the event that goes off-host.
|
|
||||||
func TestSentryScrub_RedactsQueryString(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
event := sentryEventFor(
|
|
||||||
t, "url=https://hooks.slack.com/services/"+sentrySecretSegment,
|
|
||||||
)
|
|
||||||
|
|
||||||
scrubbed := server.ScrubSentryRequestForTest(event, nil)
|
|
||||||
require.NotNil(t, scrubbed)
|
|
||||||
|
|
||||||
encoded, err := json.Marshal(scrubbed)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.NotContains(t, string(encoded), sentrySecretSegment)
|
handler := sentryhttp.New(sentryhttp.Options{}).Handle(
|
||||||
assert.NotContains(t, string(encoded), "hooks.slack.com")
|
http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||||
|
// This call is what drains the tee and fills the
|
||||||
|
// buffer. Its success is asserted by the unscrubbed
|
||||||
|
// case below, which sees the body in the event.
|
||||||
|
_ = r.ParseForm()
|
||||||
|
|
||||||
|
panic("boom")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.ServeHTTP(
|
||||||
|
httptest.NewRecorder(),
|
||||||
|
sentryLoginRequest(client),
|
||||||
|
)
|
||||||
|
|
||||||
|
require.Len(t, transport.events, 1)
|
||||||
|
|
||||||
|
return transport.events[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// sentryLoginRequest builds the password POST the capture above drives,
|
||||||
|
// with a credential planted in the body, the query and a header.
|
||||||
|
func sentryLoginRequest(client *sentry.Client) *http.Request {
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("username", "admin")
|
||||||
|
form.Set("password", sentryBodyMarker)
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
sentry.SetHubOnContext(
|
||||||
|
context.Background(),
|
||||||
|
sentry.NewHub(client, sentry.NewScope()),
|
||||||
|
),
|
||||||
|
http.MethodPost,
|
||||||
|
"/pages/login?url=https://hooks.slack.com/services/"+
|
||||||
|
sentryQueryMarker,
|
||||||
|
strings.NewReader(form.Encode()),
|
||||||
|
)
|
||||||
|
|
||||||
|
req.Header.Set(
|
||||||
|
"Content-Type", "application/x-www-form-urlencoded",
|
||||||
|
)
|
||||||
|
req.Header.Set("X-Csrf-Token", sentryHeaderMarker)
|
||||||
|
req.Header.Set("User-Agent", sentryKeptUserAgent)
|
||||||
|
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
// marshalEvent encodes an event the way the transport does.
|
||||||
|
func marshalEvent(t *testing.T, event *sentry.Event) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
encoded, err := json.Marshal(event)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return string(encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSentryScrub_SDKCollectsTheRequestUnscrubbed pins the premise the
|
||||||
|
// hook exists for. Without it the SDK ships the whole POST body, the
|
||||||
|
// raw query and the CSRF header, none of which SendDefaultPII=false
|
||||||
|
// suppresses.
|
||||||
|
func TestSentryScrub_SDKCollectsTheRequestUnscrubbed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
event := captureThroughSentryHTTP(t, false)
|
||||||
|
require.NotNil(t, event.Request)
|
||||||
|
|
||||||
|
assert.Contains(
|
||||||
|
t, event.Request.Data, sentryBodyMarker,
|
||||||
|
"the SDK is expected to collect the POST body; if it no "+
|
||||||
|
"longer does, the scrub hook's premise changed",
|
||||||
|
)
|
||||||
|
assert.Contains(t, event.Request.QueryString, sentryQueryMarker)
|
||||||
|
assert.Contains(
|
||||||
|
t, marshalEvent(t, event), sentryHeaderMarker,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSentryScrub_RedactsTheCapturedRequest is the regression test: no
|
||||||
|
// byte of any planted credential may survive into the marshalled event
|
||||||
|
// that leaves the process.
|
||||||
|
func TestSentryScrub_RedactsTheCapturedRequest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
event := captureThroughSentryHTTP(t, true)
|
||||||
|
require.NotNil(t, event.Request)
|
||||||
|
|
||||||
|
encoded := marshalEvent(t, event)
|
||||||
|
|
||||||
|
assert.NotContains(t, encoded, sentryBodyMarker)
|
||||||
|
assert.NotContains(t, encoded, sentryQueryMarker)
|
||||||
|
assert.NotContains(t, encoded, sentryHeaderMarker)
|
||||||
|
assert.NotContains(t, encoded, "hooks.slack.com")
|
||||||
|
|
||||||
|
assert.Equal(t, "(redacted)", event.Request.Data)
|
||||||
|
assert.Equal(t, "(redacted)", event.Request.QueryString)
|
||||||
|
assert.Empty(t, event.Request.Cookies)
|
||||||
|
assert.Empty(t, event.Request.Env)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSentryScrub_KeepsTheRoutingContext checks the hook does not cost
|
// TestSentryScrub_KeepsTheRoutingContext checks the hook does not cost
|
||||||
// the debugging signal: the path still identifies the failing route.
|
// the debugging signal: the route, the method and the metadata headers
|
||||||
|
// still identify what failed.
|
||||||
func TestSentryScrub_KeepsTheRoutingContext(t *testing.T) {
|
func TestSentryScrub_KeepsTheRoutingContext(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
event := sentryEventFor(t, "page=2")
|
event := captureThroughSentryHTTP(t, true)
|
||||||
|
require.NotNil(t, event.Request)
|
||||||
|
|
||||||
scrubbed := server.ScrubSentryRequestForTest(event, nil)
|
assert.Contains(t, event.Request.URL, "/pages/login")
|
||||||
require.NotNil(t, scrubbed)
|
assert.Equal(t, http.MethodPost, event.Request.Method)
|
||||||
|
assert.Equal(
|
||||||
assert.Contains(t, scrubbed.Request.URL, "/source/abc/targets")
|
t,
|
||||||
assert.Equal(t, http.MethodPost, scrubbed.Request.Method)
|
sentryKeptUserAgent,
|
||||||
|
event.Request.Headers["User-Agent"],
|
||||||
|
)
|
||||||
|
assert.Equal(
|
||||||
|
t,
|
||||||
|
"application/x-www-form-urlencoded",
|
||||||
|
event.Request.Headers["Content-Type"],
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSentryScrub_ToleratesEventsWithoutARequest covers the events the
|
// TestSentryScrub_ToleratesEventsWithoutARequest covers the events the
|
||||||
@@ -95,9 +217,9 @@ func TestSentryScrub_KeepsTheRoutingContext(t *testing.T) {
|
|||||||
func TestSentryScrub_ToleratesEventsWithoutARequest(t *testing.T) {
|
func TestSentryScrub_ToleratesEventsWithoutARequest(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
event := sentry.NewEvent()
|
scrubbed := server.ScrubSentryRequestForTest(
|
||||||
|
sentry.NewEvent(), nil,
|
||||||
scrubbed := server.ScrubSentryRequestForTest(event, nil)
|
)
|
||||||
|
|
||||||
require.NotNil(t, scrubbed)
|
require.NotNil(t, scrubbed)
|
||||||
assert.Nil(t, scrubbed.Request)
|
assert.Nil(t, scrubbed.Request)
|
||||||
|
|||||||
@@ -141,18 +141,14 @@ func (s *Server) enableSentry() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := sentry.Init(sentry.ClientOptions{
|
err := sentry.Init(sentryClientOptions(
|
||||||
Dsn: s.params.Config.SentryDSN,
|
s.params.Config.SentryDSN,
|
||||||
Release: fmt.Sprintf(
|
fmt.Sprintf(
|
||||||
"%s-%s",
|
"%s-%s",
|
||||||
s.params.Globals.Appname,
|
s.params.Globals.Appname,
|
||||||
s.params.Globals.Version,
|
s.params.Globals.Version,
|
||||||
),
|
),
|
||||||
// Both hooks, because the SDK runs one for error events
|
))
|
||||||
// and the other for transactions.
|
|
||||||
BeforeSend: scrubSentryRequest,
|
|
||||||
BeforeSendTransaction: scrubSentryRequest,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error("sentry init failure", "error", err)
|
s.log.Error("sentry init failure", "error", err)
|
||||||
// Don't use fatal since we still want the service to run
|
// Don't use fatal since we still want the service to run
|
||||||
|
|||||||
@@ -3,20 +3,14 @@
|
|||||||
# this repo. Idempotent: every install is guarded by a check so already
|
# this repo. Idempotent: every install is guarded by a check so already
|
||||||
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
||||||
# or apk (detected in that order); assumes NOTHING is present (not git,
|
# or apk (detected in that order); assumes NOTHING is present (not git,
|
||||||
# make, or go). golangci-lint is packaged in nix, brew, and apk; on apt
|
# make, or go). golangci-lint is deliberately not installed: linting runs
|
||||||
# it is installed from a hash-verified GitHub release archive (never
|
# only in docker, via script/lint and Dockerfile.lint. Finishes by running
|
||||||
# curl | sh). Finishes by running script/fetch-assets, which installs the
|
# script/fetch-assets, which installs the hash-pinned third-party browser
|
||||||
# hash-pinned third-party browser assets the repo does not commit.
|
# assets the repo does not commit.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
# Pinned versions, 2026-08-07. Never "latest"; exact versions only.
|
|
||||||
GOLANGCI_LINT_VERSION="2.12.2"
|
|
||||||
# sha256 of golangci-lint-2.12.2-linux-<arch>.tar.gz release archives
|
|
||||||
GOLANGCI_LINT_SHA256_AMD64="8df580d2670fed8fa984aac0507099af8df275e665215f5c7a2ae3943893a553"
|
|
||||||
GOLANGCI_LINT_SHA256_ARM64="44cd40a8c76c86755375adfeea52cfd3533cb43d7bd647771e0ae065e166df3a"
|
|
||||||
|
|
||||||
PKGMGR=""
|
PKGMGR=""
|
||||||
SUDO=""
|
SUDO=""
|
||||||
|
|
||||||
@@ -57,52 +51,6 @@ missing() {
|
|||||||
! command -v "$1" >/dev/null 2>&1
|
! command -v "$1" >/dev/null 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
# verify_sha256 <file> <expected-hash>
|
|
||||||
verify_sha256() {
|
|
||||||
if command -v sha256sum >/dev/null 2>&1; then
|
|
||||||
actual="$(sha256sum "$1" | cut -d' ' -f1)"
|
|
||||||
else
|
|
||||||
actual="$(shasum -a 256 "$1" | cut -d' ' -f1)"
|
|
||||||
fi
|
|
||||||
if [ "$actual" != "$2" ]; then
|
|
||||||
echo "bootstrap: sha256 mismatch for $1" >&2
|
|
||||||
echo " expected: $2" >&2
|
|
||||||
echo " actual: $actual" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# apt has no golangci-lint package: install a pinned release archive
|
|
||||||
# from GitHub, verified by hardcoded sha256 (never curl | sh).
|
|
||||||
install_golangci_lint_release() {
|
|
||||||
case "$(uname -m)" in
|
|
||||||
x86_64) goarch="amd64"; sha="$GOLANGCI_LINT_SHA256_AMD64" ;;
|
|
||||||
aarch64|arm64) goarch="arm64"; sha="$GOLANGCI_LINT_SHA256_ARM64" ;;
|
|
||||||
*)
|
|
||||||
echo "bootstrap: unsupported architecture $(uname -m)" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
if missing curl; then pkg_install curl curl curl curl; fi
|
|
||||||
name="golangci-lint-${GOLANGCI_LINT_VERSION}-linux-${goarch}"
|
|
||||||
tmp="$(mktemp -d)"
|
|
||||||
curl -fsSL -o "$tmp/$name.tar.gz" \
|
|
||||||
"https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCI_LINT_VERSION}/${name}.tar.gz"
|
|
||||||
verify_sha256 "$tmp/$name.tar.gz" "$sha"
|
|
||||||
tar -xzf "$tmp/$name.tar.gz" -C "$tmp"
|
|
||||||
$SUDO install -m 0755 "$tmp/$name/golangci-lint" /usr/local/bin/golangci-lint
|
|
||||||
rm -rf "$tmp"
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_golangci_lint() {
|
|
||||||
if ! missing golangci-lint; then return 0; fi
|
|
||||||
detect_pkgmgr
|
|
||||||
case "$PKGMGR" in
|
|
||||||
apt) install_golangci_lint_release ;;
|
|
||||||
*) pkg_install golangci-lint golangci-lint golangci-lint golangci-lint ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
|
||||||
@@ -110,9 +58,14 @@ main() {
|
|||||||
if missing git; then pkg_install git git git git; fi
|
if missing git; then pkg_install git git git git; fi
|
||||||
if missing make; then pkg_install gnumake make make make; fi
|
if missing make; then pkg_install gnumake make make make; fi
|
||||||
|
|
||||||
# Go toolchain and linter
|
# Go toolchain
|
||||||
if missing go; then pkg_install go golang go go; fi
|
if missing go; then pkg_install go golang go go; fi
|
||||||
ensure_golangci_lint
|
|
||||||
|
# Not installed here: docker is platform-specific and out of scope for a
|
||||||
|
# package-manager bootstrap, but script/lint needs it.
|
||||||
|
if missing docker; then
|
||||||
|
echo "bootstrap: docker not found; script/lint requires it" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
go mod download
|
go mod download
|
||||||
|
|
||||||
|
|||||||
47
script/lint
47
script/lint
@@ -1,12 +1,55 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/lint: run the linter.
|
# script/lint: run the linter. golangci-lint is never installed locally: it
|
||||||
|
# runs via docker only, one way, everywhere — script/lint builds
|
||||||
|
# Dockerfile.lint, which COPYs the repo into the pinned golangci-lint image
|
||||||
|
# and lints as a build step. This works even when the docker daemon is remote
|
||||||
|
# and bind mounts are impossible, and it removes the host linter's shared
|
||||||
|
# cache, which has attributed other checkouts' findings to this one.
|
||||||
|
#
|
||||||
|
# --no-cache-filter=lint forces the lint stage to re-execute on every run; a
|
||||||
|
# cached lint stage exits 0 in under a second having linted nothing. The deps
|
||||||
|
# stage keeps its cache, so module downloads are not repeated.
|
||||||
|
# --progress=plain keeps the linter's own output visible on success, so a
|
||||||
|
# passing run shows the issue count rather than nothing.
|
||||||
|
# --output=type=cacheonly leaves no image behind to clean up.
|
||||||
|
#
|
||||||
|
# docker silently ignores --no-cache-filter for a stage name that does not
|
||||||
|
# match, so a rename or a typo would restore the cached false green with no
|
||||||
|
# warning and a fast exit 0. The flag is therefore not trusted: the build
|
||||||
|
# output is teed to a log and a run is only a pass if golangci-lint's own
|
||||||
|
# summary line ("N issues." / "N issues:") is in it. No summary, no lint,
|
||||||
|
# whatever the exit code says.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
golangci-lint run --config .golangci.yml ./...
|
|
||||||
|
log="$(mktemp -t webhooker-lint.XXXXXXXX)"
|
||||||
|
rcfile="$(mktemp -t webhooker-lint-rc.XXXXXXXX)"
|
||||||
|
trap 'rm -f "$log" "$rcfile"' EXIT INT TERM
|
||||||
|
|
||||||
|
# The pipeline's status is tee's, and POSIX sh has no pipefail, so the
|
||||||
|
# build's status travels via a file. Output still streams live.
|
||||||
|
{
|
||||||
|
docker build \
|
||||||
|
-f Dockerfile.lint \
|
||||||
|
--no-cache-filter=lint \
|
||||||
|
--progress=plain \
|
||||||
|
--output=type=cacheonly \
|
||||||
|
. 2>&1 && echo 0 >"$rcfile" || echo $? >"$rcfile"
|
||||||
|
} | tee "$log" >&2
|
||||||
|
|
||||||
|
rc="$(cat "$rcfile")"
|
||||||
|
[ "$rc" -eq 0 ] || exit "$rc"
|
||||||
|
|
||||||
|
if ! grep -qE '[0-9]+ issues[.:]' "$log"; then
|
||||||
|
echo "script/lint: golangci-lint printed no summary line; the linter" >&2
|
||||||
|
echo " did not run. Check that the stage named in --no-cache-filter" >&2
|
||||||
|
echo " still matches a stage in Dockerfile.lint." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
Reference in New Issue
Block a user