Feature: Sicherheit Tab - SSH Login Audit + Session History
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
package collector
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"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+)`)
|
||||
// Syslog with ISO 8601 after priority: <NN>2024-01-15T10:30:00+01:00 ...
|
||||
reTimestamp = regexp.MustCompile(`^<\d+>(\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 := parseAuditLog(path)
|
||||
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 {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -127,6 +127,7 @@ func sendHeartbeat(c *client.Client, agentID string) error {
|
||||
updates := collector.CollectUpdates()
|
||||
cronJobs := collector.CollectCron()
|
||||
license := collector.CollectLicense()
|
||||
security := collector.CollectSecurity()
|
||||
|
||||
req := map[string]interface{}{
|
||||
"agent_id": agentID,
|
||||
@@ -167,6 +168,7 @@ func sendHeartbeat(c *client.Client, agentID string) error {
|
||||
"updates": updates,
|
||||
"cron_jobs": cronJobs,
|
||||
"license": license,
|
||||
"security": security,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user