package collector import ( "bufio" "fmt" "log" "os" "os/exec" "regexp" "sort" "strings" "time" ) type LoginEvent struct { Timestamp string `json:"timestamp"` User string `json:"user"` Source string `json:"source"` Port string `json:"port"` Method string `json:"method"` Status string `json:"status"` Key string `json:"key,omitempty"` } type Session struct { User string `json:"user"` TTY string `json:"tty"` From string `json:"from"` Login string `json:"login"` Logout string `json:"logout"` Duration string `json:"duration"` } type SecuritySummary struct { TotalAccepted int `json:"total_accepted"` TotalFailed int `json:"total_failed"` UniqueIPs int `json:"unique_ips"` UniqueUsers int `json:"unique_users"` LastLogin string `json:"last_login"` } type SecurityData struct { SSHLogins []LoginEvent `json:"ssh_logins"` WebGUILogins []LoginEvent `json:"webgui_logins"` Sessions []Session `json:"sessions"` Summary SecuritySummary `json:"summary"` } var ( reAccepted = regexp.MustCompile(`Accepted\s+(publickey|password)\s+for\s+(\S+)\s+from\s+(\S+)\s+port\s+(\d+)`) reFailed = regexp.MustCompile(`Failed\s+password\s+for\s+(?:invalid user\s+)?(\S+)\s+from\s+(\S+)\s+port\s+(\d+)`) reInvalid = regexp.MustCompile(`Invalid user\s+(\S+)\s+from\s+(\S+)\s+port\s+(\d+)`) // RFC 5424 Syslog: 1 2024-01-15T10:30:00+01:00 ... (with version digit) reTimestamp = regexp.MustCompile(`^<\d+>(?:1\s+)?(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[^\s]*)`) // BSD syslog: Jan 15 10:30:00 reBSDTimestamp = regexp.MustCompile(`^<\d+>\s*([A-Z][a-z]{2}\s+\d+\s+\d{2}:\d{2}:\d{2})`) ) func CollectSecurity() *SecurityData { data := &SecurityData{ SSHLogins: []LoginEvent{}, WebGUILogins: []LoginEvent{}, Sessions: []Session{}, } // Collect audit log files logFiles := []string{"/var/log/audit/latest.log"} now := time.Now() for i := 0; i < 7; i++ { d := now.AddDate(0, 0, -i) logFiles = append(logFiles, fmt.Sprintf("/var/log/audit/audit_%s.log", d.Format("20060102"))) } seen := make(map[string]bool) for _, path := range logFiles { events, err := parseAuditLog(path) if err != nil { log.Printf("Security: %s: %v", path, err) continue } log.Printf("Security: %s: %d events", path, len(events)) for _, e := range events { key := e.Timestamp + e.User + e.Source + e.Port + e.Status if !seen[key] { seen[key] = true data.SSHLogins = append(data.SSHLogins, e) } } } // Sort by timestamp descending, limit to 100 sort.Slice(data.SSHLogins, func(i, j int) bool { return data.SSHLogins[i].Timestamp > data.SSHLogins[j].Timestamp }) if len(data.SSHLogins) > 100 { data.SSHLogins = data.SSHLogins[:100] } // Collect sessions data.Sessions = collectSessions() // Build summary ips := make(map[string]bool) users := make(map[string]bool) for _, e := range data.SSHLogins { if e.Status == "accepted" { data.Summary.TotalAccepted++ } else { data.Summary.TotalFailed++ } if e.Source != "" { ips[e.Source] = true } if e.User != "" { users[e.User] = true } } data.Summary.UniqueIPs = len(ips) data.Summary.UniqueUsers = len(users) if len(data.SSHLogins) > 0 { data.Summary.LastLogin = data.SSHLogins[0].Timestamp } return data } func parseAuditLog(path string) ([]LoginEvent, error) { f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() var events []LoginEvent scanner := bufio.NewScanner(f) // Increase buffer for long lines scanner.Buffer(make([]byte, 0, 64*1024), 256*1024) for scanner.Scan() { line := scanner.Text() // Extract timestamp ts := extractTimestamp(line) if m := reAccepted.FindStringSubmatch(line); m != nil { events = append(events, LoginEvent{ Timestamp: ts, User: m[2], Source: m[3], Port: m[4], Method: m[1], Status: "accepted", }) } else if m := reFailed.FindStringSubmatch(line); m != nil { events = append(events, LoginEvent{ Timestamp: ts, User: m[1], Source: m[2], Port: m[3], Method: "password", Status: "failed", }) } else if m := reInvalid.FindStringSubmatch(line); m != nil { events = append(events, LoginEvent{ Timestamp: ts, User: m[1], Source: m[2], Port: m[3], Method: "invalid_user", Status: "failed", }) } } return events, nil } func extractTimestamp(line string) string { if m := reTimestamp.FindStringSubmatch(line); m != nil { return m[1] } if m := reBSDTimestamp.FindStringSubmatch(line); m != nil { // Parse BSD timestamp and add current year t, err := time.Parse("Jan 2 15:04:05", m[1]) if err == nil { t = t.AddDate(time.Now().Year(), 0, 0) return t.Format(time.RFC3339) } return m[1] } return "" } func collectSessions() []Session { out, err := exec.Command("/usr/bin/last", "-100").Output() if err != nil { return nil } var sessions []Session for _, line := range strings.Split(string(out), "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "wtmp") || strings.HasPrefix(line, "utx.log") { continue } fields := strings.Fields(line) if len(fields) < 4 { continue } s := Session{ User: fields[0], TTY: fields[1], } // Determine if there's a "from" field (IP or hostname) idx := 2 if len(fields) > 4 && !isMonth(fields[2]) { s.From = fields[2] idx = 3 } // Rest is login time, logout, duration rest := strings.Join(fields[idx:], " ") // Try to extract login/logout/duration from the rest if dashIdx := strings.Index(rest, " - "); dashIdx >= 0 { s.Login = strings.TrimSpace(rest[:dashIdx]) after := rest[dashIdx+3:] if parenIdx := strings.Index(after, "("); parenIdx >= 0 { s.Logout = strings.TrimSpace(after[:parenIdx]) dur := after[parenIdx:] dur = strings.Trim(dur, "()") s.Duration = dur } else { s.Logout = strings.TrimSpace(after) } } else { s.Login = rest if strings.Contains(rest, "still logged in") { s.Logout = "still logged in" } } sessions = append(sessions, s) } return sessions } func isMonth(s string) bool { months := []string{"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"} for _, m := range months { if s == m { return true } } return false }