createToken(TokenKind::Admin, role: Role::Viewer); $this->seedScored('203.0.113.10', 'brute_force', 1.5); $this->seedScored('203.0.113.11', 'brute_force', 0.5); $response = $this->request('GET', '/api/v1/admin/ips', [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame(200, $response->getStatusCode()); $body = $this->decode($response); self::assertSame(2, $body['total']); $ips = array_map(static fn (array $r): string => $r['ip'], $body['items']); self::assertContains('203.0.113.10', $ips); self::assertContains('203.0.113.11', $ips); } public function testSearchSortsByMaxScoreDescending(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Viewer); $this->seedScored('203.0.113.10', 'brute_force', 0.5); $this->seedScored('203.0.113.11', 'brute_force', 5.0); $response = $this->request('GET', '/api/v1/admin/ips', [ 'Authorization' => 'Bearer ' . $token, ]); $body = $this->decode($response); self::assertSame('203.0.113.11', $body['items'][0]['ip']); self::assertSame('203.0.113.10', $body['items'][1]['ip']); } public function testSearchFiltersByPrefix(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Viewer); $this->seedScored('203.0.113.10', 'brute_force', 1.0); $this->seedScored('198.51.100.5', 'brute_force', 1.0); $this->seedScored('2001:db8::1', 'brute_force', 1.0); $response = $this->request('GET', '/api/v1/admin/ips?q=2001', [ 'Authorization' => 'Bearer ' . $token, ]); $body = $this->decode($response); self::assertSame(1, $body['total']); self::assertSame('2001:db8::1', $body['items'][0]['ip']); self::assertFalse($body['items'][0]['is_ipv4']); } public function testSearchFiltersByCategory(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Viewer); $this->seedScored('203.0.113.10', 'brute_force', 1.0); $this->seedScored('203.0.113.11', 'spam', 1.0); $response = $this->request('GET', '/api/v1/admin/ips?category=spam', [ 'Authorization' => 'Bearer ' . $token, ]); $body = $this->decode($response); self::assertSame(1, $body['total']); self::assertSame('203.0.113.11', $body['items'][0]['ip']); } public function testSearchPaginates(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Viewer); for ($i = 0; $i < 5; ++$i) { $this->seedScored(sprintf('203.0.113.%d', 10 + $i), 'brute_force', 1.0 + ($i / 10)); } $page1 = $this->decode($this->request('GET', '/api/v1/admin/ips?page=1&page_size=2', [ 'Authorization' => 'Bearer ' . $token, ])); $page2 = $this->decode($this->request('GET', '/api/v1/admin/ips?page=2&page_size=2', [ 'Authorization' => 'Bearer ' . $token, ])); self::assertSame(5, $page1['total']); self::assertCount(2, $page1['items']); self::assertCount(2, $page2['items']); self::assertNotSame($page1['items'][0]['ip'], $page2['items'][0]['ip']); } public function testSearchRejectsUnknownCategory(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Viewer); $response = $this->request('GET', '/api/v1/admin/ips?category=bogus', [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame(400, $response->getStatusCode()); } /** * SEC_REVIEW F30: the substring fallback (`%q%`) used to drive a * full-table scan on `ip_scores`. The controller now rejects any `q` * that isn't IP-shaped so the repo only ever runs an anchored * `LIKE 'q%'` prefix scan. */ public function testSearchRejectsNonIpShapedQuery(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Viewer); // Each of these would previously have hit the `%q%` substring path. $bad = ['foo', '203.0.113.10 OR 1=1', '%X%', 'g00d', '203.0.113.10%', '_____']; foreach ($bad as $q) { $response = $this->request('GET', '/api/v1/admin/ips?q=' . rawurlencode($q), [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame(400, $response->getStatusCode(), "expected 400 for q={$q}"); $body = $this->decode($response); self::assertSame('validation_failed', $body['error']); self::assertArrayHasKey('q', $body['details']); } } /** * SEC_REVIEW F30: cap on `q` length so even an IP-shaped value cannot * push a multi-megabyte LIKE parameter through the repository. */ public function testSearchRejectsOverlongQuery(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Viewer); $tooLong = str_repeat('1', 65); // 64 is the cap. $response = $this->request('GET', '/api/v1/admin/ips?q=' . $tooLong, [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame(400, $response->getStatusCode()); $body = $this->decode($response); self::assertArrayHasKey('q', $body['details']); } /** * SEC_REVIEW F30: an IP-shaped `q` is still treated as an anchored * prefix — the substring path is gone, so a value that previously * matched mid-string no longer matches at all. */ public function testSearchQueryIsPrefixAnchoredNotSubstring(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Viewer); $this->seedScored('203.0.113.10', 'brute_force', 1.0); $this->seedScored('198.51.100.5', 'brute_force', 1.0); // "113" is a substring of 203.0.113.10 but not a prefix; with the // substring path removed the result is empty. $body = $this->decode($this->request('GET', '/api/v1/admin/ips?q=113', [ 'Authorization' => 'Bearer ' . $token, ])); self::assertSame(0, $body['total']); // The legitimate prefix still matches. $body = $this->decode($this->request('GET', '/api/v1/admin/ips?q=203.0.113', [ 'Authorization' => 'Bearer ' . $token, ])); self::assertSame(1, $body['total']); self::assertSame('203.0.113.10', $body['items'][0]['ip']); } /** * SEC_REVIEW F32: the list endpoint now batches enrichment, top * category, and effective-status lookups so the per-row data surfaces * correctly without an extra DB round-trip per IP. Seed enough rows * that the previous N+1 loop would have made hundreds of round-trips. */ public function testSearchBatchesPerRowLookups(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Viewer); // 25 scored IPs, of which two also carry enrichment rows. The // assertion checks that the batch lookup correctly attaches // enrichment to exactly those IPs and nulls for the rest. for ($i = 0; $i < 25; ++$i) { $this->seedScored(sprintf('203.0.113.%d', 10 + $i), 'brute_force', 1.0 + ($i / 100)); } $this->seedEnrichment('203.0.113.12', 'AA', 64500, 'AS-AA'); $this->seedEnrichment('203.0.113.20', 'BB', 64501, 'AS-BB'); $body = $this->decode($this->request('GET', '/api/v1/admin/ips?page=1&page_size=200', [ 'Authorization' => 'Bearer ' . $token, ])); self::assertSame(25, $body['total']); $byIp = []; foreach ($body['items'] as $row) { $byIp[$row['ip']] = $row; } self::assertSame('AA', $byIp['203.0.113.12']['enrichment']['country_code']); self::assertSame(64500, $byIp['203.0.113.12']['enrichment']['asn']); self::assertSame('BB', $byIp['203.0.113.20']['enrichment']['country_code']); self::assertNull($byIp['203.0.113.10']['enrichment']); // top_category resolves via the batched score lookup. self::assertSame('brute_force', $byIp['203.0.113.10']['top_category']); self::assertSame('brute_force', $byIp['203.0.113.20']['top_category']); // Status falls out of `max_score > 0` without a per-row hasAnyScore. foreach ($body['items'] as $row) { self::assertSame('scored', $row['status']); } } /** * SEC_REVIEW F32: when an IP appears in the search results but its * `max_score` is 0 (e.g. score row exists but has decayed), the row * resolves to `clean` without a per-row `hasAnyScore` query. */ public function testSearchStatusUsesMaxScoreColumn(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Viewer); $this->seedScored('203.0.113.10', 'brute_force', 0.0); $body = $this->decode($this->request('GET', '/api/v1/admin/ips', [ 'Authorization' => 'Bearer ' . $token, ])); self::assertSame(1, $body['total']); self::assertSame('203.0.113.10', $body['items'][0]['ip']); self::assertSame('clean', $body['items'][0]['status']); self::assertNull($body['items'][0]['top_category']); } public function testDetailReturnsScoresAndStatus(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Viewer); $reporterId = $this->createReporter('rep-detail'); $this->seedScored('203.0.113.42', 'brute_force', 2.0); $this->seedReport('203.0.113.42', 'brute_force', $reporterId); $response = $this->request('GET', '/api/v1/admin/ips/203.0.113.42', [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame(200, $response->getStatusCode()); $body = $this->decode($response); self::assertSame('203.0.113.42', $body['ip']); self::assertTrue($body['is_ipv4']); self::assertSame('scored', $body['status']); self::assertNotEmpty($body['scores']); self::assertSame('brute_force', $body['scores'][0]['category']); self::assertNotEmpty($body['history']); self::assertSame('report', $body['history'][0]['type']); } public function testDetailIncludesEnrichmentNullByDefault(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Viewer); $this->seedScored('203.0.113.42', 'brute_force', 2.0); $body = $this->decode($this->request('GET', '/api/v1/admin/ips/203.0.113.42', [ 'Authorization' => 'Bearer ' . $token, ])); self::assertNull($body['enrichment']['country_code']); self::assertNull($body['enrichment']['asn']); } public function testDetail404OnInvalidIp(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Viewer); $response = $this->request('GET', '/api/v1/admin/ips/not-an-ip', [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame(404, $response->getStatusCode()); } /** * @return iterable */ public static function nonIpShapedPaths(): iterable { // SEC_REVIEW F43: the {ip} route now uses a strict // `[0-9a-fA-F.:%]+` charset. Anything outside that — slashes, // dots-and-slashes traversal, query separators, spaces, dashes — // 404s at the route layer before `$args['ip']` is read by the // handler. Even though `IpAddress::fromString` would also // reject these inputs today, the tightened route protects // any future code that uses `$args['ip']` as a filename, log // key, or downstream URL component. yield 'path traversal' => ['/api/v1/admin/ips/..%2Fetc%2Fpasswd']; yield 'with subpath' => ['/api/v1/admin/ips/192.0.2.1/extra']; yield 'with query injection' => ['/api/v1/admin/ips/?injected']; yield 'with backslash' => ['/api/v1/admin/ips/192.0.2.1\\admin']; yield 'with space' => ['/api/v1/admin/ips/192.0.2.1 admin']; yield 'with dash' => ['/api/v1/admin/ips/not-an-ip']; yield 'with brackets' => ['/api/v1/admin/ips/[2001:db8::1]']; } #[\PHPUnit\Framework\Attributes\DataProvider('nonIpShapedPaths')] public function testDetailRejectsNonIpShapedPaths(string $path): void { $token = $this->createToken(TokenKind::Admin, role: Role::Viewer); $response = $this->request('GET', $path, [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame(404, $response->getStatusCode(), "expected 404 for {$path}"); } public function testDetailRendersForUnknownIpWithCleanStatus(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Viewer); $body = $this->decode($this->request('GET', '/api/v1/admin/ips/198.51.100.99', [ 'Authorization' => 'Bearer ' . $token, ])); self::assertSame('clean', $body['status']); self::assertSame([], $body['scores']); self::assertSame([], $body['history']); } private function seedScored(string $ip, string $categorySlug, float $score): void { $catId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => $categorySlug]); $ipObj = IpAddress::fromString($ip); $stmt = $this->db->prepare( 'INSERT INTO ip_scores (ip_bin, ip_text, category_id, score, report_count_30d, last_report_at, recomputed_at) ' . 'VALUES (:b, :t, :c, :s, 1, :now, :now)' ); $stmt->bindValue('b', $ipObj->binary(), ParameterType::LARGE_OBJECT); $stmt->bindValue('t', $ipObj->text()); $stmt->bindValue('c', $catId, ParameterType::INTEGER); $stmt->bindValue('s', number_format($score, 4, '.', '')); $stmt->bindValue('now', (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s')); $stmt->executeStatement(); } private function seedEnrichment(string $ip, string $countryCode, int $asn, string $asOrg): void { $ipObj = IpAddress::fromString($ip); $stmt = $this->db->prepare( 'INSERT INTO ip_enrichment (ip_bin, country_code, asn, as_org, enriched_at) ' . 'VALUES (:b, :c, :a, :o, :now)' ); $stmt->bindValue('b', $ipObj->binary(), ParameterType::LARGE_OBJECT); $stmt->bindValue('c', $countryCode); $stmt->bindValue('a', $asn, ParameterType::INTEGER); $stmt->bindValue('o', $asOrg); $stmt->bindValue('now', (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s')); $stmt->executeStatement(); } private function seedReport(string $ip, string $categorySlug, int $reporterId): void { $catId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => $categorySlug]); $ipObj = IpAddress::fromString($ip); $stmt = $this->db->prepare( 'INSERT INTO reports (ip_bin, ip_text, category_id, reporter_id, weight_at_report, received_at, metadata_json) ' . 'VALUES (:b, :t, :c, :r, :w, :now, NULL)' ); $stmt->bindValue('b', $ipObj->binary(), ParameterType::LARGE_OBJECT); $stmt->bindValue('t', $ipObj->text()); $stmt->bindValue('c', $catId, ParameterType::INTEGER); $stmt->bindValue('r', $reporterId, ParameterType::INTEGER); $stmt->bindValue('w', '1.00'); $stmt->bindValue('now', (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s')); $stmt->executeStatement(); } }