MaintenanceController.php 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Application\Admin;
  4. use App\Application\Jobs\RecomputeScoresJob;
  5. use App\Domain\Audit\AuditAction;
  6. use App\Domain\Audit\AuditEmitter;
  7. use App\Domain\Auth\TokenKind;
  8. use App\Domain\Enrichment\EnrichmentResult;
  9. use App\Domain\Ip\Cidr;
  10. use App\Domain\Ip\IpAddress;
  11. use App\Domain\Time\Clock;
  12. use App\Infrastructure\Allowlist\AllowlistRepository;
  13. use App\Infrastructure\Category\CategoryRepository;
  14. use App\Infrastructure\Consumer\ConsumerRepository;
  15. use App\Infrastructure\Jobs\JobRunner;
  16. use App\Infrastructure\ManualBlock\ManualBlockRepository;
  17. use App\Infrastructure\Policy\PolicyRepository;
  18. use App\Infrastructure\Reporter\ReporterRepository;
  19. use App\Infrastructure\Reputation\IpEnrichmentRepository;
  20. use App\Infrastructure\Reputation\ReportRepository;
  21. use Doctrine\DBAL\Connection;
  22. use Psr\Http\Message\ResponseInterface;
  23. use Psr\Http\Message\ServerRequestInterface;
  24. /**
  25. * Admin-only "wipe everything" + "load demo dataset" surface.
  26. *
  27. * - `POST /api/v1/admin/maintenance/purge` — wipes operational data
  28. * (reports, scores, blocks, audit, etc.) plus reporters/consumers/
  29. * tokens/policies. Preserves users, OIDC role mappings, categories,
  30. * and the `service`-kind token. Body must include `confirm: "PURGE"`
  31. * to execute.
  32. * - `POST /api/v1/admin/maintenance/seed-demo` — populates the database
  33. * with realistic-looking sample reporters, consumers, IPs, reports,
  34. * manual blocks, allowlist entries, and synthetic GeoIP. Triggers a
  35. * full score recompute on completion. Rejects with 409 if demo data
  36. * is already present.
  37. */
  38. final class MaintenanceController
  39. {
  40. use AdminControllerSupport;
  41. private const DEMO_REPORTERS = [
  42. ['name' => 'demo-web-edge', 'description' => 'Demo: edge webserver', 'trust_weight' => 1.0],
  43. ['name' => 'demo-web-eu', 'description' => 'Demo: EU edge webserver', 'trust_weight' => 1.0],
  44. ['name' => 'demo-web-us', 'description' => 'Demo: US edge webserver', 'trust_weight' => 1.0],
  45. ['name' => 'demo-fail2ban', 'description' => 'Demo: fail2ban agents', 'trust_weight' => 1.2],
  46. ['name' => 'demo-fail2ban-prod','description' => 'Demo: fail2ban prod fleet', 'trust_weight' => 1.3],
  47. ['name' => 'demo-honeypot', 'description' => 'Demo: research honeypot', 'trust_weight' => 1.5],
  48. ['name' => 'demo-honeypot-asia','description' => 'Demo: APAC honeypot mesh', 'trust_weight' => 1.6],
  49. ['name' => 'demo-ids-suricata', 'description' => 'Demo: Suricata IDS sensors', 'trust_weight' => 1.4],
  50. ['name' => 'demo-mail-gateway', 'description' => 'Demo: anti-spam mail gateway', 'trust_weight' => 1.1],
  51. ['name' => 'demo-waf-cloud', 'description' => 'Demo: cloud WAF aggregator', 'trust_weight' => 1.3],
  52. ];
  53. private const DEMO_CONSUMERS = [
  54. ['name' => 'demo-firewall-prod', 'description' => 'Demo: production edge firewall', 'policy' => 'moderate'],
  55. ['name' => 'demo-firewall-dmz', 'description' => 'Demo: DMZ perimeter firewall', 'policy' => 'paranoid'],
  56. ['name' => 'demo-proxy-staging', 'description' => 'Demo: staging proxy', 'policy' => 'strict'],
  57. ['name' => 'demo-haproxy-public', 'description' => 'Demo: public HAProxy LB', 'policy' => 'moderate'],
  58. ['name' => 'demo-nginx-api', 'description' => 'Demo: API nginx ingress', 'policy' => 'strict'],
  59. ['name' => 'demo-vpn-gateway', 'description' => 'Demo: VPN gateway', 'policy' => 'paranoid'],
  60. ['name' => 'demo-mail-relay', 'description' => 'Demo: outbound mail relay', 'policy' => 'moderate'],
  61. ];
  62. private const DEMO_MANUAL_BLOCKS = [
  63. ['kind' => 'ip', 'value' => '198.51.100.42', 'reason' => 'Demo: known malicious actor', 'age_days' => 1],
  64. ['kind' => 'ip', 'value' => '198.51.100.55', 'reason' => 'Demo: persistent brute-force', 'age_days' => 2],
  65. ['kind' => 'ip', 'value' => '203.0.113.190', 'reason' => 'Demo: WAF auto-block', 'age_days' => 3],
  66. ['kind' => 'ip', 'value' => '192.0.2.244', 'reason' => 'Demo: spam relay', 'age_days' => 5],
  67. ['kind' => 'ip', 'value' => '198.51.100.140', 'reason' => 'Demo: scanner cluster member', 'age_days' => 7],
  68. ['kind' => 'ip', 'value' => '203.0.113.230', 'reason' => 'Demo: confirmed C2 endpoint', 'age_days' => 10],
  69. ['kind' => 'ip', 'value' => '192.0.2.99', 'reason' => 'Demo: malware dropper host', 'age_days' => 14],
  70. ['kind' => 'ip', 'value' => '198.51.100.250', 'reason' => 'Demo: botnet member', 'age_days' => 21],
  71. ['kind' => 'ip', 'value' => '203.0.113.120', 'reason' => 'Demo: credential stuffing', 'age_days' => 30],
  72. ['kind' => 'ip', 'value' => '192.0.2.42', 'reason' => 'Demo: reflective DDoS source', 'age_days' => 45],
  73. ['kind' => 'subnet', 'value' => '198.51.100.0/24','reason' => 'Demo: blanket block on test net','age_days' => 60],
  74. ['kind' => 'subnet', 'value' => '192.0.2.128/26', 'reason' => 'Demo: hostile subnet range', 'age_days' => 75],
  75. ];
  76. private const DEMO_ALLOWLIST = [
  77. ['kind' => 'ip', 'value' => '203.0.113.10', 'reason' => 'Demo: trusted partner'],
  78. ['kind' => 'ip', 'value' => '203.0.113.50', 'reason' => 'Demo: monitoring probe'],
  79. ['kind' => 'ip', 'value' => '203.0.113.77', 'reason' => 'Demo: corporate VPN exit'],
  80. ['kind' => 'subnet', 'value' => '203.0.113.0/28', 'reason' => 'Demo: office NAT range'],
  81. ['kind' => 'subnet', 'value' => '198.18.0.0/16', 'reason' => 'Demo: lab benchmark range'],
  82. ];
  83. /**
  84. * 70 demo IPs spanning many countries / ASNs / address blocks. RFC 5737
  85. * documentation prefixes plus the 100.64.0.0/10 CGNAT space and a
  86. * handful of 2001:db8::/32 doc IPv6 endpoints. These IPs are not
  87. * routable on the internet so they're safe to seed.
  88. */
  89. private const DEMO_IPS = [
  90. ['ip' => '192.0.2.5', 'country' => 'US', 'asn' => 13335, 'org' => 'Cloudflare, Inc.'],
  91. ['ip' => '192.0.2.17', 'country' => 'DE', 'asn' => 24940, 'org' => 'Hetzner Online GmbH'],
  92. ['ip' => '192.0.2.31', 'country' => 'DE', 'asn' => 8881, 'org' => '1&1 Versatel Deutschland'],
  93. ['ip' => '192.0.2.42', 'country' => 'RU', 'asn' => 12389, 'org' => 'Rostelecom'],
  94. ['ip' => '192.0.2.58', 'country' => 'RU', 'asn' => 8359, 'org' => 'MTS PJSC'],
  95. ['ip' => '192.0.2.71', 'country' => 'BY', 'asn' => 6697, 'org' => 'Beltelecom'],
  96. ['ip' => '192.0.2.86', 'country' => 'CN', 'asn' => 4134, 'org' => 'China Telecom'],
  97. ['ip' => '192.0.2.99', 'country' => 'CN', 'asn' => 4837, 'org' => 'China Unicom'],
  98. ['ip' => '192.0.2.114', 'country' => 'CN', 'asn' => 9808, 'org' => 'China Mobile'],
  99. ['ip' => '192.0.2.130', 'country' => 'BR', 'asn' => 8167, 'org' => 'V.tal'],
  100. ['ip' => '192.0.2.142', 'country' => 'BR', 'asn' => 26599, 'org' => 'TELEFONICA BRASIL'],
  101. ['ip' => '192.0.2.156', 'country' => 'AR', 'asn' => 10481, 'org' => 'Telefonica de Argentina'],
  102. ['ip' => '192.0.2.170', 'country' => 'MX', 'asn' => 8151, 'org' => 'Uninet S.A. de C.V.'],
  103. ['ip' => '192.0.2.180', 'country' => 'FR', 'asn' => 16276, 'org' => 'OVH SAS'],
  104. ['ip' => '192.0.2.196', 'country' => 'FR', 'asn' => 3215, 'org' => 'Orange S.A.'],
  105. ['ip' => '192.0.2.211', 'country' => 'NL', 'asn' => 60781, 'org' => 'LeaseWeb Netherlands'],
  106. ['ip' => '192.0.2.225', 'country' => 'NL', 'asn' => 1136, 'org' => 'KPN B.V.'],
  107. ['ip' => '192.0.2.239', 'country' => 'BE', 'asn' => 5432, 'org' => 'Proximus NV'],
  108. ['ip' => '192.0.2.244', 'country' => 'VN', 'asn' => 7552, 'org' => 'Viettel Group'],
  109. ['ip' => '192.0.2.250', 'country' => 'TH', 'asn' => 23969, 'org' => 'TOT Public Company'],
  110. ['ip' => '198.51.100.7', 'country' => 'IN', 'asn' => 9498, 'org' => 'BHARTI Airtel'],
  111. ['ip' => '198.51.100.21', 'country' => 'IN', 'asn' => 55836, 'org' => 'Reliance Jio'],
  112. ['ip' => '198.51.100.34', 'country' => 'PK', 'asn' => 17557, 'org' => 'Pakistan Telecom'],
  113. ['ip' => '198.51.100.42', 'country' => 'BD', 'asn' => 24389, 'org' => 'Grameenphone Ltd.'],
  114. ['ip' => '198.51.100.55', 'country' => 'GB', 'asn' => 5089, 'org' => 'Virgin Media'],
  115. ['ip' => '198.51.100.66', 'country' => 'GB', 'asn' => 2856, 'org' => 'BT Group'],
  116. ['ip' => '198.51.100.79', 'country' => 'IE', 'asn' => 5466, 'org' => 'eircom Limited'],
  117. ['ip' => '198.51.100.88', 'country' => 'JP', 'asn' => 4713, 'org' => 'NTT Communications'],
  118. ['ip' => '198.51.100.103', 'country' => 'JP', 'asn' => 17676, 'org' => 'SoftBank Corp.'],
  119. ['ip' => '198.51.100.117', 'country' => 'KR', 'asn' => 4766, 'org' => 'Korea Telecom'],
  120. ['ip' => '198.51.100.140', 'country' => 'KR', 'asn' => 9318, 'org' => 'SK Broadband'],
  121. ['ip' => '198.51.100.155', 'country' => 'TW', 'asn' => 3462, 'org' => 'Chunghwa Telecom'],
  122. ['ip' => '198.51.100.168', 'country' => 'HK', 'asn' => 4760, 'org' => 'HKT Limited'],
  123. ['ip' => '198.51.100.180', 'country' => 'SG', 'asn' => 3758, 'org' => 'Singtel'],
  124. ['ip' => '198.51.100.193', 'country' => 'MY', 'asn' => 4788, 'org' => 'TM Net'],
  125. ['ip' => '198.51.100.200', 'country' => 'TR', 'asn' => 9121, 'org' => 'Turk Telekom'],
  126. ['ip' => '198.51.100.213', 'country' => 'GR', 'asn' => 3329, 'org' => 'OTE SA'],
  127. ['ip' => '198.51.100.225', 'country' => 'RO', 'asn' => 8708, 'org' => 'RCS & RDS'],
  128. ['ip' => '198.51.100.240', 'country' => 'BG', 'asn' => 8866, 'org' => 'Vivacom'],
  129. ['ip' => '198.51.100.250', 'country' => 'ID', 'asn' => 17974, 'org' => 'PT Telekomunikasi Indonesia'],
  130. ['ip' => '203.0.113.7', 'country' => 'US', 'asn' => 7922, 'org' => 'Comcast Cable'],
  131. ['ip' => '203.0.113.18', 'country' => 'US', 'asn' => 20115, 'org' => 'Charter Communications'],
  132. ['ip' => '203.0.113.29', 'country' => 'US', 'asn' => 32934, 'org' => 'Meta Platforms, Inc.'],
  133. ['ip' => '203.0.113.50', 'country' => 'CA', 'asn' => 577, 'org' => 'Bell Canada'],
  134. ['ip' => '203.0.113.63', 'country' => 'CA', 'asn' => 812, 'org' => 'Rogers Communications'],
  135. ['ip' => '203.0.113.77', 'country' => 'AU', 'asn' => 7545, 'org' => 'TPG Telecom'],
  136. ['ip' => '203.0.113.91', 'country' => 'AU', 'asn' => 1221, 'org' => 'Telstra Corporation'],
  137. ['ip' => '203.0.113.105', 'country' => 'NZ', 'asn' => 4648, 'org' => 'Spark New Zealand'],
  138. ['ip' => '203.0.113.120', 'country' => 'IT', 'asn' => 12874, 'org' => 'Fastweb'],
  139. ['ip' => '203.0.113.135', 'country' => 'IT', 'asn' => 1267, 'org' => 'Wind Tre S.p.A.'],
  140. ['ip' => '203.0.113.150', 'country' => 'ES', 'asn' => 3352, 'org' => 'Telefonica de Espana'],
  141. ['ip' => '203.0.113.165', 'country' => 'ES', 'asn' => 12479, 'org' => 'Orange Espagne'],
  142. ['ip' => '203.0.113.178', 'country' => 'PT', 'asn' => 8657, 'org' => 'MEO'],
  143. ['ip' => '203.0.113.190', 'country' => 'PL', 'asn' => 5617, 'org' => 'Orange Polska'],
  144. ['ip' => '203.0.113.205', 'country' => 'CZ', 'asn' => 6855, 'org' => 'O2 Czech Republic'],
  145. ['ip' => '203.0.113.218', 'country' => 'HU', 'asn' => 5483, 'org' => 'Magyar Telekom'],
  146. ['ip' => '203.0.113.230', 'country' => 'UA', 'asn' => 13188, 'org' => 'Content Delivery Network'],
  147. ['ip' => '203.0.113.245', 'country' => 'EG', 'asn' => 8452, 'org' => 'TE Data'],
  148. ['ip' => '100.64.5.10', 'country' => 'ZA', 'asn' => 36937, 'org' => 'MTN South Africa'],
  149. ['ip' => '100.64.32.55', 'country' => 'NG', 'asn' => 36873, 'org' => 'MTN Nigeria'],
  150. ['ip' => '100.64.71.7', 'country' => 'KE', 'asn' => 33771, 'org' => 'Safaricom'],
  151. ['ip' => '100.64.99.42', 'country' => 'AE', 'asn' => 5384, 'org' => 'Emirates Telecom'],
  152. ['ip' => '100.64.128.3', 'country' => 'SA', 'asn' => 25019, 'org' => 'Saudi Telecom'],
  153. ['ip' => '100.64.155.88', 'country' => 'IL', 'asn' => 8551, 'org' => 'Bezeq International'],
  154. ['ip' => '100.64.180.21', 'country' => 'CL', 'asn' => 7418, 'org' => 'Movistar Chile'],
  155. ['ip' => '2001:db8::1', 'country' => 'US', 'asn' => 15169, 'org' => 'Google LLC'],
  156. ['ip' => '2001:db8::42', 'country' => 'US', 'asn' => 8075, 'org' => 'Microsoft Corporation'],
  157. ['ip' => '2001:db8::abcd', 'country' => 'IE', 'asn' => 16509, 'org' => 'Amazon.com, Inc.'],
  158. ['ip' => '2001:db8::cafe', 'country' => 'NL', 'asn' => 14061, 'org' => 'DigitalOcean LLC'],
  159. ['ip' => '2001:db8::beef', 'country' => 'SG', 'asn' => 16509, 'org' => 'Amazon AWS Asia'],
  160. ['ip' => '2001:db8:1::55', 'country' => 'JP', 'asn' => 2497, 'org' => 'IIJ Inc.'],
  161. ];
  162. /**
  163. * Reports span this many days into the past. Older reports decay more,
  164. * which yields a long-tail distribution in the dashboard chart.
  165. */
  166. private const DEMO_REPORT_HORIZON_DAYS = 90;
  167. /**
  168. * Per-IP report-volume tiers. Indexed by `ip_index % count`. Each tuple
  169. * is `[low, high]` inclusive — actual count is jittered between them.
  170. */
  171. private const DEMO_REPORT_VOLUME_TIERS = [
  172. [15, 22], // mostly-quiet IPs
  173. [25, 40], // moderate offenders
  174. [45, 70], // heavy offenders
  175. [80, 110], // hostile / persistent
  176. ];
  177. public function __construct(
  178. private readonly Connection $connection,
  179. private readonly ReporterRepository $reporters,
  180. private readonly ConsumerRepository $consumers,
  181. private readonly PolicyRepository $policies,
  182. private readonly CategoryRepository $categories,
  183. private readonly ManualBlockRepository $manualBlocks,
  184. private readonly AllowlistRepository $allowlist,
  185. private readonly ReportRepository $reports,
  186. private readonly IpEnrichmentRepository $enrichment,
  187. private readonly RecomputeScoresJob $recomputeJob,
  188. private readonly JobRunner $jobRunner,
  189. private readonly Clock $clock,
  190. private readonly AuditEmitter $audit,
  191. ) {
  192. }
  193. /**
  194. * Wipe operational data. Preserves users, OIDC role mappings, categories,
  195. * and the `service`-kind token (the UI's own credential).
  196. */
  197. public function purge(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
  198. {
  199. $body = self::jsonBody($request);
  200. $confirm = isset($body['confirm']) && is_string($body['confirm']) ? $body['confirm'] : '';
  201. if ($confirm !== 'PURGE') {
  202. return self::validationFailed($response, [
  203. 'confirm' => 'must be the literal string "PURGE" to authorize the wipe',
  204. ]);
  205. }
  206. $deleted = $this->connection->transactional(function (Connection $conn): array {
  207. $counts = [];
  208. // Order: child tables first, then parents. RESTRICT FKs:
  209. // reports.reporter_id, reports.category_id, consumers.policy_id.
  210. // Categories are preserved, so reports must be wiped first to
  211. // free reporters; consumers must go before policies.
  212. $tables = [
  213. 'reports',
  214. 'ip_scores',
  215. 'ip_enrichment',
  216. 'manual_blocks',
  217. 'allowlist',
  218. 'audit_log',
  219. 'job_runs',
  220. 'job_locks',
  221. ];
  222. foreach ($tables as $table) {
  223. $counts[$table] = (int) $conn->executeStatement('DELETE FROM ' . $table);
  224. }
  225. // Tokens: keep the service token; everything else goes.
  226. $counts['api_tokens'] = (int) $conn->executeStatement(
  227. 'DELETE FROM api_tokens WHERE kind != :svc',
  228. ['svc' => TokenKind::Service->value],
  229. );
  230. $counts['consumers'] = (int) $conn->executeStatement('DELETE FROM consumers');
  231. $counts['policy_category_thresholds'] = (int) $conn->executeStatement(
  232. 'DELETE FROM policy_category_thresholds',
  233. );
  234. $counts['policies'] = (int) $conn->executeStatement('DELETE FROM policies');
  235. $counts['reporters'] = (int) $conn->executeStatement('DELETE FROM reporters');
  236. return $counts;
  237. });
  238. $this->audit->emit(
  239. AuditAction::MAINTENANCE_PURGED,
  240. 'maintenance',
  241. null,
  242. ['deleted' => $deleted],
  243. self::auditContext($request),
  244. 'purge',
  245. );
  246. return self::json($response, 200, [
  247. 'status' => 'purged',
  248. 'deleted' => $deleted,
  249. ]);
  250. }
  251. /**
  252. * Populate the database with a demo dataset. Idempotent: returns 409 if
  253. * the marker reporter ("demo-web-edge") already exists.
  254. */
  255. public function seedDemo(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
  256. {
  257. if ($this->reporters->findByName(self::DEMO_REPORTERS[0]['name']) !== null) {
  258. return self::json($response, 409, [
  259. 'error' => 'already_seeded',
  260. 'message' => 'Demo data already present. Purge first to re-seed.',
  261. ]);
  262. }
  263. $categoryIds = [];
  264. foreach ($this->categories->listAll() as $category) {
  265. $categoryIds[$category->slug] = $category->id;
  266. }
  267. if ($categoryIds === []) {
  268. return self::error($response, 412, 'no_categories_configured');
  269. }
  270. $now = $this->clock->now();
  271. $actingUserId = self::actingUserId($request);
  272. $summary = [
  273. 'reporters' => 0,
  274. 'consumers' => 0,
  275. 'policies' => 0,
  276. 'reports' => 0,
  277. 'ips' => 0,
  278. 'manual_blocks' => 0,
  279. 'allowlist' => 0,
  280. 'enrichment' => 0,
  281. ];
  282. // Wrap the bulk seed in a single transaction. The new dataset is
  283. // ~3,000 reports — without a transaction, SQLite fsyncs per insert
  284. // and the seed takes tens of seconds.
  285. $this->connection->beginTransaction();
  286. try {
  287. $reporterIds = [];
  288. foreach (self::DEMO_REPORTERS as $r) {
  289. $reporterIds[$r['name']] = $this->reporters->create(
  290. $r['name'],
  291. $r['description'],
  292. $r['trust_weight'],
  293. $actingUserId,
  294. );
  295. ++$summary['reporters'];
  296. }
  297. $policyIds = $this->ensureDefaultPolicies($categoryIds, $summary);
  298. foreach (self::DEMO_CONSUMERS as $c) {
  299. $policyId = $policyIds[$c['policy']] ?? reset($policyIds);
  300. if ($policyId === false) {
  301. continue;
  302. }
  303. $this->consumers->create($c['name'], $c['description'], (int) $policyId, $actingUserId);
  304. ++$summary['consumers'];
  305. }
  306. $horizonHours = self::DEMO_REPORT_HORIZON_DAYS * 24;
  307. $reporterCount = count(self::DEMO_REPORTERS);
  308. $tierCount = count(self::DEMO_REPORT_VOLUME_TIERS);
  309. $categorySlugs = array_keys($categoryIds);
  310. $catCount = count($categorySlugs);
  311. foreach (self::DEMO_IPS as $i => $row) {
  312. try {
  313. $ip = IpAddress::fromString($row['ip']);
  314. } catch (\Throwable) {
  315. continue;
  316. }
  317. $this->enrichment->upsert($ip->binary(), new EnrichmentResult(
  318. countryCode: $row['country'],
  319. asn: $row['asn'],
  320. asOrg: $row['org'],
  321. enrichedAt: $now,
  322. ));
  323. ++$summary['enrichment'];
  324. ++$summary['ips'];
  325. // Stagger reports across the past DEMO_REPORT_HORIZON_DAYS so
  326. // decay produces visually distinct scores. Each IP draws from a
  327. // volume tier so the dataset has a realistic long-tail shape.
  328. [$tierLow, $tierHigh] = self::DEMO_REPORT_VOLUME_TIERS[$i % $tierCount];
  329. $reportCount = $tierLow + (($i * 7 + 3) % max(1, $tierHigh - $tierLow + 1));
  330. // Each IP "specialises" in one or two primary categories so the
  331. // category breakdown isn't perfectly uniform.
  332. $primaryCatIdx = $i % $catCount;
  333. $secondaryCatIdx = ($i * 3 + 1) % $catCount;
  334. for ($n = 0; $n < $reportCount; ++$n) {
  335. // 70% primary, 20% secondary, 10% any other category.
  336. $rolled = ($n * 13 + $i * 5) % 10;
  337. if ($rolled < 7) {
  338. $slugIdx = $primaryCatIdx;
  339. } elseif ($rolled < 9) {
  340. $slugIdx = $secondaryCatIdx;
  341. } else {
  342. $slugIdx = ($i + $n * 2) % $catCount;
  343. }
  344. $slug = $categorySlugs[$slugIdx];
  345. $catId = $categoryIds[$slug];
  346. $reporterIdx = ($n + $i) % $reporterCount;
  347. $reporterName = self::DEMO_REPORTERS[$reporterIdx]['name'];
  348. $reporterId = $reporterIds[$reporterName];
  349. $weight = self::DEMO_REPORTERS[$reporterIdx]['trust_weight'];
  350. // Ages spread across the horizon with a bias toward "recent"
  351. // (more activity in last 14 days), and minute-level jitter.
  352. $bucket = ($n * 31 + $i * 19) % 100;
  353. if ($bucket < 35) {
  354. $ageHours = ($n * 11 + $i * 7) % (14 * 24);
  355. } elseif ($bucket < 70) {
  356. $ageHours = (14 * 24) + (($n * 17 + $i * 23) % (30 * 24));
  357. } else {
  358. $ageHours = (44 * 24) + (($n * 29 + $i * 13) % max(1, $horizonHours - 44 * 24));
  359. }
  360. $ageMinutes = ($n * 7 + $i * 11) % 60;
  361. $receivedAt = $now
  362. ->modify('-' . $ageHours . ' hours')
  363. ->modify('-' . $ageMinutes . ' minutes');
  364. $this->reports->insert(
  365. $ip->binary(),
  366. $ip->text(),
  367. $catId,
  368. $reporterId,
  369. $weight,
  370. json_encode([
  371. 'demo' => true,
  372. 'context' => $slug . ' attempt #' . ($n + 1),
  373. ]) ?: null,
  374. $receivedAt,
  375. );
  376. ++$summary['reports'];
  377. }
  378. }
  379. foreach (self::DEMO_MANUAL_BLOCKS as $mb) {
  380. try {
  381. if ($mb['kind'] === 'ip') {
  382. $blockId = $this->manualBlocks->createIp(
  383. IpAddress::fromString($mb['value']),
  384. $mb['reason'],
  385. null,
  386. $actingUserId,
  387. );
  388. } else {
  389. $blockId = $this->manualBlocks->createSubnet(
  390. Cidr::fromString($mb['value']),
  391. $mb['reason'],
  392. null,
  393. $actingUserId,
  394. );
  395. }
  396. ++$summary['manual_blocks'];
  397. // Backfill `created_at` so the bans-by-day chart on the
  398. // dashboard shows realistic spread instead of a single
  399. // spike at seed time.
  400. $ageDays = isset($mb['age_days']) ? (int) $mb['age_days'] : 0;
  401. if ($ageDays > 0) {
  402. $createdAt = $now->modify('-' . $ageDays . ' days');
  403. $this->connection->executeStatement(
  404. 'UPDATE manual_blocks SET created_at = :created_at WHERE id = :id',
  405. [
  406. 'created_at' => $createdAt->format('Y-m-d H:i:s'),
  407. 'id' => $blockId,
  408. ],
  409. );
  410. }
  411. } catch (\Throwable) {
  412. // Ignore — demo data shouldn't block setup on a single bad row.
  413. }
  414. }
  415. foreach (self::DEMO_ALLOWLIST as $al) {
  416. try {
  417. if ($al['kind'] === 'ip') {
  418. $this->allowlist->createIp(
  419. IpAddress::fromString($al['value']),
  420. $al['reason'],
  421. $actingUserId,
  422. );
  423. } else {
  424. $this->allowlist->createSubnet(
  425. Cidr::fromString($al['value']),
  426. $al['reason'],
  427. $actingUserId,
  428. );
  429. }
  430. ++$summary['allowlist'];
  431. } catch (\Throwable) {
  432. // Ignore.
  433. }
  434. }
  435. $this->connection->commit();
  436. } catch (\Throwable $e) {
  437. if ($this->connection->isTransactionActive()) {
  438. $this->connection->rollBack();
  439. }
  440. throw $e;
  441. }
  442. // Recompute so dashboards show the seeded scores immediately.
  443. $outcome = $this->jobRunner->run($this->recomputeJob, ['full' => true], 'manual');
  444. $this->audit->emit(
  445. AuditAction::MAINTENANCE_SEEDED,
  446. 'maintenance',
  447. null,
  448. ['summary' => $summary],
  449. self::auditContext($request),
  450. 'seed-demo',
  451. );
  452. return self::json($response, 200, [
  453. 'status' => 'seeded',
  454. 'summary' => $summary,
  455. 'recompute' => $outcome->toArray(),
  456. ]);
  457. }
  458. /**
  459. * If no policies exist after a purge, recreate the three defaults so the
  460. * blocklist endpoint and policies page aren't broken. Returns
  461. * `name => id` for every policy currently in the table.
  462. *
  463. * @param array<string, int> $categoryIds slug -> id
  464. * @param array<string, int> $summary incremented in place when a row is created
  465. * @return array<string, int> name -> id
  466. */
  467. private function ensureDefaultPolicies(array $categoryIds, array &$summary): array
  468. {
  469. $existing = $this->policies->listAll();
  470. $byName = [];
  471. foreach ($existing as $p) {
  472. $byName[$p->name] = $p->id;
  473. }
  474. if ($byName !== []) {
  475. return $byName;
  476. }
  477. $defaults = [
  478. 'strict' => ['desc' => 'Conservative: high-confidence blocks only.', 'threshold' => 2.5],
  479. 'moderate' => ['desc' => 'Balanced: moderate accumulated abuse signal.', 'threshold' => 1.0],
  480. 'paranoid' => ['desc' => 'Aggressive: block on faint signal.', 'threshold' => 0.3],
  481. ];
  482. foreach ($defaults as $name => $spec) {
  483. $thresholds = [];
  484. foreach ($categoryIds as $catId) {
  485. $thresholds[$catId] = $spec['threshold'];
  486. }
  487. $byName[$name] = $this->policies->create($name, $spec['desc'], true, $thresholds);
  488. ++$summary['policies'];
  489. }
  490. return $byName;
  491. }
  492. }