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
172 lines
4.6 KiB
Go
172 lines
4.6 KiB
Go
package web
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
// Stable audit event names. Keep these stable: operators build alerts and
|
|
// dashboards on them. Every record also carries audit=true.
|
|
const (
|
|
auditWSConnect = "web.ws.connect"
|
|
auditWSReject = "web.ws.reject"
|
|
auditWSClose = "web.ws.close"
|
|
auditLoginOK = "web.login.success"
|
|
auditLoginFail = "web.login.failure"
|
|
auditLoginThrot = "web.login.throttled"
|
|
auditSession = "web.session.resume"
|
|
auditBadToken = "web.auth.invalid_token"
|
|
auditDenied = "web.authz.denied"
|
|
auditUnknownCmd = "web.command.unknown"
|
|
auditSaltProbe = "web.salt.unknown_user"
|
|
|
|
auditScreenStart = "remote.screen.start"
|
|
auditScreenStop = "remote.screen.stop"
|
|
auditScreenDenied = "remote.screen.denied"
|
|
auditTermStart = "remote.terminal.start"
|
|
auditTermStop = "remote.terminal.stop"
|
|
auditTermDenied = "remote.terminal.denied"
|
|
auditClipSet = "remote.clipboard.set"
|
|
auditClipGet = "remote.clipboard.get"
|
|
auditRdpReset = "remote.rdp_reset"
|
|
|
|
auditUserCreate = "admin.user.create"
|
|
auditUserDelete = "admin.user.delete"
|
|
auditDeviceAdmin = "admin.device.delete"
|
|
auditQuery = "admin.audit.query"
|
|
)
|
|
|
|
// audit emits one audit record for a WebSocket client. Identity fields are
|
|
// read under h.mu because requireAuth/handleLogin install them there and
|
|
// broadcast goroutines may observe the same client concurrently.
|
|
//
|
|
// Callers MUST NOT hold h.mu when calling this method.
|
|
func (h *wsHub) audit(c *wsClient, warn bool, event string, fields map[string]any) {
|
|
if fields == nil {
|
|
fields = make(map[string]any)
|
|
}
|
|
if c != nil {
|
|
h.mu.RLock()
|
|
connID := c.id
|
|
addr := c.addr
|
|
peer := c.peerAddr
|
|
user := c.user
|
|
role := c.role
|
|
h.mu.RUnlock()
|
|
|
|
if _, ok := fields["conn"]; !ok && connID != "" {
|
|
fields["conn"] = connID
|
|
}
|
|
if _, ok := fields["ip"]; !ok && addr != "" {
|
|
fields["ip"] = addr
|
|
}
|
|
if _, ok := fields["peer_ip"]; !ok && peer != "" && peer != addr {
|
|
fields["peer_ip"] = peer
|
|
}
|
|
if _, ok := fields["actor"]; !ok && user != "" {
|
|
fields["actor"] = user
|
|
}
|
|
if _, ok := fields["role"]; !ok && role != "" {
|
|
fields["role"] = role
|
|
}
|
|
}
|
|
if warn {
|
|
h.log.AuditWarn(event, fields)
|
|
} else {
|
|
h.log.Audit(event, fields)
|
|
}
|
|
}
|
|
|
|
// auditClose records the end of a browser WebSocket. Anonymous scanners that
|
|
// connect and do nothing produce only the connect event; authenticated
|
|
// sessions, failed-login probes, and command traffic always produce a close
|
|
// record with counters that help spot brute-force / probing behavior.
|
|
func (h *wsHub) auditClose(c *wsClient, screens int) {
|
|
suspicious := c.statInvalidToken > 0 ||
|
|
c.statLoginFailures > 0 ||
|
|
c.statAdminDenied > 0 ||
|
|
c.statUnknownSalt > 0 ||
|
|
c.statUnknownCmd > 0
|
|
if c.user == "" && c.statCommands == 0 && !suspicious {
|
|
return
|
|
}
|
|
|
|
fields := map[string]any{
|
|
"duration_ms": time.Since(c.connectedAt).Milliseconds(),
|
|
"commands": c.statCommands,
|
|
}
|
|
if screens > 0 {
|
|
fields["screens"] = screens
|
|
}
|
|
if c.statInvalidToken > 0 {
|
|
fields["invalid_tokens"] = c.statInvalidToken
|
|
}
|
|
if c.statLoginFailures > 0 {
|
|
fields["login_failures"] = c.statLoginFailures
|
|
}
|
|
if c.statAdminDenied > 0 {
|
|
fields["admin_denied"] = c.statAdminDenied
|
|
}
|
|
if c.statUnknownSalt > 0 {
|
|
fields["unknown_salt"] = c.statUnknownSalt
|
|
}
|
|
if c.statUnknownCmd > 0 {
|
|
fields["unknown_commands"] = c.statUnknownCmd
|
|
}
|
|
h.audit(c, suspicious, auditWSClose, fields)
|
|
}
|
|
|
|
// requestFields builds the audit context available before a WebSocket client
|
|
// exists (upgrade rejected) or for plain HTTP endpoints (SSO).
|
|
func requestFields(r *http.Request, trustForwardedFor bool) map[string]any {
|
|
fields := make(map[string]any)
|
|
if r == nil {
|
|
return fields
|
|
}
|
|
ip := clientIP(r, trustForwardedFor)
|
|
if ip != "" {
|
|
fields["ip"] = ip
|
|
}
|
|
if peer := stripPort(r.RemoteAddr); peer != "" && peer != ip {
|
|
fields["peer_ip"] = peer
|
|
}
|
|
if origin := r.Header.Get("Origin"); origin != "" {
|
|
fields["origin"] = origin
|
|
}
|
|
if ua := r.UserAgent(); ua != "" {
|
|
fields["ua"] = ua
|
|
}
|
|
return fields
|
|
}
|
|
|
|
// wsRequestFields returns request metadata worth attaching to login events.
|
|
func (c *wsClient) wsRequestFields() map[string]any {
|
|
fields := make(map[string]any)
|
|
if c.origin != "" {
|
|
fields["origin"] = c.origin
|
|
}
|
|
if c.userAgent != "" {
|
|
fields["ua"] = c.userAgent
|
|
}
|
|
return fields
|
|
}
|
|
|
|
func durationMs(start time.Time) int64 {
|
|
if start.IsZero() {
|
|
return 0
|
|
}
|
|
return time.Since(start).Milliseconds()
|
|
}
|
|
|
|
// newConnID returns a short random identifier used to correlate all audit
|
|
// records belonging to one browser WebSocket connection.
|
|
func newConnID() string {
|
|
var b [4]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
return "conn"
|
|
}
|
|
return hex.EncodeToString(b[:])
|
|
}
|