#!/usr/bin/env php * * This file is part of a Moko Consulting project. * * SPDX-License-Identifier: GPL-3.0-or-later * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 3 of the License, or * (at your option) any later version. * * FILE INFORMATION * DEFGROUP: MokoPlatform.Enterprise.CLI * INGROUP: MokoPlatform.Enterprise * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/audit_query.php * BRIEF: Search, filter, and export audit logs */ declare(strict_types=1); require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; use MokoEnterprise\CliFramework; /** * CLI tool to search, filter, and export audit logs. * * Reads JSONL audit log files from var/logs/audit/ and provides * filtering by service, user, event type, level, and date range. * * @since 09.01.00 */ class AuditQueryCli extends CliFramework { protected function configure(): void { $this->setDescription('Search, filter, and export audit logs'); $this->addArgument('--path', 'Repository root (for var/logs/audit/)', '.'); $this->addArgument('--log-dir', 'Custom log directory', ''); $this->addArgument('--service', 'Filter by service name', ''); $this->addArgument('--user', 'Filter by user', ''); $this->addArgument('--event', 'Filter by event type', ''); $this->addArgument('--level', 'Filter by log level (info/warning/error)', ''); $this->addArgument('--since', 'Show entries since date (YYYY-MM-DD)', ''); $this->addArgument('--until', 'Show entries until date (YYYY-MM-DD)', ''); $this->addArgument('--limit', 'Max entries to show', '50'); $this->addArgument('--format', 'Output format: table, json, jsonl', 'table'); $this->addArgument('--tail', 'Show last N entries (like tail)', false); $this->addArgument('--stats', 'Show summary statistics instead of entries', false); } protected function run(): int { $logDir = $this->resolveLogDir(); if ($logDir === null) { return self::EXIT_NOT_FOUND; } $files = $this->findLogFiles($logDir); if (empty($files)) { $this->log('WARNING', 'No audit log files found in ' . $logDir); return self::EXIT_SUCCESS; } $this->log('DEBUG', sprintf('Found %d log file(s) in %s', count($files), $logDir)); $entries = $this->loadEntries($files); $entries = $this->filterEntries($entries); // Sort by timestamp descending (newest first). usort($entries, static function (array $a, array $b): int { return ($b['timestamp'] ?? '') <=> ($a['timestamp'] ?? ''); }); // Stats mode — show aggregated counts. if ($this->getArgument('--stats')) { return $this->showStats($entries); } // Apply limit. $limit = (int) $this->getArgument('--limit', '50'); if ($limit > 0 && count($entries) > $limit) { $entries = array_slice($entries, 0, $limit); } if (empty($entries)) { $this->log('INFO', 'No entries match the given filters.'); return self::EXIT_SUCCESS; } return $this->outputEntries($entries); } /** * Resolve the audit log directory path. * * @return string|null Resolved directory path or null if not found. */ private function resolveLogDir(): ?string { $customDir = $this->getArgument('--log-dir'); if ($customDir !== '' && $customDir !== null) { $logDir = (string) $customDir; } else { $repoPath = (string) $this->getArgument('--path', '.'); $logDir = rtrim($repoPath, '/\\') . '/var/logs/audit'; } if (!is_dir($logDir)) { $this->log('ERROR', 'Audit log directory not found: ' . $logDir); return null; } return $logDir; } /** * Find audit log files matching date range filter. * * @param string $logDir Path to audit log directory. * @return string[] Array of file paths sorted by name. */ private function findLogFiles(string $logDir): array { $pattern = $logDir . '/audit_*.jsonl'; $allFiles = glob($pattern) ?: []; $serviceFilter = (string) $this->getArgument('--service'); $sinceDate = (string) $this->getArgument('--since'); $untilDate = (string) $this->getArgument('--until'); $filtered = []; foreach ($allFiles as $file) { $basename = basename($file); // Parse service and date from filename: audit__.jsonl if (!preg_match('/^audit_(.+)_(\d{8})\.jsonl$/', $basename, $matches)) { continue; } $fileService = $matches[1]; $fileDate = $matches[2]; // Filter by service name from filename (efficient pre-filter). if ($serviceFilter !== '' && $fileService !== $serviceFilter) { continue; } // Filter by date range from filename (efficient pre-filter). if ($sinceDate !== '') { $sinceCompact = str_replace('-', '', $sinceDate); if ($fileDate < $sinceCompact) { continue; } } if ($untilDate !== '') { $untilCompact = str_replace('-', '', $untilDate); if ($fileDate > $untilCompact) { continue; } } $filtered[] = $file; } sort($filtered); return $filtered; } /** * Load and parse JSONL entries from log files. * * @param string[] $files Array of file paths. * @return array> Parsed entries. */ private function loadEntries(array $files): array { $entries = []; $lineCount = 0; foreach ($files as $file) { $handle = fopen($file, 'r'); if ($handle === false) { $this->log('WARNING', 'Cannot open file: ' . $file); continue; } while (($line = fgets($handle)) !== false) { $line = trim($line); if ($line === '') { continue; } $entry = json_decode($line, true); if (!is_array($entry)) { $lineCount++; continue; } $entries[] = $entry; $lineCount++; } fclose($handle); } $this->log('DEBUG', sprintf('Parsed %d entries from %d lines', count($entries), $lineCount)); return $entries; } /** * Apply user/event/level/date filters to entries. * * @param array> $entries Raw entries. * @return array> Filtered entries. */ private function filterEntries(array $entries): array { $userFilter = (string) $this->getArgument('--user'); $eventFilter = (string) $this->getArgument('--event'); $levelFilter = (string) $this->getArgument('--level'); $serviceFilter = (string) $this->getArgument('--service'); $sinceDate = (string) $this->getArgument('--since'); $untilDate = (string) $this->getArgument('--until'); $filtered = []; foreach ($entries as $entry) { // Filter by service (in case filename pre-filter was not exact). if ($serviceFilter !== '' && ($entry['service'] ?? '') !== $serviceFilter) { continue; } // Filter by user. if ($userFilter !== '' && ($entry['user'] ?? '') !== $userFilter) { continue; } // Filter by event type (matches event_type or event_subtype). if ($eventFilter !== '') { $eventType = $entry['event_type'] ?? ''; $eventSubtype = $entry['event_subtype'] ?? ''; if ($eventType !== $eventFilter && $eventSubtype !== $eventFilter) { continue; } } // Filter by level. if ($levelFilter !== '' && ($entry['level'] ?? '') !== $levelFilter) { continue; } // Filter by timestamp (precise, within-file filtering). $timestamp = $entry['timestamp'] ?? ''; if ($timestamp !== '' && $sinceDate !== '') { $entryDate = substr($timestamp, 0, 10); // YYYY-MM-DD from ISO 8601 if ($entryDate < $sinceDate) { continue; } } if ($timestamp !== '' && $untilDate !== '') { $entryDate = substr($timestamp, 0, 10); if ($entryDate > $untilDate) { continue; } } $filtered[] = $entry; } return $filtered; } /** * Output entries in the requested format. * * @param array> $entries Filtered entries. * @return int Exit code. */ private function outputEntries(array $entries): int { $format = (string) $this->getArgument('--format', 'table'); $this->section('Audit Log Results'); $this->log('INFO', sprintf('Showing %d entries', count($entries))); switch ($format) { case 'json': echo json_encode($entries, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; break; case 'jsonl': foreach ($entries as $entry) { echo json_encode($entry, JSON_UNESCAPED_SLASHES) . "\n"; } break; case 'table': default: $this->renderTable($entries); break; } return self::EXIT_SUCCESS; } /** * Render entries as a formatted table. * * @param array> $entries Entries to display. */ private function renderTable(array $entries): void { $headers = ['Time', 'Service', 'User', 'Event', 'Message']; $rows = []; foreach ($entries as $entry) { $timestamp = $entry['timestamp'] ?? ''; // Shorten timestamp to YYYY-MM-DD HH:MM:SS. if (strlen($timestamp) >= 19) { $time = substr($timestamp, 0, 19); $time = str_replace('T', ' ', $time); } else { $time = $timestamp; } $service = $entry['service'] ?? ''; $user = $entry['user'] ?? ''; // Build event string from event_type + event_subtype. $eventParts = []; if (!empty($entry['event_type'])) { $eventParts[] = $entry['event_type']; } if (!empty($entry['event_subtype'])) { $eventParts[] = $entry['event_subtype']; } $event = implode('/', $eventParts); // Build message from message field or data summary. $message = $entry['message'] ?? ''; if ($message === '' && !empty($entry['data']) && is_array($entry['data'])) { $dataParts = []; foreach ($entry['data'] as $key => $value) { if (is_scalar($value)) { $dataParts[] = "{$key}={$value}"; } } $message = implode(', ', array_slice($dataParts, 0, 3)); if (count($dataParts) > 3) { $message .= '...'; } } // Truncate long messages. if (strlen($message) > 60) { $message = substr($message, 0, 57) . '...'; } $rows[] = [$time, $service, $user, $event, $message]; } $this->table($headers, $rows); } /** * Show aggregate statistics from filtered entries. * * @param array> $entries Filtered entries. * @return int Exit code. */ private function showStats(array $entries): int { $this->section('Audit Log Statistics'); $total = count($entries); if ($total === 0) { $this->log('INFO', 'No entries match the given filters.'); return self::EXIT_SUCCESS; } // Aggregate counts. $byService = []; $byUser = []; $byEventType = []; $byLevel = []; foreach ($entries as $entry) { $service = $entry['service'] ?? 'unknown'; $user = $entry['user'] ?? 'unknown'; $eventType = $entry['event_type'] ?? 'unknown'; $level = $entry['level'] ?? '-'; $byService[$service] = ($byService[$service] ?? 0) + 1; $byUser[$user] = ($byUser[$user] ?? 0) + 1; $byEventType[$eventType] = ($byEventType[$eventType] ?? 0) + 1; $byLevel[$level] = ($byLevel[$level] ?? 0) + 1; } arsort($byService); arsort($byUser); arsort($byEventType); arsort($byLevel); // Build summary rows. $rows = ['Total entries' => $total]; // Top services. $i = 0; foreach ($byService as $name => $count) { if ($i >= 5) { break; } $rows["Service: {$name}"] = $count; $i++; } // Top users. $i = 0; foreach ($byUser as $name => $count) { if ($i >= 5) { break; } $rows["User: {$name}"] = $count; $i++; } // Event types. foreach ($byEventType as $name => $count) { $rows["Event: {$name}"] = $count; } // Levels. foreach ($byLevel as $name => $count) { $rows["Level: {$name}"] = $count; } $this->printSummaryBox($rows); return self::EXIT_SUCCESS; } } $app = new AuditQueryCli(); exit($app->execute());