diff --git a/.gitignore b/.gitignore index b002762..4089315 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,10 @@ yarn-error.log* pnpm-debug.log* lerna-debug.log* +# But allow component directories named 'logs' +!apps/web/src/components/logs/ +!apps/api/src/domains/logs/ + # Dependencies node_modules diff --git a/apps/api/src/domains/logs/logs.types.ts b/apps/api/src/domains/logs/logs.types.ts index 9054111..3b29cb6 100644 --- a/apps/api/src/domains/logs/logs.types.ts +++ b/apps/api/src/domains/logs/logs.types.ts @@ -15,6 +15,16 @@ export interface ParsedLogEntry { path?: string; statusCode?: number; responseTime?: number; + // ModSecurity specific fields + ruleId?: string; + severity?: string; + tags?: string[]; + uri?: string; + uniqueId?: string; + file?: string; + line?: string; + data?: string; + fullMessage?: string; // Store complete log message without truncation } export interface LogFilterOptions { diff --git a/apps/api/src/domains/logs/services/log-parser.service.ts b/apps/api/src/domains/logs/services/log-parser.service.ts index 7905a84..7399a08 100644 --- a/apps/api/src/domains/logs/services/log-parser.service.ts +++ b/apps/api/src/domains/logs/services/log-parser.service.ts @@ -70,7 +70,7 @@ export function parseErrorLogLine(line: string, index: number): ParsedLogEntry | if (!match) return null; - const [, timeStr, levelStr, message] = match; + const [, timeStr, levelStr, fullMessageText] = match; // Parse time: 2025/03/29 14:35:18 const timestamp = timeStr.replace(/\//g, '-').replace(' ', 'T') + 'Z'; @@ -89,16 +89,23 @@ export function parseErrorLogLine(line: string, index: number): ParsedLogEntry | const level = levelMap[levelStr] || 'error'; // Extract IP if present - const ipMatch = message.match(/client: ([\d.]+)/); + const ipMatch = fullMessageText.match(/client: ([\d.]+)/); const ip = ipMatch ? ipMatch[1] : undefined; + // Check if this is a ModSecurity error log entry + if (fullMessageText.includes('ModSecurity:')) { + // Use ModSecurity parser for better extraction + return parseModSecLogLine(line, index); + } + return { id: `error_${Date.now()}_${index}`, timestamp, level, type: 'error', source: 'nginx', - message: message.substring(0, 200), // Truncate long messages + message: fullMessageText.substring(0, 500), // Show more context but still truncate for display + fullMessage: fullMessageText, // Store complete message ip }; } catch (error) { @@ -109,7 +116,7 @@ export function parseErrorLogLine(line: string, index: number): ParsedLogEntry | /** * Parse ModSecurity audit log line - * Format varies, look for key patterns + * Format varies, look for key patterns and extract all relevant fields */ export function parseModSecLogLine(line: string, index: number): ParsedLogEntry | null { try { @@ -134,25 +141,70 @@ export function parseModSecLogLine(line: string, index: number): ParsedLogEntry } } - // Extract message + // Extract Rule ID - [id "942100"] + const ruleIdMatch = line.match(/\[id "([^"]+)"\]/); + const ruleId = ruleIdMatch ? ruleIdMatch[1] : undefined; + + // Extract message (msg) - [msg "SQL Injection Attack Detected via libinjection"] const msgMatch = line.match(/\[msg "([^"]+)"\]/); - const message = msgMatch ? msgMatch[1] : line.substring(0, 200); + const message = msgMatch ? msgMatch[1] : 'ModSecurity Alert'; - // Extract IP - const ipMatch = line.match(/\[client ([\d.]+)\]/) || line.match(/\[hostname "([\d.]+)"\]/); - const ip = ipMatch ? ipMatch[1] : undefined; + // Extract severity - [severity "2"] + const severityMatch = line.match(/\[severity "([^"]+)"\]/); + const severity = severityMatch ? severityMatch[1] : undefined; + + // Extract all tags - [tag "application-multi"] [tag "language-multi"] ... + const tagMatches = line.matchAll(/\[tag "([^"]+)"\]/g); + const tags: string[] = []; + for (const match of tagMatches) { + tags.push(match[1]); + } + + // Extract IP - from [client 52.186.182.85] or [hostname "10.0.0.203"] + const clientIpMatch = line.match(/\[client ([\d.]+)\]/); + const hostnameMatch = line.match(/\[hostname "([^"]+)"\]/); + const ip = clientIpMatch ? clientIpMatch[1] : (hostnameMatch ? hostnameMatch[1] : undefined); - // Extract request info + // Extract URI - [uri "/device.rsp"] + const uriMatch = line.match(/\[uri "([^"]+)"\]/); + const uri = uriMatch ? uriMatch[1] : undefined; + + // Extract unique ID - [unique_id "176094161071.529267"] + const uniqueIdMatch = line.match(/\[unique_id "([^"]+)"\]/); + const uniqueId = uniqueIdMatch ? uniqueIdMatch[1] : undefined; + + // Extract file - [file "/etc/nginx/modsec/coreruleset/rules/REQUEST-942-APPLICATION-ATTACK-SQLI.conf"] + const fileMatch = line.match(/\[file "([^"]+)"\]/); + const file = fileMatch ? fileMatch[1] : undefined; + + // Extract line number - [line "46"] + const lineMatch = line.match(/\[line "([^"]+)"\]/); + const lineNumber = lineMatch ? lineMatch[1] : undefined; + + // Extract data field if present - [data "..."] + const dataMatch = line.match(/\[data "([^"]+)"\]/); + const data = dataMatch ? dataMatch[1] : undefined; + + // Extract request info from log line const methodMatch = line.match(/"(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS) ([^"]+)"/); const method = methodMatch ? methodMatch[1] : undefined; - const path = methodMatch ? methodMatch[2] : undefined; + const path = methodMatch ? methodMatch[2] : (uri || undefined); - // Determine level + // Determine level based on content let level: 'info' | 'warning' | 'error' = 'warning'; if (line.includes('Access denied') || line.includes('blocked')) { level = 'error'; + } else if (line.includes('Warning')) { + level = 'warning'; } + // Extract status code + const statusMatch = line.match(/with code (\d+)/); + const statusCode = statusMatch ? parseInt(statusMatch[1]) : undefined; + + // Store full message without truncation + const fullMessage = line; + return { id: `modsec_${Date.now()}_${index}`, timestamp, @@ -160,10 +212,20 @@ export function parseModSecLogLine(line: string, index: number): ParsedLogEntry type: 'error', source: 'modsecurity', message: `ModSecurity: ${message}`, + fullMessage, // Complete log without truncation ip, method, path, - statusCode: line.includes('403') ? 403 : undefined + statusCode, + // ModSecurity specific fields + ruleId, + severity, + tags, + uri, + uniqueId, + file, + line: lineNumber, + data }; } catch (error) { logger.warn(`Failed to parse ModSecurity log line: ${line}`); diff --git a/apps/web/src/components/logs/LogDetailsDialog.tsx b/apps/web/src/components/logs/LogDetailsDialog.tsx new file mode 100644 index 0000000..1b5e0cb --- /dev/null +++ b/apps/web/src/components/logs/LogDetailsDialog.tsx @@ -0,0 +1,246 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { LogEntry } from "@/types"; + +interface LogDetailsDialogProps { + log: LogEntry | null; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function LogDetailsDialog({ log, open, onOpenChange }: LogDetailsDialogProps) { + if (!log) return null; + + const getLevelColor = ( + level: string + ): "destructive" | "default" | "secondary" | "outline" => { + switch (level) { + case "error": + return "destructive"; + case "warning": + return "outline"; + case "info": + return "default"; + default: + return "secondary"; + } + }; + + const getTypeColor = (type: string) => { + switch (type) { + case "access": + return "default"; + case "error": + return "destructive"; + case "system": + return "secondary"; + default: + return "outline"; + } + }; + + return ( + + + + + Log Details + {log.level} + {log.type} + + + {new Date(log.timestamp).toLocaleString()} + + + + +
+ {/* Basic Information */} +
+

Basic Information

+
+
+ Source: {log.source} +
+
+ Timestamp:{" "} + {new Date(log.timestamp).toLocaleString()} +
+ {log.domain && ( +
+ Domain:{" "} + + {log.domain} + +
+ )} + {log.ip && ( +
+ IP Address: {log.ip} +
+ )} +
+
+ + {/* Request Information */} + {(log.method || log.path || log.uri || log.statusCode) && ( +
+

Request Information

+
+ {log.method && ( +
+ Method:{" "} + {log.method} +
+ )} + {log.path && ( +
+ Path:{" "} + + {log.path} + +
+ )} + {log.uri && ( +
+ URI:{" "} + + {log.uri} + +
+ )} + {log.statusCode && ( +
+ Status Code:{" "} + = 500 + ? "destructive" + : log.statusCode >= 400 + ? "outline" + : "default" + } + > + {log.statusCode} + +
+ )} + {log.responseTime && ( +
+ Response Time:{" "} + {log.responseTime}ms +
+ )} +
+
+ )} + + {/* ModSecurity Specific Information */} + {(log.ruleId || log.severity || log.tags || log.file || log.uniqueId) && ( +
+

+ ModSecurity WAF Details +

+
+ {log.ruleId && ( +
+ Rule ID: + + {log.ruleId} + +
+ )} + {log.severity && ( +
+ Severity: + = 3 + ? "destructive" + : parseInt(log.severity) >= 2 + ? "outline" + : "default" + } + > + Level {log.severity} + +
+ )} + {log.tags && log.tags.length > 0 && ( +
+ Tags: +
+ {log.tags.map((tag, index) => ( + + {tag} + + ))} +
+
+ )} + {log.file && ( +
+ Rule File: + + {log.file} + +
+ )} + {log.line && ( +
+ Line Number: + {log.line} +
+ )} + {log.uniqueId && ( +
+ Unique ID: + + {log.uniqueId} + +
+ )} + {log.data && ( +
+ Data: + + {log.data} + +
+ )} +
+
+ )} + + {/* Message */} +
+

Message

+
+

{log.message}

+
+
+ + {/* Full Log Entry */} + {log.fullMessage && ( +
+

Complete Log Entry

+
+
+                    {log.fullMessage}
+                  
+
+
+ )} +
+
+
+
+ ); +} diff --git a/apps/web/src/components/pages/Logs.tsx b/apps/web/src/components/pages/Logs.tsx index ee25a68..bb5d875 100644 --- a/apps/web/src/components/pages/Logs.tsx +++ b/apps/web/src/components/pages/Logs.tsx @@ -73,6 +73,7 @@ import { useSuspenseLogs, useLogs } from "@/queries/logs.query-options"; +import { LogDetailsDialog } from "@/components/logs/LogDetailsDialog"; // Component for fast-loading statistics data const LogStatistics = () => { @@ -160,7 +161,9 @@ const LogEntries = ({ autoRefresh, setAutoRefresh, toast, - onRefetch + onRefetch, + selectedLog, + setSelectedLog }: { page: number; limit: number; @@ -182,6 +185,8 @@ const LogEntries = ({ setAutoRefresh: (refresh: boolean) => void; toast: any; onRefetch: (refetch: () => Promise) => void; + selectedLog: LogEntry | null; + setSelectedLog: (log: LogEntry | null) => void; }) => { const [isPageChanging, setIsPageChanging] = useState(false); // Build query parameters @@ -363,11 +368,21 @@ const LogEntries = ({ { accessorKey: "message", header: "Message", - cell: ({ row }) => ( -
- {row.getValue("message")} -
- ), + cell: ({ row }) => { + const log = row.original; + const displayMessage = log.fullMessage || log.message; + return ( +
+ {/* Show truncated version in table, full message in title tooltip */} +
{log.message}
+ {log.fullMessage && log.fullMessage.length > log.message.length && ( +
+ Click for full details +
+ )} +
+ ); + }, }, { accessorKey: "details", @@ -375,7 +390,7 @@ const LogEntries = ({ cell: ({ row }) => { const log = row.original; return ( -
+
{log.ip &&
IP: {log.ip}
} {log.method && log.path && (
@@ -384,6 +399,19 @@ const LogEntries = ({ )} {log.statusCode &&
Status: {log.statusCode}
} {log.responseTime &&
RT: {log.responseTime}ms
} + {/* ModSecurity specific details */} + {log.ruleId && ( +
Rule ID: {log.ruleId}
+ )} + {log.severity && ( +
Severity: {log.severity}
+ )} + {log.tags && log.tags.length > 0 && ( +
+ Tags: {log.tags.join(', ')} +
+ )} + {log.uri &&
URI: {log.uri}
}
); }, @@ -582,6 +610,8 @@ const LogEntries = ({ data-state={ rowSelection[String(log.id || index)] && "selected" } + className="cursor-pointer hover:bg-muted/50" + onClick={() => setSelectedLog(log)} > {new Date(log.timestamp).toLocaleString()} @@ -610,13 +640,17 @@ const LogEntries = ({ )} - - {log.message} + +
+ {log.message} +
+ {log.fullMessage && log.fullMessage.length > log.message.length && ( +
+ Click for full details +
+ )}
- + {log.ip &&
IP: {log.ip}
} {log.method && log.path && (
@@ -627,6 +661,21 @@ const LogEntries = ({ {log.responseTime && (
RT: {log.responseTime}ms
)} + {/* ModSecurity specific details */} + {log.ruleId && ( +
+ Rule ID: {log.ruleId} +
+ )} + {log.severity && ( +
Severity: {log.severity}
+ )} + {log.tags && log.tags.length > 0 && ( +
+ Tags: {log.tags.join(', ')} +
+ )} + {log.uri &&
URI: {log.uri}
} )) @@ -726,6 +775,7 @@ const Logs = () => { const [autoRefresh, setAutoRefresh] = useState(false); const [logsRefetch, setLogsRefetch] = useState<(() => Promise) | null>(null); const [isReloading, setIsReloading] = useState(false); + const [selectedLog, setSelectedLog] = useState(null); // URL state management with nuqs const [page, setPage] = useQueryState("page", parseAsInteger.withDefault(1)); @@ -897,6 +947,15 @@ const Logs = () => { setAutoRefresh={setAutoRefresh} toast={toast} onRefetch={(refetch) => setLogsRefetch(() => refetch)} + selectedLog={selectedLog} + setSelectedLog={setSelectedLog} + /> + + {/* Log Details Dialog */} + !open && setSelectedLog(null)} />
); diff --git a/apps/web/src/components/ui/scroll-area.tsx b/apps/web/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000..cf253cf --- /dev/null +++ b/apps/web/src/components/ui/scroll-area.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" + +import { cn } from "@/lib/utils" + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)) +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)) +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName + +export { ScrollArea, ScrollBar } diff --git a/apps/web/src/types/index.ts b/apps/web/src/types/index.ts index 5d2f479..995c400 100644 --- a/apps/web/src/types/index.ts +++ b/apps/web/src/types/index.ts @@ -162,6 +162,16 @@ export interface LogEntry { path?: string; statusCode?: number; responseTime?: number; + // ModSecurity specific fields + ruleId?: string; + severity?: string; + tags?: string[]; + uri?: string; + uniqueId?: string; + file?: string; + line?: string; + data?: string; + fullMessage?: string; // Complete log message without truncation } export interface NotificationChannel {