fix(P10): audit log tamper-evidence (REQ-125, F2)
---ci--- project: orca phase: 10 milestone: v0.12 status: execute ---/ci--- Migration 0008: add prev_hash + entry_hash columns + append-only triggers (UPDATE/DELETE blocked with ABORT). audit_repo.go: Append computes hash chain (sha256(prev_hash || timestamp || actor || action || resource || result || error || metadata)). VerifyChain recomputes from first entry, detects tampering. 2 new tests: VerifyChain (5-entry chain verifies), TamperDetection (UPDATE + DELETE blocked by trigger). All store tests pass.
This commit is contained in:
@@ -2,7 +2,9 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
@@ -27,6 +29,45 @@ func NewAuditRepo(db *sql.DB) *AuditRepo {
|
|||||||
return &AuditRepo{db: db}
|
return &AuditRepo{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// computeEntryHash computes sha256(prev_hash || timestamp || actor ||
|
||||||
|
// action || resource || result || error || metadata) for the hash
|
||||||
|
// chain (REQ-125, F2). The prev_hash is the entry_hash of the most
|
||||||
|
// recent prior entry (empty string for the first entry).
|
||||||
|
func computeEntryHash(prevHash, timestamp, actor, action, resource, result, errMsg, metaJSON string) string {
|
||||||
|
h := sha256.New()
|
||||||
|
h.Write([]byte(prevHash))
|
||||||
|
h.Write([]byte{0})
|
||||||
|
h.Write([]byte(timestamp))
|
||||||
|
h.Write([]byte{0})
|
||||||
|
h.Write([]byte(actor))
|
||||||
|
h.Write([]byte{0})
|
||||||
|
h.Write([]byte(action))
|
||||||
|
h.Write([]byte{0})
|
||||||
|
h.Write([]byte(resource))
|
||||||
|
h.Write([]byte{0})
|
||||||
|
h.Write([]byte(result))
|
||||||
|
h.Write([]byte{0})
|
||||||
|
h.Write([]byte(errMsg))
|
||||||
|
h.Write([]byte{0})
|
||||||
|
h.Write([]byte(metaJSON))
|
||||||
|
return hex.EncodeToString(h.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// getLastEntryHash returns the entry_hash of the most recent audit_log
|
||||||
|
// entry, or "" if the table is empty.
|
||||||
|
func (r *AuditRepo) getLastEntryHash(ctx context.Context) (string, error) {
|
||||||
|
var prevHash string
|
||||||
|
err := r.db.QueryRowContext(ctx,
|
||||||
|
`SELECT entry_hash FROM audit_log ORDER BY id DESC LIMIT 1`).Scan(&prevHash)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("get last entry hash: %w", err)
|
||||||
|
}
|
||||||
|
return prevHash, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *AuditRepo) Append(ctx context.Context, e *AuditEntry) error {
|
func (r *AuditRepo) Append(ctx context.Context, e *AuditEntry) error {
|
||||||
if e.Timestamp.IsZero() {
|
if e.Timestamp.IsZero() {
|
||||||
e.Timestamp = time.Now().UTC()
|
e.Timestamp = time.Now().UTC()
|
||||||
@@ -35,24 +76,65 @@ func (r *AuditRepo) Append(ctx context.Context, e *AuditEntry) error {
|
|||||||
e.Actor = "system"
|
e.Actor = "system"
|
||||||
}
|
}
|
||||||
metaJSON, _ := json.Marshal(e.Metadata)
|
metaJSON, _ := json.Marshal(e.Metadata)
|
||||||
if e.Error == "" {
|
tsStr := e.Timestamp.UTC().Format(time.RFC3339Nano)
|
||||||
_, err := r.db.ExecContext(ctx,
|
|
||||||
`INSERT INTO audit_log (timestamp, actor, action, resource, result, metadata) VALUES (?, ?, ?, ?, ?, ?)`,
|
// Compute the hash chain (REQ-125, F2).
|
||||||
e.Timestamp, e.Actor, e.Action, e.Resource, e.Result, string(metaJSON))
|
prevHash, err := r.getLastEntryHash(ctx)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("insert audit: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
_, err := r.db.ExecContext(ctx,
|
|
||||||
`INSERT INTO audit_log (timestamp, actor, action, resource, result, error, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
e.Timestamp, e.Actor, e.Action, e.Resource, e.Result, e.Error, string(metaJSON))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("insert audit (with error): %w", err)
|
return fmt.Errorf("audit hash chain: %w", err)
|
||||||
|
}
|
||||||
|
entryHash := computeEntryHash(prevHash, tsStr, e.Actor, e.Action, e.Resource, e.Result, e.Error, string(metaJSON))
|
||||||
|
|
||||||
|
_, err = r.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO audit_log (timestamp, actor, action, resource, result, error, metadata, prev_hash, entry_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
e.Timestamp, e.Actor, e.Action, e.Resource, e.Result, e.Error, string(metaJSON), prevHash, entryHash)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert audit: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VerifyChain recomputes the hash chain from the first entry and
|
||||||
|
// returns an error if any entry's entry_hash does not match. Used by
|
||||||
|
// `orca doctor audit` (REQ-125).
|
||||||
|
func (r *AuditRepo) VerifyChain(ctx context.Context) error {
|
||||||
|
rows, err := r.db.QueryContext(ctx,
|
||||||
|
`SELECT id, timestamp, actor, action, resource, result, COALESCE(error, ''), COALESCE(metadata, ''), prev_hash, entry_hash FROM audit_log ORDER BY id ASC`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("verify chain: query: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
prevHash := ""
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
id int64
|
||||||
|
ts time.Time
|
||||||
|
actor string
|
||||||
|
action string
|
||||||
|
resource string
|
||||||
|
result string
|
||||||
|
errMsg string
|
||||||
|
metaJSON string
|
||||||
|
storedPrev string
|
||||||
|
storedHash string
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&id, &ts, &actor, &action, &resource, &result, &errMsg, &metaJSON, &storedPrev, &storedHash); err != nil {
|
||||||
|
return fmt.Errorf("verify chain: scan: %w", err)
|
||||||
|
}
|
||||||
|
// Verify the prev_hash link.
|
||||||
|
if storedPrev != prevHash {
|
||||||
|
return fmt.Errorf("verify chain: entry %d prev_hash mismatch (expected %q, got %q)", id, prevHash, storedPrev)
|
||||||
|
}
|
||||||
|
// Recompute the entry hash.
|
||||||
|
expected := computeEntryHash(prevHash, ts.UTC().Format(time.RFC3339Nano), actor, action, resource, result, errMsg, metaJSON)
|
||||||
|
if expected != storedHash {
|
||||||
|
return fmt.Errorf("verify chain: entry %d hash mismatch (entry may have been tampered)", id)
|
||||||
|
}
|
||||||
|
prevHash = storedHash
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func (r *AuditRepo) List(ctx context.Context, limit int) ([]*AuditEntry, error) {
|
func (r *AuditRepo) List(ctx context.Context, limit int) ([]*AuditEntry, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 100
|
limit = 100
|
||||||
|
|||||||
@@ -149,3 +149,56 @@ func TestAuditRepo_ListDefaultLimit(t *testing.T) {
|
|||||||
t.Errorf("List(-1): got %d, want 5", len(entries))
|
t.Errorf("List(-1): got %d, want 5", len(entries))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- REQ-125 / F2 audit tamper-evidence tests ---
|
||||||
|
|
||||||
|
// TestAuditRepo_VerifyChain verifies the hash chain verifies after append.
|
||||||
|
func TestAuditRepo_VerifyChain(t *testing.T) {
|
||||||
|
repo, cleanup := openAuditTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
if err := repo.Append(ctx, &AuditEntry{
|
||||||
|
Action: "test.action",
|
||||||
|
Resource: "res",
|
||||||
|
Result: "success",
|
||||||
|
Actor: "user",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Append %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := repo.VerifyChain(ctx); err != nil {
|
||||||
|
t.Errorf("VerifyChain: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuditRepo_TamperDetection verifies VerifyChain detects a modified
|
||||||
|
// entry. We use raw SQL to UPDATE (which the trigger should block).
|
||||||
|
func TestAuditRepo_TamperDetection(t *testing.T) {
|
||||||
|
repo, cleanup := openAuditTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := repo.Append(ctx, &AuditEntry{
|
||||||
|
Action: "cert.issued", Resource: "node1", Result: "success", Actor: "system",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Append: %v", err)
|
||||||
|
}
|
||||||
|
// Verify chain is intact.
|
||||||
|
if err := repo.VerifyChain(ctx); err != nil {
|
||||||
|
t.Fatalf("VerifyChain before tamper: %v", err)
|
||||||
|
}
|
||||||
|
// Attempt UPDATE — the trigger should block it.
|
||||||
|
_, err := repo.db.ExecContext(ctx, `UPDATE audit_log SET actor='hacker' WHERE id=1`)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("UPDATE should be blocked by append-only trigger (REQ-125)")
|
||||||
|
}
|
||||||
|
// Attempt DELETE — also blocked.
|
||||||
|
_, err = repo.db.ExecContext(ctx, `DELETE FROM audit_log WHERE id=1`)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("DELETE should be blocked by append-only trigger (REQ-125)")
|
||||||
|
}
|
||||||
|
// Chain still verifies (nothing was modified).
|
||||||
|
if err := repo.VerifyChain(ctx); err != nil {
|
||||||
|
t.Errorf("VerifyChain after blocked tamper: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ func TestMigrationVersion(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("migration version: %v", err)
|
t.Fatalf("migration version: %v", err)
|
||||||
}
|
}
|
||||||
if version != "0007_certs_serial_unique.sql" {
|
if version != "0008_audit_tamper_evidence.sql" {
|
||||||
t.Errorf("MigrationVersion = %q, want 0007_certs_serial_unique.sql", version)
|
t.Errorf("MigrationVersion = %q, want 0008_audit_tamper_evidence.sql", version)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Empty the migrations table → should return ("", nil).
|
// Empty the migrations table → should return ("", nil).
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- REQ-125 / F2: audit log tamper-evidence.
|
||||||
|
-- Add hash-chain columns + append-only trigger blocking UPDATE/DELETE.
|
||||||
|
ALTER TABLE audit_log ADD COLUMN prev_hash TEXT;
|
||||||
|
ALTER TABLE audit_log ADD COLUMN entry_hash TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
-- Append-only trigger: block UPDATE and DELETE on audit_log.
|
||||||
|
-- A tampered entry (UPDATE) or deleted entry (DELETE) is rejected.
|
||||||
|
CREATE TRIGGER IF NOT EXISTS audit_log_no_update
|
||||||
|
BEFORE UPDATE ON audit_log
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'audit_log is append-only (REQ-125)');
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS audit_log_no_delete
|
||||||
|
BEFORE DELETE ON audit_log
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'audit_log is append-only (REQ-125)');
|
||||||
|
END;
|
||||||
Reference in New Issue
Block a user