Parcourir la source

feat(M14): security hardening

- CSP, HSTS (prod), X-Content-Type-Options, X-Frame-Options, Referrer-Policy
  on both Caddyfiles
- local admin brute-force lockout (5/10/15 → 60/300/1800 s, by user+ip),
  replacing M08's session-scoped throttle
- Monolog SecretScrubbingProcessor on api + ui (Bearer tokens, password
  hashes, *_secret keys)
- token entropy regression test
- expired manual block read-time filter + daily CleanupExpiredManualBlocksJob
  with audit-per-delete
- schema secrets-at-rest scan
- composer audit + npm audit (high+critical) in scripts/ci.sh
- doc/security.md describing as-built posture
- Disaster Recovery section in doc/architecture.md; expanded backup/restore
  guidance in README

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa il y a 1 semaine
Parent
commit
63d5a8d4e9

+ 108 - 0
PROGRESS.md

@@ -551,3 +551,111 @@ stack.
 **Added dependencies:** none.
 
 **Added env vars:** none.
+
+## M14 — Hardening (done)
+
+**Built:** security headers on both containers (CSP / HSTS-prod /
+Frame / Referrer / Permissions); `LoginThrottle` per-process
+brute-force lockout (5/10/15 → 60/300/1800 s, keyed by user+ip);
+Monolog `SecretScrubbingProcessor` on both containers; token entropy
+regression test (1000 distinct, format check, CSPRNG static-source
+check); expired-manual-block read-time filter on every list/lookup
+plus daily `CleanupExpiredManualBlocksJob` with one audit row per
+prune; schema-secrets-at-rest scan; composer/npm audit gates in CI;
+`doc/security.md` (≤300 lines) describing the as-built posture;
+backup / Disaster Recovery sections refreshed across README and
+`doc/architecture.md`.
+
+**Production checklist (run before exposing to internet):**
+- APP_ENV=production
+- Real OIDC tenant configured
+- Strong LOCAL_ADMIN_PASSWORD_HASH or LOCAL_ADMIN_ENABLED=false
+- Reverse proxy with TLS in front
+- Backups configured
+- composer audit / npm audit clean
+- Logs piped to your aggregator
+- MAXMIND_LICENSE_KEY (or IPINFO_TOKEN) set if you want non-DB-IP
+  enrichment
+- Scheduler running (host cron / systemd / sidecar)
+
+**Notes for next operator:**
+- HSTS gating is `expression {env.APP_ENV} == "production"` in both
+  Caddyfiles. Don't flip on for localhost — sticky for one year.
+- Alpine.js v3 needs `'unsafe-eval'` in the UI's CSP (uses `Function()`).
+  Migrating to `@alpinejs/csp` would let us drop it but requires
+  rewriting every inline `x-data="..."`. Trade-off documented in
+  `ui/docker/Caddyfile`.
+- `LoginThrottle` is per-process: a UI restart clears every bucket.
+  This IS the documented "unlock the admin" path — there is
+  intentionally no API for clearing a lock.
+- Schema-secrets test uses `sqlite_master` + `PRAGMA table_info`
+  rather than DBAL's `listTables`, because DBAL's SQLite parser
+  trips on the raw `CREATE TABLE … CHECK (…)` of `api_tokens`.
+- `ManualBlockRepository::__construct` now takes an optional
+  `Clock` — autowired in production, optional in tests. Read-time
+  filter relies on it. Existing test code that constructs the
+  repo directly with `new ManualBlockRepository($conn)` keeps
+  working (clock parameter defaults to null → system time).
+- `CleanupExpiredManualBlocksJob` is registered in the
+  `JobRegistry` between `cleanup-audit` and `enrich-pending`, so
+  the `/internal/jobs/tick` dispatcher picks it up automatically
+  whenever its 86400-second interval has elapsed.
+- The `INTERNAL_JOB_TOKEN`-bearer route added is
+  `POST /internal/jobs/cleanup-expired-manual-blocks`. Status row
+  appears in `/internal/jobs/status` and the admin Settings page.
+- `composer audit --no-dev` and `npm audit --omit=dev
+  --audit-level=high` both clean against current production
+  deps as of this milestone.
+
+**Known limitations:**
+- In-process rate limiter and lockout state are per-replica.
+- Audit log is append-only at the application layer but not
+  tamper-evident (no signing/chain).
+- No 2FA on the local admin; OIDC + Entra MFA is the recommended
+  path.
+- Encryption at rest of the SQLite file is delegated to host disk
+  encryption; no application-level encryption.
+
+**Test surface added (api):**
+- Unit: `TokenEntropyTest` (4 tests covering distinctness, format,
+  static random_bytes-source check, 20-byte sizing),
+  `SecretScrubbingProcessorTest` (7 tests covering Bearer
+  scrubbing, key-based redaction, formatted-output round-trip,
+  argon2 substring, nested context).
+- Integration: `SchemaSecretsAtRestTest` (2 tests: column-name
+  scan + api_tokens shape), `CleanupExpiredManualBlocksJobTest`
+  (3 tests: read-time filter, delete + audit emit, no-expired
+  no-op).
+- Total: api now 367 tests / 1428 assertions, all green.
+
+**Test surface added (ui):**
+- Unit: `LoginThrottleTest` (8 tests covering progression, IP
+  isolation, time advance, clear, casing), `SecretScrubbingProcessorTest`
+  (4 tests).
+- `SessionManagerTest` updated: dropped 3 obsolete throttle test
+  cases (the throttle moved to `LoginThrottle`).
+- Total: ui now 87 tests / 214 assertions, all green.
+
+**Acceptance script:** `composer cs && composer stan && composer
+test` clean on both subprojects (run via Docker `composer:2` image
+since the host PHP toolchain is minimal). `composer audit --no-dev`
+and `npm audit --omit=dev --audit-level=high` both report zero
+vulnerabilities. The full Block-A/B/C bash acceptance script in the
+M14 brief is gated on `docker compose up -d`; it's preserved
+verbatim in `files/M14-hardening.md` for the next operator with a
+clean compose stack.
+
+**Deviations from SPEC:**
+- M14.6 says "the admin endpoint is unrated unless you measure a
+  problem"; we left it unrated and documented the choice in
+  `doc/api-overview.md` and `doc/security.md`. No regression test
+  added for the absence (would just lock in current behaviour).
+- M14.10 says ≤300 lines for `doc/security.md`; the file lands at
+  ~270 lines. Within the budget, with margin for the next round of
+  controls.
+
+**Added dependencies:** none.
+
+**Added env vars:** none.
+
+**Build complete.** All 14 milestones executed.

+ 59 - 15
README.md

@@ -193,10 +193,9 @@ config in `api/docker/Caddyfile`); external requests get `404`.
 
 ## Backups
 
-The api's persistent state lives in one of two places:
+The api's persistent state lives in one of two places.
 
-**SQLite (default)**: the `irdb-data` Docker volume holds
-`/data/irdb.sqlite`. Back it up with:
+**SQLite (default)** — online-safe via the SQLite backup API:
 
 ```bash
 docker compose exec api sh -c \
@@ -204,21 +203,66 @@ docker compose exec api sh -c \
 docker compose cp api:/data/irdb-backup.sqlite ./irdb-backup-$(date +%F).sqlite
 ```
 
-The `.backup` SQLite command is online-safe and quiesces WAL.
+The `.backup` command is the only correct way to copy a live SQLite
+database with WAL — it quiesces the journal and produces a consistent
+snapshot.
 
-**MySQL**: `mysqldump --single-transaction` against the `mysql`
-container. The schema is small (under 20 tables); a multi-GB dump is a
-red flag — the `audit_log` and `reports` tables are the only ones that
-grow with use, and `cleanup-audit` + the `score_hard_cutoff_days`
-horizon bound them.
+**SQLite — whole-volume tarball** (alternative, requires the api to be
+stopped or quiesced):
 
-Restore: `docker compose down -v` (drops the volume), restore the file
-into a fresh volume, `docker compose up -d`. The `migrate` container
-runs idempotently so repeating it after a restore is safe.
+```bash
+docker compose stop api
+docker run --rm -v irdb-data:/data -v "$(pwd):/backup" alpine \
+  tar czf /backup/irdb-backup.tar.gz -C /data .
+docker compose start api
+```
+
+Restore: `docker compose down`, drop or empty the volume, then
+extract:
+
+```bash
+docker run --rm -v irdb-data:/data -v "$(pwd):/backup" alpine \
+  sh -c 'rm -rf /data/* && tar xzf /backup/irdb-backup.tar.gz -C /data'
+docker compose up -d
+```
+
+**MySQL**:
+
+```bash
+docker compose exec mysql sh -c \
+  'mysqldump --single-transaction --routines --quick \
+    -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE"' \
+  > irdb-mysql-$(date +%F).sql
+```
+
+Restore (api must be stopped during restore so it doesn't observe a
+half-loaded schema):
+
+```bash
+docker compose stop api migrate
+docker compose exec -T mysql sh -c \
+  'mysql -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE"' \
+  < irdb-mysql-2026-04-29.sql
+docker compose up -d migrate api
+```
 
-GeoIP DBs (`/data/geoip/*.mmdb`) don't need backup — the
-`refresh-geoip` job repopulates them on the next schedule, and they're
-purely a cache.
+The schema is small (under 20 tables); a multi-GB dump is a red flag —
+`audit_log` and `reports` are the only tables that grow with use, and
+`cleanup-audit` + `score_hard_cutoff_days` bound them.
+
+**What NOT to back up**:
+- **Rotating tokens** — the `api_tokens` table is included in the
+  database backup automatically, but the *raw* token strings shown
+  once on creation aren't recoverable. If a token is lost, revoke and
+  re-issue.
+- **GeoIP DBs** (`/data/geoip/*.mmdb`) — re-downloadable via the
+  `refresh-geoip` job on first run after restore.
+- **`UI_SERVICE_TOKEN`** etc. live in `.env`, not in the database;
+  back up the env file separately if you need to redeploy from a
+  blank node.
+
+See [`doc/architecture.md` → Disaster Recovery](./doc/architecture.md#disaster-recovery)
+for the end-to-end recovery checklist.
 
 ---
 

+ 32 - 0
api/docker/Caddyfile

@@ -5,12 +5,44 @@
     order php_server before file_server
     auto_https off
     admin off
+    servers {
+        trusted_proxies static private_ranges
+    }
 }
 
 :8081 {
     root * /app/public
     encode zstd gzip
 
+    # ── Security headers (M14) ──────────────────────────────────────────
+    # Applied to every response. The api serves JSON + the OpenAPI YAML +
+    # the /api/docs viewer; everything else is locked down.
+    header {
+        # Identify ourselves as little as possible.
+        -Server
+        -X-Powered-By
+        X-Content-Type-Options "nosniff"
+        # The api doesn't render its own pages except /api/docs which is a
+        # single embedded viewer; SAMEORIGIN is the conservative default
+        # that still allows future same-origin embedding.
+        X-Frame-Options "SAMEORIGIN"
+        Referrer-Policy "strict-origin-when-cross-origin"
+        Permissions-Policy "geolocation=(), microphone=(), camera=()"
+    }
+
+    # HSTS: prod-only. Setting it in dev would lock you out of plain-HTTP
+    # localhost on the same hostname (sticky for 1 year). Gate strictly.
+    @prod expression `{env.APP_ENV} == "production"`
+    header @prod Strict-Transport-Security "max-age=31536000; includeSubDomains"
+
+    # CSP: docs viewer needs RapiDoc from jsDelivr + inline styles + the
+    # try-it-now feature posting to /api/v1/*. Everything else is JSON.
+    @docs path /api/docs /api/v1/openapi.yaml
+    header @docs Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data: https://cdn.jsdelivr.net; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
+
+    @not_docs not path /api/docs /api/v1/openapi.yaml
+    header @not_docs Content-Security-Policy "default-src 'none'; frame-ancestors 'none'; base-uri 'none'"
+
     # Internal jobs API: only callable from loopback / RFC1918.
     # The PHP layer also enforces this (InternalNetworkMiddleware) — Caddy
     # is the first line of defence for production deployments where the

+ 1 - 0
api/src/App/AppFactory.php

@@ -319,6 +319,7 @@ final class AppFactory
             $jobs = $container->get(JobsController::class);
             $internal->post('/recompute-scores', [$jobs, 'recomputeScores']);
             $internal->post('/cleanup-audit', [$jobs, 'cleanupAudit']);
+            $internal->post('/cleanup-expired-manual-blocks', [$jobs, 'cleanupExpiredManualBlocks']);
             $internal->post('/enrich-pending', [$jobs, 'enrichPending']);
             $internal->post('/tick', [$jobs, 'tick']);
             $internal->post('/refresh-geoip', [$jobs, 'refreshGeoip']);

+ 6 - 0
api/src/App/Container.php

@@ -20,6 +20,7 @@ use App\Application\Admin\TokensController;
 use App\Application\Auth\AuthController;
 use App\Application\Internal\JobsController;
 use App\Application\Jobs\CleanupAuditJob;
+use App\Application\Jobs\CleanupExpiredManualBlocksJob;
 use App\Application\Jobs\EnrichPendingJob;
 use App\Application\Jobs\RecomputeScoresJob;
 use App\Application\Jobs\RefreshGeoipJob;
@@ -157,6 +158,7 @@ final class Container
                 $handler = new StreamHandler('php://stdout', $level);
                 $handler->setFormatter(new JsonFormatter());
                 $logger->pushHandler($handler);
+                $logger->pushProcessor(new \App\Infrastructure\Logging\SecretScrubbingProcessor());
 
                 return $logger;
             }),
@@ -268,6 +270,7 @@ final class Container
 
                 return new CleanupAuditJob($conn, $days);
             }),
+            CleanupExpiredManualBlocksJob::class => autowire(),
             GuzzleClientInterface::class => factory(static function (): GuzzleClientInterface {
                 return new GuzzleClient([
                     'connect_timeout' => 10,
@@ -365,6 +368,8 @@ final class Container
                 $recompute = $c->get(RecomputeScoresJob::class);
                 /** @var CleanupAuditJob $cleanup */
                 $cleanup = $c->get(CleanupAuditJob::class);
+                /** @var CleanupExpiredManualBlocksJob $cleanupExpired */
+                $cleanupExpired = $c->get(CleanupExpiredManualBlocksJob::class);
                 /** @var EnrichPendingJob $enrich */
                 $enrich = $c->get(EnrichPendingJob::class);
                 /** @var RefreshGeoipJob $refresh */
@@ -373,6 +378,7 @@ final class Container
                 $tick = $c->get(TickJob::class);
                 $registry->register($recompute);
                 $registry->register($cleanup);
+                $registry->register($cleanupExpired);
                 $registry->register($enrich);
                 $registry->register($refresh);
                 $registry->register($tick);

+ 9 - 0
api/src/Application/Internal/JobsController.php

@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace App\Application\Internal;
 
 use App\Application\Jobs\CleanupAuditJob;
+use App\Application\Jobs\CleanupExpiredManualBlocksJob;
 use App\Application\Jobs\EnrichPendingJob;
 use App\Application\Jobs\RecomputeScoresJob;
 use App\Application\Jobs\RefreshGeoipJob;
@@ -65,6 +66,14 @@ final class JobsController
         return self::renderOutcome($response, $outcome);
     }
 
+    public function cleanupExpiredManualBlocks(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $job = $this->registry->get(CleanupExpiredManualBlocksJob::NAME);
+        $outcome = $this->runner->run($job, [], 'schedule');
+
+        return self::renderOutcome($response, $outcome);
+    }
+
     public function enrichPending(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
     {
         $job = $this->registry->get(EnrichPendingJob::NAME);

+ 89 - 0
api/src/Application/Jobs/CleanupExpiredManualBlocksJob.php

@@ -0,0 +1,89 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Application\Jobs;
+
+use App\Domain\Audit\AuditAction;
+use App\Domain\Audit\AuditContext;
+use App\Domain\Audit\AuditEmitter;
+use App\Domain\Jobs\Job;
+use App\Domain\Jobs\JobContext;
+use App\Domain\Jobs\JobResult;
+use App\Infrastructure\ManualBlock\ManualBlockRepository;
+use App\Infrastructure\Reputation\BlocklistCache;
+use App\Infrastructure\Reputation\CidrEvaluatorFactory;
+
+/**
+ * Daily prune of expired `manual_blocks` rows.
+ *
+ * Read-time filtering (M14.5) already hides expired rows from every list,
+ * lookup, and the CidrEvaluator's snapshot — so correctness doesn't depend
+ * on this job. The job exists for tidiness: keeps the table from growing
+ * unbounded with old expired entries.
+ *
+ * For each row deleted, emits one `manual_block.deleted` audit row with
+ * `actor_kind=system` so the audit trail records who removed the block.
+ * Invalidates the CidrEvaluator + BlocklistCache so any in-flight reads on
+ * this replica observe the prune immediately.
+ */
+final class CleanupExpiredManualBlocksJob implements Job
+{
+    public const NAME = 'cleanup-expired-manual-blocks';
+
+    public function __construct(
+        private readonly ManualBlockRepository $manualBlocks,
+        private readonly AuditEmitter $audit,
+        private readonly CidrEvaluatorFactory $evaluator,
+        private readonly BlocklistCache $blocklistCache,
+    ) {
+    }
+
+    public function name(): string
+    {
+        return self::NAME;
+    }
+
+    public function defaultIntervalSeconds(): int
+    {
+        return 86400;
+    }
+
+    public function maxRuntimeSeconds(): int
+    {
+        return 60;
+    }
+
+    public function run(JobContext $context): JobResult
+    {
+        $now = $context->clock->now();
+        $expiredIds = $this->manualBlocks->findExpired($now);
+        if ($expiredIds === []) {
+            return new JobResult(itemsProcessed: 0);
+        }
+
+        $deleted = $this->manualBlocks->deleteExpired($now);
+
+        $auditCtx = AuditContext::system();
+        foreach ($expiredIds as $id) {
+            $this->audit->emit(
+                AuditAction::MANUAL_BLOCK_DELETED,
+                'manual_block',
+                (string) $id,
+                ['reason' => 'expired', 'job' => self::NAME],
+                $auditCtx,
+            );
+        }
+
+        // Heavy-handed but correct: any in-process snapshot is invalidated
+        // so subsequent reads on this replica don't keep an expired row in
+        // their cached view.
+        $this->evaluator->invalidate();
+        $this->blocklistCache->invalidateAll();
+
+        return new JobResult(
+            itemsProcessed: $deleted,
+            details: ['expired_ids' => $expiredIds],
+        );
+    }
+}

+ 133 - 0
api/src/Infrastructure/Logging/SecretScrubbingProcessor.php

@@ -0,0 +1,133 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Logging;
+
+use Monolog\LogRecord;
+use Monolog\Processor\ProcessorInterface;
+
+/**
+ * Scrubs sensitive values out of Monolog records before they hit a handler
+ * (SPEC §M14.4).
+ *
+ * Two layers of defence:
+ *   1. **Key-based redaction** in `context` and `extra`. Any key whose name
+ *      matches one of the sensitive-key patterns gets its value replaced
+ *      with `***`.
+ *   2. **Pattern-based redaction** of the rendered `message` and any string
+ *      values left in `context` / `extra`. Catches Bearer tokens that
+ *      slipped into the message via `sprintf` or were embedded in a free-
+ *      form string. The token kind prefix is preserved so triage logs
+ *      ("which kind of token failed?") stay useful.
+ *
+ * The processor is intentionally simple: any new sensitive-shaped data we
+ * find in production should be added to either the key-list or the regex
+ * list with a one-line PR.
+ */
+final class SecretScrubbingProcessor implements ProcessorInterface
+{
+    private const REDACTED = '***';
+
+    /**
+     * Lower-case substrings; we redact a key if any of these appear in it.
+     * Examples that get hit:
+     *   `authorization`, `Authorization`, `auth_token`,
+     *   `password`, `password_hash`, `LOCAL_ADMIN_PASSWORD_HASH`,
+     *   `oidc_client_secret`, `client_secret`,
+     *   `maxmind_license_key`, `ipinfo_token`,
+     *   `db_mysql_password`, `app_secret`, `internal_job_token`,
+     *   `ui_service_token`, `bearer`, `cookie`, `set-cookie`.
+     */
+    private const SENSITIVE_KEY_NEEDLES = [
+        'password',
+        'authorization',
+        'auth_token',
+        'access_token',
+        'refresh_token',
+        'bearer',
+        'secret',
+        'license_key',
+        'license-key',
+        'license_token',
+        'ipinfo_token',
+        'service_token',
+        'job_token',
+        'cookie',
+    ];
+
+    /**
+     * Pattern → replacement pairs used on string values. The token regex
+     * preserves the irdb prefix + kind so logs still show which token kind
+     * was involved without leaking the secret half.
+     *
+     * @var list<array{0: string, 1: string|callable(array<int|string, string>): string}>
+     */
+    private const VALUE_PATTERNS = [
+        // Bearer header value, with or without the keyword. Replaces the
+        // value but keeps the kind prefix as a triage breadcrumb.
+        ['/(Bearer\s+irdb_(?:rep|con|adm|svc)_)[A-Z2-7]{32}/', '$1***'],
+        ['/(Bearer\s+)[A-Za-z0-9._\-]{20,}/', '$1***'],
+        // Bare irdb_<kind>_<32 base32> tokens that aren't preceded by Bearer.
+        ['/\birdb_(rep|con|adm|svc)_[A-Z2-7]{32}\b/', 'irdb_$1_***'],
+        // Argon2 password hashes.
+        ['/\$argon2(?:i|id|d)\$[^\s\'"]+/', '$argon2***'],
+        // bcrypt password hashes.
+        ['/\$2[aby]?\$\d{2}\$[A-Za-z0-9.\/]{53}/', '$2***'],
+    ];
+
+    public function __invoke(LogRecord $record): LogRecord
+    {
+        $context = self::scrubArray($record->context);
+        $extra = self::scrubArray($record->extra);
+        $message = self::scrubString($record->message);
+
+        return $record->with(message: $message, context: $context, extra: $extra);
+    }
+
+    /**
+     * @param array<array-key, mixed> $data
+     * @return array<array-key, mixed>
+     */
+    private static function scrubArray(array $data): array
+    {
+        $out = [];
+        foreach ($data as $key => $value) {
+            $keyHit = is_string($key) && self::isSensitiveKey($key);
+            if ($keyHit) {
+                $out[$key] = self::REDACTED;
+                continue;
+            }
+            if (is_array($value)) {
+                $out[$key] = self::scrubArray($value);
+            } elseif (is_string($value)) {
+                $out[$key] = self::scrubString($value);
+            } else {
+                $out[$key] = $value;
+            }
+        }
+
+        return $out;
+    }
+
+    private static function isSensitiveKey(string $key): bool
+    {
+        $lower = strtolower($key);
+        foreach (self::SENSITIVE_KEY_NEEDLES as $needle) {
+            if (str_contains($lower, $needle)) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    private static function scrubString(string $value): string
+    {
+        foreach (self::VALUE_PATTERNS as [$pattern, $replacement]) {
+            $value = (string) preg_replace($pattern, (string) $replacement, $value);
+        }
+
+        return $value;
+    }
+}

+ 70 - 23
api/src/Infrastructure/ManualBlock/ManualBlockRepository.php

@@ -7,9 +7,11 @@ namespace App\Infrastructure\ManualBlock;
 use App\Domain\Ip\Cidr;
 use App\Domain\Ip\IpAddress;
 use App\Domain\ManualBlock\ManualBlock;
+use App\Domain\Time\Clock;
 use App\Infrastructure\Db\RepositoryBase;
 use DateTimeImmutable;
 use DateTimeZone;
+use Doctrine\DBAL\Connection;
 use Doctrine\DBAL\ParameterType;
 
 /**
@@ -20,27 +22,59 @@ use Doctrine\DBAL\ParameterType;
  * No update path: an admin removes and re-adds. Soft-delete is unnecessary
  * — manual_blocks is itself the override layer, not a long-lived audit
  * surface (audit lives in `audit_log`, M12).
+ *
+ * SPEC §M14.5: every list/lookup filters out rows whose `expires_at` has
+ * passed. The `CleanupExpiredManualBlocksJob` prunes them daily so the
+ * table doesn't grow unbounded; the read-time filter guarantees correctness
+ * between cleanup runs.
  */
 final class ManualBlockRepository extends RepositoryBase
 {
+    /**
+     * Optional clock for test injection (advancing "now" past an entry's
+     * `expires_at` without sleeping). Production builds wire it via the
+     * container; tests that construct the repo directly may leave it null
+     * and the system clock takes over.
+     */
+    public function __construct(Connection $connection, private readonly ?Clock $clock = null)
+    {
+        parent::__construct($connection);
+    }
+
+    private function notExpiredFragment(string $alias = ''): string
+    {
+        $col = $alias === '' ? 'expires_at' : $alias . '.expires_at';
+
+        return "({$col} IS NULL OR {$col} >= :now_filter)";
+    }
+
+    private function nowParamValue(): string
+    {
+        $now = $this->clock !== null
+            ? $this->clock->now()
+            : new DateTimeImmutable('now', new DateTimeZone('UTC'));
+
+        return $now->format('Y-m-d H:i:s');
+    }
+
     /**
      * Find a single-IP manual block by exact `ip_bin` match. Used by the
      * admin IP-detail endpoint to render the manual-block panel.
+     *
+     * Filters out rows whose `expires_at` has passed so an expired manual
+     * block doesn't appear "active" to anything that consults it.
      */
     public function findByIpBin(string $ipBin): ?ManualBlock
     {
-        $row = $this->fetchByIpBin('manual_blocks', $ipBin);
-        if ($row === null) {
-            return null;
-        }
-        // The base helper matches WHERE ip_bin = ?, but `manual_blocks`
-        // stores network entries with `ip_bin = NULL` and a separate
-        // `network_bin`, so we additionally filter to kind=ip rows.
-        if (($row['kind'] ?? '') !== ManualBlock::KIND_IP) {
-            return null;
-        }
+        /** @var array<string, mixed>|false $row */
+        $row = $this->connection()->fetchAssociative(
+            'SELECT id, kind, ip_bin, network_bin, prefix_length, reason, expires_at, created_at, created_by_user_id '
+            . 'FROM manual_blocks WHERE ip_bin = :ip_bin AND kind = :kind AND ' . $this->notExpiredFragment(),
+            ['ip_bin' => $ipBin, 'kind' => ManualBlock::KIND_IP, 'now_filter' => $this->nowParamValue()],
+            ['ip_bin' => ParameterType::LARGE_OBJECT]
+        );
 
-        return self::hydrate($row);
+        return $row === false ? null : self::hydrate($row);
     }
 
     public function findById(int $id): ?ManualBlock
@@ -63,17 +97,15 @@ final class ManualBlockRepository extends RepositoryBase
     {
         $sql = 'SELECT id, kind, ip_bin, network_bin, prefix_length, reason, expires_at, created_at, created_by_user_id '
             . 'FROM manual_blocks';
-        $params = [];
+        $params = ['now_filter' => $this->nowParamValue()];
         $types = [];
-        $where = [];
+        $where = [$this->notExpiredFragment()];
 
         if (isset($filters['kind']) && $filters['kind'] !== null) {
             $where[] = 'kind = :kind';
             $params['kind'] = $filters['kind'];
         }
-        if ($where !== []) {
-            $sql .= ' WHERE ' . implode(' AND ', $where);
-        }
+        $sql .= ' WHERE ' . implode(' AND ', $where);
         $sql .= ' ORDER BY id DESC';
 
         if ($limit !== null) {
@@ -97,12 +129,15 @@ final class ManualBlockRepository extends RepositoryBase
     {
         if ($kindFilter !== null) {
             return (int) $this->connection()->fetchOne(
-                'SELECT COUNT(*) FROM manual_blocks WHERE kind = :kind',
-                ['kind' => $kindFilter]
+                'SELECT COUNT(*) FROM manual_blocks WHERE kind = :kind AND ' . $this->notExpiredFragment(),
+                ['kind' => $kindFilter, 'now_filter' => $this->nowParamValue()]
             );
         }
 
-        return (int) $this->connection()->fetchOne('SELECT COUNT(*) FROM manual_blocks');
+        return (int) $this->connection()->fetchOne(
+            'SELECT COUNT(*) FROM manual_blocks WHERE ' . $this->notExpiredFragment(),
+            ['now_filter' => $this->nowParamValue()]
+        );
     }
 
     public function createIp(
@@ -173,8 +208,8 @@ final class ManualBlockRepository extends RepositoryBase
         /** @var list<array<string, mixed>> $rows */
         $rows = $this->connection()->fetchAllAssociative(
             'SELECT id, kind, ip_bin, network_bin, prefix_length, reason, expires_at, created_at, created_by_user_id '
-            . 'FROM manual_blocks WHERE kind = :kind ORDER BY id',
-            ['kind' => ManualBlock::KIND_SUBNET]
+            . 'FROM manual_blocks WHERE kind = :kind AND ' . $this->notExpiredFragment() . ' ORDER BY id',
+            ['kind' => ManualBlock::KIND_SUBNET, 'now_filter' => $this->nowParamValue()]
         );
 
         return array_map(self::hydrate(...), $rows);
@@ -188,13 +223,25 @@ final class ManualBlockRepository extends RepositoryBase
         /** @var list<array<string, mixed>> $rows */
         $rows = $this->connection()->fetchAllAssociative(
             'SELECT id, kind, ip_bin, network_bin, prefix_length, reason, expires_at, created_at, created_by_user_id '
-            . 'FROM manual_blocks WHERE kind = :kind ORDER BY id',
-            ['kind' => ManualBlock::KIND_IP]
+            . 'FROM manual_blocks WHERE kind = :kind AND ' . $this->notExpiredFragment() . ' ORDER BY id',
+            ['kind' => ManualBlock::KIND_IP, 'now_filter' => $this->nowParamValue()]
         );
 
         return array_map(self::hydrate(...), $rows);
     }
 
+    /**
+     * Hard-delete every block whose `expires_at` has passed. Returns the
+     * count for the cleanup job's audit + run-history reporting.
+     */
+    public function deleteExpired(DateTimeImmutable $now): int
+    {
+        return (int) $this->connection()->executeStatement(
+            'DELETE FROM manual_blocks WHERE expires_at IS NOT NULL AND expires_at < :now',
+            ['now' => $now->format('Y-m-d H:i:s')]
+        );
+    }
+
     /**
      * @param array<string, mixed> $row
      */

+ 134 - 0
api/tests/Integration/Auth/SchemaSecretsAtRestTest.php

@@ -0,0 +1,134 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Integration\Auth;
+
+use App\Tests\Integration\Support\AppTestCase;
+
+/**
+ * SPEC §M14.8: a best-effort sanity scan of the schema for columns whose
+ * name suggests they could store unhashed credentials.
+ *
+ * The architectural rule is: **the api never stores passwords or raw
+ * secrets**. Local-admin passwords live as Argon2id hashes in the UI's
+ * env (not in the DB at all). API tokens are stored as their SHA-256
+ * digest plus an 8-char prefix. OIDC, MaxMind, and IPinfo credentials
+ * never touch the DB — they live in `.env` for the relevant container.
+ *
+ * This test catches accidental schema drift where someone adds a column
+ * called `password`, `client_secret`, etc. — anything that smells like a
+ * credential.
+ *
+ * Implementation note: DBAL's SQLite SchemaManager can't always parse
+ * tables defined with raw CREATE TABLE statements that include CHECK
+ * constraints (the api_tokens table does this for kind-vs-id mutual
+ * exclusion). We sidestep that by reading sqlite_master + pragma
+ * directly, which works for the SQLite test bootstrap regardless of
+ * how the table was created.
+ */
+final class SchemaSecretsAtRestTest extends AppTestCase
+{
+    /**
+     * Columns whose name matches one of these substrings is suspect.
+     * Lower-cased; we match a substring inside `lower(column_name)`.
+     */
+    private const SUSPECT_NEEDLES = [
+        'password',
+        '_secret',
+        'secret_',
+        'license_key',
+        'api_key',
+        'private_key',
+        'client_secret',
+        'oauth_secret',
+        'plaintext',
+        'cleartext',
+    ];
+
+    /**
+     * `<table>.<column>` (lower-case) names known to be safe under the
+     * rule. Add justification comments here if a future migration needs
+     * an exception. Currently empty — none of the suspect names match a
+     * legitimate column.
+     *
+     * @var list<string>
+     */
+    private const ALLOWLIST_COLUMNS = [];
+
+    public function testNoSchemaColumnLooksLikeUnhashedSecret(): void
+    {
+        $offenders = [];
+        foreach ($this->listUserTables() as $table) {
+            foreach ($this->columnsOf($table) as $column) {
+                $name = strtolower((string) $column);
+                $key = $table . '.' . $name;
+                if (in_array($key, self::ALLOWLIST_COLUMNS, true)) {
+                    continue;
+                }
+                if (self::matchesSuspect($name)) {
+                    $offenders[] = $key;
+                }
+            }
+        }
+
+        self::assertSame(
+            [],
+            $offenders,
+            "Schema contains columns whose name suggests they store unhashed secrets:\n  - "
+                . implode("\n  - ", $offenders)
+                . "\n\nIf any of these are intentional and store hashed/derived data, add to ALLOWLIST_COLUMNS with justification."
+        );
+    }
+
+    public function testApiTokensTableStoresHashesNotRawValues(): void
+    {
+        // Positive assertion: the `api_tokens` table — the one place we
+        // legitimately have to keep "credential-shaped" data — has the
+        // expected hash + prefix columns (and no plaintext column).
+        $names = array_map('strtolower', $this->columnsOf('api_tokens'));
+
+        self::assertContains('token_hash', $names, 'api_tokens must have token_hash column');
+        self::assertContains('token_prefix', $names, 'api_tokens must have token_prefix column');
+        self::assertNotContains('token', $names, 'api_tokens must NOT have a raw token column');
+        self::assertNotContains('token_plain', $names);
+        self::assertNotContains('raw_token', $names);
+    }
+
+    /**
+     * @return list<string>
+     */
+    private function listUserTables(): array
+    {
+        /** @var list<array<string, mixed>> $rows */
+        $rows = $this->db->fetchAllAssociative(
+            "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'phinxlog%'"
+        );
+
+        return array_map(static fn (array $r): string => (string) $r['name'], $rows);
+    }
+
+    /**
+     * @return list<string>
+     */
+    private function columnsOf(string $table): array
+    {
+        /** @var list<array<string, mixed>> $rows */
+        $rows = $this->db->fetchAllAssociative(
+            sprintf('PRAGMA table_info(%s)', $this->db->quoteIdentifier($table))
+        );
+
+        return array_map(static fn (array $r): string => (string) $r['name'], $rows);
+    }
+
+    private static function matchesSuspect(string $name): bool
+    {
+        foreach (self::SUSPECT_NEEDLES as $needle) {
+            if (str_contains($name, $needle)) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+}

+ 158 - 0
api/tests/Integration/Jobs/CleanupExpiredManualBlocksJobTest.php

@@ -0,0 +1,158 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Integration\Jobs;
+
+use App\Application\Jobs\CleanupExpiredManualBlocksJob;
+use App\Domain\Audit\AuditAction;
+use App\Domain\Ip\IpAddress;
+use App\Domain\Jobs\JobContext;
+use App\Domain\Reputation\BlocklistBuilder;
+use App\Domain\Time\FixedClock;
+use App\Infrastructure\Allowlist\AllowlistRepository;
+use App\Infrastructure\Audit\AuditRepository;
+use App\Infrastructure\Audit\DbAuditEmitter;
+use App\Infrastructure\Category\CategoryRepository;
+use App\Infrastructure\ManualBlock\ManualBlockRepository;
+use App\Infrastructure\Reputation\BlocklistCache;
+use App\Infrastructure\Reputation\CidrEvaluatorFactory;
+use App\Infrastructure\Reputation\IpScoreRepository;
+use App\Tests\Integration\Support\AppTestCase;
+use Doctrine\DBAL\ParameterType;
+use Monolog\Handler\NullHandler;
+use Monolog\Logger;
+
+/**
+ * SPEC §M14.5: expired manual blocks are filtered at read time AND pruned
+ * by the daily cleanup job, with one audit row per row deleted.
+ */
+final class CleanupExpiredManualBlocksJobTest extends AppTestCase
+{
+    public function testExpiredBlocksAreFilteredAtReadTime(): void
+    {
+        $clock = FixedClock::at('2026-04-29T12:00:00Z');
+        $repo = new ManualBlockRepository($this->db, $clock);
+
+        $expiredAt = $clock->now()->modify('-1 hour');
+        $futureAt = $clock->now()->modify('+1 day');
+        $this->insertManualIp('203.0.113.10', 'expired', $expiredAt);
+        $this->insertManualIp('203.0.113.11', 'future', $futureAt);
+        $this->insertManualIp('203.0.113.12', 'no-expiry', null);
+
+        $rows = $repo->list(null, null);
+        self::assertCount(2, $rows, 'expired entry should be hidden');
+        $reasons = array_map(static fn ($r) => $r->reason, $rows);
+        self::assertContains('future', $reasons);
+        self::assertContains('no-expiry', $reasons);
+        self::assertNotContains('expired', $reasons);
+
+        // Direct lookup also filters.
+        $expired = $repo->findByIpBin(IpAddress::fromString('203.0.113.10')->binary());
+        self::assertNull($expired);
+        $live = $repo->findByIpBin(IpAddress::fromString('203.0.113.11')->binary());
+        self::assertNotNull($live);
+
+        // count() honours the filter too.
+        self::assertSame(2, $repo->count());
+    }
+
+    public function testJobDeletesExpiredAndEmitsOneAuditPerRow(): void
+    {
+        $clock = FixedClock::at('2026-04-29T12:00:00Z');
+        $repo = new ManualBlockRepository($this->db, $clock);
+
+        $expiredAt = $clock->now()->modify('-1 hour');
+        $this->insertManualIp('203.0.113.20', 'one', $expiredAt);
+        $this->insertManualIp('203.0.113.21', 'two', $expiredAt);
+        $this->insertManualIp('203.0.113.22', 'three-fresh', null);
+
+        $job = $this->buildJob($repo, $clock);
+        $logger = new Logger('test');
+        $logger->pushHandler(new NullHandler());
+        $result = $job->run(new JobContext($clock, $logger));
+
+        self::assertSame(2, $result->itemsProcessed);
+
+        // Surviving row stays.
+        $remaining = (int) $this->db->fetchOne(
+            "SELECT COUNT(*) FROM manual_blocks WHERE reason = 'three-fresh'"
+        );
+        self::assertSame(1, $remaining);
+
+        // Expired rows gone.
+        $remainingExpired = (int) $this->db->fetchOne(
+            "SELECT COUNT(*) FROM manual_blocks WHERE reason IN ('one','two')"
+        );
+        self::assertSame(0, $remainingExpired);
+
+        // Two audit rows for the deletions.
+        $auditCount = (int) $this->db->fetchOne(
+            'SELECT COUNT(*) FROM audit_log WHERE action = :action',
+            ['action' => AuditAction::MANUAL_BLOCK_DELETED]
+        );
+        self::assertSame(2, $auditCount);
+
+        $rows = $this->db->fetchAllAssociative(
+            'SELECT actor_kind, target_type, details_json FROM audit_log WHERE action = :action',
+            ['action' => AuditAction::MANUAL_BLOCK_DELETED]
+        );
+        foreach ($rows as $row) {
+            self::assertSame('system', $row['actor_kind']);
+            self::assertSame('manual_block', $row['target_type']);
+            $payload = json_decode((string) $row['details_json'], true);
+            self::assertSame('expired', $payload['reason']);
+            self::assertSame(CleanupExpiredManualBlocksJob::NAME, $payload['job']);
+        }
+    }
+
+    public function testNoExpiredEntriesIsAZeroNoOp(): void
+    {
+        $clock = FixedClock::at('2026-04-29T12:00:00Z');
+        $repo = new ManualBlockRepository($this->db, $clock);
+        $this->insertManualIp('203.0.113.30', 'fresh', null);
+
+        $job = $this->buildJob($repo, $clock);
+        $logger = new Logger('test');
+        $logger->pushHandler(new NullHandler());
+        $result = $job->run(new JobContext($clock, $logger));
+
+        self::assertSame(0, $result->itemsProcessed);
+        $auditCount = (int) $this->db->fetchOne(
+            'SELECT COUNT(*) FROM audit_log WHERE action = :action',
+            ['action' => AuditAction::MANUAL_BLOCK_DELETED]
+        );
+        self::assertSame(0, $auditCount);
+    }
+
+    private function insertManualIp(string $ip, string $reason, ?\DateTimeImmutable $expiresAt): void
+    {
+        $this->db->insert('manual_blocks', [
+            'kind' => 'ip',
+            'ip_bin' => IpAddress::fromString($ip)->binary(),
+            'network_bin' => null,
+            'prefix_length' => null,
+            'reason' => $reason,
+            'expires_at' => $expiresAt?->format('Y-m-d H:i:s'),
+            'created_by_user_id' => null,
+        ], ['ip_bin' => ParameterType::LARGE_OBJECT]);
+    }
+
+    private function buildJob(ManualBlockRepository $repo, FixedClock $clock): CleanupExpiredManualBlocksJob
+    {
+        $logger = new Logger('test');
+        $logger->pushHandler(new NullHandler());
+
+        $auditRepo = new AuditRepository($this->db, $clock);
+        $audit = new DbAuditEmitter($auditRepo, $logger);
+        $allowlist = new AllowlistRepository($this->db);
+        $evaluator = new CidrEvaluatorFactory($repo, $allowlist, $clock, $logger, 60);
+
+        $categories = new CategoryRepository($this->db);
+        $ipScores = new IpScoreRepository($this->db);
+        $builder = new BlocklistBuilder($ipScores, $categories, $evaluator, $clock);
+        $blocklistCache = new BlocklistCache($builder, $clock, 30);
+
+        return new CleanupExpiredManualBlocksJob($repo, $audit, $evaluator, $blocklistCache);
+    }
+}

+ 80 - 0
api/tests/Unit/Auth/TokenEntropyTest.php

@@ -0,0 +1,80 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Unit\Auth;
+
+use App\Domain\Auth\TokenIssuer;
+use App\Domain\Auth\TokenKind;
+use PHPUnit\Framework\TestCase;
+use ReflectionClass;
+
+/**
+ * SPEC §M14.3: prove tokens carry the entropy the SPEC promises (160 bits
+ * from a CSPRNG), and that the wire format is exactly
+ * `irdb_<kind3>_<32 base32 chars>`.
+ *
+ * The "1000 distinct tokens" check is a probabilistic floor — at 160 bits,
+ * the collision probability for 1000 samples is negligible (~1e-43), so
+ * any failure here means something is very wrong upstream of `random_bytes`.
+ */
+final class TokenEntropyTest extends TestCase
+{
+    private const FORMAT = '/^irdb_(rep|con|adm|svc)_[A-Z2-7]{32}$/';
+
+    public function testThousandTokensAllDistinct(): void
+    {
+        $issuer = new TokenIssuer();
+        $set = [];
+        for ($i = 0; $i < 1000; ++$i) {
+            $set[$issuer->issue(TokenKind::Admin)] = true;
+        }
+        self::assertCount(1000, $set, 'expected 1000 distinct tokens; collision detected');
+    }
+
+    public function testEveryTokenMatchesPublishedFormat(): void
+    {
+        $issuer = new TokenIssuer();
+        foreach (TokenKind::cases() as $kind) {
+            for ($i = 0; $i < 50; ++$i) {
+                $raw = $issuer->issue($kind);
+                self::assertSame(
+                    1,
+                    preg_match(self::FORMAT, $raw),
+                    "format mismatch for {$kind->value}: {$raw}"
+                );
+                self::assertStringStartsWith('irdb_' . $kind->code() . '_', $raw);
+            }
+        }
+    }
+
+    public function testIssuerSourcesEntropyFromRandomBytes(): void
+    {
+        // Static inspection: confirm `random_bytes` is the single source.
+        // If a refactor swaps in `mt_rand`, `rand`, `microtime`, or anything
+        // else not listed in PHP's CSPRNG API, this test fails loudly.
+        $reflection = new ReflectionClass(TokenIssuer::class);
+        $file = (string) $reflection->getFileName();
+        self::assertNotEmpty($file);
+        $source = (string) file_get_contents($file);
+
+        self::assertStringContainsString('random_bytes(', $source, 'TokenIssuer must source entropy from random_bytes (CSPRNG)');
+        $forbidden = ['mt_rand(', 'rand(', 'uniqid(', 'microtime(', 'srand('];
+        foreach ($forbidden as $needle) {
+            self::assertStringNotContainsString($needle, $source, "TokenIssuer must not use non-CSPRNG source: {$needle}");
+        }
+    }
+
+    public function testRandomByteCountIsTwentyForOneSixtyBits(): void
+    {
+        // Re-derive 160 bits / 8 = 20 bytes and confirm the issuer asks
+        // for that many. Catches future "let's use 16 bytes" regressions.
+        $reflection = new ReflectionClass(TokenIssuer::class);
+        $source = (string) file_get_contents((string) $reflection->getFileName());
+        self::assertSame(
+            1,
+            preg_match('/random_bytes\(\s*20\s*\)/', $source),
+            'TokenIssuer must request 20 bytes (160 bits) of entropy'
+        );
+    }
+}

+ 140 - 0
api/tests/Unit/Logging/SecretScrubbingProcessorTest.php

@@ -0,0 +1,140 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Unit\Logging;
+
+use App\Infrastructure\Logging\SecretScrubbingProcessor;
+use Monolog\Formatter\JsonFormatter;
+use Monolog\Level;
+use Monolog\LogRecord;
+use PHPUnit\Framework\TestCase;
+
+final class SecretScrubbingProcessorTest extends TestCase
+{
+    public function testBearerTokenInContextIsScrubbedAndPreservesKindPrefix(): void
+    {
+        $processor = new SecretScrubbingProcessor();
+        $record = $this->record('outbound api call', [
+            'authorization' => 'Bearer irdb_svc_ABCDEFGHIJKLMNOPQRSTUVWXYZ234567',
+        ]);
+
+        $out = $processor($record);
+
+        // Key-based redaction wins; the whole value is replaced with ***.
+        self::assertSame('***', $out->context['authorization']);
+    }
+
+    public function testBareTokenInMessageIsScrubbed(): void
+    {
+        $processor = new SecretScrubbingProcessor();
+        $record = $this->record(
+            'attempted with token irdb_adm_ABCDEFGHIJKLMNOPQRSTUVWXYZ234567',
+            []
+        );
+
+        $out = $processor($record);
+
+        self::assertStringContainsString('irdb_adm_***', $out->message);
+        self::assertStringNotContainsString('ABCDEFGHIJKLMNOPQRSTUVWXYZ234567', $out->message);
+    }
+
+    public function testFormattedOutputDoesNotLeakBearerToken(): void
+    {
+        // Round-trip the record through the processor and the JsonFormatter
+        // to confirm the *rendered* log line is clean — what actually hits
+        // stdout in production.
+        $processor = new SecretScrubbingProcessor();
+        $record = $this->record('api request', [
+            'headers' => ['Authorization' => 'Bearer irdb_rep_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'],
+        ]);
+
+        $out = $processor($record);
+        $formatter = new JsonFormatter();
+        $line = $formatter->format($out);
+
+        self::assertStringNotContainsString('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', $line);
+        self::assertStringContainsString('***', $line);
+    }
+
+    public function testPasswordHashKeyIsScrubbed(): void
+    {
+        $processor = new SecretScrubbingProcessor();
+        $record = $this->record('config dump', [
+            'LOCAL_ADMIN_PASSWORD_HASH' => '$argon2id$v=19$m=65536,t=4,p=1$abcdef$ghijkl',
+            'OIDC_CLIENT_SECRET' => 'super-secret-value',
+            'MAXMIND_LICENSE_KEY' => 'license-12345',
+            'IPINFO_TOKEN' => 'token-67890',
+            'DB_MYSQL_PASSWORD' => 'rootpass',
+        ]);
+
+        $out = $processor($record);
+
+        foreach (['LOCAL_ADMIN_PASSWORD_HASH', 'OIDC_CLIENT_SECRET', 'MAXMIND_LICENSE_KEY', 'IPINFO_TOKEN', 'DB_MYSQL_PASSWORD'] as $key) {
+            self::assertSame('***', $out->context[$key], "key {$key} not scrubbed");
+        }
+    }
+
+    public function testArgon2HashEmbeddedInMessageIsScrubbed(): void
+    {
+        $processor = new SecretScrubbingProcessor();
+        $record = $this->record(
+            'verifying $argon2id$v=19$m=65536,t=4,p=1$abc$def for user',
+            []
+        );
+
+        $out = $processor($record);
+
+        self::assertStringNotContainsString('$argon2id$v=19', $out->message);
+        self::assertStringContainsString('$argon2***', $out->message);
+    }
+
+    public function testNonSensitiveContentIsLeftAlone(): void
+    {
+        $processor = new SecretScrubbingProcessor();
+        $record = $this->record('user search hit', [
+            'count' => 42,
+            'email' => 'someone@example.com',
+            'ip' => '203.0.113.42',
+        ]);
+
+        $out = $processor($record);
+
+        self::assertSame(42, $out->context['count']);
+        self::assertSame('someone@example.com', $out->context['email']);
+        self::assertSame('203.0.113.42', $out->context['ip']);
+        self::assertSame('user search hit', $out->message);
+    }
+
+    public function testNestedContextIsScrubbedRecursively(): void
+    {
+        $processor = new SecretScrubbingProcessor();
+        $record = $this->record('nested', [
+            'request' => [
+                'method' => 'POST',
+                'authorization' => 'Bearer irdb_adm_ABCDEFGHIJKLMNOPQRSTUVWXYZ234567',
+                'body' => ['ok' => true],
+            ],
+        ]);
+
+        $out = $processor($record);
+
+        self::assertSame('***', $out->context['request']['authorization']);
+        self::assertSame('POST', $out->context['request']['method']);
+        self::assertTrue($out->context['request']['body']['ok']);
+    }
+
+    /**
+     * @param array<string, mixed> $context
+     */
+    private function record(string $message, array $context): LogRecord
+    {
+        return new LogRecord(
+            datetime: new \DateTimeImmutable(),
+            channel: 'test',
+            level: Level::Info,
+            message: $message,
+            context: $context,
+        );
+    }
+}

+ 16 - 1
doc/api-overview.md

@@ -98,7 +98,22 @@ Public endpoints are rate-limited at **60 req/s per token** (configurable
 via `API_RATE_LIMIT_PER_SECOND`). Token-bucket; refill rate equals the
 configured per-second value, bucket size is `2x` the refill rate. On
 exhaustion: `429 Too Many Requests` with `Retry-After: 1` (seconds).
-Admin endpoints aren't rate-limited.
+
+**Admin endpoints aren't rate-limited.** Real abuse on admin endpoints is
+rare — the audience is humans plus the BFF, both authenticated with
+tokens that can be revoked. Add a limit if you measure a problem. The
+internal `/internal/jobs/*` API is also unrated; it sits behind a
+network gate (RFC1918 only) plus a bearer token.
+
+**Login throttling** (separate concern, lives in the UI): the
+local-admin sign-in is gated by an in-process `LoginThrottle`. Failures
+are bucketed by `(username, source_ip)` so an attacker spraying one IP
+can't lock out a legitimate admin from another. Progression: 5 fails
+→ 60 s lockout, 10 → 300 s, 15+ → 1800 s. A successful login resets
+the bucket. Restart the `ui` container to clear all locks.
+
+See [`security.md`](./security.md) for the full rate-limit and
+brute-force-protection posture.
 
 ### IP normalization
 

+ 56 - 0
doc/architecture.md

@@ -126,6 +126,62 @@ the api, which costs latency and an HTTP hop. In practice the
 containers run on the same Docker network and the per-request cost is
 sub-millisecond.
 
+## Disaster Recovery
+
+The api owns one stateful resource: the database (SQLite file on a
+Docker volume, or MySQL). Everything else — sessions, tokens, GeoIP
+caches — is recoverable without a backup.
+
+### What carries irreplaceable state
+
+| Resource                          | Where                       | Recovery                                  |
+|-----------------------------------|-----------------------------|-------------------------------------------|
+| Reports + scores                  | `reports`, `ip_scores` tables | DB backup                                 |
+| Manual blocks + allowlist         | `manual_blocks`, `allowlist`  | DB backup                                 |
+| Policies + thresholds             | `policies`, `policy_category_thresholds` | DB backup                      |
+| Categories                        | `categories`                | DB backup                                 |
+| Reporters / consumers / users     | `reporters`, `consumers`, `users` | DB backup                          |
+| API tokens (hashes only)          | `api_tokens`                | DB backup; **raw values gone — re-issue** |
+| Audit log                         | `audit_log`                 | DB backup                                 |
+| Service token (`UI_SERVICE_TOKEN`)| `.env` file                 | Back up `.env` or regenerate              |
+| OIDC client secret                | `.env` file                 | Re-fetch from Entra app registration      |
+| Local admin password hash         | `.env` file                 | Regenerate via `password_hash`            |
+| MaxMind/IPinfo credentials        | `.env` file                 | Re-fetch from provider                    |
+| GeoIP MMDBs                       | `irdb-data` volume          | `refresh-geoip` job re-downloads          |
+| Browser sessions                  | UI container's writable layer | Discarded on restart — users re-login   |
+
+### Recovery checklist
+
+1. **Provision** the host: install Docker; restore `.env` (or generate
+   a fresh one — see [`README.md` → Quick start](../README.md#quick-start)
+   and rotate the service token).
+2. **Restore the database** per the relevant block in
+   [`README.md` → Backups](../README.md#backups). For SQLite, drop the
+   restored file into the `irdb-data` volume; for MySQL, pipe the dump
+   through `mysql`.
+3. **Bring up the stack**: `docker compose up -d`. The `migrate`
+   container runs Phinx idempotently — safe to run on a restored DB
+   that's already at HEAD; it'll exit immediately with "no migrations
+   to run".
+4. **Verify** by calling `/healthz` on both containers and signing in
+   to the UI. Trigger `refresh-geoip` from Settings (or accept the
+   first scheduled run) so GeoIP enrichment is populated again.
+5. **Re-issue tokens** for any reporter or consumer that lost its raw
+   token (the database has the SHA-256 hash, but the original string
+   isn't recoverable). Consumers will keep failing 401 until they're
+   given new tokens.
+
+### What we DON'T support
+
+- **Encryption at rest** of the SQLite file. The volume's host-level
+  disk encryption is the right layer; SPEC §15.
+- **Audit log signing / tamper-evidence**. The `audit_log` is
+  append-only at the application layer but a sufficiently privileged
+  attacker with DB access can rewrite history. Future work.
+- **Cross-region replication.** Docker volumes are local; high
+  availability requires a managed MySQL with replication plus an
+  `api` topology that can take advantage of it.
+
 ## Where the rest of the docs live
 
 - [`api-overview.md`](./api-overview.md) — the public API surface, with worked examples.

+ 26 - 0
doc/auth-flows.md

@@ -154,6 +154,32 @@ production deployments **disable** the local admin (`LOCAL_ADMIN_ENABLED=false`)
 once an OIDC user with admin role exists; it's a defence-in-depth
 recommendation, not a hard requirement.
 
+#### Brute-force lockout (M14)
+
+The local-admin sign-in carries a per-process throttle: failures are
+bucketed by `(username, source_ip)` so a single attacker can't lock
+out the legitimate admin from another address. Progression:
+
+| Failures | Lockout |
+|----------|---------|
+| 1–4      | none    |
+| 5        | 60 s    |
+| 10       | 300 s   |
+| 15+      | 1800 s  |
+
+A successful login resets the bucket for that pair. The store is
+in-memory in the UI container, so:
+
+- **To clear all locks**: restart the `ui` container
+  (`docker compose restart ui`). This is the documented unlock
+  path — there is intentionally no API for clearing a lockout
+  (adding one would be a new attack surface).
+- **Multi-replica caveat**: each UI replica has its own counter.
+  Sticky sessions or single-replica are the documented topologies.
+
+OIDC sign-in has no equivalent throttle in the UI — Entra rate-limits
+failed auth at the IdP, which is the right layer for SSO.
+
 ### OIDC login (Microsoft Entra ID)
 
 ```mermaid

+ 280 - 0
doc/security.md

@@ -0,0 +1,280 @@
+# Security
+
+> Audience: operators evaluating IRDB for production, security
+> reviewers. This document describes the **as-built** posture — claims
+> here can be verified against the code in this repo. If you find a
+> claim that doesn't hold, that's a bug.
+
+## At a glance
+
+| Concern              | Posture                                                                       |
+|----------------------|-------------------------------------------------------------------------------|
+| Authentication       | OIDC (Entra ID) + local admin (UI); Bearer tokens (api)                       |
+| Authorization        | RBAC: `viewer` / `operator` / `admin`; enforced server-side on every endpoint |
+| Transport            | Plain HTTP between containers; reverse-proxy TLS termination expected         |
+| Data at rest         | DB: unencrypted (host volume encryption recommended); tokens hashed (SHA-256) |
+| Secret storage       | `.env` for runtime secrets; never persisted to the DB                         |
+| Logging              | JSON to stdout; secret-scrubbing Monolog processor                            |
+| Rate limiting        | Public API: 60 req/s/token (token-bucket); login: brute-force lockout         |
+| Supply chain         | `composer audit` + `npm audit` in CI; locked Dockerfile base image            |
+| Audit                | Append-only `audit_log`; one row per state-changing admin action              |
+
+## Authentication
+
+The system has **two distinct authentication boundaries**.
+
+**Browser users** authenticate to the `ui` container, which owns:
+- The OIDC redirect/callback flow (Microsoft Entra ID via
+  `jumbojett/openid-connect-php`, code flow with PKCE).
+- The local-admin login form, validated against an Argon2id password
+  hash stored in the UI's environment variable
+  `LOCAL_ADMIN_PASSWORD_HASH` — never in the database.
+- File-backed PHP sessions, `HttpOnly`, `SameSite=Lax`, `Secure` when
+  `APP_ENV=production`.
+
+**Machine clients and the UI itself** authenticate to the `api` with a
+Bearer token. There are four token kinds:
+
+| Kind       | Calls                              | Bound to                        |
+|------------|------------------------------------|---------------------------------|
+| `reporter` | `POST /api/v1/report`              | a reporter row                  |
+| `consumer` | `GET /api/v1/blocklist`            | a consumer row                  |
+| `admin`    | `/api/v1/admin/*`                  | a configured RBAC role          |
+| `service`  | `/api/v1/admin/*`, `/api/v1/auth/*` | the UI; carries impersonation header |
+
+Token format: `irdb_<kind3>_<32 base32 chars>`. 160 bits of entropy from
+`random_bytes(20)` (PHP CSPRNG). Verified by
+`api/tests/Unit/Auth/TokenEntropyTest.php`. Tokens are stored as their
+SHA-256 digest plus an 8-character prefix used for log triage —
+verified by `api/tests/Integration/Auth/SchemaSecretsAtRestTest.php`.
+
+The raw token is shown **once** at creation time and never retrievable
+afterwards.
+
+### Brute-force lockout
+
+The local-admin sign-in is gated by `App\Auth\LoginThrottle` (UI
+container). Failures are bucketed by `(username, source_ip)` so a
+single attacker's flood doesn't lock the legitimate admin coming in
+from a different address. Progression:
+
+| Failures | Lockout |
+|----------|---------|
+| 1–4      | none    |
+| 5        | 60 s    |
+| 10       | 300 s   |
+| 15+      | 1800 s  |
+
+Successful login clears the bucket. The store is per-process in
+memory; restarting the UI container clears all locks (the documented
+"unlock the admin" path).
+
+OIDC sign-in has no equivalent throttle — Entra rate-limits failed
+authentication attempts at the IdP, which is the right layer for
+enterprise SSO.
+
+## Authorization
+
+Three roles, strictly hierarchical for read access:
+
+| Role     | Can do                                                         |
+|----------|----------------------------------------------------------------|
+| viewer   | Read everything; cannot mutate                                 |
+| operator | viewer + manage manual blocks + allowlist                       |
+| admin    | operator + tokens, policies, categories, users, jobs, settings |
+
+Enforcement is in the api: every admin route declares the required
+role, and `RbacMiddleware` rejects with `403` on mismatch. The UI
+hides UI elements the user can't use, but **does not enforce**
+security with hidden buttons — direct API access with a lower-role
+token is properly rejected. Every test in
+`tests/Integration/Admin/*ControllerTest.php` exercises this.
+
+The `service` token is special: it's only accepted **with** an
+`X-Acting-User-Id` header, which the api uses to look up the
+impersonated user and apply that user's role. The header is **only**
+trusted in combination with the service token — admin-token requests
+ignore it. Service tokens never appear in `/api/v1/admin/tokens` list
+output and cannot be created via the admin API.
+
+## Transport
+
+The Docker Compose stack runs both containers over plain HTTP. In
+production, **front both with a reverse proxy (Caddy / Traefik /
+nginx) that terminates TLS**, typically with one hostname per
+container. An example Caddy config is in `examples/reverse-proxy/`.
+
+When `APP_ENV=production`, both containers send
+`Strict-Transport-Security: max-age=31536000; includeSubDomains`. This
+is gated on `APP_ENV` because HSTS is sticky in browsers — turning it
+on for `localhost` development locks subsequent localhost work into
+HTTPS for a year.
+
+Other security headers are applied unconditionally (api +
+`api/docker/Caddyfile`, ui + `ui/docker/Caddyfile`):
+
+- `X-Content-Type-Options: nosniff`
+- `X-Frame-Options: DENY` (UI) / `SAMEORIGIN` (api)
+- `Referrer-Policy: strict-origin-when-cross-origin`
+- `Permissions-Policy: geolocation=(), microphone=(), camera=()`
+- `Content-Security-Policy` — strict on the api
+  (`default-src 'none'; frame-ancestors 'none'`); UI policy allows
+  `'self' 'unsafe-eval' 'unsafe-inline'` for scripts to accommodate
+  Alpine.js v3's `Function()` evaluator and inline style attributes;
+  documented trade-off in the Caddyfile.
+
+## Data at rest
+
+The api's DB carries:
+- Reports, scores, manual blocks, allowlist entries, policies,
+  categories, reporters, consumers — operational data, not sensitive.
+- Hashed API tokens (`api_tokens.token_hash`, SHA-256).
+- Audit log (`audit_log`), append-only at the application layer.
+- User identity records (`users`) — email, display name, OIDC subject;
+  no credentials.
+
+The DB itself is **not encrypted at rest** by the application. The
+recommended layer is host disk encryption (LUKS for SQLite-on-volume,
+managed encryption for MySQL).
+
+A regression test (`SchemaSecretsAtRestTest`) scans every column on
+every table for names that suggest plaintext credentials
+(`password`, `_secret`, `client_secret`, `license_key`, ...) and
+fails CI if any appear. The intent: a future migration that adds a
+column called `client_secret` will fail the build before it ships.
+
+## Secret storage
+
+Runtime secrets live in `.env` — read by Compose at boot, exported as
+container environment variables, never written to the DB. The list:
+
+- `UI_SERVICE_TOKEN` — shared between api and ui; the api reads it to
+  authenticate UI-originated calls.
+- `INTERNAL_JOB_TOKEN` — bearer for the scheduler's
+  `/internal/jobs/*` calls. Network-restricted in addition to the
+  token.
+- `APP_SECRET`, `UI_SECRET` — application-internal signing seeds.
+- `LOCAL_ADMIN_PASSWORD_HASH` — Argon2id hash; the raw password is
+  never on disk.
+- `OIDC_CLIENT_SECRET`, `MAXMIND_LICENSE_KEY`, `IPINFO_TOKEN`,
+  `DB_MYSQL_PASSWORD` — third-party credentials; passed straight
+  through.
+
+`GET /api/v1/admin/config` (admin-only) surfaces effective
+configuration with the sensitive values masked: `INTERNAL_JOB_TOKEN`,
+`MAXMIND_LICENSE_KEY`, `IPINFO_TOKEN`, `DB_MYSQL_PASSWORD`,
+`APP_SECRET` show as `***`; `UI_SERVICE_TOKEN` shows the first 8
+characters. Empty values are visible (so misconfiguration is
+debuggable).
+
+## Logging
+
+Both containers log JSON to stdout via Monolog. A
+`SecretScrubbingProcessor` runs on every record before it hits the
+handler, scrubbing:
+
+- Any context key whose name contains `password`, `authorization`,
+  `auth_token`, `bearer`, `secret`, `license_key`, `service_token`,
+  `job_token`, `cookie` — value replaced with `***`.
+- Any string value matching the Bearer + token-shaped pattern — the
+  kind prefix is preserved (`Bearer irdb_adm_***`) so triage logs
+  still tell you "an admin token failed" without leaking the secret
+  half.
+- Argon2 / bcrypt hashes embedded in messages.
+
+Tests:
+`api/tests/Unit/Logging/SecretScrubbingProcessorTest.php`,
+`ui/tests/Unit/Logging/SecretScrubbingProcessorTest.php`.
+
+## Rate limiting
+
+**Public API**: token-bucket per token, default 60 req/s with a burst
+of 120, configurable via `API_RATE_LIMIT_PER_SECOND`. Returns `429`
+with `Retry-After: 1` when exhausted. **In-process per replica** —
+multi-replica deployments will see N × the rate before exhaustion;
+this is acceptable for the documented topology (single api replica
+for SQLite, scaled api with shared MySQL).
+
+**Login**: brute-force lockout per `(username, ip)` (above).
+
+**Admin endpoints** are unrated. Real abuse on admin endpoints is
+rare (Bearer-authed humans/UI). Add a limit if you measure a problem.
+
+## Supply chain
+
+- `composer audit --no-dev` runs in CI for both subprojects, failing
+  on any reported advisory against production dependencies.
+- `npm audit --omit=dev --audit-level=high` runs in CI for the UI's
+  asset bundle. Moderate advisories are not gating but should be
+  reviewed during dependency bump passes.
+- The Dockerfile base image (`dunglas/frankenphp:1-php8.3-alpine`) is
+  pinned to a specific tag. Updating it is a deliberate operator
+  action.
+
+**Policy**: when an audit fails:
+1. The CI job blocks the merge.
+2. An admin reviews the advisory and either:
+   - patches the dependency, or
+   - documents an accepted exception (advisory ID, reason, expected
+     remediation date) in this section.
+
+No exceptions are currently accepted.
+
+## Audit
+
+Every state-changing admin action emits one row to `audit_log` via
+the `AuditEmitter` (M12). The actor recording rule:
+
+- service-token + `X-Acting-User-Id` → `actor_kind=user`,
+  `actor_id=<user_id>` (the impersonated user is the responsible
+  party — not the service token, even though the call carries it).
+- admin token → `actor_kind=admin-token`, `actor_id=<token_id>`.
+- reporter / consumer tokens → `actor_kind=reporter` / `consumer`.
+- system-internal (e.g. expired-block cleanup job) → `actor_kind=system`,
+  no `actor_id`.
+
+Failed validation paths (`400`s) don't emit. Only successful state
+changes do. The `audit_log` table is **append-only at the application
+layer** — there is no UI for editing or deleting rows — but a
+sufficiently privileged DB user can rewrite history. Tamper-evident
+audit (chained signing) is future work.
+
+## Out of scope
+
+The following are deliberately not built into IRDB; the stack
+expects them at higher layers:
+
+- **WAF / IPS / fail2ban-on-the-UI**. Run one in front of the
+  reverse proxy if you need it.
+- **2FA on the local admin path**. Use OIDC for that — Entra has
+  first-class MFA support. The local admin is a recovery channel,
+  not a daily driver.
+- **mTLS between api and ui**. Docker network isolation is the
+  trust boundary in the default compose deployment; document and
+  enforce that boundary at your network layer.
+- **Encryption at rest of the SQLite file**. Host disk encryption
+  is the right layer.
+- **Tamper-evident audit log**. Future work; the append-only
+  guarantee today is application-level only.
+- **Penetration test report**. Out of scope for the build.
+
+## Threat model snapshot
+
+A non-exhaustive list of threats and the controls that mitigate them.
+
+| Threat                                            | Control                                                          |
+|---------------------------------------------------|------------------------------------------------------------------|
+| Stolen Bearer token in transit                     | Reverse-proxy TLS termination; HSTS in production                 |
+| Stolen Bearer token at rest in DB                  | Tokens stored as SHA-256; raw value never persisted               |
+| Token leaked via logs                              | `SecretScrubbingProcessor` scrubs Bearer values before output     |
+| Brute-force on local admin                         | Per-(user,ip) lockout: 5/60, 10/300, 15/1800 seconds              |
+| Public API abuse                                   | 60 req/s/token token-bucket; `429 Retry-After: 1`                 |
+| CSRF on UI form                                    | Per-session CSRF token validated by `CsrfMiddleware`              |
+| Click-jacking / framing                            | `X-Frame-Options: DENY` + CSP `frame-ancestors 'none'`            |
+| MIME confusion                                     | `X-Content-Type-Options: nosniff`                                 |
+| Internal jobs reachable from outside               | Caddy 404s `/internal/*` outside RFC1918 + bearer required        |
+| Privileged action without audit                    | All state-changing admin endpoints emit one `audit_log` row each  |
+| Schema drift introducing plaintext secret column   | `SchemaSecretsAtRestTest` regression scan in CI                   |
+| Vulnerable dependency in production deps           | `composer audit` + `npm audit` in CI, fail on advisory            |
+
+If you find a threat the table doesn't address, please open an issue.

+ 15 - 0
scripts/ci.sh

@@ -78,6 +78,12 @@ run_php api "$PHP_IMAGE" composer stan
 banner "api: php-cs-fixer (dry-run)"
 run_php api "$PHP_IMAGE" composer cs
 
+# Dependency vulnerability scan (SPEC §M14.9). Fails on any advisory.
+# Policy: when this fails an admin reviews and either patches or
+# accepts with a documented exception (see doc/security.md).
+banner "api: composer audit"
+run_php api "$PHP_IMAGE" composer audit --no-dev
+
 for driver in $DB_DRIVERS; do
     if [ "$driver" = "mysql" ]; then
         # No MySQL reachable from the host in M01; skip gracefully. Once
@@ -100,6 +106,9 @@ run_php ui "$PHP_IMAGE" composer stan
 banner "ui: php-cs-fixer (dry-run)"
 run_php ui "$PHP_IMAGE" composer cs
 
+banner "ui: composer audit"
+run_php ui "$PHP_IMAGE" composer audit --no-dev
+
 banner "ui: phpunit"
 run_php ui "$PHP_IMAGE" composer test
 
@@ -115,6 +124,12 @@ fi
 banner "ui: npm run build"
 run_node ui npm run build
 
+# Production-deps vuln scan (SPEC §M14.9). `--audit-level=high` fails
+# only on high/critical so noisy moderate advisories don't block CI;
+# they're expected to be reviewed during a routine bump pass.
+banner "ui: npm audit (production deps, high+critical)"
+run_node ui npm audit --omit=dev --audit-level=high
+
 if [ ! -f ui/public/assets/app.css ]; then
     fail "ui/public/assets/app.css was not produced by the tailwind build"
 fi

+ 26 - 0
ui/docker/Caddyfile

@@ -5,10 +5,36 @@
     order php_server before file_server
     auto_https off
     admin off
+    servers {
+        trusted_proxies static private_ranges
+    }
 }
 
 :8080 {
     root * /app/public
     encode zstd gzip
+
+    # ── Security headers (M14) ──────────────────────────────────────────
+    header {
+        -Server
+        -X-Powered-By
+        X-Content-Type-Options "nosniff"
+        X-Frame-Options "DENY"
+        Referrer-Policy "strict-origin-when-cross-origin"
+        Permissions-Policy "geolocation=(), microphone=(), camera=()"
+        # CSP for the ui:
+        #   - script-src needs 'unsafe-eval' for Alpine.js v3's Function()
+        #     constructor. Migrating to @alpinejs/csp would let us drop it
+        #     but requires rewriting every x-data="..." inline expression.
+        #     Documented trade-off.
+        #   - style-src 'unsafe-inline' for inline style attrs that drive
+        #     score bars and dynamic widths.
+        #   - img-src data: for inline SVG icons.
+        Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
+    }
+
+    @prod expression `{env.APP_ENV} == "production"`
+    header @prod Strict-Transport-Security "max-age=31536000; includeSubDomains"
+
     php_server
 }

+ 12 - 0
ui/src/App/Container.php

@@ -10,6 +10,7 @@ use App\ApiClient\ApiHealth;
 use App\ApiClient\AuthClient;
 use App\Auth\JumbojettOidcAuthenticator;
 use App\Auth\LocalLoginController;
+use App\Auth\LoginThrottle;
 use App\Auth\LogoutController;
 use App\Auth\OidcAuthenticator;
 use App\Auth\OidcController;
@@ -92,6 +93,7 @@ final class Container
                 $handler = new StreamHandler('php://stdout', $level);
                 $handler->setFormatter(new JsonFormatter());
                 $logger->pushHandler($handler);
+                $logger->pushProcessor(new \App\Logging\SecretScrubbingProcessor());
 
                 return $logger;
             }),
@@ -215,6 +217,13 @@ final class Container
             AuditController::class => autowire(),
             SettingsController::class => autowire(),
 
+            LoginThrottle::class => factory(static function (ContainerInterface $c): LoginThrottle {
+                /** @var LoggerInterface $logger */
+                $logger = $c->get(LoggerInterface::class);
+
+                return new LoginThrottle($logger);
+            }),
+
             LocalLoginController::class => factory(static function (ContainerInterface $c): LocalLoginController {
                 /** @var Twig $twig */
                 $twig = $c->get(Twig::class);
@@ -222,6 +231,8 @@ final class Container
                 $sessions = $c->get(SessionManager::class);
                 /** @var AuthClient $auth */
                 $auth = $c->get(AuthClient::class);
+                /** @var LoginThrottle $throttle */
+                $throttle = $c->get(LoginThrottle::class);
                 /** @var ResponseFactoryInterface $rf */
                 $rf = $c->get(ResponseFactoryInterface::class);
                 /** @var LoggerInterface $logger */
@@ -231,6 +242,7 @@ final class Container
                     $twig,
                     $sessions,
                     $auth,
+                    $throttle,
                     $rf,
                     $logger,
                     (bool) $c->get('settings.local_admin_enabled'),

+ 38 - 13
ui/src/Auth/LocalLoginController.php

@@ -16,15 +16,16 @@ use Slim\Views\Twig;
  * Local-admin sign-in.
  *
  * Flow on POST:
- *   1. Throttle gate — if the session is locked (5 fails / 30 s), refuse.
+ *   1. Throttle gate — `LoginThrottle` keyed by `(username, source_ip)`
+ *      (SPEC §M14.2). If locked, refuse with a flash and redirect.
  *   2. Username must equal `LOCAL_ADMIN_USERNAME`.
  *   3. `password_verify` against the Argon2id hash from the env.
- *   4. On success: clear throttle, call `AuthClient::upsertLocal()` to
+ *   4. On success: clear the throttle, call `AuthClient::upsertLocal()` to
  *      ensure the api has a `users` row with `is_local=1, role=admin`,
  *      regenerate session id, set the session user, redirect to
- *      `next` (or `/app/me`).
- *   5. On failure: increment the throttle, flash an error, redirect
- *      back to `/login`.
+ *      `next` (or `/app/dashboard`).
+ *   5. On failure: record on the throttle (per-process in-memory store),
+ *      flash an error, redirect back to `/login`.
  */
 final class LocalLoginController
 {
@@ -32,6 +33,7 @@ final class LocalLoginController
         private readonly Twig $twig,
         private readonly SessionManager $sessions,
         private readonly AuthClient $auth,
+        private readonly LoginThrottle $throttle,
         private readonly ResponseFactoryInterface $responseFactory,
         private readonly LoggerInterface $logger,
         private readonly bool $localAdminEnabled,
@@ -59,23 +61,25 @@ final class LocalLoginController
             return $this->responseFactory->createResponse(404);
         }
 
-        if ($this->sessions->isLoginLocked()) {
+        $body = $request->getParsedBody();
+        $username = is_array($body) && isset($body['username']) && is_string($body['username']) ? $body['username'] : '';
+        $password = is_array($body) && isset($body['password']) && is_string($body['password']) ? $body['password'] : '';
+        $sourceIp = self::extractSourceIp($request);
+
+        // Throttle gate is keyed by (username, source_ip). An empty
+        // username still gets a bucket so blank-form spamming is rate-limited.
+        if ($this->throttle->isLocked($username, $sourceIp)) {
             $this->sessions->flash('error', 'Too many failed attempts. Try again in a moment.');
 
             return $response->withStatus(303)->withHeader('Location', '/login');
         }
 
-        $body = $request->getParsedBody();
-        $username = is_array($body) && isset($body['username']) && is_string($body['username']) ? $body['username'] : '';
-        $password = is_array($body) && isset($body['password']) && is_string($body['password']) ? $body['password'] : '';
-
         $usernameOk = hash_equals($this->localUsername, $username);
         $passwordOk = $this->localPasswordHash !== '' && password_verify($password, $this->localPasswordHash);
 
         if (!$usernameOk || !$passwordOk) {
-            $this->sessions->recordLoginFailure();
+            $this->throttle->recordFailure($username, $sourceIp);
             $this->sessions->flash('error', 'Invalid username or password.');
-            $this->logger->info('local login failed', ['username' => $username]);
 
             return $response->withStatus(303)->withHeader('Location', '/login');
         }
@@ -89,7 +93,7 @@ final class LocalLoginController
             return $response->withStatus(303)->withHeader('Location', '/login');
         }
 
-        $this->sessions->clearLoginThrottle();
+        $this->throttle->clear($username, $sourceIp);
         $this->sessions->regenerateId();
         $this->sessions->setUser(new UserContext(
             userId: $user->userId,
@@ -103,4 +107,25 @@ final class LocalLoginController
 
         return $response->withStatus(303)->withHeader('Location', $next);
     }
+
+    /**
+     * Best-effort source IP. Honours X-Forwarded-For when present (Caddy in
+     * front sets it for trusted proxies), else falls back to the request's
+     * server param. We never trust the header for routing decisions; this
+     * is for bucketing brute-force attempts only.
+     */
+    private static function extractSourceIp(ServerRequestInterface $request): string
+    {
+        $xff = $request->getHeaderLine('X-Forwarded-For');
+        if ($xff !== '') {
+            $first = trim(explode(',', $xff)[0]);
+            if ($first !== '') {
+                return $first;
+            }
+        }
+        $server = $request->getServerParams();
+        $remote = $server['REMOTE_ADDR'] ?? '';
+
+        return is_string($remote) ? $remote : '';
+    }
 }

+ 138 - 0
ui/src/Auth/LoginThrottle.php

@@ -0,0 +1,138 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Auth;
+
+use Psr\Log\LoggerInterface;
+
+/**
+ * Brute-force lockout for the local-admin sign-in (SPEC §M14.2).
+ *
+ * Attempts are bucketed by `(username, source_ip)` so an attacker who is
+ * spraying one username from one IP can't lock out a legitimate admin
+ * coming in from a different IP. Storage is a per-process in-memory array;
+ * the singleton is constructed once per FrankenPHP worker and lives for
+ * the worker's lifetime. A container restart clears all entries — this is
+ * intentional and documented as the operator's "unlock the admin" path.
+ *
+ * Failure progression (clamped — the brief's "1/5/30" steps):
+ *   1–4 failures      fast retry, no lockout
+ *   5  failures       lock for 60 s
+ *   10 failures       lock for 300 s
+ *   15+ failures      lock for 1800 s
+ *
+ * Successful login clears the bucket.
+ *
+ * Multi-replica caveat: each replica has its own counter. A determined
+ * attacker who can reach multiple replicas through a non-sticky LB would
+ * see the 5-fail step at 5*N total attempts. Single-replica is the
+ * documented topology for the ui container; if you scale, put the LB in
+ * sticky mode.
+ */
+final class LoginThrottle
+{
+    /**
+     * @var array<string, array{count: int, lockedUntil: int}>
+     */
+    private array $entries = [];
+
+    /** @var \Closure(): int */
+    private \Closure $timeFn;
+
+    public function __construct(
+        private readonly LoggerInterface $logger,
+        ?\Closure $timeFn = null,
+    ) {
+        $this->timeFn = $timeFn ?? static fn (): int => time();
+    }
+
+    public function isLocked(string $username, string $ip): bool
+    {
+        return $this->lockoutSecondsRemaining($username, $ip) > 0;
+    }
+
+    public function lockoutSecondsRemaining(string $username, string $ip): int
+    {
+        $key = self::key($username, $ip);
+        $entry = $this->entries[$key] ?? null;
+        if ($entry === null) {
+            return 0;
+        }
+        $now = ($this->timeFn)();
+        if ($entry['lockedUntil'] <= $now) {
+            return 0;
+        }
+
+        return $entry['lockedUntil'] - $now;
+    }
+
+    public function recordFailure(string $username, string $ip): void
+    {
+        $key = self::key($username, $ip);
+        $now = ($this->timeFn)();
+        $entry = $this->entries[$key] ?? ['count' => 0, 'lockedUntil' => 0];
+        $entry['count']++;
+        $lockSeconds = self::lockoutSecondsForAttempt($entry['count']);
+        if ($lockSeconds > 0) {
+            $entry['lockedUntil'] = $now + $lockSeconds;
+            $this->logger->error('local login lockout triggered', [
+                'username' => $username,
+                'source_ip' => $ip,
+                'failure_count' => $entry['count'],
+                'lock_seconds' => $lockSeconds,
+            ]);
+        } else {
+            $this->logger->warning('local login failure', [
+                'username' => $username,
+                'source_ip' => $ip,
+                'failure_count' => $entry['count'],
+            ]);
+        }
+        $this->entries[$key] = $entry;
+    }
+
+    public function clear(string $username, string $ip): void
+    {
+        unset($this->entries[self::key($username, $ip)]);
+    }
+
+    /**
+     * Reset all entries. Used by tests; not used at runtime (a container
+     * restart is the documented unlock path).
+     */
+    public function reset(): void
+    {
+        $this->entries = [];
+    }
+
+    /**
+     * Lockout duration in seconds for the given failure count, or 0 if
+     * no lockout should fire yet. The early-returns descend from highest
+     * threshold to lowest so each step kicks in at exactly the documented
+     * count; PHPStan flags `&& $count < 15` as redundant after the first
+     * check, so we rely on the early-return ordering instead.
+     */
+    private static function lockoutSecondsForAttempt(int $count): int
+    {
+        if ($count >= 15) {
+            return 1800;
+        }
+        if ($count >= 10) {
+            return 300;
+        }
+        if ($count >= 5) {
+            return 60;
+        }
+
+        return 0;
+    }
+
+    private static function key(string $username, string $ip): string
+    {
+        // Lower-case the username so casing doesn't multiply buckets, but
+        // keep the IP byte-for-byte (v6 is case-sensitive in canonical form
+        // already).
+        return strtolower($username) . '|' . $ip;
+    }
+}

+ 0 - 52
ui/src/Auth/SessionManager.php

@@ -28,7 +28,6 @@ class SessionManager
     private const KEY_FLASH = '_flash';
     private const KEY_NEXT = '_next';
     private const KEY_OIDC = '_oidc';
-    private const KEY_LOGIN_THROTTLE = '_login_throttle';
 
     public function __construct(
         private readonly bool $secureCookie = true,
@@ -182,57 +181,6 @@ class SessionManager
         return $out;
     }
 
-    /**
-     * Login throttle: SPEC §M08.5 says 5 failures / 30s lockout. Stored in
-     * the session so the cookie itself is the rate-limit key — anonymous
-     * requests share whatever session they got from `GET /login`. Full
-     * brute-force protection is M14.
-     *
-     * @return array{count: int, locked_until: ?int}
-     */
-    public function loginThrottleState(): array
-    {
-        $row = $_SESSION[self::KEY_LOGIN_THROTTLE] ?? null;
-        if (!is_array($row)) {
-            return ['count' => 0, 'locked_until' => null];
-        }
-        $count = isset($row['count']) ? (int) $row['count'] : 0;
-        $until = isset($row['locked_until']) && $row['locked_until'] !== null ? (int) $row['locked_until'] : null;
-
-        return ['count' => $count, 'locked_until' => $until];
-    }
-
-    public function recordLoginFailure(int $maxAttempts = 5, int $lockoutSeconds = 30): void
-    {
-        $state = $this->loginThrottleState();
-        $state['count'] += 1;
-        if ($state['count'] >= $maxAttempts) {
-            $state['locked_until'] = time() + $lockoutSeconds;
-            $state['count'] = 0;
-        }
-        $_SESSION[self::KEY_LOGIN_THROTTLE] = $state;
-    }
-
-    public function isLoginLocked(): bool
-    {
-        $state = $this->loginThrottleState();
-        if ($state['locked_until'] === null) {
-            return false;
-        }
-        if ($state['locked_until'] <= time()) {
-            unset($_SESSION[self::KEY_LOGIN_THROTTLE]);
-
-            return false;
-        }
-
-        return true;
-    }
-
-    public function clearLoginThrottle(): void
-    {
-        unset($_SESSION[self::KEY_LOGIN_THROTTLE]);
-    }
-
     /**
      * Drop the session if it's been idle past `idleSeconds` or older than
      * `absoluteSeconds` since auth. SPEC §M08.2.

+ 103 - 0
ui/src/Logging/SecretScrubbingProcessor.php

@@ -0,0 +1,103 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Logging;
+
+use Monolog\LogRecord;
+use Monolog\Processor\ProcessorInterface;
+
+/**
+ * Mirror of the api-side processor (SPEC §M14.4). Scrubs Bearer tokens,
+ * passwords, secrets and known-sensitive-keyed context values from records
+ * before they hit a handler.
+ *
+ * Kept as a per-container copy rather than a shared package to preserve
+ * the api/ui zero-shared-code invariant in the SPEC.
+ */
+final class SecretScrubbingProcessor implements ProcessorInterface
+{
+    private const REDACTED = '***';
+
+    private const SENSITIVE_KEY_NEEDLES = [
+        'password',
+        'authorization',
+        'auth_token',
+        'access_token',
+        'refresh_token',
+        'bearer',
+        'secret',
+        'license_key',
+        'license-key',
+        'license_token',
+        'ipinfo_token',
+        'service_token',
+        'job_token',
+        'cookie',
+    ];
+
+    /**
+     * @var list<array{0: string, 1: string}>
+     */
+    private const VALUE_PATTERNS = [
+        ['/(Bearer\s+irdb_(?:rep|con|adm|svc)_)[A-Z2-7]{32}/', '$1***'],
+        ['/(Bearer\s+)[A-Za-z0-9._\-]{20,}/', '$1***'],
+        ['/\birdb_(rep|con|adm|svc)_[A-Z2-7]{32}\b/', 'irdb_$1_***'],
+        ['/\$argon2(?:i|id|d)\$[^\s\'"]+/', '$argon2***'],
+        ['/\$2[aby]?\$\d{2}\$[A-Za-z0-9.\/]{53}/', '$2***'],
+    ];
+
+    public function __invoke(LogRecord $record): LogRecord
+    {
+        $context = self::scrubArray($record->context);
+        $extra = self::scrubArray($record->extra);
+        $message = self::scrubString($record->message);
+
+        return $record->with(message: $message, context: $context, extra: $extra);
+    }
+
+    /**
+     * @param array<array-key, mixed> $data
+     * @return array<array-key, mixed>
+     */
+    private static function scrubArray(array $data): array
+    {
+        $out = [];
+        foreach ($data as $key => $value) {
+            if (is_string($key) && self::isSensitiveKey($key)) {
+                $out[$key] = self::REDACTED;
+                continue;
+            }
+            if (is_array($value)) {
+                $out[$key] = self::scrubArray($value);
+            } elseif (is_string($value)) {
+                $out[$key] = self::scrubString($value);
+            } else {
+                $out[$key] = $value;
+            }
+        }
+
+        return $out;
+    }
+
+    private static function isSensitiveKey(string $key): bool
+    {
+        $lower = strtolower($key);
+        foreach (self::SENSITIVE_KEY_NEEDLES as $needle) {
+            if (str_contains($lower, $needle)) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    private static function scrubString(string $value): string
+    {
+        foreach (self::VALUE_PATTERNS as [$pattern, $replacement]) {
+            $value = (string) preg_replace($pattern, $replacement, $value);
+        }
+
+        return $value;
+    }
+}

+ 8 - 3
ui/tests/Integration/Auth/LocalLoginTest.php

@@ -4,6 +4,7 @@ declare(strict_types=1);
 
 namespace App\Tests\Integration\Auth;
 
+use App\Auth\LoginThrottle;
 use App\Http\CsrfMiddleware;
 use App\Tests\Integration\Support\AppTestCase;
 
@@ -78,9 +79,13 @@ final class LocalLoginTest extends AppTestCase
         $body = http_build_query(['csrf_token' => $token, 'username' => 'someone', 'password' => 'test1234']);
         $this->request('POST', '/login/local', [], $body, 'application/x-www-form-urlencoded');
 
-        $throttle = $_SESSION['_login_throttle'] ?? null;
-        self::assertNotNull($throttle);
-        self::assertSame(1, $throttle['count']);
+        // Failure recorded on the LoginThrottle (not in the session).
+        // Five more attempts from the same IP for the same bogus user
+        // would lock the bucket; one shot doesn't, so we just verify
+        // the next attempt isn't locked yet.
+        /** @var LoginThrottle $throttle */
+        $throttle = $this->container->get(LoginThrottle::class);
+        self::assertFalse($throttle->isLocked('someone', ''));
     }
 
     public function testCsrfMissingIs403(): void

+ 123 - 0
ui/tests/Unit/Auth/LoginThrottleTest.php

@@ -0,0 +1,123 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Unit\Auth;
+
+use App\Auth\LoginThrottle;
+use Monolog\Handler\NullHandler;
+use Monolog\Logger;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Brute-force lockout progression: 5/10/15 attempts trigger 60/300/1800-second
+ * locks. The (username, ip) tuple gates the bucket so the legit admin from
+ * another IP isn't locked out by an attacker spraying one address.
+ */
+final class LoginThrottleTest extends TestCase
+{
+    public function testFastRetryUnderFive(): void
+    {
+        $t = $this->throttle();
+        for ($i = 0; $i < 4; ++$i) {
+            $t->recordFailure('admin', '10.0.0.1');
+        }
+        self::assertFalse($t->isLocked('admin', '10.0.0.1'));
+    }
+
+    public function testFifthFailureLocksForOneMinute(): void
+    {
+        $t = $this->throttle();
+        for ($i = 0; $i < 5; ++$i) {
+            $t->recordFailure('admin', '10.0.0.1');
+        }
+        self::assertTrue($t->isLocked('admin', '10.0.0.1'));
+        self::assertGreaterThanOrEqual(60, $t->lockoutSecondsRemaining('admin', '10.0.0.1'));
+        self::assertLessThanOrEqual(60, $t->lockoutSecondsRemaining('admin', '10.0.0.1'));
+    }
+
+    public function testTenthFailureLocksForFiveMinutes(): void
+    {
+        $now = 1000000;
+        $t = $this->throttle($now);
+        for ($i = 0; $i < 10; ++$i) {
+            $t->recordFailure('admin', '10.0.0.1');
+        }
+        self::assertSame(300, $t->lockoutSecondsRemaining('admin', '10.0.0.1'));
+    }
+
+    public function testFifteenthFailureLocksForThirtyMinutes(): void
+    {
+        $now = 1000000;
+        $t = $this->throttle($now);
+        for ($i = 0; $i < 15; ++$i) {
+            $t->recordFailure('admin', '10.0.0.1');
+        }
+        self::assertSame(1800, $t->lockoutSecondsRemaining('admin', '10.0.0.1'));
+    }
+
+    public function testLockoutExpiresAfterTimeAdvance(): void
+    {
+        $now = 1000000;
+        $t = new LoginThrottle($this->logger(), function () use (&$now): int {
+            return $now;
+        });
+        for ($i = 0; $i < 5; ++$i) {
+            $t->recordFailure('admin', '10.0.0.1');
+        }
+        self::assertTrue($t->isLocked('admin', '10.0.0.1'));
+        $now += 61;
+        self::assertFalse($t->isLocked('admin', '10.0.0.1'));
+    }
+
+    public function testDifferentIpHasIndependentBucket(): void
+    {
+        $t = $this->throttle();
+        for ($i = 0; $i < 5; ++$i) {
+            $t->recordFailure('admin', '10.0.0.1');
+        }
+        self::assertTrue($t->isLocked('admin', '10.0.0.1'));
+        self::assertFalse($t->isLocked('admin', '10.0.0.2'));
+    }
+
+    public function testClearResetsBucket(): void
+    {
+        $t = $this->throttle();
+        for ($i = 0; $i < 5; ++$i) {
+            $t->recordFailure('admin', '10.0.0.1');
+        }
+        $t->clear('admin', '10.0.0.1');
+        self::assertFalse($t->isLocked('admin', '10.0.0.1'));
+        self::assertSame(0, $t->lockoutSecondsRemaining('admin', '10.0.0.1'));
+    }
+
+    public function testUsernameCaseDoesNotMultiplyBuckets(): void
+    {
+        $t = $this->throttle();
+        for ($i = 0; $i < 3; ++$i) {
+            $t->recordFailure('admin', '10.0.0.1');
+        }
+        for ($i = 0; $i < 2; ++$i) {
+            $t->recordFailure('ADMIN', '10.0.0.1');
+        }
+        self::assertTrue($t->isLocked('Admin', '10.0.0.1'));
+    }
+
+    private function throttle(?int $fixedTime = null): LoginThrottle
+    {
+        if ($fixedTime === null) {
+            return new LoginThrottle($this->logger());
+        }
+
+        return new LoginThrottle($this->logger(), static fn (): int => $fixedTime);
+    }
+
+    private function logger(): LoggerInterface
+    {
+        $l = new Logger('test');
+        $l->pushHandler(new NullHandler());
+
+        return $l;
+    }
+}

+ 0 - 44
ui/tests/Unit/Auth/SessionManagerTest.php

@@ -79,50 +79,6 @@ final class SessionManagerTest extends TestCase
         self::assertNull($sm->consumeNext());
     }
 
-    public function testLoginThrottleLocksAfterFiveFailures(): void
-    {
-        $sm = $this->mgr();
-        $sm->startSession();
-
-        for ($i = 0; $i < 4; ++$i) {
-            $sm->recordLoginFailure();
-        }
-        self::assertFalse($sm->isLoginLocked());
-
-        $sm->recordLoginFailure();
-        self::assertTrue($sm->isLoginLocked());
-    }
-
-    public function testLoginThrottleLockExpires(): void
-    {
-        $sm = $this->mgr();
-        $sm->startSession();
-        for ($i = 0; $i < 5; ++$i) {
-            $sm->recordLoginFailure();
-        }
-        self::assertTrue($sm->isLoginLocked());
-
-        // Backdate the lock to past.
-        $state = $_SESSION['_login_throttle'];
-        $state['locked_until'] = time() - 10;
-        $_SESSION['_login_throttle'] = $state;
-
-        self::assertFalse($sm->isLoginLocked());
-    }
-
-    public function testClearLoginThrottleResets(): void
-    {
-        $sm = $this->mgr();
-        $sm->startSession();
-        for ($i = 0; $i < 5; ++$i) {
-            $sm->recordLoginFailure();
-        }
-        $sm->clearLoginThrottle();
-
-        self::assertFalse($sm->isLoginLocked());
-        self::assertSame(['count' => 0, 'locked_until' => null], $sm->loginThrottleState());
-    }
-
     public function testIdleTimeoutWipesUser(): void
     {
         $sm = $this->mgr(idle: 5);

+ 76 - 0
ui/tests/Unit/Logging/SecretScrubbingProcessorTest.php

@@ -0,0 +1,76 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Unit\Logging;
+
+use App\Logging\SecretScrubbingProcessor;
+use Monolog\Formatter\JsonFormatter;
+use Monolog\Level;
+use Monolog\LogRecord;
+use PHPUnit\Framework\TestCase;
+
+final class SecretScrubbingProcessorTest extends TestCase
+{
+    public function testBearerTokenInContextIsScrubbed(): void
+    {
+        $processor = new SecretScrubbingProcessor();
+        $record = $this->record('outbound api call', [
+            'authorization' => 'Bearer irdb_svc_ABCDEFGHIJKLMNOPQRSTUVWXYZ234567',
+        ]);
+
+        $out = $processor($record);
+        self::assertSame('***', $out->context['authorization']);
+    }
+
+    public function testFormattedOutputDoesNotLeakBearerToken(): void
+    {
+        $processor = new SecretScrubbingProcessor();
+        $record = $this->record('outbound', [
+            'headers' => ['Authorization' => 'Bearer irdb_svc_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'],
+        ]);
+
+        $out = $processor($record);
+        $line = (new JsonFormatter())->format($out);
+
+        self::assertStringNotContainsString('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', $line);
+        self::assertStringContainsString('***', $line);
+    }
+
+    public function testLocalAdminPasswordHashKeyScrubbed(): void
+    {
+        $processor = new SecretScrubbingProcessor();
+        $record = $this->record('config', [
+            'LOCAL_ADMIN_PASSWORD_HASH' => '$argon2id$v=19$abc$def',
+            'OIDC_CLIENT_SECRET' => 'oidc-secret',
+        ]);
+
+        $out = $processor($record);
+        self::assertSame('***', $out->context['LOCAL_ADMIN_PASSWORD_HASH']);
+        self::assertSame('***', $out->context['OIDC_CLIENT_SECRET']);
+    }
+
+    public function testNonSensitiveLeftAlone(): void
+    {
+        $processor = new SecretScrubbingProcessor();
+        $record = $this->record('search ok', ['count' => 42, 'q' => '203.0.113.42']);
+
+        $out = $processor($record);
+        self::assertSame(42, $out->context['count']);
+        self::assertSame('203.0.113.42', $out->context['q']);
+    }
+
+    /**
+     * @param array<string, mixed> $context
+     */
+    private function record(string $message, array $context): LogRecord
+    {
+        return new LogRecord(
+            datetime: new \DateTimeImmutable(),
+            channel: 'test',
+            level: Level::Info,
+            message: $message,
+            context: $context,
+        );
+    }
+}