fetch_ml/internal/audit/checkpoint.go
Jeremie Fraeys a981e89005
feat(security): add audit subsystem and tenant isolation
Implement comprehensive audit and security infrastructure:
- Immutable audit logs with platform-specific backends (Linux/Other)
- Sealed log entries with tamper-evident checksums
- Audit alert system for real-time security notifications
- Log rotation with retention policies
- Checkpoint-based audit verification

Add multi-tenant security features:
- Tenant manager with quota enforcement
- Middleware for tenant authentication/authorization
- Per-tenant cryptographic key isolation
- Supply chain security for container verification
- Cross-platform secure file utilities (Unix/Windows)

Add test coverage:
- Unit tests for audit alerts and sealed logs
- Platform-specific audit backend tests
2026-02-26 12:03:45 -05:00

207 lines
5.6 KiB
Go

// Package audit provides tamper-evident audit logging with hash chaining
package audit
import (
"bufio"
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"os"
"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,
})
}
}
}
}
}
}
// sha256File computes the SHA256 hash of a file (reused from rotation.go)
func sha256FileCheckpoint(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
// Hash the raw line including newline
h.Write(scanner.Bytes())
h.Write([]byte{'\n'})
}
if err := scanner.Err(); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}