Conversation
Build rekor-watch on top of rekor-monitor: a long-running daemon that
polls the Rekor transparency log, matches new entries against per-user
subscriptions, and delivers notifications, fronted by a web dashboard.
Core watcher (cmd/rekor_watch):
- Monitor loop that polls Rekor, stores checkpoints, and persists matches.
- Track Rekor shard rollovers and close boundary gaps so entries are not
silently dropped when the active shard changes.
- Persist failed entries for later inspection.
Persistence (pkg/store, pkg/store/sqlite):
- SQLite-backed store for users, subscriptions, matches, and checkpoints,
with golang-migrate migrations (each up paired with a down) covering
cascade deletes, webhook-failure tracking, notification type, indexes,
failed entries, and subscription names.
- Foreign keys and pragmas configured via the DSN.
Subscriptions and matching:
- Users and subscriptions with full CRUD REST APIs.
- Required, per-user subscription names surfaced on matches/notifications.
- Fulcio OID names for creating/editing subscriptions and tightened OID
validation; per-user subscription cap and per-subscription match cap.
Notifications (cmd/rekor_watch/notifications, pkg/email):
- Webhook and email backends; dispatch by notification type.
- Batched webhook delivery and email digests per subscription per cycle.
- Webhook payloads wrapped in a {type, timestamp, data} envelope.
- Exponential-backoff retries, auto-disable, and persistent retry
scheduling for failing webhooks.
- SMTP configuration with XOAUTH2 support; SMTP password kept out of help.
Web UI and HTTP server (cmd/rekor_watch/web):
- Dashboard with subscription management, channel selector, and match views.
- Magic-link auth with login UI and hardened token handling.
- Per-user rate limiting for login/auth, reusable rate-limit middleware,
and SSRF prevention (safe dialer) around webhook destinations.
Tooling and ops:
- Dockerfile.watch and env-file-driven Docker Compose profile.
- Init and DB-seeding scripts, .env.example, and updated README/.gitignore.
Signed-off-by: Riccardo Schirone <riccardo.schirone@trailofbits.com>
mainLoopV2 had grown to ten parameters and threaded the same (store, searchOpts, maxMatches) bundle through a set of free functions on every cycle. Restructure the v2 main loop into collaborator types so the loop body becomes pure composition. - Promote the monitoring iteration (runMonitoringIterationV2, catchUpShard, decideSearchRange and the subscription helpers) to a monitor type that holds its stable dependencies once and exposes a runOnce method satisfying IterationFunc. The monitoring domain moves to a new monitor.go, leaving main.go to wiring. catchUpShard becomes a method so the store, shard set, search options and per-subscription match cap are reached through the receiver. - Promote sendNotifications to a notifier type built once with its stable dependencies (store, webhook sender, email sender, rate limiter). The webhook sender is constructed a single time in newNotifier rather than rebuilt every cycle, and runOnce takes now as a parameter so tests drive retry/backoff windows deterministically. - Drop the intermediate mainLoopV2Config bag and build the monitor and notifier directly at the composition root, handing their runOnce methods to monitorLoop. No behavior change. Signed-off-by: Riccardo Schirone <riccardo.schirone@trailofbits.com>
Add the HKDF-based per-subscription webhook signing-secret deriver, plus the Standard Webhooks library we sign with (no dispatch wiring yet): - WebhookSecretDeriver derives a secret on demand from a master key via HKDF-SHA256 over (subID, version). Nothing is stored; a regenerate is just a version bump. The master key loads from a 0600 file (>= 32 bytes, base64) and fails closed. - Secrets use the Standard Webhooks whsec_<base64(24B)> format. Signing is done by the official standard-webhooks Go library (pure stdlib); an interop test pins our usage against the spec's canonical v1 test vector so reference verifiers stay compatible. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Persist a per-subscription signing-secret version counter (migration 000009, default 1, no backfill) that on-demand secret derivation keys on. The secret itself is never stored. - Subscription gains WebhookSecretVersion (json:"-", internal bookkeeping). SaveSubscription reflects the persisted version back onto the struct via INSERT ... RETURNING, so a caller deriving the reveal-once secret right after create uses the same version dispatch signs with. - RegenerateWebhookSecret bumps the counter scoped to the owning user and returns the new version (ErrNotFound for missing/not-owner). - GetSubscription(id, userID) returns one owner-scoped subscription (ErrNotFound otherwise), matching the other scoped store methods. - All subscription SELECTs, scanSubscriptionRows, and the ListPendingMatches join carry the new column so dispatch can derive the current secret. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire the secret deriver into the web server and expose the per-subscription
signing secret to its owner, reveal-once:
- ServerConfig/Server gain a SecretDeriver; main.go loads it from
REKOR_WATCH_WEBHOOK_SECRET_KEY_FILE and fails closed (the key is mandatory
so deliveries are never sent unsigned, and the deriver is always present).
- Creating a webhook subscription returns the derived secret once in the
response; email subscriptions omit it.
- POST /api/subscriptions/{id}/regenerate-secret bumps the version (hard
cutover) and returns the new secret reveal-once, rejecting email subs (400)
and non-owners (404). Ownership/type are checked via store.GetSubscription.
- Dashboard shows the secret once with a copy/dismiss control and adds a
per-webhook "Regenerate secret" button.
Signing is not yet applied to deliveries; that follows in the next change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Use a shared typed secretResponse for both the create and regenerate endpoints instead of an ad-hoc map, document why handleRegenerateSecret makes two store calls (400-vs-404 before mutating), and trim over-verbose comments (migration prose, struct field, SaveSubscription RETURNING). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
These were string-presence greps over the embedded HTML/JS, not behavior tests: they pass even if the JS is broken and break on harmless renames or copy tweaks. They also don't cross-check the JS route against the route constant or the element id against the template, so they don't guard the one gap they gesture at. Server-side behavior is covered by webhook_secret_handlers_test.go. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- rejectsEmailSubscription: assert exactly 400 instead of "400 or 409"; the handler returns 400 deterministically, so the looser check could mask a status-code regression. - rejectsNonOwner: drop the captured-then-discarded owner var (only the session string is used). - returnsNewSecret: pin the regenerated secret to the version-2 derivation, mirroring the create test's version-1 check, so a wrong-version bump can't slip past the "differs from create" assertion. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The derived signing secret now incorporates the webhook URL in the HKDF info (Secret(subID, version, webhookURL)), so each secret is bound to the destination it was issued for. Changing a subscription's webhook URL bumps the signing-secret version in the same UPDATE statement (atomic with the URL change, so dispatch never pairs the new URL with the old version) and the update endpoint reveals the freshly rotated secret reveal-once, exactly like create/regenerate. Updates that don't change the URL neither bump the version nor reveal a secret. Tests: deriver differs across URLs; store bumps version on URL change only; update handler reveals the version-2/new-URL secret on URL change and omits it on a name-only change. Existing create/regenerate derivation assertions updated for the new signature. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The route constant name ends in 'Secret' and is assigned a string literal, which gosec G101 flags as a hardcoded credential. It is an HTTP route path; suppress with the same nolint directive main.go already uses for env-var name constants. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop the webhook URL from the HKDF derivation: Secret(subID, version). The URL is not something a receiver verifies, so binding the secret to it added no verifiable property — only internal defense-in-depth already covered by the atomic version bump. The version is now the single rotation counter, bumped on an explicit regenerate and (atomically) whenever the URL changes, so a URL change still rotates the secret and reveals it once. Behavior is unchanged; the derivation just has one fewer (user-controlled, normalization- sensitive) input. Reverts only the derivation part of the earlier URL-binding change; the version-bump-on-URL-change and reveal-on-update logic stays. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Leave cmd/rekor_watch/notifications/webhook_secret.go untouched by this PR; the version-rotation context lives in the migration and store/interface docs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
handleRegenerateSecret and handleUpdateSubscription each did a GetSubscription pre-read before the mutating store call: regenerate to return 400 (email sub) vs 404 (missing/not-owner), update to detect a webhook URL change and reveal the rotated secret. Fold both decisions into the mutating call so each handler makes a single store call, which also closes the read-then-mutate window. - RegenerateWebhookSecret bumps the version only for webhook rows (CASE) and RETURNs the type, yielding ErrNotFound for missing/not-owner and the new ErrNotWebhook sentinel for an owned non-webhook subscription. - UpdateSubscription now returns secretRotated; it reads the prior URL and updates within one transaction so rotation detection and the version bump observe a consistent snapshot. The handler reveals the secret iff secretRotated. Tests: add store-level ErrNotWebhook coverage and assert the secretRotated result on URL-change/no-change updates; adapt existing UpdateSubscription call sites to the new signature. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
UpdateSubscription reads the row then writes it, so a racing update or secret regeneration in between could make it write a version bump derived from stale data. Instead of a transaction, gate the write on the secret version read up front: the UPDATE carries WHERE ... AND webhook_secret_version = <read value>, so a racing bump makes it match no row and the caller gets the new ErrConcurrentModification (mapped to 409) rather than a stale write. Each statement is its own autocommit, so there is no transaction and no held-snapshot race. Add a concurrent-update regression test (final version == 1 + commits) and trim the over-verbose comments from the earlier commit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
REKOR_WATCH_WEBHOOK_SECRET_KEY_FILE is required (the watcher refuses to start without it) but was absent from the README env table and .env.example. Add a "Signing secret" section with the key-generation command (openssl rand -base64 32), an env-table row, and an .env.example entry. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
facutuesca
reviewed
Jun 29, 2026
Following the docker-compose instructions failed to start because the mandatory webhook signing-secret master key file was never made available inside the container. Bind-mount it read-only at a fixed in-container path (host path from REKOR_WATCH_WEBHOOK_SECRET_KEY_FILE, default ./webhook_secret.key), pin the container env to that path, and document the host-vs-container distinction in the README and .env.example. The quick start now generates the key before `up`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
facutuesca
approved these changes
Jun 29, 2026
The deploy image runs as distroless nonroot (UID 65532), whose UID differs from the host user that creates webhook_secret.key. A read-only bind-mount preserves host ownership and mode, so a chmod 600 file owned by the host user is unreadable inside the container and the service fails to start on os.ReadFile (EACCES) — the same symptom as a missing file. Use chmod 644 in the Docker quick start and explain the requirement (and the production alternative of matching ownership) in the Signing secret section. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Keep the full explanation in the Signing secret section; the quick start now just points there. Also gitignore webhook_secret.key so the generated local master key is never committed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ret2libc
force-pushed
the
signed-webhooks-3-server
branch
from
June 29, 2026 15:50
df17653 to
e397647
Compare
644 isn't required for security — the nonroot container user (65532) just needs read access. Spell out the chmod 640 + chgrp 65532 alternative that keeps the key out of world-read while preserving host-user ownership. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop the nonroot-UID explanation and the 640/chgrp alternative; use chmod 644 consistently across the quick start and the Signing secret section. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Updating a subscription (including changing its webhook URL) no longer bumps webhook_secret_version; the signing secret rotates only on an explicit Regenerate secret. Decoupling the secret from updates removes the reason updateSubscription needed a read-modify-write: it no longer has to compute "did the URL change" to reveal a rotated secret. That comparison was the source of a TOCTOU race — it was evaluated at SELECT time while the version bump happened at UPDATE time, so a concurrent writer in between could rotate the secret without revealing it (or reveal a stale one). updateSubscription is now a single, race-free UPDATE with no optimistic-concurrency guard, and ErrConcurrentModification is gone. The update handler no longer reveals a secret, and the docs/UI note that editing a subscription does not rotate its secret. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GetSubscription had no production callers (there is no single-subscription GET endpoint) — only tests used it. Drop it from the SubscriptionStore interface and the Store/Tx implementations, and replace the test assertions with a small direct-read helper. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A missing host ./webhook_secret.key made Docker silently create a directory at the bind-mount source, which the app then rejected at runtime with "failed to read webhook master key file: ... is a directory" — confusing and only visible in container logs. Switch to the long-form bind mount with bind.create_host_path:false so a missing key file fails fast at `docker compose up` with a clear "bind source path does not exist", and no stray directory is created. Behaviour with the file present is unchanged (read-only, SELinux relabel preserved). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dump the full service list (URL + MajorAPIVersion) alongside the count so a URL that matches no service — and thus falls back to the unimplemented v1 — is diagnosable from the logs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add protojson-serialized dev.sigstore.monitor.v1.MonitorConfig files that stand in for the TUF targets not yet published by root-signing. Only rekorLogs is populated. Entries are derived from each environment's trusted_root.json, with every logOrigin confirmed against the log's live checkpoint. Monitors need this because the trusted root and signing config can no longer be joined on URL. In staging the v2 signing URL global.rekor.sigstage.dev is write-only (501 on reads), while reads and the checkpoint origin live at log2026-1.us-east4.rekor.sigstage.dev. Signed-off-by: Riccardo Schirone <riccardo.schirone@trailofbits.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Use --monitor-config / REKOR_WATCH_MONITOR_CONFIG to discover current and retired Rekor v2 shards by read URL, checkpoint origin, and API version. Resolve shard validity against the trusted root and refresh targets on each iteration, retaining existing clients if refresh fails. This restores staging monitoring when the signing config advertises a write-only endpoint. Remove the redundant URL selector and ship the monitor configs in the watch image, defaulting to staging. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Riccardo Schirone <riccardo.schirone@trailofbits.com>
ret2libc
added this pull request to stack #39
September 14, 2026 14:29
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds reveal-once signing secrets to webhook subscriptions, derived from the required master-key file and the subscription ID/version. Secrets are shown on creation and explicit regeneration; editing a subscription or its URL preserves the secret. Regeneration increments the stored version, and replacing the master key invalidates all derived secrets.
Includes the version migration, authenticated regeneration endpoint, dashboard controls, startup validation, and Docker Compose key-file mounting and setup instructions. Updated against current
main, preserving monitor-config shard discovery and the notifier refactor. Signed dispatch is added separately in #8.Validation:
go test ./...,go test -race ./..., and golangci-lint v2.6.2. Fixed the session-cookie duration naming lint finding. The existing CodeQL configuration is unchanged: its custom analysis fails because GitHub default setup is enabled and rejects duplicate advanced-configuration uploads.Fresh GitHub CI passed unit tests, race tests, end-to-end tests, lint, license checks, dependency review, and default CodeQL scanning. The duplicate custom CodeQL upload remains failing with the default/advanced setup conflict; configuration was left unchanged as requested.