184 lines
5.8 KiB
PHP
184 lines
5.8 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Api;
|
|
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Str;
|
|
|
|
class LegacyApiAccessLogAnalyzer
|
|
{
|
|
/**
|
|
* @param list<string> $paths
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function analyze(array $paths, int $top = 20): array
|
|
{
|
|
$report = [
|
|
'generated_at' => now()->toIso8601String(),
|
|
'input_paths' => $paths,
|
|
'missing_paths' => [],
|
|
'summary' => [
|
|
'files' => 0,
|
|
'total_lines' => 0,
|
|
'matched_requests' => 0,
|
|
'legacy_key_requests' => 0,
|
|
'unique_client_ips' => 0,
|
|
'unique_api_key_fingerprints' => 0,
|
|
],
|
|
'endpoints' => [],
|
|
'client_ips' => [],
|
|
'api_key_fingerprints' => [],
|
|
'status_codes' => [],
|
|
'sample_requests' => [],
|
|
];
|
|
|
|
foreach ($this->expandPaths($paths) as $path) {
|
|
if (! is_readable($path)) {
|
|
$report['missing_paths'][] = $path;
|
|
|
|
continue;
|
|
}
|
|
|
|
$report['summary']['files']++;
|
|
$file = new \SplFileObject($path);
|
|
|
|
while (! $file->eof()) {
|
|
$line = trim((string) $file->fgets());
|
|
|
|
if ($line === '') {
|
|
continue;
|
|
}
|
|
|
|
$report['summary']['total_lines']++;
|
|
$request = $this->parseLine($line);
|
|
|
|
if ($request === null) {
|
|
continue;
|
|
}
|
|
|
|
$endpoint = $this->legacyEndpoint($request['path']);
|
|
|
|
if ($endpoint === null) {
|
|
continue;
|
|
}
|
|
|
|
$this->recordMatch($report, $request, $endpoint);
|
|
}
|
|
}
|
|
|
|
$report['summary']['unique_client_ips'] = count($report['client_ips']);
|
|
$report['summary']['unique_api_key_fingerprints'] = count($report['api_key_fingerprints']);
|
|
|
|
$this->sortAndLimit($report, $top);
|
|
|
|
return $report;
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $paths
|
|
* @return list<string>
|
|
*/
|
|
private function expandPaths(array $paths): array
|
|
{
|
|
$expanded = [];
|
|
|
|
foreach ($paths as $path) {
|
|
$matches = Str::contains($path, ['*', '?', '['])
|
|
? glob($path) ?: []
|
|
: [$path];
|
|
|
|
foreach ($matches as $match) {
|
|
$expanded[] = $match;
|
|
}
|
|
}
|
|
|
|
return array_values(array_unique($expanded));
|
|
}
|
|
|
|
/**
|
|
* @return array{client_ip: string, method: string, target: string, path: string, query: string, status: string|null}|null
|
|
*/
|
|
private function parseLine(string $line): ?array
|
|
{
|
|
if (! preg_match('/^(?P<client_ip>\S+).*"(?P<method>GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(?P<target>\S+)[^"]*"\s+(?P<status>\d{3}|-)?/', $line, $matches)) {
|
|
return null;
|
|
}
|
|
|
|
$target = $matches['target'];
|
|
$parsedUrl = parse_url($target);
|
|
$path = Arr::get($parsedUrl, 'path', Str::before($target, '?'));
|
|
$query = Arr::get($parsedUrl, 'query', Str::contains($target, '?') ? Str::after($target, '?') : '');
|
|
|
|
return [
|
|
'client_ip' => $matches['client_ip'],
|
|
'method' => $matches['method'],
|
|
'target' => $target,
|
|
'path' => $path,
|
|
'query' => $query,
|
|
'status' => $matches['status'] !== '-' ? ($matches['status'] ?? null) : null,
|
|
];
|
|
}
|
|
|
|
private function legacyEndpoint(string $path): ?string
|
|
{
|
|
$normalizedPath = Str::of($path)
|
|
->replace('/index.php', '')
|
|
->trim('/')
|
|
->toString();
|
|
|
|
if (! preg_match('~(?:^|/)(pressrelease|pressreleaseimage|company|category|newsletter)/([a-zA-Z0-9_-]+)~', $normalizedPath, $matches)) {
|
|
return null;
|
|
}
|
|
|
|
return "{$matches[1]}/{$matches[2]}";
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $report
|
|
* @param array{client_ip: string, method: string, target: string, path: string, query: string, status: string|null} $request
|
|
*/
|
|
private function recordMatch(array &$report, array $request, string $endpoint): void
|
|
{
|
|
$report['summary']['matched_requests']++;
|
|
$report['endpoints'][$endpoint] = ($report['endpoints'][$endpoint] ?? 0) + 1;
|
|
$report['client_ips'][$request['client_ip']] = ($report['client_ips'][$request['client_ip']] ?? 0) + 1;
|
|
|
|
if ($request['status'] !== null) {
|
|
$report['status_codes'][$request['status']] = ($report['status_codes'][$request['status']] ?? 0) + 1;
|
|
}
|
|
|
|
parse_str($request['query'], $query);
|
|
|
|
if (filled($query['api_key'] ?? null)) {
|
|
$report['summary']['legacy_key_requests']++;
|
|
$fingerprint = hash('sha256', (string) $query['api_key']);
|
|
$report['api_key_fingerprints'][$fingerprint] = ($report['api_key_fingerprints'][$fingerprint] ?? 0) + 1;
|
|
}
|
|
|
|
if (count($report['sample_requests']) < 10) {
|
|
$report['sample_requests'][] = [
|
|
'method' => $request['method'],
|
|
'target' => $this->maskApiKey($request['target']),
|
|
'endpoint' => $endpoint,
|
|
'client_ip' => $request['client_ip'],
|
|
'status' => $request['status'],
|
|
];
|
|
}
|
|
}
|
|
|
|
private function maskApiKey(string $target): string
|
|
{
|
|
return preg_replace('/([?&]api_key=)[^&]+/', '$1***', $target) ?? $target;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $report
|
|
*/
|
|
private function sortAndLimit(array &$report, int $top): void
|
|
{
|
|
foreach (['endpoints', 'client_ips', 'api_key_fingerprints', 'status_codes'] as $key) {
|
|
arsort($report[$key]);
|
|
$report[$key] = array_slice($report[$key], 0, $top, true);
|
|
}
|
|
}
|
|
}
|