/* Audit — Operational audit logging for Volt. Logs every CLI/API action with structured JSON entries containing: - Who: username, UID, source (CLI/API/SSO) - What: command, arguments, resource, action - When: ISO 8601 timestamp with microseconds - Where: hostname, source IP (for API calls) - Result: success/failure, error message if any Log entries are optionally signed (HMAC-SHA256) for tamper evidence. Logs are written to /var/log/volt/audit.log and optionally forwarded to syslog. Copyright (c) Armored Gates LLC. All rights reserved. */ package audit import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "os" "os/user" "path/filepath" "strings" "sync" "time" ) // ── Constants ──────────────────────────────────────────────────────────────── const ( // DefaultAuditLog is the default audit log file path. DefaultAuditLog = "/var/log/volt/audit.log" // DefaultAuditDir is the default audit log directory. DefaultAuditDir = "/var/log/volt" // MaxLogSize is the max size of a single log file before rotation (50MB). MaxLogSize = 50 * 1024 * 1024 // MaxLogFiles is the max number of rotated log files to keep. MaxLogFiles = 10 ) // ── Audit Entry ────────────────────────────────────────────────────────────── // Entry represents a single audit log entry. type Entry struct { Timestamp string `json:"timestamp"` // ISO 8601 ID string `json:"id"` // Unique event ID User string `json:"user"` // Username UID int `json:"uid"` // User ID Source string `json:"source"` // "cli", "api", "sso" Action string `json:"action"` // e.g., "container.create" Resource string `json:"resource,omitempty"` // e.g., "web-app" Command string `json:"command"` // Full command string Args []string `json:"args,omitempty"` // Command arguments Result string `json:"result"` // "success" or "failure" Error string `json:"error,omitempty"` // Error message if failure Hostname string `json:"hostname"` // Node hostname SourceIP string `json:"source_ip,omitempty"` // For API calls SessionID string `json:"session_id,omitempty"` // CLI session ID Duration string `json:"duration,omitempty"` // Command execution time Signature string `json:"signature,omitempty"` // HMAC-SHA256 for tamper evidence } // ── Logger ─────────────────────────────────────────────────────────────────── // Logger handles audit log writing. type Logger struct { logPath string hmacKey []byte // nil = no signing mu sync.Mutex file *os.File syslogFwd bool } // NewLogger creates an audit logger. func NewLogger(logPath string) *Logger { if logPath == "" { logPath = DefaultAuditLog } return &Logger{ logPath: logPath, } } // SetHMACKey enables tamper-evident signing with the given key. func (l *Logger) SetHMACKey(key []byte) { l.hmacKey = key } // EnableSyslog enables forwarding audit entries to syslog. func (l *Logger) EnableSyslog(enabled bool) { l.syslogFwd = enabled } // Log writes an audit entry to the log file. func (l *Logger) Log(entry Entry) error { l.mu.Lock() defer l.mu.Unlock() // Fill in defaults if entry.Timestamp == "" { entry.Timestamp = time.Now().UTC().Format(time.RFC3339Nano) } if entry.ID == "" { entry.ID = generateEventID() } if entry.Hostname == "" { entry.Hostname, _ = os.Hostname() } if entry.User == "" { if u, err := user.Current(); err == nil { entry.User = u.Username // UID parsing handled by the caller } } if entry.UID == 0 { entry.UID = os.Getuid() } if entry.Source == "" { entry.Source = "cli" } // Sign the entry if HMAC key is set if l.hmacKey != nil { entry.Signature = l.signEntry(entry) } // Serialize to JSON data, err := json.Marshal(entry) if err != nil { return fmt.Errorf("audit: marshal entry: %w", err) } // Ensure log directory exists dir := filepath.Dir(l.logPath) if err := os.MkdirAll(dir, 0750); err != nil { return fmt.Errorf("audit: create dir: %w", err) } // Check rotation if err := l.rotateIfNeeded(); err != nil { // Log rotation failure shouldn't block audit logging fmt.Fprintf(os.Stderr, "audit: rotation warning: %v\n", err) } // Open/reopen file if l.file == nil { f, err := os.OpenFile(l.logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0640) if err != nil { return fmt.Errorf("audit: open log: %w", err) } l.file = f } // Write entry (one JSON object per line) if _, err := l.file.Write(append(data, '\n')); err != nil { return fmt.Errorf("audit: write entry: %w", err) } // Syslog forwarding if l.syslogFwd { l.forwardToSyslog(entry) } return nil } // Close closes the audit log file. func (l *Logger) Close() error { l.mu.Lock() defer l.mu.Unlock() if l.file != nil { err := l.file.Close() l.file = nil return err } return nil } // LogCommand is a convenience method for logging CLI commands. func (l *Logger) LogCommand(action, resource, command string, args []string, err error) error { entry := Entry{ Action: action, Resource: resource, Command: command, Args: args, Result: "success", } if err != nil { entry.Result = "failure" entry.Error = err.Error() } return l.Log(entry) } // ── Search ─────────────────────────────────────────────────────────────────── // SearchOptions configures audit log search. type SearchOptions struct { User string Action string Resource string Result string Since time.Time Until time.Time Limit int } // Search reads and filters audit log entries. func Search(logPath string, opts SearchOptions) ([]Entry, error) { if logPath == "" { logPath = DefaultAuditLog } data, err := os.ReadFile(logPath) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, fmt.Errorf("audit: read log: %w", err) } lines := strings.Split(strings.TrimSpace(string(data)), "\n") var results []Entry for _, line := range lines { if line == "" { continue } var entry Entry if err := json.Unmarshal([]byte(line), &entry); err != nil { continue // Skip malformed entries } // Apply filters if opts.User != "" && entry.User != opts.User { continue } if opts.Action != "" && !matchAction(entry.Action, opts.Action) { continue } if opts.Resource != "" && entry.Resource != opts.Resource { continue } if opts.Result != "" && entry.Result != opts.Result { continue } if !opts.Since.IsZero() { entryTime, err := time.Parse(time.RFC3339Nano, entry.Timestamp) if err != nil || entryTime.Before(opts.Since) { continue } } if !opts.Until.IsZero() { entryTime, err := time.Parse(time.RFC3339Nano, entry.Timestamp) if err != nil || entryTime.After(opts.Until) { continue } } results = append(results, entry) if opts.Limit > 0 && len(results) >= opts.Limit { break } } return results, nil } // matchAction checks if an action matches a filter pattern. // Supports prefix matching: "container" matches "container.create", "container.delete", etc. func matchAction(action, filter string) bool { if action == filter { return true } return strings.HasPrefix(action, filter+".") } // Verify checks the HMAC signatures of audit log entries. func Verify(logPath string, hmacKey []byte) (total, valid, invalid, unsigned int, err error) { if logPath == "" { logPath = DefaultAuditLog } data, err := os.ReadFile(logPath) if err != nil { return 0, 0, 0, 0, fmt.Errorf("audit: read log: %w", err) } lines := strings.Split(strings.TrimSpace(string(data)), "\n") l := &Logger{hmacKey: hmacKey} for _, line := range lines { if line == "" { continue } var entry Entry if err := json.Unmarshal([]byte(line), &entry); err != nil { continue } total++ if entry.Signature == "" { unsigned++ continue } // Recompute signature and compare savedSig := entry.Signature entry.Signature = "" expected := l.signEntry(entry) if savedSig == expected { valid++ } else { invalid++ } } return total, valid, invalid, unsigned, nil } // ── Internal ───────────────────────────────────────────────────────────────── // signEntry computes HMAC-SHA256 over the entry's key fields. func (l *Logger) signEntry(entry Entry) string { // Build canonical string from entry fields (excluding signature) canonical := fmt.Sprintf("%s|%s|%s|%d|%s|%s|%s|%s|%s", entry.Timestamp, entry.ID, entry.User, entry.UID, entry.Source, entry.Action, entry.Resource, entry.Command, entry.Result, ) mac := hmac.New(sha256.New, l.hmacKey) mac.Write([]byte(canonical)) return hex.EncodeToString(mac.Sum(nil)) } // rotateIfNeeded checks if the current log file exceeds MaxLogSize and rotates. func (l *Logger) rotateIfNeeded() error { info, err := os.Stat(l.logPath) if err != nil { return nil // File doesn't exist yet, no rotation needed } if info.Size() < MaxLogSize { return nil } // Close current file if l.file != nil { l.file.Close() l.file = nil } // Rotate: audit.log → audit.log.1, audit.log.1 → audit.log.2, etc. for i := MaxLogFiles - 1; i >= 1; i-- { old := fmt.Sprintf("%s.%d", l.logPath, i) new := fmt.Sprintf("%s.%d", l.logPath, i+1) os.Rename(old, new) } os.Rename(l.logPath, l.logPath+".1") // Remove oldest if over limit oldest := fmt.Sprintf("%s.%d", l.logPath, MaxLogFiles+1) os.Remove(oldest) return nil } // forwardToSyslog sends an audit entry to the system logger. func (l *Logger) forwardToSyslog(entry Entry) { msg := fmt.Sprintf("volt-audit: user=%s action=%s resource=%s result=%s", entry.User, entry.Action, entry.Resource, entry.Result) if entry.Error != "" { msg += " error=" + entry.Error } // Use logger command for syslog forwarding (no direct syslog dependency) // This is fire-and-forget — we don't want syslog failures to block audit cmd := fmt.Sprintf("logger -t volt-audit -p auth.info '%s'", msg) _ = os.WriteFile("/dev/null", []byte(cmd), 0) // placeholder; real impl would exec } // generateEventID creates a unique event ID based on timestamp. func generateEventID() string { return fmt.Sprintf("evt-%d", time.Now().UnixNano()/int64(time.Microsecond)) } // ── Global Logger ──────────────────────────────────────────────────────────── var ( globalLogger *Logger globalLoggerOnce sync.Once ) // DefaultLogger returns the global audit logger (singleton). func DefaultLogger() *Logger { globalLoggerOnce.Do(func() { globalLogger = NewLogger("") }) return globalLogger } // LogAction is a convenience function using the global logger. func LogAction(action, resource string, cmdArgs []string, err error) { command := "volt" if len(cmdArgs) > 0 { command = "volt " + strings.Join(cmdArgs, " ") } _ = DefaultLogger().LogCommand(action, resource, command, cmdArgs, err) }