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

219 lines
5.7 KiB
Go

package audit
import (
"bufio"
"encoding/json"
"fmt"
"os"
"time"
"github.com/jfraeys/fetch_ml/internal/logging"
)
// ChainVerifier provides continuous verification of audit log integrity
// by checking the chained hash structure and detecting any tampering.
type ChainVerifier struct {
logger *logging.Logger
}
// NewChainVerifier creates a new audit chain verifier
func NewChainVerifier(logger *logging.Logger) *ChainVerifier {
return &ChainVerifier{
logger: logger,
}
}
// VerificationResult contains the outcome of a chain verification
//type VerificationResult struct {
// Timestamp time.Time
// TotalEvents int
// Valid bool
// FirstTampered int64 // Sequence number of first tampered event, -1 if none
// Error string // Error message if verification failed
// ChainRootHash string // Hash of the last valid event (for external verification)
//}
// VerificationResult contains the outcome of a chain verification
type VerificationResult struct {
Timestamp time.Time
Error string
ChainRootHash string
TotalEvents int
FirstTampered int64
Valid bool
}
// VerifyLogFile performs a complete verification of an audit log file.
// It checks the integrity chain by verifying each event's hash and
// ensuring the previous hash links are unbroken.
func (cv *ChainVerifier) VerifyLogFile(logPath string) (*VerificationResult, error) {
result := &VerificationResult{
Timestamp: time.Now().UTC(),
TotalEvents: 0,
Valid: true,
FirstTampered: -1,
}
// Open the log file
file, err := os.Open(logPath)
if err != nil {
if os.IsNotExist(err) {
// No log file yet - this is valid (no entries to verify)
return result, nil
}
result.Valid = false
result.Error = fmt.Sprintf("failed to open log file: %v", err)
return result, err
}
defer file.Close()
// Create a temporary logger to calculate hashes
tempLogger, _ := NewLogger(false, "", cv.logger)
var events []Event
scanner := bufio.NewScanner(file)
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Text()
if line == "" {
continue
}
var event Event
if err := json.Unmarshal([]byte(line), &event); err != nil {
result.Valid = false
result.Error = fmt.Sprintf("failed to parse event at line %d: %v", lineNum, err)
return result, fmt.Errorf("parse error at line %d: %w", lineNum, err)
}
events = append(events, event)
result.TotalEvents++
}
if err := scanner.Err(); err != nil {
result.Valid = false
result.Error = fmt.Sprintf("error reading log file: %v", err)
return result, err
}
// Verify the chain
tamperedSeq, err := tempLogger.VerifyChain(events)
if err != nil {
result.Valid = false
result.FirstTampered = int64(tamperedSeq)
result.Error = err.Error()
return result, err
}
if tamperedSeq != -1 {
result.Valid = false
result.FirstTampered = int64(tamperedSeq)
result.Error = fmt.Sprintf("tampering detected at sequence %d", tamperedSeq)
}
// Set the chain root hash (hash of the last event)
if len(events) > 0 {
lastEvent := events[len(events)-1]
result.ChainRootHash = lastEvent.EventHash
}
return result, nil
}
// ContinuousVerification runs verification at regular intervals and reports any issues.
// This should be run as a background goroutine in long-running services.
func (cv *ChainVerifier) ContinuousVerification(logPath string, interval time.Duration, alertFunc func(*VerificationResult)) {
if interval <= 0 {
interval = 15 * time.Minute // Default: 15 minutes for HIPAA, use 1 hour otherwise
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
// Run initial verification
cv.runAndReport(logPath, alertFunc)
for range ticker.C {
cv.runAndReport(logPath, alertFunc)
}
}
// runAndReport performs verification and calls the alert function if issues are found
func (cv *ChainVerifier) runAndReport(logPath string, alertFunc func(*VerificationResult)) {
result, err := cv.VerifyLogFile(logPath)
if err != nil {
if cv.logger != nil {
cv.logger.Error("audit chain verification error", "error", err, "log_path", logPath)
}
// Still report the error
if alertFunc != nil {
alertFunc(result)
}
return
}
// Report if not valid or if we just want to log successful verification periodically
if !result.Valid {
if cv.logger != nil {
cv.logger.Error("audit chain tampering detected",
"first_tampered", result.FirstTampered,
"total_events", result.TotalEvents,
"chain_root", result.ChainRootHash[:16])
}
if alertFunc != nil {
alertFunc(result)
}
} else {
if cv.logger != nil {
cv.logger.Debug("audit chain verification passed",
"total_events", result.TotalEvents,
"chain_root", result.ChainRootHash[:16])
}
}
}
// VerifyAndAlert performs a single verification and returns true if tampering detected
func (cv *ChainVerifier) VerifyAndAlert(logPath string) (bool, error) {
result, err := cv.VerifyLogFile(logPath)
if err != nil {
return true, err // Treat errors as potential tampering
}
return !result.Valid, nil
}
// GetChainRootHash returns the hash of the last event in the chain
// This can be published to an external append-only store for independent verification
func (cv *ChainVerifier) GetChainRootHash(logPath string) (string, error) {
file, err := os.Open(logPath)
if err != nil {
return "", err
}
defer file.Close()
var lastLine string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if line != "" {
lastLine = line
}
}
if err := scanner.Err(); err != nil {
return "", err
}
if lastLine == "" {
return "", fmt.Errorf("no events in log file")
}
var event Event
if err := json.Unmarshal([]byte(lastLine), &event); err != nil {
return "", fmt.Errorf("failed to parse last event: %w", err)
}
return event.EventHash, nil
}