Files
2026-09-27 16:04:02 -06:00

151 lines
3.9 KiB
Go

// Package audit implementa el logging por niveles y el registro de auditoría
// (agent.log) del agente.
//
// Seguridad: todo texto pasa por un redactor que sustituye los secretos
// registrados (p.ej. API_KEY) por su versión enmascarada. Es una defensa en
// profundidad: el código no debería intentar loguear secretos, pero si un
// mensaje de error los incluye por accidente, no llegan al disco.
package audit
import (
"fmt"
"io"
"os"
"strings"
"sync"
"time"
)
// Level es la severidad de un mensaje.
type Level int
const (
Debug Level = iota
Info
Warn
Error
)
func (l Level) String() string {
switch l {
case Debug:
return "DEBUG"
case Info:
return "INFO"
case Warn:
return "WARN"
default:
return "ERROR"
}
}
// Logger escribe mensajes en la consola y en el archivo de auditoría.
//
// Consola: WARN y ERROR siempre; DEBUG e INFO solo con --verbose.
// Archivo: INFO en adelante siempre; DEBUG solo con --verbose.
type Logger struct {
mu sync.Mutex
console io.Writer
file *os.File
verbose bool
replacer *strings.Replacer
secrets []string
now func() time.Time
}
// New crea un Logger. Si path está vacío no se escribe archivo de auditoría.
// El archivo se crea con permisos 0600 porque puede contener rutas y
// comandos sensibles del sistema administrado.
func New(console io.Writer, path string, verbose bool) (*Logger, error) {
l := &Logger{console: console, verbose: verbose, now: time.Now}
if path != "" {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600)
if err != nil {
return nil, fmt.Errorf("failed to open audit log %s: %w", path, err)
}
l.file = f
}
return l, nil
}
// AddSecret registra un valor que nunca debe aparecer en claro.
// Se ignoran valores muy cortos para no destrozar texto normal.
func (l *Logger) AddSecret(secret, masked string) {
if len(secret) < 4 {
return
}
l.mu.Lock()
defer l.mu.Unlock()
l.secrets = append(l.secrets, secret, masked)
l.replacer = strings.NewReplacer(l.secrets...)
}
// Redact aplica el redactor a un texto arbitrario.
func (l *Logger) Redact(s string) string {
l.mu.Lock()
defer l.mu.Unlock()
return l.redactLocked(s)
}
func (l *Logger) redactLocked(s string) string {
if l.replacer == nil {
return s
}
return l.replacer.Replace(s)
}
// Debugf, Infof, Warnf y Errorf registran mensajes por nivel.
func (l *Logger) Debugf(format string, args ...any) { l.log(Debug, format, args...) }
func (l *Logger) Infof(format string, args ...any) { l.log(Info, format, args...) }
func (l *Logger) Warnf(format string, args ...any) { l.log(Warn, format, args...) }
func (l *Logger) Errorf(format string, args ...any) { l.log(Error, format, args...) }
func (l *Logger) log(level Level, format string, args ...any) {
l.mu.Lock()
defer l.mu.Unlock()
msg := l.redactLocked(fmt.Sprintf(format, args...))
ts := l.now().Format(time.RFC3339)
if level >= Warn || l.verbose {
fmt.Fprintf(l.console, "%s %s\n", level, msg)
}
if l.file != nil && (level >= Info || l.verbose) {
fmt.Fprintf(l.file, "%s %s %s\n", ts, level, msg)
}
}
// Action registra una entrada de auditoría estructurada en agent.log.
// fields se escribe como key="value" en el orden recibido (pares clave/valor).
//
// Ejemplo:
//
// 2026-09-27T10:35:17-06:00 ACTION task="01-nginx.md" action="llm_chat" result="ok"
func (l *Logger) Action(fields ...string) {
l.mu.Lock()
defer l.mu.Unlock()
if l.file == nil {
return
}
var b strings.Builder
b.WriteString(l.now().Format(time.RFC3339))
b.WriteString(" ACTION")
for i := 0; i+1 < len(fields); i += 2 {
fmt.Fprintf(&b, " %s=%q", fields[i], l.redactLocked(fields[i+1]))
}
b.WriteByte('\n')
l.file.WriteString(b.String())
}
// Close cierra el archivo de auditoría tras sincronizarlo a disco.
func (l *Logger) Close() error {
l.mu.Lock()
defer l.mu.Unlock()
if l.file == nil {
return nil
}
l.file.Sync()
err := l.file.Close()
l.file = nil
return err
}