fetch_ml/internal/audit/sealed.go
Jeremie Fraeys 1c7205c0a0
feat(audit): add HTTP audit middleware and tamper-evident logging
Comprehensive audit system for security and compliance:

- middleware/audit.go: HTTP request/response auditing middleware
  * Captures request details, user identity, response status
  * Chains audit events with cryptographic hashes for tamper detection
  * Configurable filtering for sensitive data redaction

- audit/chain.go: Blockchain-style audit log chaining
  * Each entry includes hash of previous entry
  * Tamper detection through hash verification
  * Supports incremental verification without full scan

- checkpoint.go: Periodic integrity checkpoints
  * Creates signed checkpoints for fast verification
  * Configurable checkpoint intervals
  * Recovery from last known good checkpoint

- rotation.go: Automatic log rotation and archival
  * Size-based and time-based rotation policies
  * Compressed archival with integrity seals
  * Retention policy enforcement

- sealed.go: Cryptographic sealing of audit logs
  * Digital signatures for log integrity
  * HSM support preparation
  * Exportable sealed bundles for external auditors

- verifier.go: Log verification and forensic analysis
  * Complete chain verification from genesis to latest
  * Detects gaps, tampering, unauthorized modifications
  * Forensic export for incident response
2026-03-08 13:03:02 -04:00

179 lines
4.7 KiB
Go

// Package audit provides tamper-evident audit logging with hash chaining
package audit
import (
"bufio"
"encoding/json"
"fmt"
"os"
"sync"
"time"
"github.com/jfraeys/fetch_ml/internal/fileutil"
)
// StateEntry represents a sealed checkpoint entry
type StateEntry struct {
Seq uint64 `json:"seq"`
Hash string `json:"hash"`
Timestamp time.Time `json:"ts"`
Type string `json:"type"`
}
// SealedStateManager maintains tamper-evident state checkpoints.
// It writes to an append-only chain file and an overwritten current file.
// The chain file is fsynced before returning to ensure crash safety.
type SealedStateManager struct {
chainFile string
currentFile string
mu sync.Mutex
}
// NewSealedStateManager creates a new sealed state manager
func NewSealedStateManager(chainFile, currentFile string) *SealedStateManager {
return &SealedStateManager{
chainFile: chainFile,
currentFile: currentFile,
}
}
// Checkpoint writes current state to sealed files.
// It writes to the append-only chain file first, fsyncs it, then overwrites the current file.
// This ordering ensures crash safety: the chain file is always the source of truth.
func (ssm *SealedStateManager) Checkpoint(seq uint64, hash string) error {
ssm.mu.Lock()
defer ssm.mu.Unlock()
entry := StateEntry{
Seq: seq,
Hash: hash,
Timestamp: time.Now().UTC(),
Type: "fsync",
}
data, err := json.Marshal(entry)
if err != nil {
return fmt.Errorf("marshal state entry: %w", err)
}
// Write to append-only chain file first
f, err := os.OpenFile(ssm.chainFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("open chain file: %w", err)
}
if _, err := f.Write(append(data, '\n')); err != nil {
if errClose := f.Close(); errClose != nil {
return fmt.Errorf("write chain entry: %w, close: %v", err, errClose)
}
return fmt.Errorf("write chain entry: %w", err)
}
// CRITICAL: fsync chain before returning — crash safety
if err := f.Sync(); err != nil {
if errClose := f.Close(); errClose != nil {
return fmt.Errorf("sync sealed chain: %w, close: %v", err, errClose)
}
return fmt.Errorf("sync sealed chain: %w", err)
}
if err := f.Close(); err != nil {
return fmt.Errorf("close chain file: %w", err)
}
// Overwrite current-state file (fast lookup) with crash safety (fsync)
if err := fileutil.WriteFileSafe(ssm.currentFile, data, 0o600); err != nil {
return fmt.Errorf("write current file: %w", err)
}
return nil
}
// RecoverState reads last valid state from sealed files.
// It tries the current file first (fast path), then falls back to scanning the chain file.
func (ssm *SealedStateManager) RecoverState() (uint64, string, error) {
// Try current file first (fast path)
data, err := os.ReadFile(ssm.currentFile)
if err == nil {
var entry StateEntry
if json.Unmarshal(data, &entry) == nil {
return entry.Seq, entry.Hash, nil
}
}
// Fall back to scanning chain file for last valid entry
return ssm.scanChainFileForLastValid()
}
// scanChainFileForLastValid scans the chain file and returns the last valid entry
func (ssm *SealedStateManager) scanChainFileForLastValid() (uint64, string, error) {
f, err := os.Open(ssm.chainFile)
if err != nil {
if os.IsNotExist(err) {
return 0, "", nil
}
return 0, "", fmt.Errorf("open chain file: %w", err)
}
defer f.Close()
var lastEntry StateEntry
scanner := bufio.NewScanner(f)
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Text()
if line == "" {
continue
}
var entry StateEntry
if err := json.Unmarshal([]byte(line), &entry); err != nil {
// Corrupted line - log but continue
continue
}
lastEntry = entry
}
if err := scanner.Err(); err != nil {
return 0, "", fmt.Errorf("scan chain file: %w", err)
}
return lastEntry.Seq, lastEntry.Hash, nil
}
// VerifyChainIntegrity checks that the chain file is intact and returns the number of valid entries
func (ssm *SealedStateManager) VerifyChainIntegrity() (int, error) {
f, err := os.Open(ssm.chainFile)
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, fmt.Errorf("open chain file: %w", err)
}
defer f.Close()
validCount := 0
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
var entry StateEntry
if err := json.Unmarshal([]byte(line), &entry); err != nil {
continue // Skip corrupted lines
}
validCount++
}
if err := scanner.Err(); err != nil {
return validCount, fmt.Errorf("scan chain file: %w", err)
}
return validCount, nil
}
// Close is a no-op for SealedStateManager (state is written immediately)
func (ssm *SealedStateManager) Close() error {
return nil
}