// 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, }) } } } } } }