Policy Gateway — Registry Webhook Operator Runbook
Sprint reference. SPRINT_20260430_001_Policy_registry_webhook_signature_validation closes audit finding A1 (
docs-archive/qa/audits/microservice-audit-pass2-2026-04-29.md§A1). The Policy Gateway endpoints under/api/v1/webhooks/registry/{docker,harbor,generic}previously sat behindMapGroup(...).AllowAnonymous()with no payload authentication. They are now HMAC-only; unsigned requests are rejected with HTTP 401. There is no bearer/cookie auth on these routes — the per-registry shared secret is the sender proof.
1. Why HMAC-only
A Docker auth-service JWT proves only that the caller has pull access to a public registry. Anyone with that pull access can mint such a token, so it fails to authenticate the sender of a webhook. The only accepted scheme is HMAC-SHA256 with a per-registry shared secret resolved through the Stella secret-resolver (env var → Vault / SOPS / sealed-secret volume).
HMAC computation routes through the regional crypto plug-in interface (StellaOps.Cryptography.IHmacAlgorithm), so FIPS / GOST / SM / eIDAS deployments substitute their compliant implementation without source edits to the gateway.
2. Operator quickstart (compose)
Three env vars in your .env (template at devops/compose/env/stellaops.env.example):
POLICY_REGISTRY_WEBHOOK_DOCKER_SECRET=<32+ bytes of base64 or random text>
POLICY_REGISTRY_WEBHOOK_HARBOR_SECRET=<32+ bytes of base64 or random text>
POLICY_REGISTRY_WEBHOOK_GENERIC_SECRET=<32+ bytes of base64 or random text>
Then enable the per-registry validators in etc/policy-gateway.yaml:
Policy:
Gateway:
RegistryWebhooks:
AllowUnsigned: false # production MUST be false
Docker:
Enabled: true
SharedSecret:
EnvVar: POLICY_REGISTRY_WEBHOOK_DOCKER_SECRET
Harbor:
Enabled: true
SharedSecret:
EnvVar: POLICY_REGISTRY_WEBHOOK_HARBOR_SECRET
Generic:
Enabled: true
SharedSecret:
EnvVar: POLICY_REGISTRY_WEBHOOK_GENERIC_SECRET
AllowedKeyIds: # optional; empty list means "any key id"
- ci-prod-1
- ci-stage-1
Restart the Policy Gateway. RegistryWebhookProductionGuard fails the host at startup if AllowUnsigned=true while ASPNETCORE_ENVIRONMENT=Production, and emits a LogLevel.Critical startup line in non-Production hosts so the toggle is auditable.
3. Sender-side recipes
3.1 Docker Registry v2
Docker Registry’s notification system can send arbitrary headers. Configure config.yml (or the equivalent for your distribution) to ship the X-StellaOps-Signature header pre-computed by an HMAC-aware proxy:
notifications:
endpoints:
- name: stellaops-policy
url: https://policy-gateway.stella-ops.local/api/v1/webhooks/registry/docker
headers:
# The proxy that fronts the registry pre-computes
# sha256=<hex(HMAC_SHA256(SECRET, body))> over the JSON body and
# sets this header. The Stella validator also accepts the legacy
# Authorization: Basic <user>:<hex> form for distributions that
# cannot inject custom headers.
X-StellaOps-Signature: ["sha256=<computed-by-proxy>"]
timeout: 5s
threshold: 5
backoff: 10s
ignore:
actions: ["pull"]
If your registry deployment cannot intercept and sign the body before forwarding, run a small sidecar that mints the HMAC and forwards. The validator does not accept Docker auth-service JWTs.
3.2 Harbor
Harbor’s webhook policy supports an Auth Header field. Set it to the literal string sha256=<hex> and configure your sender wrapper to recompute the HMAC on each request. If your Harbor version does not let you set a custom header name, run a sidecar proxy that inspects the outgoing webhook, computes the HMAC over the body, and injects the X-Harbor-Signature header expected by the gateway:
X-Harbor-Signature: sha256=<hex(HMAC_SHA256(POLICY_REGISTRY_WEBHOOK_HARBOR_SECRET, body))>
Harbor’s payload schema (PUSH_ARTIFACT / pushImage) is recognised verbatim; non-push event types are silently acknowledged with 0 queued jobs (no signature failure).
3.3 Generic (anything that can curl)
The generic endpoint accepts any caller that can compute an HMAC. Example shell sender:
SECRET=$(cat /run/secrets/policy-webhook-generic)
BODY='{"imageDigest":"sha256:1234","repository":"library/nginx","tag":"latest"}'
HMAC=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')
curl -sS https://policy-gateway.stella-ops.local/api/v1/webhooks/registry/generic \
-H "Content-Type: application/json" \
-H "X-StellaOps-Signature: sha256=$HMAC" \
-H "X-StellaOps-Key-Id: ci-prod-1" \
--data-binary "$BODY"
X-StellaOps-Key-Id is optional. When the validator is configured with a non-empty AllowedKeyIds list, the header is required and must match one of the configured ids; missing or unknown ids are rejected with reasons missing_key_id / unknown_key_id.
4. Failure-mode reference
The signature filter returns HTTP 401 with a problem+json body whose detail field carries one of the stable reason tokens below. Reasons never include secret material.
| Reason | Meaning |
|---|---|
validator_disabled | The kind’s Enabled flag is false; flip it to true after wiring the secret. |
secret_not_configured | Enabled=true but the resolved secret is empty (env var unset). |
missing_signature_header | Caller did not send the expected header. |
signature_scheme_unsupported | Header present but not the sha256=... form. |
signature_malformed | Header present, scheme correct, hex blob malformed. |
signature_invalid | Hex parses correctly but does not match the body MAC. |
bearer_not_supported | Caller sent Authorization: Bearer ... (Docker JWT path is rejected). |
unknown_key_id | X-StellaOps-Key-Id did not match any entry in AllowedKeyIds. |
missing_key_id | AllowedKeyIds is non-empty but the request did not carry the header. |
5. Migration path for existing deployments
Existing customers whose registries are not yet HMAC-signing will see 401s on upgrade. The migration sequence is:
- Before the upgrade. Generate a 32-byte random secret per registry, export it via your secret system, set
POLICY_REGISTRY_WEBHOOK_*_SECRETin the gateway’s environment. - At the upgrade. Set
Policy:Gateway:RegistryWebhooks:AllowUnsigned: true. The host will start with aCriticalstartup log and accept unsigned requests with aWarningper request. This is forbidden in production; the production guard will refuse to start otherwise. - Within one release cycle. Wire each registry’s sender to send the correct header (recipes in §3 above). Verify the
Warninglog line goes silent — every accepted request is now signed. - Final state. Set
AllowUnsigned: false(or remove the line) and restart. The gateway is now fail-closed.
6. Bypass auditability
Every webhook decision (accepted / rejected) is logged with the kind (docker/harbor/generic), the keyId (when present), and the rejection reason (when applicable). Plug a downstream Loki/ELK alert on Reason=signature_invalid to detect tampering or misconfigured senders.
7. Cross-references
- Module dossier (gateway side):
docs/modules/policy/architecture.md§“Webhook Integration” → “Webhook signature contract”. - Sprint:
docs-archive/implplan/SPRINT_20260430_001_Policy_registry_webhook_signature_validation.md. - Cryptography binding:
docs/modules/cryptography/architecture.md— the gateway depends onIHmacAlgorithmfrom the legacysrc/__Libraries/StellaOps.Cryptographyassembly (collision resolved by SPRINT_20260430_017).
