IpsController.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Application\Admin;
  4. use App\Domain\Category\Category;
  5. use App\Domain\Ip\InvalidIpException;
  6. use App\Domain\Ip\IpAddress;
  7. use App\Domain\Reputation\EffectiveStatus;
  8. use App\Domain\Reputation\EffectiveStatusService;
  9. use App\Infrastructure\Allowlist\AllowlistRepository;
  10. use App\Infrastructure\Category\CategoryRepository;
  11. use App\Infrastructure\ManualBlock\ManualBlockRepository;
  12. use App\Infrastructure\Reputation\CidrEvaluatorFactory;
  13. use App\Infrastructure\Reputation\IpEnrichmentRepository;
  14. use App\Infrastructure\Reputation\IpHistoryRepository;
  15. use App\Infrastructure\Reputation\IpScoreRepository;
  16. use Psr\Http\Message\ResponseInterface;
  17. use Psr\Http\Message\ServerRequestInterface;
  18. /**
  19. * Admin IP browser — search + detail.
  20. *
  21. * The search composes filters against `ip_scores` (joined with
  22. * `ip_enrichment` when country/asn is set), with the manual-block /
  23. * allowlist single-IP universes pre-resolved from `CidrEvaluatorFactory`
  24. * so the SQL stays a single query. Subnet membership is NOT expanded
  25. * here — IPs that are only "covered by" a manual /24 don't appear in
  26. * the search unless they also have a row in `ip_scores`. The IP-detail
  27. * page shows the precise effective status.
  28. *
  29. * Pagination: `page` (1-indexed) + `page_size` (default 25, max 200).
  30. *
  31. * RBAC: Viewer.
  32. */
  33. final class IpsController
  34. {
  35. use AdminControllerSupport;
  36. public function __construct(
  37. private readonly IpScoreRepository $ipScores,
  38. private readonly CategoryRepository $categories,
  39. private readonly ManualBlockRepository $manualBlocks,
  40. private readonly AllowlistRepository $allowlist,
  41. private readonly IpEnrichmentRepository $enrichment,
  42. private readonly IpHistoryRepository $history,
  43. private readonly CidrEvaluatorFactory $cidrEvaluatorFactory,
  44. private readonly EffectiveStatusService $effectiveStatus,
  45. ) {
  46. }
  47. public function list(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
  48. {
  49. $query = $request->getQueryParams();
  50. $filters = $this->parseSearchFilters($query);
  51. $errors = $filters['errors'];
  52. if ($errors !== []) {
  53. return self::validationFailed($response, $errors);
  54. }
  55. $page = $filters['page'];
  56. $pageSize = $filters['page_size'];
  57. $evaluator = $this->cidrEvaluatorFactory->get();
  58. $repoFilters = [
  59. 'q' => $filters['q'],
  60. 'category_id' => $filters['category_id'],
  61. 'min_score' => $filters['min_score'],
  62. 'max_score' => $filters['max_score'],
  63. 'country' => $filters['country'],
  64. 'asn' => $filters['asn'],
  65. 'status' => $filters['status'],
  66. 'manual_bins' => $evaluator->manualBlockedIpBins(),
  67. 'allow_bins' => $evaluator->allowlistedIpBins(),
  68. ];
  69. $result = $this->ipScores->searchIps($repoFilters, $pageSize, ($page - 1) * $pageSize);
  70. $slugByCategoryId = $this->slugByCategoryId();
  71. // Per-row: top category slug + raw enrichment (country flag rendering happens in the UI).
  72. $items = [];
  73. foreach ($result['items'] as $row) {
  74. $top = $this->topCategoryFor($row['ip_bin']);
  75. $enrichmentRow = $this->enrichment->findByIpBin($row['ip_bin']);
  76. $effective = $this->effectiveStatusFor($row['ip_bin']);
  77. $items[] = [
  78. 'ip' => $row['ip_text'],
  79. 'is_ipv4' => str_contains($row['ip_text'], '.') && !str_contains($row['ip_text'], ':'),
  80. 'max_score' => round($row['max_score'], 4),
  81. 'top_category' => $top !== null ? ($slugByCategoryId[$top] ?? null) : null,
  82. 'pair_count' => $row['pair_count'],
  83. 'last_report_at' => $this->formatTimestamp($row['last_report_at']),
  84. 'status' => $effective->value,
  85. 'enrichment' => $enrichmentRow,
  86. ];
  87. }
  88. return self::json($response, 200, [
  89. 'items' => $items,
  90. 'page' => $page,
  91. 'page_size' => $pageSize,
  92. 'total' => $result['total'],
  93. ]);
  94. }
  95. /**
  96. * Country dropdown source for the IPs list page. Returns every
  97. * country code seen so far in `ip_enrichment` with its population.
  98. */
  99. public function countries(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
  100. {
  101. return self::json($response, 200, [
  102. 'items' => $this->enrichment->countryCounts(),
  103. ]);
  104. }
  105. /**
  106. * @param array{ip: string} $args
  107. */
  108. public function show(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
  109. {
  110. try {
  111. $ip = IpAddress::fromString(rawurldecode($args['ip']));
  112. } catch (InvalidIpException) {
  113. return self::error($response, 404, 'not_found');
  114. }
  115. $bin = $ip->binary();
  116. $scoreRows = $this->ipScores->scoresForIp($bin);
  117. $slugById = $this->slugByCategoryId();
  118. $scores = [];
  119. foreach ($scoreRows as $row) {
  120. $scores[] = [
  121. 'category' => $slugById[$row['category_id']] ?? null,
  122. 'category_id' => $row['category_id'],
  123. 'score' => round($row['score'], 4),
  124. 'last_report_at' => $this->formatTimestamp($row['last_report_at']),
  125. 'report_count_30d' => $row['report_count_30d'],
  126. ];
  127. }
  128. $manual = $this->manualBlocks->findByIpBin($bin);
  129. $allow = $this->allowlist->findByIpBin($bin);
  130. $enrichment = $this->enrichment->findByIpBin($bin) ?? [
  131. 'country_code' => null,
  132. 'asn' => null,
  133. 'as_org' => null,
  134. 'enriched_at' => null,
  135. ];
  136. $status = $this->effectiveStatus->forIp($ip);
  137. $history = $this->history->forIp($bin);
  138. return self::json($response, 200, [
  139. 'ip' => $ip->text(),
  140. 'is_ipv4' => $ip->isIpv4(),
  141. 'scores' => $scores,
  142. 'enrichment' => $enrichment,
  143. 'status' => $status->value,
  144. 'manual_block' => $manual?->toArray(),
  145. 'allowlist' => $allow?->toArray(),
  146. 'history' => $history['items'],
  147. 'has_more' => $history['has_more'],
  148. ]);
  149. }
  150. /**
  151. * @param array<string, mixed> $query
  152. * @return array{
  153. * errors: array<string, string>,
  154. * page: int,
  155. * page_size: int,
  156. * q: ?string,
  157. * category_id: ?int,
  158. * min_score: ?float,
  159. * max_score: ?float,
  160. * country: ?string,
  161. * asn: ?int,
  162. * status: ?string,
  163. * }
  164. */
  165. private function parseSearchFilters(array $query): array
  166. {
  167. $errors = [];
  168. $page = isset($query['page']) && ctype_digit((string) $query['page']) ? max(1, (int) $query['page']) : 1;
  169. $pageSize = isset($query['page_size']) && ctype_digit((string) $query['page_size'])
  170. ? (int) $query['page_size']
  171. : 25;
  172. $pageSize = max(1, min(200, $pageSize));
  173. $q = isset($query['q']) && is_string($query['q']) && trim($query['q']) !== '' ? trim((string) $query['q']) : null;
  174. if ($q !== null) {
  175. // SEC_REVIEW F30 / F46: bound `q` to characters that can appear in
  176. // a literal IP (digits, hex, `:`, `.`) and cap its length. The
  177. // repository uses `q` as an anchored LIKE prefix; rejecting other
  178. // shapes here forecloses non-anchored full-table scans and the
  179. // `%`/`_` wildcard-injection vector in one go. 64 is well above
  180. // the 39-char IPv6 maximum.
  181. if (strlen($q) > 64 || preg_match('/^[0-9a-fA-F:.]+$/', $q) !== 1) {
  182. $errors['q'] = 'must be an IP or IP prefix (digits, hex, `:`, `.`; max 64 chars)';
  183. $q = null;
  184. }
  185. }
  186. $categoryId = null;
  187. if (isset($query['category']) && is_string($query['category']) && $query['category'] !== '') {
  188. $cat = $this->categories->findActiveBySlug($query['category']);
  189. if ($cat === null) {
  190. $errors['category'] = 'unknown category slug';
  191. } else {
  192. $categoryId = $cat->id;
  193. }
  194. }
  195. $minScore = $this->parseFloat($query['min_score'] ?? null);
  196. $maxScore = $this->parseFloat($query['max_score'] ?? null);
  197. $country = null;
  198. if (isset($query['country']) && is_string($query['country']) && $query['country'] !== '') {
  199. if (preg_match('/^[A-Za-z]{2}$/', $query['country']) !== 1) {
  200. $errors['country'] = 'must be a 2-letter ISO code';
  201. } else {
  202. $country = strtoupper($query['country']);
  203. }
  204. }
  205. $asn = null;
  206. if (isset($query['asn']) && (string) $query['asn'] !== '') {
  207. if (!ctype_digit((string) $query['asn'])) {
  208. $errors['asn'] = 'must be a positive integer';
  209. } else {
  210. $asn = (int) $query['asn'];
  211. }
  212. }
  213. $status = null;
  214. if (isset($query['status']) && is_string($query['status']) && $query['status'] !== '') {
  215. if (!in_array($query['status'], ['scored', 'manual', 'allowlisted', 'clean'], true)) {
  216. $errors['status'] = 'must be scored | manual | allowlisted | clean';
  217. } else {
  218. $status = $query['status'];
  219. }
  220. }
  221. return [
  222. 'errors' => $errors,
  223. 'page' => $page,
  224. 'page_size' => $pageSize,
  225. 'q' => $q,
  226. 'category_id' => $categoryId,
  227. 'min_score' => $minScore,
  228. 'max_score' => $maxScore,
  229. 'country' => $country,
  230. 'asn' => $asn,
  231. 'status' => $status,
  232. ];
  233. }
  234. private function parseFloat(mixed $value): ?float
  235. {
  236. if ($value === null || $value === '') {
  237. return null;
  238. }
  239. if (is_numeric($value)) {
  240. return (float) $value;
  241. }
  242. return null;
  243. }
  244. private function topCategoryFor(string $ipBin): ?int
  245. {
  246. $rows = $this->ipScores->scoresForIp($ipBin);
  247. foreach ($rows as $row) {
  248. if ($row['score'] > 0) {
  249. return $row['category_id'];
  250. }
  251. }
  252. return null;
  253. }
  254. private function effectiveStatusFor(string $ipBin): EffectiveStatus
  255. {
  256. return $this->effectiveStatus->forIp(IpAddress::fromBinary($ipBin));
  257. }
  258. /**
  259. * @return array<int, string>
  260. */
  261. private function slugByCategoryId(): array
  262. {
  263. $out = [];
  264. foreach ($this->categories->listAll() as $cat) {
  265. /** @var Category $cat */
  266. $out[$cat->id] = $cat->slug;
  267. }
  268. return $out;
  269. }
  270. private function formatTimestamp(?string $value): ?string
  271. {
  272. if ($value === null) {
  273. return null;
  274. }
  275. // Convert "YYYY-MM-DD HH:MM:SS" → ISO 8601 "YYYY-MM-DDTHH:MM:SSZ".
  276. $s = trim($value);
  277. if (str_contains($s, 'T')) {
  278. return $s;
  279. }
  280. return str_replace(' ', 'T', $s) . 'Z';
  281. }
  282. }