Feature: Security audit logging with admin web query page
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
This commit is contained in:
1 parent
b1ddec1b8a
commit
a522424491
12 files changed
+2272
-88
No files matched your search
+185
-16
@@ -1,10 +1,12 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
@@ -24,6 +26,13 @@ const (
|
||||
// Logger wraps zerolog.Logger
|
||||
type Logger struct {
|
||||
zl zerolog.Logger
|
||||
// audit is a second, file-only logger used for security audit records.
|
||||
// It writes JSON lines to AuditFile so audit data is not buried in
|
||||
// debug/operational output and can be retained independently.
|
||||
audit zerolog.Logger
|
||||
auditEnabled bool
|
||||
auditPath string // "" when the separate audit file is disabled
|
||||
closers []io.Closer
|
||||
}
|
||||
|
||||
// Config holds logger configuration
|
||||
@@ -42,6 +51,19 @@ type Config struct {
|
||||
MaxAge int
|
||||
// Compress enables gzip compression for rotated files
|
||||
Compress bool
|
||||
|
||||
// AuditFile is the dedicated security-audit log path ("" disables the
|
||||
// separate audit file; audit records still go to the main log). Audit
|
||||
// records are JSON lines with audit=true, event=<name>, and contextual
|
||||
// fields such as actor/role/ip/device_id.
|
||||
AuditFile string
|
||||
// AuditMaxSize / AuditMaxBackups / AuditMaxAge tune audit rotation.
|
||||
// Zero values pick 100 MB / 20 files / 90 days.
|
||||
AuditMaxSize int
|
||||
AuditMaxBackups int
|
||||
AuditMaxAge int
|
||||
// AuditCompress enables gzip compression for rotated audit files.
|
||||
AuditCompress bool
|
||||
}
|
||||
|
||||
// DefaultConfig returns default configuration
|
||||
@@ -60,6 +82,7 @@ func DefaultConfig() Config {
|
||||
// New creates a new logger with config
|
||||
func New(cfg Config) *Logger {
|
||||
var writers []io.Writer
|
||||
var closers []io.Closer
|
||||
|
||||
// Console output with color
|
||||
if cfg.Console {
|
||||
@@ -87,6 +110,7 @@ func New(cfg Config) *Logger {
|
||||
LocalTime: true,
|
||||
}
|
||||
writers = append(writers, fileWriter)
|
||||
closers = append(closers, fileWriter)
|
||||
}
|
||||
|
||||
// Combine writers
|
||||
@@ -106,14 +130,78 @@ func New(cfg Config) *Logger {
|
||||
Timestamp().
|
||||
Logger()
|
||||
|
||||
return &Logger{zl: zl}
|
||||
l := &Logger{zl: zl, closers: closers}
|
||||
|
||||
if cfg.AuditFile != "" {
|
||||
dir := filepath.Dir(cfg.AuditFile)
|
||||
if dir != "" && dir != "." {
|
||||
_ = os.MkdirAll(dir, 0755)
|
||||
}
|
||||
maxSize := cfg.AuditMaxSize
|
||||
if maxSize <= 0 {
|
||||
maxSize = 100
|
||||
}
|
||||
maxBackups := cfg.AuditMaxBackups
|
||||
if maxBackups <= 0 {
|
||||
maxBackups = 20
|
||||
}
|
||||
maxAge := cfg.AuditMaxAge
|
||||
if maxAge <= 0 {
|
||||
maxAge = 90
|
||||
}
|
||||
auditWriter := &lumberjack.Logger{
|
||||
Filename: cfg.AuditFile,
|
||||
MaxSize: maxSize,
|
||||
MaxBackups: maxBackups,
|
||||
MaxAge: maxAge,
|
||||
Compress: cfg.AuditCompress,
|
||||
LocalTime: true,
|
||||
}
|
||||
l.audit = zerolog.New(auditWriter).
|
||||
Level(zerolog.InfoLevel).
|
||||
With().
|
||||
Timestamp().
|
||||
Logger()
|
||||
l.auditEnabled = true
|
||||
l.auditPath = cfg.AuditFile
|
||||
l.closers = append(l.closers, auditWriter)
|
||||
}
|
||||
|
||||
return l
|
||||
}
|
||||
|
||||
// Close releases any file handles owned by the logger. Safe to call multiple
|
||||
// times. Loggers derived with WithPrefix share the base logger's files but do
|
||||
// not own them, so call Close on the logger returned by New.
|
||||
func (l *Logger) Close() error {
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
for _, c := range l.closers {
|
||||
_ = c.Close()
|
||||
}
|
||||
l.closers = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// WithPrefix returns a new logger with a prefix field
|
||||
func (l *Logger) WithPrefix(prefix string) *Logger {
|
||||
return &Logger{
|
||||
zl: l.zl.With().Str("module", prefix).Logger(),
|
||||
n := &Logger{zl: l.zl.With().Str("module", prefix).Logger(), auditPath: l.auditPath}
|
||||
if l.auditEnabled {
|
||||
n.audit = l.audit.With().Str("module", prefix).Logger()
|
||||
n.auditEnabled = true
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// AuditPath returns the path of the dedicated audit log file, or "" when the
|
||||
// separate audit file is disabled (audit records then only reach the main log).
|
||||
// Derived loggers (WithPrefix) report the same path.
|
||||
func (l *Logger) AuditPath() string {
|
||||
if l == nil {
|
||||
return ""
|
||||
}
|
||||
return l.auditPath
|
||||
}
|
||||
|
||||
// Debug logs a debug message
|
||||
@@ -151,21 +239,102 @@ func (l *Logger) GetLevel() Level {
|
||||
return l.zl.GetLevel()
|
||||
}
|
||||
|
||||
// ClientEvent logs client online/offline events
|
||||
// ClientEvent logs client online/offline events as audit records.
|
||||
func (l *Logger) ClientEvent(event string, clientID uint64, ip string, extra ...string) {
|
||||
e := l.zl.Info().
|
||||
Str("event", event).
|
||||
Uint64("client_id", clientID).
|
||||
Str("ip", ip).
|
||||
Time("time", time.Now())
|
||||
|
||||
if len(extra) >= 2 {
|
||||
for i := 0; i+1 < len(extra); i += 2 {
|
||||
e = e.Str(extra[i], extra[i+1])
|
||||
}
|
||||
// clientID is the TCP connection id; the device's stable YAMA id arrives
|
||||
// as the "clientID" extra and is normalized to device_id so every audit
|
||||
// record uses the same field names (conn / device_id).
|
||||
fields := map[string]any{
|
||||
"conn": clientID,
|
||||
"ip": ip,
|
||||
}
|
||||
for i := 0; i+1 < len(extra); i += 2 {
|
||||
key := extra[i]
|
||||
if key == "clientID" {
|
||||
key = "device_id"
|
||||
}
|
||||
fields[key] = extra[i+1]
|
||||
}
|
||||
l.Audit("device."+event, fields)
|
||||
}
|
||||
|
||||
e.Msg("")
|
||||
// Audit writes one security-audit record at info level.
|
||||
func (l *Logger) Audit(event string, fields map[string]any) {
|
||||
l.AuditAt(LevelInfo, event, fields)
|
||||
}
|
||||
|
||||
// AuditWarn writes one security-audit record at warn level. Use it for
|
||||
// rejected / failed security-relevant operations.
|
||||
func (l *Logger) AuditWarn(event string, fields map[string]any) {
|
||||
l.AuditAt(LevelWarn, event, fields)
|
||||
}
|
||||
|
||||
// AuditAt writes a security-audit record to the dedicated audit file (when
|
||||
// configured) and mirrors it to the main logger so operators tailing
|
||||
// server.log still see security events. event is a stable dotted name such
|
||||
// as "web.login.failure"; fields carry actor/role/ip/device_id/reason.
|
||||
//
|
||||
// Untrusted string values (usernames, User-Agent, Origin, device strings)
|
||||
// are sanitized: control characters are replaced so they cannot forge log
|
||||
// lines, and long values are truncated.
|
||||
func (l *Logger) AuditAt(level Level, event string, fields map[string]any) {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
clean := make(map[string]any, len(fields)+2)
|
||||
for k, v := range fields {
|
||||
clean[k] = sanitizeAuditValue(v)
|
||||
}
|
||||
clean["audit"] = true
|
||||
clean["event"] = event
|
||||
|
||||
if l.auditEnabled {
|
||||
l.audit.WithLevel(level).Fields(clean).Msg("audit")
|
||||
}
|
||||
l.zl.WithLevel(level).Fields(clean).Msg("audit")
|
||||
}
|
||||
|
||||
// maxAuditValueLen bounds untrusted log values (runes). A 1 MB WebSocket
|
||||
// frame could otherwise be reflected verbatim into every audit line.
|
||||
const maxAuditValueLen = 512
|
||||
|
||||
// maxAuditValueBytes is the pre-sanitization byte cap. It keeps a single
|
||||
// hostile value from allocating an unbounded rune slice before truncation.
|
||||
const maxAuditValueBytes = 4096
|
||||
|
||||
func sanitizeAuditValue(v any) any {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return sanitizeAuditString(x)
|
||||
case []byte:
|
||||
return sanitizeAuditString(string(x))
|
||||
case error:
|
||||
return sanitizeAuditString(x.Error())
|
||||
case fmt.Stringer:
|
||||
return sanitizeAuditString(x.String())
|
||||
default:
|
||||
// Numeric/bool values are code-controlled and log-safe.
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeAuditString(s string) string {
|
||||
if len(s) > maxAuditValueBytes {
|
||||
s = s[:maxAuditValueBytes]
|
||||
}
|
||||
s = strings.Map(func(r rune) rune {
|
||||
// C0 controls, DEL, and Unicode line/paragraph separators can be
|
||||
// used to forge or visually split log lines in console output.
|
||||
if r < 0x20 || r == 0x7f || r == 0x2028 || r == 0x2029 {
|
||||
return ' '
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
if utf8.RuneCountInString(s) > maxAuditValueLen {
|
||||
r := []rune(s)
|
||||
s = string(r[:maxAuditValueLen])
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// default global logger
|
||||
|
||||
Reference in new issue
Block a user