181 lines
5.1 KiB
Go
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
|
|
}
|