post inicial
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoggerRedactsSecrets(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "agent.log")
|
||||
var console bytes.Buffer
|
||||
l, err := New(&console, path, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l.AddSecret("sk-supersecretvalue123", "sk-sup...123")
|
||||
l.Infof("connecting with key sk-supersecretvalue123")
|
||||
l.Warnf("warn sk-supersecretvalue123")
|
||||
l.Action("task", "cli", "command", "curl -H 'Bearer sk-supersecretvalue123'")
|
||||
l.Debugf("hidden debug")
|
||||
l.Close()
|
||||
|
||||
data, _ := os.ReadFile(path)
|
||||
all := string(data) + console.String()
|
||||
if strings.Contains(all, "sk-supersecretvalue123") {
|
||||
t.Fatalf("secret leaked:\n%s", all)
|
||||
}
|
||||
if !strings.Contains(string(data), "sk-sup...123") || !strings.Contains(string(data), "ACTION task=\"cli\"") {
|
||||
t.Errorf("log content:\n%s", data)
|
||||
}
|
||||
if strings.Contains(all, "hidden debug") {
|
||||
t.Error("DEBUG must be hidden without --verbose")
|
||||
}
|
||||
if strings.Contains(console.String(), "connecting") {
|
||||
t.Error("INFO must not reach console without --verbose")
|
||||
}
|
||||
st, _ := os.Stat(path)
|
||||
if st.Mode().Perm() != 0o600 {
|
||||
t.Errorf("perm = %o", st.Mode().Perm())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user