Add a dedicated security audit trail to the Go server. Login attempts, authorization denials, WebSocket lifecycle, remote screen and terminal sessions, clipboard operations, device authentication and admin mutations now emit stable JSON-line events (audit=true, event=<name>) to a separate logs/audit.log, mirrored to server.log. The path is configurable through YAMA_AUDIT_LOG and the separate file can be switched off. Records never contain passcodes, password hashes, tokens or clipboard contents, and untrusted string fields are stripped of control characters and truncated so they cannot forge log lines. Expose the trail to administrators through a query_audit WebSocket command and an Audit Log page in the web UI. The page filters by time range, event (exact or prefix), actor, IP, device id, keyword and warning level, pages newest-first, and stays hidden unless the server advertises the auditLog capability, so older C++ servers degrade cleanly. Each query scans at most the newest 32 MB of the current file to bound latency, records itself as admin.audit.query, and tolerates torn or non-audit lines. Add unit tests for event emission and redaction, query filtering, paging, malformed lines, missing or disabled audit files, and handler authorization. Co-Authored-By: deepseek-flash
329 lines
9.3 KiB
Go
329 lines
9.3 KiB
Go
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
|
|
}
|