package web import ( "bufio" "encoding/json" "os" "strings" "time" ) // Audit-log query support for the admin-only "Audit Log" page. // // The server reads its own JSON-lines audit file (logs/audit.log) back to the // browser. Records are already redacted at write time (see logger.AuditAt), so // returning raw lines is safe as long as we never widen what is written. // // Only the CURRENT audit file is searched. Rotated files (audit-*.log[.gz]) // are deliberately out of scope: they need gzip support and a separate index, // and the UI is meant for recent-window triage ("what happened just now"). const ( auditQueryDefaultLimit = 100 auditQueryMaxLimit = 500 auditQueryMaxOffset = 10000 // auditQueryScanBudget bounds how much of the audit file a single query // reads. The tail is read when the file is larger, so the newest records // (what operators actually triage) are always covered and a multi-GB log // cannot stall the WS goroutine. auditQueryScanBudget = 32 << 20 // 32 MB ) // auditQueryRequest is the JSON body of the `query_audit` command. type auditQueryRequest struct { From string `json:"from"` // RFC3339 ("2026-10-18T10:00:00Z"), optional To string `json:"to"` // RFC3339, optional Event string `json:"event"` // exact ("web.login.failure"), prefix ("web." / "web.*"), or "" Actor string `json:"actor"` // exact, case-insensitive IP string `json:"ip"` // exact DeviceID string `json:"device_id"` Keyword string `json:"keyword"` // case-insensitive substring of the raw record WarnOnly bool `json:"warn_only"` // only level=warn records Limit int `json:"limit"` Offset int `json:"offset"` // skip the newest N matching records } // auditQueryResult is the internal result of scanning the audit file. type auditQueryResult struct { Events []map[string]any Total int // matching records in the scanned window FileExists bool FileSize int64 ScannedBytes int64 Truncated bool // the file was larger than auditQueryScanBudget } // handleQueryAudit implements the admin-only audit query command. The reply is // `{cmd:"audit_result", ok, events, total, ...}`; events are newest-first. func (h *wsHub) handleQueryAudit(c *wsClient, raw []byte) { const replyCmd = "audit_result" if !h.requireAdmin(c, raw, replyCmd) { return } var in auditQueryRequest if err := json.Unmarshal(raw, &in); err != nil { c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": "Invalid request"})) return } in.Limit = clampAuditLimit(in.Limit) in.Offset = clampAuditOffset(in.Offset) path := h.log.AuditPath() if path == "" { h.audit(c, true, auditQuery, map[string]any{ "ok": false, "reason": "audit_log_disabled", }) c.queue(mustJSON(map[string]any{ "cmd": replyCmd, "ok": false, "msg": "Audit log file is disabled on this server (YAMA_AUDIT_LOG=off)", })) return } res, err := queryAuditLog(path, in) if err != nil { h.audit(c, true, auditQuery, map[string]any{ "ok": false, "reason": "read_failed", "error": err.Error(), }) c.queue(mustJSON(map[string]any{ "cmd": replyCmd, "ok": false, "msg": "Failed to read audit log: " + err.Error(), })) return } // Record the query itself: an audit viewer is privileged, and reading the // trail is itself an auditable action. Filter terms are included so an // investigator can see what was searched for. h.audit(c, false, auditQuery, map[string]any{ "ok": true, "event_filter": in.Event, "actor_filter": in.Actor, "ip_filter": in.IP, "device_filter": in.DeviceID, "keyword": in.Keyword, "warn_only": in.WarnOnly, "matches": res.Total, "returned": len(res.Events), "offset": in.Offset, }) c.queue(mustJSON(map[string]any{ "cmd": replyCmd, "ok": true, "events": res.Events, "total": res.Total, "offset": in.Offset, "limit": in.Limit, "file": path, "file_exists": res.FileExists, "file_size": res.FileSize, "scanned_bytes": res.ScannedBytes, "truncated": res.Truncated, })) } func clampAuditLimit(n int) int { if n <= 0 { return auditQueryDefaultLimit } if n > auditQueryMaxLimit { return auditQueryMaxLimit } return n } func clampAuditOffset(n int) int { if n < 0 { return 0 } if n > auditQueryMaxOffset { return auditQueryMaxOffset } return n } // queryAuditLog scans path for audit records matching q. The returned events // are the newest limit records after skipping offset, in newest-first order. func queryAuditLog(path string, q auditQueryRequest) (auditQueryResult, error) { res := auditQueryResult{Events: []map[string]any{}} f, err := os.Open(path) if err != nil { if os.IsNotExist(err) { // No audit record has been written yet (lumberjack creates the file // lazily). An empty result is correct, not an error. return res, nil } return res, err } defer f.Close() info, err := f.Stat() if err != nil { return res, err } res.FileExists = true res.FileSize = info.Size() // For very large files, scan only the tail; the newest matches live there. start := int64(0) if info.Size() > auditQueryScanBudget { start = info.Size() - auditQueryScanBudget if _, err := f.Seek(start, 0); err != nil { return res, err } res.Truncated = true } from, hasFrom := parseAuditTime(q.From) to, hasTo := parseAuditTime(q.To) keep := q.Offset + q.Limit ring := make([]map[string]any, keep) filled := 0 sc := bufio.NewScanner(f) // Sanitized values are capped at 4096 bytes each; a record with many // fields stays well under 1 MB. Allow headroom rather than aborting the // whole query on one long line. sc.Buffer(make([]byte, 64*1024), 4*1024*1024) first := true for sc.Scan() { line := sc.Text() if start > 0 && first { // Seeking into the middle of the file lands mid-line. Drop that // partial record. first = false continue } first = false res.ScannedBytes += int64(len(line)) + 1 line = strings.TrimSpace(line) if line == "" { continue } var rec map[string]any if err := json.Unmarshal([]byte(line), &rec); err != nil { continue // tolerate a torn line instead of failing the page } if _, ok := rec["event"].(string); !ok { continue } // Only audit records belong on the page. The dedicated file contains // nothing else, but a hand-edited or mixed file must not surface // ordinary operational lines here. if rec["audit"] != true { continue } if !auditRecordMatches(rec, line, q, from, hasFrom, to, hasTo) { continue } // Ring buffer of the last `keep` matches (file order is chronological). ring[res.Total%keep] = rec if filled < keep { filled++ } res.Total++ } if err := sc.Err(); err != nil { return res, err } // Newest first, skipping Offset. The match with 0-based index i lives at // ring[i%keep] as long as i >= Total-filled. for i := res.Total - 1 - q.Offset; i >= 0 && i >= res.Total-filled; i-- { res.Events = append(res.Events, ring[i%keep]) if len(res.Events) >= q.Limit { break } } return res, nil } // auditRecordMatches applies the non-time and time filters to one record. // line is the raw JSON line, used for the keyword substring search. func auditRecordMatches(rec map[string]any, line string, q auditQueryRequest, from time.Time, hasFrom bool, to time.Time, hasTo bool) bool { if q.Event != "" && !auditEventFilterMatches(q.Event, auditStringField(rec, "event")) { return false } if q.Actor != "" && !strings.EqualFold(auditStringField(rec, "actor"), q.Actor) { return false } if q.IP != "" && auditStringField(rec, "ip") != q.IP { return false } if q.DeviceID != "" && auditStringField(rec, "device_id") != q.DeviceID { return false } if q.WarnOnly && auditStringField(rec, "level") != "warn" { return false } if q.Keyword != "" && !strings.Contains(strings.ToLower(line), strings.ToLower(q.Keyword)) { return false } if hasFrom || hasTo { t, ok := parseAuditTime(auditStringField(rec, "time")) if !ok { return false // cannot prove it is in range } if hasFrom && t.Before(from) { return false } if hasTo && t.After(to) { return false } } return true } // auditEventFilterMatches supports three forms so the UI stays simple: // // "web.login.failure" exact // "web.*" prefix (trailing "*") // "web." prefix (trailing ".") func auditEventFilterMatches(filter, event string) bool { switch { case strings.HasSuffix(filter, "*"): return strings.HasPrefix(event, strings.TrimSuffix(filter, "*")) case strings.HasSuffix(filter, "."): return strings.HasPrefix(event, filter) default: return event == filter } } func auditStringField(rec map[string]any, key string) string { s, _ := rec[key].(string) return s } // parseAuditTime accepts the formats operators and browsers realistically send. // Layouts without an explicit zone are parsed as UTC; the frontend always sends // Date.toISOString(), so the RFC3339 path is what production uses. func parseAuditTime(s string) (time.Time, bool) { s = strings.TrimSpace(s) if s == "" { return time.Time{}, false } layouts := []string{ time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05", "2006-01-02T15:04", "2006-01-02 15:04:05", "2006-01-02", } for _, layout := range layouts { if t, err := time.Parse(layout, s); err == nil { return t, true } } return time.Time{}, false }