'demo-web-edge', 'description' => 'Demo: edge webserver', 'trust_weight' => 1.0], ['name' => 'demo-web-eu', 'description' => 'Demo: EU edge webserver', 'trust_weight' => 1.0], ['name' => 'demo-web-us', 'description' => 'Demo: US edge webserver', 'trust_weight' => 1.0], ['name' => 'demo-fail2ban', 'description' => 'Demo: fail2ban agents', 'trust_weight' => 1.2], ['name' => 'demo-fail2ban-prod','description' => 'Demo: fail2ban prod fleet', 'trust_weight' => 1.3], ['name' => 'demo-honeypot', 'description' => 'Demo: research honeypot', 'trust_weight' => 1.5], ['name' => 'demo-honeypot-asia','description' => 'Demo: APAC honeypot mesh', 'trust_weight' => 1.6], ['name' => 'demo-ids-suricata', 'description' => 'Demo: Suricata IDS sensors', 'trust_weight' => 1.4], ['name' => 'demo-mail-gateway', 'description' => 'Demo: anti-spam mail gateway', 'trust_weight' => 1.1], ['name' => 'demo-waf-cloud', 'description' => 'Demo: cloud WAF aggregator', 'trust_weight' => 1.3], ]; private const DEMO_CONSUMERS = [ ['name' => 'demo-firewall-prod', 'description' => 'Demo: production edge firewall', 'policy' => 'moderate'], ['name' => 'demo-firewall-dmz', 'description' => 'Demo: DMZ perimeter firewall', 'policy' => 'paranoid'], ['name' => 'demo-proxy-staging', 'description' => 'Demo: staging proxy', 'policy' => 'strict'], ['name' => 'demo-haproxy-public', 'description' => 'Demo: public HAProxy LB', 'policy' => 'moderate'], ['name' => 'demo-nginx-api', 'description' => 'Demo: API nginx ingress', 'policy' => 'strict'], ['name' => 'demo-vpn-gateway', 'description' => 'Demo: VPN gateway', 'policy' => 'paranoid'], ['name' => 'demo-mail-relay', 'description' => 'Demo: outbound mail relay', 'policy' => 'moderate'], ]; private const DEMO_MANUAL_BLOCKS = [ ['kind' => 'ip', 'value' => '198.51.100.42', 'reason' => 'Demo: known malicious actor', 'age_days' => 1], ['kind' => 'ip', 'value' => '198.51.100.55', 'reason' => 'Demo: persistent brute-force', 'age_days' => 2], ['kind' => 'ip', 'value' => '203.0.113.190', 'reason' => 'Demo: WAF auto-block', 'age_days' => 3], ['kind' => 'ip', 'value' => '192.0.2.244', 'reason' => 'Demo: spam relay', 'age_days' => 5], ['kind' => 'ip', 'value' => '198.51.100.140', 'reason' => 'Demo: scanner cluster member', 'age_days' => 7], ['kind' => 'ip', 'value' => '203.0.113.230', 'reason' => 'Demo: confirmed C2 endpoint', 'age_days' => 10], ['kind' => 'ip', 'value' => '192.0.2.99', 'reason' => 'Demo: malware dropper host', 'age_days' => 14], ['kind' => 'ip', 'value' => '198.51.100.250', 'reason' => 'Demo: botnet member', 'age_days' => 21], ['kind' => 'ip', 'value' => '203.0.113.120', 'reason' => 'Demo: credential stuffing', 'age_days' => 30], ['kind' => 'ip', 'value' => '192.0.2.42', 'reason' => 'Demo: reflective DDoS source', 'age_days' => 45], ['kind' => 'subnet', 'value' => '198.51.100.0/24','reason' => 'Demo: blanket block on test net','age_days' => 60], ['kind' => 'subnet', 'value' => '192.0.2.128/26', 'reason' => 'Demo: hostile subnet range', 'age_days' => 75], ]; private const DEMO_ALLOWLIST = [ ['kind' => 'ip', 'value' => '203.0.113.10', 'reason' => 'Demo: trusted partner'], ['kind' => 'ip', 'value' => '203.0.113.50', 'reason' => 'Demo: monitoring probe'], ['kind' => 'ip', 'value' => '203.0.113.77', 'reason' => 'Demo: corporate VPN exit'], ['kind' => 'subnet', 'value' => '203.0.113.0/28', 'reason' => 'Demo: office NAT range'], ['kind' => 'subnet', 'value' => '198.18.0.0/16', 'reason' => 'Demo: lab benchmark range'], ]; /** * 70 demo IPs spanning many countries / ASNs / address blocks. RFC 5737 * documentation prefixes plus the 100.64.0.0/10 CGNAT space and a * handful of 2001:db8::/32 doc IPv6 endpoints. These IPs are not * routable on the internet so they're safe to seed. */ private const DEMO_IPS = [ ['ip' => '192.0.2.5', 'country' => 'US', 'asn' => 13335, 'org' => 'Cloudflare, Inc.'], ['ip' => '192.0.2.17', 'country' => 'DE', 'asn' => 24940, 'org' => 'Hetzner Online GmbH'], ['ip' => '192.0.2.31', 'country' => 'DE', 'asn' => 8881, 'org' => '1&1 Versatel Deutschland'], ['ip' => '192.0.2.42', 'country' => 'RU', 'asn' => 12389, 'org' => 'Rostelecom'], ['ip' => '192.0.2.58', 'country' => 'RU', 'asn' => 8359, 'org' => 'MTS PJSC'], ['ip' => '192.0.2.71', 'country' => 'BY', 'asn' => 6697, 'org' => 'Beltelecom'], ['ip' => '192.0.2.86', 'country' => 'CN', 'asn' => 4134, 'org' => 'China Telecom'], ['ip' => '192.0.2.99', 'country' => 'CN', 'asn' => 4837, 'org' => 'China Unicom'], ['ip' => '192.0.2.114', 'country' => 'CN', 'asn' => 9808, 'org' => 'China Mobile'], ['ip' => '192.0.2.130', 'country' => 'BR', 'asn' => 8167, 'org' => 'V.tal'], ['ip' => '192.0.2.142', 'country' => 'BR', 'asn' => 26599, 'org' => 'TELEFONICA BRASIL'], ['ip' => '192.0.2.156', 'country' => 'AR', 'asn' => 10481, 'org' => 'Telefonica de Argentina'], ['ip' => '192.0.2.170', 'country' => 'MX', 'asn' => 8151, 'org' => 'Uninet S.A. de C.V.'], ['ip' => '192.0.2.180', 'country' => 'FR', 'asn' => 16276, 'org' => 'OVH SAS'], ['ip' => '192.0.2.196', 'country' => 'FR', 'asn' => 3215, 'org' => 'Orange S.A.'], ['ip' => '192.0.2.211', 'country' => 'NL', 'asn' => 60781, 'org' => 'LeaseWeb Netherlands'], ['ip' => '192.0.2.225', 'country' => 'NL', 'asn' => 1136, 'org' => 'KPN B.V.'], ['ip' => '192.0.2.239', 'country' => 'BE', 'asn' => 5432, 'org' => 'Proximus NV'], ['ip' => '192.0.2.244', 'country' => 'VN', 'asn' => 7552, 'org' => 'Viettel Group'], ['ip' => '192.0.2.250', 'country' => 'TH', 'asn' => 23969, 'org' => 'TOT Public Company'], ['ip' => '198.51.100.7', 'country' => 'IN', 'asn' => 9498, 'org' => 'BHARTI Airtel'], ['ip' => '198.51.100.21', 'country' => 'IN', 'asn' => 55836, 'org' => 'Reliance Jio'], ['ip' => '198.51.100.34', 'country' => 'PK', 'asn' => 17557, 'org' => 'Pakistan Telecom'], ['ip' => '198.51.100.42', 'country' => 'BD', 'asn' => 24389, 'org' => 'Grameenphone Ltd.'], ['ip' => '198.51.100.55', 'country' => 'GB', 'asn' => 5089, 'org' => 'Virgin Media'], ['ip' => '198.51.100.66', 'country' => 'GB', 'asn' => 2856, 'org' => 'BT Group'], ['ip' => '198.51.100.79', 'country' => 'IE', 'asn' => 5466, 'org' => 'eircom Limited'], ['ip' => '198.51.100.88', 'country' => 'JP', 'asn' => 4713, 'org' => 'NTT Communications'], ['ip' => '198.51.100.103', 'country' => 'JP', 'asn' => 17676, 'org' => 'SoftBank Corp.'], ['ip' => '198.51.100.117', 'country' => 'KR', 'asn' => 4766, 'org' => 'Korea Telecom'], ['ip' => '198.51.100.140', 'country' => 'KR', 'asn' => 9318, 'org' => 'SK Broadband'], ['ip' => '198.51.100.155', 'country' => 'TW', 'asn' => 3462, 'org' => 'Chunghwa Telecom'], ['ip' => '198.51.100.168', 'country' => 'HK', 'asn' => 4760, 'org' => 'HKT Limited'], ['ip' => '198.51.100.180', 'country' => 'SG', 'asn' => 3758, 'org' => 'Singtel'], ['ip' => '198.51.100.193', 'country' => 'MY', 'asn' => 4788, 'org' => 'TM Net'], ['ip' => '198.51.100.200', 'country' => 'TR', 'asn' => 9121, 'org' => 'Turk Telekom'], ['ip' => '198.51.100.213', 'country' => 'GR', 'asn' => 3329, 'org' => 'OTE SA'], ['ip' => '198.51.100.225', 'country' => 'RO', 'asn' => 8708, 'org' => 'RCS & RDS'], ['ip' => '198.51.100.240', 'country' => 'BG', 'asn' => 8866, 'org' => 'Vivacom'], ['ip' => '198.51.100.250', 'country' => 'ID', 'asn' => 17974, 'org' => 'PT Telekomunikasi Indonesia'], ['ip' => '203.0.113.7', 'country' => 'US', 'asn' => 7922, 'org' => 'Comcast Cable'], ['ip' => '203.0.113.18', 'country' => 'US', 'asn' => 20115, 'org' => 'Charter Communications'], ['ip' => '203.0.113.29', 'country' => 'US', 'asn' => 32934, 'org' => 'Meta Platforms, Inc.'], ['ip' => '203.0.113.50', 'country' => 'CA', 'asn' => 577, 'org' => 'Bell Canada'], ['ip' => '203.0.113.63', 'country' => 'CA', 'asn' => 812, 'org' => 'Rogers Communications'], ['ip' => '203.0.113.77', 'country' => 'AU', 'asn' => 7545, 'org' => 'TPG Telecom'], ['ip' => '203.0.113.91', 'country' => 'AU', 'asn' => 1221, 'org' => 'Telstra Corporation'], ['ip' => '203.0.113.105', 'country' => 'NZ', 'asn' => 4648, 'org' => 'Spark New Zealand'], ['ip' => '203.0.113.120', 'country' => 'IT', 'asn' => 12874, 'org' => 'Fastweb'], ['ip' => '203.0.113.135', 'country' => 'IT', 'asn' => 1267, 'org' => 'Wind Tre S.p.A.'], ['ip' => '203.0.113.150', 'country' => 'ES', 'asn' => 3352, 'org' => 'Telefonica de Espana'], ['ip' => '203.0.113.165', 'country' => 'ES', 'asn' => 12479, 'org' => 'Orange Espagne'], ['ip' => '203.0.113.178', 'country' => 'PT', 'asn' => 8657, 'org' => 'MEO'], ['ip' => '203.0.113.190', 'country' => 'PL', 'asn' => 5617, 'org' => 'Orange Polska'], ['ip' => '203.0.113.205', 'country' => 'CZ', 'asn' => 6855, 'org' => 'O2 Czech Republic'], ['ip' => '203.0.113.218', 'country' => 'HU', 'asn' => 5483, 'org' => 'Magyar Telekom'], ['ip' => '203.0.113.230', 'country' => 'UA', 'asn' => 13188, 'org' => 'Content Delivery Network'], ['ip' => '203.0.113.245', 'country' => 'EG', 'asn' => 8452, 'org' => 'TE Data'], ['ip' => '100.64.5.10', 'country' => 'ZA', 'asn' => 36937, 'org' => 'MTN South Africa'], ['ip' => '100.64.32.55', 'country' => 'NG', 'asn' => 36873, 'org' => 'MTN Nigeria'], ['ip' => '100.64.71.7', 'country' => 'KE', 'asn' => 33771, 'org' => 'Safaricom'], ['ip' => '100.64.99.42', 'country' => 'AE', 'asn' => 5384, 'org' => 'Emirates Telecom'], ['ip' => '100.64.128.3', 'country' => 'SA', 'asn' => 25019, 'org' => 'Saudi Telecom'], ['ip' => '100.64.155.88', 'country' => 'IL', 'asn' => 8551, 'org' => 'Bezeq International'], ['ip' => '100.64.180.21', 'country' => 'CL', 'asn' => 7418, 'org' => 'Movistar Chile'], ['ip' => '2001:db8::1', 'country' => 'US', 'asn' => 15169, 'org' => 'Google LLC'], ['ip' => '2001:db8::42', 'country' => 'US', 'asn' => 8075, 'org' => 'Microsoft Corporation'], ['ip' => '2001:db8::abcd', 'country' => 'IE', 'asn' => 16509, 'org' => 'Amazon.com, Inc.'], ['ip' => '2001:db8::cafe', 'country' => 'NL', 'asn' => 14061, 'org' => 'DigitalOcean LLC'], ['ip' => '2001:db8::beef', 'country' => 'SG', 'asn' => 16509, 'org' => 'Amazon AWS Asia'], ['ip' => '2001:db8:1::55', 'country' => 'JP', 'asn' => 2497, 'org' => 'IIJ Inc.'], ]; /** * Reports span this many days into the past. Older reports decay more, * which yields a long-tail distribution in the dashboard chart. */ private const DEMO_REPORT_HORIZON_DAYS = 90; /** * Per-IP report-volume tiers. Indexed by `ip_index % count`. Each tuple * is `[low, high]` inclusive — actual count is jittered between them. */ private const DEMO_REPORT_VOLUME_TIERS = [ [15, 22], // mostly-quiet IPs [25, 40], // moderate offenders [45, 70], // heavy offenders [80, 110], // hostile / persistent ]; public function __construct( private readonly Connection $connection, private readonly ReporterRepository $reporters, private readonly ConsumerRepository $consumers, private readonly PolicyRepository $policies, private readonly CategoryRepository $categories, private readonly ManualBlockRepository $manualBlocks, private readonly AllowlistRepository $allowlist, private readonly ReportRepository $reports, private readonly IpEnrichmentRepository $enrichment, private readonly RecomputeScoresJob $recomputeJob, private readonly JobRunner $jobRunner, private readonly Clock $clock, private readonly AuditEmitter $audit, ) { } /** * Wipe operational data. Preserves users, OIDC role mappings, categories, * and the `service`-kind token (the UI's own credential). */ public function purge(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface { $body = self::jsonBody($request); $confirm = isset($body['confirm']) && is_string($body['confirm']) ? $body['confirm'] : ''; if ($confirm !== 'PURGE') { return self::validationFailed($response, [ 'confirm' => 'must be the literal string "PURGE" to authorize the wipe', ]); } $deleted = $this->connection->transactional(function (Connection $conn): array { $counts = []; // Order: child tables first, then parents. RESTRICT FKs: // reports.reporter_id, reports.category_id, consumers.policy_id. // Categories are preserved, so reports must be wiped first to // free reporters; consumers must go before policies. $tables = [ 'reports', 'ip_scores', 'ip_enrichment', 'manual_blocks', 'allowlist', 'audit_log', 'job_runs', 'job_locks', ]; foreach ($tables as $table) { $counts[$table] = (int) $conn->executeStatement('DELETE FROM ' . $table); } // Tokens: keep the service token; everything else goes. $counts['api_tokens'] = (int) $conn->executeStatement( 'DELETE FROM api_tokens WHERE kind != :svc', ['svc' => TokenKind::Service->value], ); $counts['consumers'] = (int) $conn->executeStatement('DELETE FROM consumers'); $counts['policy_category_thresholds'] = (int) $conn->executeStatement( 'DELETE FROM policy_category_thresholds', ); $counts['policies'] = (int) $conn->executeStatement('DELETE FROM policies'); $counts['reporters'] = (int) $conn->executeStatement('DELETE FROM reporters'); return $counts; }); $this->audit->emit( AuditAction::MAINTENANCE_PURGED, 'maintenance', null, ['deleted' => $deleted], self::auditContext($request), 'purge', ); return self::json($response, 200, [ 'status' => 'purged', 'deleted' => $deleted, ]); } /** * Populate the database with a demo dataset. Idempotent: returns 409 if * the marker reporter ("demo-web-edge") already exists. */ public function seedDemo(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface { if ($this->reporters->findByName(self::DEMO_REPORTERS[0]['name']) !== null) { return self::json($response, 409, [ 'error' => 'already_seeded', 'message' => 'Demo data already present. Purge first to re-seed.', ]); } $categoryIds = []; foreach ($this->categories->listAll() as $category) { $categoryIds[$category->slug] = $category->id; } if ($categoryIds === []) { return self::error($response, 412, 'no_categories_configured'); } $now = $this->clock->now(); $actingUserId = self::actingUserId($request); $summary = [ 'reporters' => 0, 'consumers' => 0, 'policies' => 0, 'reports' => 0, 'ips' => 0, 'manual_blocks' => 0, 'allowlist' => 0, 'enrichment' => 0, ]; // Wrap the bulk seed in a single transaction. The new dataset is // ~3,000 reports — without a transaction, SQLite fsyncs per insert // and the seed takes tens of seconds. $this->connection->beginTransaction(); try { $reporterIds = []; foreach (self::DEMO_REPORTERS as $r) { $reporterIds[$r['name']] = $this->reporters->create( $r['name'], $r['description'], $r['trust_weight'], $actingUserId, ); ++$summary['reporters']; } $policyIds = $this->ensureDefaultPolicies($categoryIds, $summary); foreach (self::DEMO_CONSUMERS as $c) { $policyId = $policyIds[$c['policy']] ?? reset($policyIds); if ($policyId === false) { continue; } $this->consumers->create($c['name'], $c['description'], (int) $policyId, $actingUserId); ++$summary['consumers']; } $horizonHours = self::DEMO_REPORT_HORIZON_DAYS * 24; $reporterCount = count(self::DEMO_REPORTERS); $tierCount = count(self::DEMO_REPORT_VOLUME_TIERS); $categorySlugs = array_keys($categoryIds); $catCount = count($categorySlugs); foreach (self::DEMO_IPS as $i => $row) { try { $ip = IpAddress::fromString($row['ip']); } catch (\Throwable) { continue; } $this->enrichment->upsert($ip->binary(), new EnrichmentResult( countryCode: $row['country'], asn: $row['asn'], asOrg: $row['org'], enrichedAt: $now, )); ++$summary['enrichment']; ++$summary['ips']; // Stagger reports across the past DEMO_REPORT_HORIZON_DAYS so // decay produces visually distinct scores. Each IP draws from a // volume tier so the dataset has a realistic long-tail shape. [$tierLow, $tierHigh] = self::DEMO_REPORT_VOLUME_TIERS[$i % $tierCount]; $reportCount = $tierLow + (($i * 7 + 3) % max(1, $tierHigh - $tierLow + 1)); // Each IP "specialises" in one or two primary categories so the // category breakdown isn't perfectly uniform. $primaryCatIdx = $i % $catCount; $secondaryCatIdx = ($i * 3 + 1) % $catCount; for ($n = 0; $n < $reportCount; ++$n) { // 70% primary, 20% secondary, 10% any other category. $rolled = ($n * 13 + $i * 5) % 10; if ($rolled < 7) { $slugIdx = $primaryCatIdx; } elseif ($rolled < 9) { $slugIdx = $secondaryCatIdx; } else { $slugIdx = ($i + $n * 2) % $catCount; } $slug = $categorySlugs[$slugIdx]; $catId = $categoryIds[$slug]; $reporterIdx = ($n + $i) % $reporterCount; $reporterName = self::DEMO_REPORTERS[$reporterIdx]['name']; $reporterId = $reporterIds[$reporterName]; $weight = self::DEMO_REPORTERS[$reporterIdx]['trust_weight']; // Ages spread across the horizon with a bias toward "recent" // (more activity in last 14 days), and minute-level jitter. $bucket = ($n * 31 + $i * 19) % 100; if ($bucket < 35) { $ageHours = ($n * 11 + $i * 7) % (14 * 24); } elseif ($bucket < 70) { $ageHours = (14 * 24) + (($n * 17 + $i * 23) % (30 * 24)); } else { $ageHours = (44 * 24) + (($n * 29 + $i * 13) % max(1, $horizonHours - 44 * 24)); } $ageMinutes = ($n * 7 + $i * 11) % 60; $receivedAt = $now ->modify('-' . $ageHours . ' hours') ->modify('-' . $ageMinutes . ' minutes'); $this->reports->insert( $ip->binary(), $ip->text(), $catId, $reporterId, $weight, json_encode([ 'demo' => true, 'context' => $slug . ' attempt #' . ($n + 1), ]) ?: null, $receivedAt, ); ++$summary['reports']; } } foreach (self::DEMO_MANUAL_BLOCKS as $mb) { try { if ($mb['kind'] === 'ip') { $blockId = $this->manualBlocks->createIp( IpAddress::fromString($mb['value']), $mb['reason'], null, $actingUserId, ); } else { $blockId = $this->manualBlocks->createSubnet( Cidr::fromString($mb['value']), $mb['reason'], null, $actingUserId, ); } ++$summary['manual_blocks']; // Backfill `created_at` so the bans-by-day chart on the // dashboard shows realistic spread instead of a single // spike at seed time. $ageDays = isset($mb['age_days']) ? (int) $mb['age_days'] : 0; if ($ageDays > 0) { $createdAt = $now->modify('-' . $ageDays . ' days'); $this->connection->executeStatement( 'UPDATE manual_blocks SET created_at = :created_at WHERE id = :id', [ 'created_at' => $createdAt->format('Y-m-d H:i:s'), 'id' => $blockId, ], ); } } catch (\Throwable) { // Ignore — demo data shouldn't block setup on a single bad row. } } foreach (self::DEMO_ALLOWLIST as $al) { try { if ($al['kind'] === 'ip') { $this->allowlist->createIp( IpAddress::fromString($al['value']), $al['reason'], $actingUserId, ); } else { $this->allowlist->createSubnet( Cidr::fromString($al['value']), $al['reason'], $actingUserId, ); } ++$summary['allowlist']; } catch (\Throwable) { // Ignore. } } $this->connection->commit(); } catch (\Throwable $e) { if ($this->connection->isTransactionActive()) { $this->connection->rollBack(); } throw $e; } // Recompute so dashboards show the seeded scores immediately. $outcome = $this->jobRunner->run($this->recomputeJob, ['full' => true], 'manual'); $this->audit->emit( AuditAction::MAINTENANCE_SEEDED, 'maintenance', null, ['summary' => $summary], self::auditContext($request), 'seed-demo', ); return self::json($response, 200, [ 'status' => 'seeded', 'summary' => $summary, 'recompute' => $outcome->toArray(), ]); } /** * If no policies exist after a purge, recreate the three defaults so the * blocklist endpoint and policies page aren't broken. Returns * `name => id` for every policy currently in the table. * * @param array $categoryIds slug -> id * @param array $summary incremented in place when a row is created * @return array name -> id */ private function ensureDefaultPolicies(array $categoryIds, array &$summary): array { $existing = $this->policies->listAll(); $byName = []; foreach ($existing as $p) { $byName[$p->name] = $p->id; } if ($byName !== []) { return $byName; } $defaults = [ 'strict' => ['desc' => 'Conservative: high-confidence blocks only.', 'threshold' => 2.5], 'moderate' => ['desc' => 'Balanced: moderate accumulated abuse signal.', 'threshold' => 1.0], 'paranoid' => ['desc' => 'Aggressive: block on faint signal.', 'threshold' => 0.3], ]; foreach ($defaults as $name => $spec) { $thresholds = []; foreach ($categoryIds as $catId) { $thresholds[$catId] = $spec['threshold']; } $byName[$name] = $this->policies->create($name, $spec['desc'], true, $thresholds); ++$summary['policies']; } return $byName; } }