fetch_ml/internal/audit/chain.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

278 lines
6.9 KiB
Go

// Package audit provides tamper-evident audit logging with hash chaining
package audit
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"sync"
)
// ChainEntry represents an audit log entry with hash chaining
type ChainEntry struct {
PrevHash string `json:"prev_hash"`
ThisHash string `json:"this_hash"`
Event Event `json:"event"`
SeqNum uint64 `json:"seq_num"`
}
// HashChain maintains a chain of tamper-evident audit entries
type HashChain struct {
file *os.File
encoder *json.Encoder
lastHash string
seqNum uint64
mu sync.RWMutex
}
// NewHashChain creates a new hash chain for audit logging
// If filePath is empty, the chain operates in-memory only
func NewHashChain(filePath string) (*HashChain, error) {
hc := &HashChain{
lastHash: "",
seqNum: 0,
}
if filePath != "" {
// Append to existing file if it exists, checking chain integrity
file, seq, lastHash, err := openOrCreateChainFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open chain file: %w", err)
}
hc.file = file
hc.encoder = json.NewEncoder(file)
hc.seqNum = seq
hc.lastHash = lastHash
}
return hc, nil
}
// AddEvent adds an event to the hash chain
func (hc *HashChain) AddEvent(event Event) (*ChainEntry, error) {
hc.mu.Lock()
defer hc.mu.Unlock()
hc.seqNum++
entry := ChainEntry{
Event: event,
PrevHash: hc.lastHash,
SeqNum: hc.seqNum,
}
// Compute hash of this entry
data, err := json.Marshal(struct {
PrevHash string `json:"prev_hash"`
Event Event `json:"event"`
SeqNum uint64 `json:"seq_num"`
}{
Event: entry.Event,
PrevHash: entry.PrevHash,
SeqNum: entry.SeqNum,
})
if err != nil {
hc.seqNum-- // Rollback sequence number
return nil, fmt.Errorf("failed to marshal entry: %w", err)
}
hash := sha256.Sum256(data)
entry.ThisHash = hex.EncodeToString(hash[:])
hc.lastHash = entry.ThisHash
// Write to file if configured
if hc.encoder != nil {
if err := hc.encoder.Encode(entry); err != nil {
return nil, fmt.Errorf("failed to write entry: %w", err)
}
// fsync ensures crash safety for tamper-evident chain
if hc.file != nil {
if syncErr := hc.file.Sync(); syncErr != nil {
return nil, fmt.Errorf("failed to sync chain entry: %w", syncErr)
}
}
}
return &entry, nil
}
// VerifyChain verifies the integrity of a chain from a file
func VerifyChain(filePath string) error {
file, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("failed to open chain file: %w", err)
}
defer file.Close()
decoder := json.NewDecoder(file)
var prevHash string
var prevSeq uint64
for lineNum := 1; decoder.More(); lineNum++ {
var entry ChainEntry
if err := decoder.Decode(&entry); err != nil {
return fmt.Errorf("failed to decode entry at line %d: %w", lineNum, err)
}
// Verify sequence number
if entry.SeqNum != prevSeq+1 {
return fmt.Errorf("sequence number mismatch at entry %d: expected %d, got %d",
lineNum, prevSeq+1, entry.SeqNum)
}
// Verify previous hash linkage
if entry.PrevHash != prevHash {
return fmt.Errorf("hash chain broken at entry %d: expected prev_hash=%s, got %s",
lineNum, prevHash, entry.PrevHash)
}
// Verify this entry's hash
data, err := json.Marshal(struct {
PrevHash string `json:"prev_hash"`
Event Event `json:"event"`
SeqNum uint64 `json:"seq_num"`
}{
Event: entry.Event,
PrevHash: entry.PrevHash,
SeqNum: entry.SeqNum,
})
if err != nil {
return fmt.Errorf("failed to marshal entry %d for verification: %w", lineNum, err)
}
computedHash := sha256.Sum256(data)
computedHashStr := hex.EncodeToString(computedHash[:])
if computedHashStr != entry.ThisHash {
return fmt.Errorf("hash mismatch at entry %d: computed=%s, stored=%s",
lineNum, computedHashStr, entry.ThisHash)
}
prevHash = entry.ThisHash
prevSeq = entry.SeqNum
}
return nil
}
// GetLastHash returns the current chain head hash
func (hc *HashChain) GetLastHash() string {
hc.mu.RLock()
defer hc.mu.RUnlock()
return hc.lastHash
}
// GetSeqNum returns the current sequence number
func (hc *HashChain) GetSeqNum() uint64 {
hc.mu.RLock()
defer hc.mu.RUnlock()
return hc.seqNum
}
// Close closes the hash chain file
func (hc *HashChain) Close() error {
hc.mu.Lock()
defer hc.mu.Unlock()
if hc.file != nil {
return hc.file.Close()
}
return nil
}
// openOrCreateChainFile opens an existing chain file and validates its integrity,
// or creates a new one if it doesn't exist
func openOrCreateChainFile(filePath string) (*os.File, uint64, string, error) {
// Check if file exists
if _, err := os.Stat(filePath); os.IsNotExist(err) {
// Create new file
file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
return nil, 0, "", fmt.Errorf("failed to create chain file: %w", err)
}
return file, 0, "", nil
}
// File exists - verify integrity and get last state
file, err := os.OpenFile(filePath, os.O_RDONLY, 0600)
if err != nil {
return nil, 0, "", fmt.Errorf("failed to open existing chain file: %w", err)
}
decoder := json.NewDecoder(file)
var lastSeq uint64
var lastHash string
for decoder.More() {
var entry ChainEntry
if err := decoder.Decode(&entry); err != nil {
file.Close()
return nil, 0, "", fmt.Errorf("corrupted chain file: %w", err)
}
lastSeq = entry.SeqNum
lastHash = entry.ThisHash
}
file.Close()
// Reopen for appending
file, err = os.OpenFile(filePath, os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
return nil, 0, "", fmt.Errorf("failed to reopen chain file for append: %w", err)
}
return file, lastSeq, lastHash, nil
}
// TamperEvidenceLogger wraps a standard audit logger with hash chaining
type TamperEvidenceLogger struct {
logger *Logger
hashChain *HashChain
}
// NewTamperEvidenceLogger creates a new tamper-evident audit logger
func NewTamperEvidenceLogger(logger *Logger, chainFilePath string) (*TamperEvidenceLogger, error) {
hashChain, err := NewHashChain(chainFilePath)
if err != nil {
return nil, fmt.Errorf("failed to create hash chain: %w", err)
}
return &TamperEvidenceLogger{
logger: logger,
hashChain: hashChain,
}, nil
}
// Log logs an event with tamper-evident hash chaining
func (tel *TamperEvidenceLogger) Log(event Event) error {
// Log to standard logger
if tel.logger != nil {
tel.logger.Log(event)
}
// Add to hash chain
_, err := tel.hashChain.AddEvent(event)
if err != nil {
return fmt.Errorf("failed to add event to hash chain: %w", err)
}
return nil
}
// Verify verifies the integrity of the tamper-evident log
func (tel *TamperEvidenceLogger) Verify() error {
if tel.hashChain.file == nil {
return fmt.Errorf("no chain file configured for verification")
}
return VerifyChain(tel.hashChain.file.Name())
}
// Close closes the tamper-evident logger
func (tel *TamperEvidenceLogger) Close() error {
if tel.hashChain != nil {
return tel.hashChain.Close()
}
return nil
}