Add ChainVerifier for cryptographic audit log verification: - VerifyLogFile(): Validates entire audit chain integrity - Detects tampering at specific event index (FirstTampered) - Returns chain root hash for external verification - GetChainRootHash(): Standalone hash computation - VerifyAndAlert(): Boolean tampering detection with logging Add audit-verifier CLI tool: - Standalone binary for audit chain verification - Takes log path argument and reports tampering Update audit logger for chain integrity: - Each event includes sequence number and hash chain - SHA-256 linking: hash_n = SHA-256(prev_hash || event_n) - Tamper detection through hash chain validation Add comprehensive test coverage: - Empty log handling - Valid chain verification - Tampering detection with modification - Root hash consistency - Alert mechanism tests Part of: V.7 audit verification from security plan
219 lines
5.9 KiB
Go
219 lines
5.9 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
|
|
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)
|
|
}
|
|
|
|
// 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
|
|
}
|