| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316 |
- <?php
- declare(strict_types=1);
- namespace App\Application\Admin;
- use App\Domain\Category\Category;
- use App\Domain\Ip\InvalidIpException;
- use App\Domain\Ip\IpAddress;
- use App\Domain\Reputation\EffectiveStatus;
- use App\Domain\Reputation\EffectiveStatusService;
- use App\Infrastructure\Allowlist\AllowlistRepository;
- use App\Infrastructure\Category\CategoryRepository;
- use App\Infrastructure\ManualBlock\ManualBlockRepository;
- use App\Infrastructure\Reputation\CidrEvaluatorFactory;
- use App\Infrastructure\Reputation\IpEnrichmentRepository;
- use App\Infrastructure\Reputation\IpHistoryRepository;
- use App\Infrastructure\Reputation\IpScoreRepository;
- use Psr\Http\Message\ResponseInterface;
- use Psr\Http\Message\ServerRequestInterface;
- /**
- * Admin IP browser — search + detail.
- *
- * The search composes filters against `ip_scores` (joined with
- * `ip_enrichment` when country/asn is set), with the manual-block /
- * allowlist single-IP universes pre-resolved from `CidrEvaluatorFactory`
- * so the SQL stays a single query. Subnet membership is NOT expanded
- * here — IPs that are only "covered by" a manual /24 don't appear in
- * the search unless they also have a row in `ip_scores`. The IP-detail
- * page shows the precise effective status.
- *
- * Pagination: `page` (1-indexed) + `page_size` (default 25, max 200).
- *
- * RBAC: Viewer.
- */
- final class IpsController
- {
- use AdminControllerSupport;
- public function __construct(
- private readonly IpScoreRepository $ipScores,
- private readonly CategoryRepository $categories,
- private readonly ManualBlockRepository $manualBlocks,
- private readonly AllowlistRepository $allowlist,
- private readonly IpEnrichmentRepository $enrichment,
- private readonly IpHistoryRepository $history,
- private readonly CidrEvaluatorFactory $cidrEvaluatorFactory,
- private readonly EffectiveStatusService $effectiveStatus,
- ) {
- }
- public function list(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
- {
- $query = $request->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<string, mixed> $query
- * @return array{
- * errors: array<string, string>,
- * 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<int, string>
- */
- 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';
- }
- }
|