getQueryParams(); $filters = $this->parseSearchFilters($query); $errors = $filters['errors']; if ($errors !== []) { return self::validationFailed($response, $errors); } $page = $filters['page']; $pageSize = $filters['page_size']; $evaluator = $this->cidrEvaluatorFactory->get(); $repoFilters = [ 'q' => $filters['q'], 'category_id' => $filters['category_id'], 'min_score' => $filters['min_score'], 'max_score' => $filters['max_score'], 'country' => $filters['country'], 'asn' => $filters['asn'], 'status' => $filters['status'], 'manual_bins' => $evaluator->manualBlockedIpBins(), 'allow_bins' => $evaluator->allowlistedIpBins(), ]; $result = $this->ipScores->searchIps($repoFilters, $pageSize, ($page - 1) * $pageSize); $slugByCategoryId = $this->slugByCategoryId(); // Per-row: top category slug + raw enrichment (country flag rendering happens in the UI). $items = []; foreach ($result['items'] as $row) { $top = $this->topCategoryFor($row['ip_bin']); $enrichmentRow = $this->enrichment->findByIpBin($row['ip_bin']); $effective = $this->effectiveStatusFor($row['ip_bin']); $items[] = [ 'ip' => $row['ip_text'], 'is_ipv4' => str_contains($row['ip_text'], '.') && !str_contains($row['ip_text'], ':'), 'max_score' => round($row['max_score'], 4), 'top_category' => $top !== null ? ($slugByCategoryId[$top] ?? null) : null, 'pair_count' => $row['pair_count'], 'last_report_at' => $this->formatTimestamp($row['last_report_at']), 'status' => $effective->value, 'enrichment' => $enrichmentRow, ]; } return self::json($response, 200, [ 'items' => $items, 'page' => $page, 'page_size' => $pageSize, 'total' => $result['total'], ]); } /** * Country dropdown source for the IPs list page. Returns every * country code seen so far in `ip_enrichment` with its population. */ public function countries(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface { return self::json($response, 200, [ 'items' => $this->enrichment->countryCounts(), ]); } /** * @param array{ip: string} $args */ public function show(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface { try { $ip = IpAddress::fromString(rawurldecode($args['ip'])); } catch (InvalidIpException) { return self::error($response, 404, 'not_found'); } $bin = $ip->binary(); $scoreRows = $this->ipScores->scoresForIp($bin); $slugById = $this->slugByCategoryId(); $scores = []; foreach ($scoreRows as $row) { $scores[] = [ 'category' => $slugById[$row['category_id']] ?? null, 'category_id' => $row['category_id'], 'score' => round($row['score'], 4), 'last_report_at' => $this->formatTimestamp($row['last_report_at']), 'report_count_30d' => $row['report_count_30d'], ]; } $manual = $this->manualBlocks->findByIpBin($bin); $allow = $this->allowlist->findByIpBin($bin); $enrichment = $this->enrichment->findByIpBin($bin) ?? [ 'country_code' => null, 'asn' => null, 'as_org' => null, 'enriched_at' => null, ]; $status = $this->effectiveStatus->forIp($ip); $history = $this->history->forIp($bin); return self::json($response, 200, [ 'ip' => $ip->text(), 'is_ipv4' => $ip->isIpv4(), 'scores' => $scores, 'enrichment' => $enrichment, 'status' => $status->value, 'manual_block' => $manual?->toArray(), 'allowlist' => $allow?->toArray(), 'history' => $history['items'], 'has_more' => $history['has_more'], ]); } /** * @param array $query * @return array{ * errors: array, * page: int, * page_size: int, * q: ?string, * category_id: ?int, * min_score: ?float, * max_score: ?float, * country: ?string, * asn: ?int, * status: ?string, * } */ private function parseSearchFilters(array $query): array { $errors = []; $page = isset($query['page']) && ctype_digit((string) $query['page']) ? max(1, (int) $query['page']) : 1; $pageSize = isset($query['page_size']) && ctype_digit((string) $query['page_size']) ? (int) $query['page_size'] : 25; $pageSize = max(1, min(200, $pageSize)); $q = isset($query['q']) && is_string($query['q']) && trim($query['q']) !== '' ? trim((string) $query['q']) : null; if ($q !== null) { // SEC_REVIEW F30 / F46: bound `q` to characters that can appear in // a literal IP (digits, hex, `:`, `.`) and cap its length. The // repository uses `q` as an anchored LIKE prefix; rejecting other // shapes here forecloses non-anchored full-table scans and the // `%`/`_` wildcard-injection vector in one go. 64 is well above // the 39-char IPv6 maximum. if (strlen($q) > 64 || preg_match('/^[0-9a-fA-F:.]+$/', $q) !== 1) { $errors['q'] = 'must be an IP or IP prefix (digits, hex, `:`, `.`; max 64 chars)'; $q = null; } } $categoryId = null; if (isset($query['category']) && is_string($query['category']) && $query['category'] !== '') { $cat = $this->categories->findActiveBySlug($query['category']); if ($cat === null) { $errors['category'] = 'unknown category slug'; } else { $categoryId = $cat->id; } } $minScore = $this->parseFloat($query['min_score'] ?? null); $maxScore = $this->parseFloat($query['max_score'] ?? null); $country = null; if (isset($query['country']) && is_string($query['country']) && $query['country'] !== '') { if (preg_match('/^[A-Za-z]{2}$/', $query['country']) !== 1) { $errors['country'] = 'must be a 2-letter ISO code'; } else { $country = strtoupper($query['country']); } } $asn = null; if (isset($query['asn']) && (string) $query['asn'] !== '') { if (!ctype_digit((string) $query['asn'])) { $errors['asn'] = 'must be a positive integer'; } else { $asn = (int) $query['asn']; } } $status = null; if (isset($query['status']) && is_string($query['status']) && $query['status'] !== '') { if (!in_array($query['status'], ['scored', 'manual', 'allowlisted', 'clean'], true)) { $errors['status'] = 'must be scored | manual | allowlisted | clean'; } else { $status = $query['status']; } } return [ 'errors' => $errors, 'page' => $page, 'page_size' => $pageSize, 'q' => $q, 'category_id' => $categoryId, 'min_score' => $minScore, 'max_score' => $maxScore, 'country' => $country, 'asn' => $asn, 'status' => $status, ]; } private function parseFloat(mixed $value): ?float { if ($value === null || $value === '') { return null; } if (is_numeric($value)) { return (float) $value; } return null; } private function topCategoryFor(string $ipBin): ?int { $rows = $this->ipScores->scoresForIp($ipBin); foreach ($rows as $row) { if ($row['score'] > 0) { return $row['category_id']; } } return null; } private function effectiveStatusFor(string $ipBin): EffectiveStatus { return $this->effectiveStatus->forIp(IpAddress::fromBinary($ipBin)); } /** * @return array */ private function slugByCategoryId(): array { $out = []; foreach ($this->categories->listAll() as $cat) { /** @var Category $cat */ $out[$cat->id] = $cat->slug; } return $out; } private function formatTimestamp(?string $value): ?string { if ($value === null) { return null; } // Convert "YYYY-MM-DD HH:MM:SS" → ISO 8601 "YYYY-MM-DDTHH:MM:SSZ". $s = trim($value); if (str_contains($s, 'T')) { return $s; } return str_replace(' ', 'T', $s) . 'Z'; } }