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

288 lines
7.2 KiB
Go

// Package audit provides tamper-evident audit logging with hash chaining
package audit
import (
"bufio"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/jfraeys/fetch_ml/internal/fileutil"
"github.com/jfraeys/fetch_ml/internal/logging"
)
// AnchorFile represents the anchor for a rotated log file
type AnchorFile struct {
Date string `json:"date"`
LastHash string `json:"last_hash"`
LastSeq uint64 `json:"last_seq"`
FileHash string `json:"file_hash"` // SHA256 of entire rotated file
}
// RotatingLogger extends Logger with daily rotation capabilities
// and maintains cross-file chain integrity using anchor files
type RotatingLogger struct {
*Logger
basePath string
anchorDir string
currentDate string
logger *logging.Logger
}
// NewRotatingLogger creates a new rotating audit logger
func NewRotatingLogger(enabled bool, basePath, anchorDir string, logger *logging.Logger) (*RotatingLogger, error) {
if !enabled {
return &RotatingLogger{
Logger: &Logger{enabled: false},
basePath: basePath,
anchorDir: anchorDir,
logger: logger,
}, nil
}
// Ensure anchor directory exists
if err := os.MkdirAll(anchorDir, 0o750); err != nil {
return nil, fmt.Errorf("create anchor directory: %w", err)
}
currentDate := time.Now().UTC().Format("2006-01-02")
fullPath := filepath.Join(basePath, fmt.Sprintf("audit-%s.log", currentDate))
// Create base directory if needed
dir := filepath.Dir(fullPath)
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, fmt.Errorf("create audit directory: %w", err)
}
// Open the log file for current date
file, err := os.OpenFile(fullPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return nil, fmt.Errorf("open audit log file: %w", err)
}
al := &Logger{
enabled: true,
filePath: fullPath,
file: file,
sequenceNum: 0,
lastHash: "",
logger: logger,
}
// Resume from file if it exists
if err := al.resumeFromFile(); err != nil {
file.Close()
return nil, fmt.Errorf("resume audit chain: %w", err)
}
rl := &RotatingLogger{
Logger: al,
basePath: basePath,
anchorDir: anchorDir,
currentDate: currentDate,
logger: logger,
}
// Check if we need to rotate (different date from file)
if al.sequenceNum > 0 {
// File has entries, check if we crossed date boundary
stat, err := os.Stat(fullPath)
if err == nil {
modTime := stat.ModTime().UTC()
if modTime.Format("2006-01-02") != currentDate {
// File was last modified on a different date, should rotate
if err := rl.Rotate(); err != nil && logger != nil {
logger.Warn("failed to rotate audit log on startup", "error", err)
}
}
}
}
return rl, nil
}
// Rotate performs log rotation and creates an anchor file
// This should be called when the date changes or when the log reaches size limit
func (rl *RotatingLogger) Rotate() error {
if !rl.enabled {
return nil
}
oldPath := rl.filePath
oldDate := rl.currentDate
// Sync and close current file
if err := rl.file.Sync(); err != nil {
return fmt.Errorf("sync before rotation: %w", err)
}
if err := rl.file.Close(); err != nil {
return fmt.Errorf("close file before rotation: %w", err)
}
// Hash the rotated file for integrity
fileHash, err := sha256File(oldPath)
if err != nil {
return fmt.Errorf("hash rotated file: %w", err)
}
// Create anchor file with last hash
anchor := AnchorFile{
Date: oldDate,
LastHash: rl.lastHash,
LastSeq: uint64(rl.sequenceNum),
FileHash: fileHash,
}
anchorPath := filepath.Join(rl.anchorDir, fmt.Sprintf("%s.anchor", oldDate))
if err := writeAnchorFile(anchorPath, anchor); err != nil {
return err
}
// Open new file for new day
rl.currentDate = time.Now().UTC().Format("2006-01-02")
newPath := filepath.Join(rl.basePath, fmt.Sprintf("audit-%s.log", rl.currentDate))
f, err := os.OpenFile(newPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return err
}
rl.file = f
rl.filePath = newPath
// First event in new file links back to previous anchor hash
rl.Log(Event{
EventType: "rotation_marker",
Metadata: map[string]any{
"previous_anchor_hash": anchor.LastHash,
"previous_date": oldDate,
},
})
if rl.logger != nil {
rl.logger.Info("audit log rotated",
"previous_date", oldDate,
"new_date", rl.currentDate,
"anchor", anchorPath,
)
}
return nil
}
// CheckRotation checks if rotation is needed based on date
func (rl *RotatingLogger) CheckRotation() error {
if !rl.enabled {
return nil
}
newDate := time.Now().UTC().Format("2006-01-02")
if newDate != rl.currentDate {
return rl.Rotate()
}
return nil
}
// writeAnchorFile writes the anchor file to disk with crash safety (fsync)
func writeAnchorFile(path string, anchor AnchorFile) error {
data, err := json.Marshal(anchor)
if err != nil {
return fmt.Errorf("marshal anchor: %w", err)
}
// SECURITY: Write with fsync for crash safety
if err := fileutil.WriteFileSafe(path, data, 0o600); err != nil {
return fmt.Errorf("write anchor file: %w", err)
}
return nil
}
// readAnchorFile reads an anchor file from disk
func readAnchorFile(path string) (*AnchorFile, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read anchor file: %w", err)
}
var anchor AnchorFile
if err := json.Unmarshal(data, &anchor); err != nil {
return nil, fmt.Errorf("unmarshal anchor: %w", err)
}
return &anchor, nil
}
// sha256File computes the SHA256 hash of a file
func sha256File(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read file: %w", err)
}
hash := sha256.Sum256(data)
return hex.EncodeToString(hash[:]), nil
}
// VerifyRotationIntegrity verifies that a rotated file matches its anchor
func VerifyRotationIntegrity(logPath, anchorPath string) error {
anchor, err := readAnchorFile(anchorPath)
if err != nil {
return err
}
// Verify file hash
actualFileHash, err := sha256File(logPath)
if err != nil {
return err
}
if !strings.EqualFold(actualFileHash, anchor.FileHash) {
return fmt.Errorf("TAMPERING DETECTED: file hash mismatch: expected=%s, got=%s",
anchor.FileHash, actualFileHash)
}
// Verify chain ends with anchor's last hash
lastSeq, lastHash, err := getLastEventFromFile(logPath)
if err != nil {
return err
}
if uint64(lastSeq) != anchor.LastSeq || lastHash != anchor.LastHash {
return fmt.Errorf("TAMPERING DETECTED: chain mismatch: expected(seq=%d,hash=%s), got(seq=%d,hash=%s)",
anchor.LastSeq, anchor.LastHash, lastSeq, lastHash)
}
return nil
}
// getLastEventFromFile returns the last event's sequence and hash from a file
func getLastEventFromFile(path string) (int64, string, error) {
file, err := os.Open(path)
if err != nil {
return 0, "", 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 0, "", err
}
if lastLine == "" {
return 0, "", fmt.Errorf("no events in file")
}
var event Event
if err := json.Unmarshal([]byte(lastLine), &event); err != nil {
return 0, "", fmt.Errorf("parse last event: %w", err)
}
return event.SequenceNum, event.EventHash, nil
}