Files
2026-05-30 17:28:38 +02:00

181 lines
5.1 KiB
Go

package main
import (
"bytes"
"compress/gzip"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"time"
_ "modernc.org/sqlite"
)
type Audit struct {
db *sql.DB
}
const auditSchema = `
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
request_id TEXT NOT NULL,
capability TEXT NOT NULL,
args_hash TEXT NOT NULL,
exit_code INTEGER NOT NULL,
prev_hash TEXT NOT NULL,
this_hash TEXT NOT NULL UNIQUE
);
CREATE INDEX IF NOT EXISTS audit_log_ts ON audit_log(ts);
CREATE INDEX IF NOT EXISTS audit_log_request_id ON audit_log(request_id);
CREATE TABLE IF NOT EXISTS audit_shell_eval (
audit_id INTEGER PRIMARY KEY,
cmd TEXT NOT NULL,
cwd TEXT,
shell TEXT NOT NULL,
stdout_b64 TEXT,
stderr_b64 TEXT,
FOREIGN KEY (audit_id) REFERENCES audit_log(id)
);
`
func OpenAudit(path string) (*Audit, error) {
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(wal)&_pragma=foreign_keys(on)")
if err != nil {
return nil, fmt.Errorf("open audit db %s: %w", path, err)
}
if _, err := db.Exec(auditSchema); err != nil {
_ = db.Close()
return nil, fmt.Errorf("apply audit schema: %w", err)
}
return &Audit{db: db}, nil
}
func (a *Audit) Close() error { return a.db.Close() }
// Append registra una invocacion. Devuelve this_hash.
// args raw → SHA256 → args_hash. Hash chain SHA256(prev || canonical).
func (a *Audit) Append(requestID, capability string, args any, exitCode int) (string, error) {
argsBytes, err := json.Marshal(args)
if err != nil {
return "", fmt.Errorf("marshal args: %w", err)
}
argsHash := sha256hex(argsBytes)
var prevHash string
_ = a.db.QueryRow("SELECT this_hash FROM audit_log ORDER BY id DESC LIMIT 1").Scan(&prevHash)
ts := time.Now().Unix()
canonical := fmt.Sprintf("%s|%d|%s|%s|%s|%d", prevHash, ts, requestID, capability, argsHash, exitCode)
thisHash := sha256hex([]byte(canonical))
_, err = a.db.Exec(
`INSERT INTO audit_log (ts, request_id, capability, args_hash, exit_code, prev_hash, this_hash)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
ts, requestID, capability, argsHash, exitCode, prevHash, thisHash,
)
if err != nil {
return "", fmt.Errorf("insert audit: %w", err)
}
return thisHash, nil
}
func sha256hex(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
// ShellEvalRecord is the verbose audit payload for shell.eval invocations.
// stdout / stderr larger than 4KB are gzip+base64 compressed in the DB.
type ShellEvalRecord struct {
Cmd string
CWD string
Shell string
Stdout string
Stderr string
}
// AppendVerbose registra una invocacion + payload cleartext (cmd/cwd/shell + stdout/stderr)
// para forense. Devuelve this_hash. Linkado via audit_log.id en audit_shell_eval.
func (a *Audit) AppendVerbose(requestID, capability string, args any, exitCode int, rec ShellEvalRecord) (string, error) {
argsBytes, err := json.Marshal(args)
if err != nil {
return "", fmt.Errorf("marshal args: %w", err)
}
argsHash := sha256hex(argsBytes)
var prevHash string
_ = a.db.QueryRow("SELECT this_hash FROM audit_log ORDER BY id DESC LIMIT 1").Scan(&prevHash)
ts := time.Now().Unix()
canonical := fmt.Sprintf("%s|%d|%s|%s|%s|%d", prevHash, ts, requestID, capability, argsHash, exitCode)
thisHash := sha256hex([]byte(canonical))
tx, err := a.db.Begin()
if err != nil {
return "", fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
res, err := tx.Exec(
`INSERT INTO audit_log (ts, request_id, capability, args_hash, exit_code, prev_hash, this_hash)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
ts, requestID, capability, argsHash, exitCode, prevHash, thisHash,
)
if err != nil {
return "", fmt.Errorf("insert audit_log: %w", err)
}
auditID, err := res.LastInsertId()
if err != nil {
return "", fmt.Errorf("lastinsertid: %w", err)
}
stdoutEnc, err := encodeMaybeGzip(rec.Stdout)
if err != nil {
return "", fmt.Errorf("encode stdout: %w", err)
}
stderrEnc, err := encodeMaybeGzip(rec.Stderr)
if err != nil {
return "", fmt.Errorf("encode stderr: %w", err)
}
if _, err := tx.Exec(
`INSERT INTO audit_shell_eval (audit_id, cmd, cwd, shell, stdout_b64, stderr_b64)
VALUES (?, ?, ?, ?, ?, ?)`,
auditID, rec.Cmd, rec.CWD, rec.Shell, stdoutEnc, stderrEnc,
); err != nil {
return "", fmt.Errorf("insert audit_shell_eval: %w", err)
}
if err := tx.Commit(); err != nil {
return "", fmt.Errorf("commit tx: %w", err)
}
return thisHash, nil
}
// encodeMaybeGzip: si len(s) <= 4KB devuelve base64(plain); si supera, gzip+base64.
// Prefijo "gz:" para distinguir. Vacio devuelve "".
func encodeMaybeGzip(s string) (string, error) {
if s == "" {
return "", nil
}
const threshold = 4096
if len(s) <= threshold {
return "plain:" + base64.StdEncoding.EncodeToString([]byte(s)), nil
}
var buf bytes.Buffer
zw := gzip.NewWriter(&buf)
if _, err := zw.Write([]byte(s)); err != nil {
_ = zw.Close()
return "", err
}
if err := zw.Close(); err != nil {
return "", err
}
return "gz:" + base64.StdEncoding.EncodeToString(buf.Bytes()), nil
}