fetch_ml/internal/audit/checkpoint.go
Jeremie Fraeys 66f262d788
security: improve audit, crypto, and config handling
- Enhance audit checkpoint system
- Update KMS provider and tenant key management
- Refine configuration constants
- Improve TUI config handling
2026-03-04 13:23:42 -05:00

180 lines
5.1 KiB
Go

// Package audit provides tamper-evident audit logging with hash chaining
package audit
import (
"context"
"database/sql"
"fmt"
"path/filepath"
"time"
)
// DBCheckpointManager stores chain state in a PostgreSQL database for external tamper detection.
// A root attacker who modifies the local log file cannot also silently modify a remote Postgres instance
// (assuming separate credentials and network controls).
type DBCheckpointManager struct {
db *sql.DB
}
// NewDBCheckpointManager creates a new database checkpoint manager
func NewDBCheckpointManager(db *sql.DB) *DBCheckpointManager {
return &DBCheckpointManager{db: db}
}
// Checkpoint stores current chain state in the database
func (dcm *DBCheckpointManager) Checkpoint(seq uint64, hash, fileName string) error {
fileHash, err := sha256File(fileName)
if err != nil {
return fmt.Errorf("hash file for checkpoint: %w", err)
}
_, err = dcm.db.Exec(
`INSERT INTO audit_chain_checkpoints
(last_seq, last_hash, file_name, file_hash, checkpoint_time)
VALUES ($1, $2, $3, $4, $5)`,
seq, hash, filepath.Base(fileName), fileHash, time.Now().UTC(),
)
if err != nil {
return fmt.Errorf("insert checkpoint: %w", err)
}
return nil
}
// VerifyAgainstDB verifies local file against the latest database checkpoint.
// This should be run from a separate host, not the app process itself.
func (dcm *DBCheckpointManager) VerifyAgainstDB(filePath string) error {
var dbSeq uint64
var dbHash string
err := dcm.db.QueryRow(
`SELECT last_seq, last_hash
FROM audit_chain_checkpoints
WHERE file_name = $1
ORDER BY checkpoint_time DESC
LIMIT 1`,
filepath.Base(filePath),
).Scan(&dbSeq, &dbHash)
if err != nil {
return fmt.Errorf("db checkpoint lookup: %w", err)
}
localSeq, localHash, err := getLastEventFromFile(filePath)
if err != nil {
return err
}
if uint64(localSeq) != dbSeq || localHash != dbHash {
return fmt.Errorf(
"TAMPERING DETECTED: local(seq=%d hash=%s) vs db(seq=%d hash=%s)",
localSeq, localHash, dbSeq, dbHash,
)
}
return nil
}
// VerifyAllFiles checks all known audit files against their latest checkpoints
func (dcm *DBCheckpointManager) VerifyAllFiles() ([]VerificationResult, error) {
rows, err := dcm.db.Query(
`SELECT DISTINCT ON (file_name) file_name, last_seq, last_hash
FROM audit_chain_checkpoints
ORDER BY file_name, checkpoint_time DESC`,
)
if err != nil {
return nil, fmt.Errorf("query checkpoints: %w", err)
}
defer rows.Close()
var results []VerificationResult
for rows.Next() {
var fileName string
var dbSeq uint64
var dbHash string
if err := rows.Scan(&fileName, &dbSeq, &dbHash); err != nil {
continue
}
result := VerificationResult{
Timestamp: time.Now().UTC(),
Valid: true,
}
localSeq, localHash, err := getLastEventFromFile(fileName)
if err != nil {
result.Valid = false
result.Error = fmt.Sprintf("read local file: %v", err)
} else if uint64(localSeq) != dbSeq || localHash != dbHash {
result.Valid = false
result.FirstTampered = localSeq
result.Error = fmt.Sprintf(
"TAMPERING DETECTED: local(seq=%d hash=%s) vs db(seq=%d hash=%s)",
localSeq, localHash, dbSeq, dbHash,
)
result.ChainRootHash = localHash
}
results = append(results, result)
}
return results, rows.Err()
}
// InitializeSchema creates the required database tables and permissions
func (dcm *DBCheckpointManager) InitializeSchema() error {
schema := `
CREATE TABLE IF NOT EXISTS audit_chain_checkpoints (
id BIGSERIAL PRIMARY KEY,
checkpoint_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seq BIGINT NOT NULL,
last_hash TEXT NOT NULL,
file_name TEXT NOT NULL,
file_hash TEXT NOT NULL,
metadata JSONB
);
CREATE INDEX IF NOT EXISTS idx_audit_checkpoints_file_time
ON audit_chain_checkpoints(file_name, checkpoint_time DESC);
`
_, err := dcm.db.Exec(schema)
return err
}
// RestrictWriterPermissions revokes UPDATE and DELETE permissions from the audit_writer role.
// This makes the table effectively append-only for the writer user.
func (dcm *DBCheckpointManager) RestrictWriterPermissions(writerRole string) error {
_, err := dcm.db.Exec(
fmt.Sprintf("REVOKE UPDATE, DELETE ON audit_chain_checkpoints FROM %s", writerRole),
)
return err
}
// ContinuousVerification runs verification at regular intervals and reports issues.
// This should be run as a background goroutine or separate process.
func (dcm *DBCheckpointManager) ContinuousVerification(
ctx context.Context,
interval time.Duration,
filePaths []string,
alerter AlertManager,
) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
for _, filePath := range filePaths {
if err := dcm.VerifyAgainstDB(filePath); err != nil {
if alerter != nil {
_ = alerter.Alert(ctx, TamperAlert{
DetectedAt: time.Now().UTC(),
Severity: "critical",
Description: fmt.Sprintf("Database checkpoint verification failed for %s", filePath),
FilePath: filePath,
})
}
}
}
}
}
}