Files
MyGoNavi/internal/sqlaudit/store.go
Syngnat 7c8e8d8dd3 feat(sql-audit): 新增 SQL 审计中心并完善事务追踪
- 新增脱敏审计存储、筛选、保留策略、完整性校验与 JSON/CSV 导出
- 覆盖查询编辑器、事务、导入同步、对象操作、AI/MCP 与 Web 运行时入口
- 完善事务完整语句日志、Windows 快捷键映射及 SQL 分析布局
- 补充多语言、健康状态、数据目录迁移和回归测试
2026-07-12 15:24:27 +08:00

881 lines
29 KiB
Go

package sqlaudit
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/google/uuid"
_ "modernc.org/sqlite"
)
const (
schemaVersion = 1
chainVersion = "sqlaudit-chain-v1"
integrityAlgorithm = "sha256-canonical-v1"
maxRetentionDays = 3650
maxConfiguredEvents = 10_000_000
)
var (
ErrClosed = errors.New("sql audit store is closed")
ErrBatchExceedsMaxRecords = errors.New("sql audit batch exceeds configured max records")
)
type Store struct {
db *sql.DB
path string
}
func Open(path string) (*Store, error) {
path = strings.TrimSpace(path)
if path == "" {
return nil, errors.New("sql audit database path is empty")
}
absPath, err := filepath.Abs(path)
if err != nil {
return nil, fmt.Errorf("resolve sql audit database path: %w", err)
}
dir := filepath.Dir(absPath)
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("create sql audit directory: %w", err)
}
if err := os.Chmod(dir, 0o700); err != nil {
return nil, fmt.Errorf("secure sql audit directory: %w", err)
}
database, err := sql.Open("sqlite", sqliteAuditDSN(absPath))
if err != nil {
return nil, fmt.Errorf("open sql audit database: %w", err)
}
database.SetMaxOpenConns(4)
database.SetMaxIdleConns(4)
store := &Store{db: database, path: absPath}
if err := store.initialize(); err != nil {
_ = database.Close()
return nil, err
}
if err := os.Chmod(absPath, 0o600); err != nil {
_ = database.Close()
return nil, fmt.Errorf("secure sql audit database: %w", err)
}
return store, nil
}
func (s *Store) Path() string {
if s == nil {
return ""
}
return s.path
}
func (s *Store) Close() error {
if s == nil || s.db == nil {
return nil
}
// Consolidate WAL content before a data-root migration or application exit.
// App lifecycle locking ensures no new store operation starts during Close.
_, checkpointErr := s.db.ExecContext(context.Background(), `PRAGMA wal_checkpoint(TRUNCATE)`)
err := errors.Join(checkpointErr, s.db.Close())
s.db = nil
return err
}
func sqliteAuditDSN(path string) string {
uriPath := filepath.ToSlash(path)
dsn := &url.URL{Scheme: "file", Path: uriPath}
if runtime.GOOS == "windows" {
if strings.HasPrefix(uriPath, "//") {
withoutPrefix := strings.TrimPrefix(uriPath, "//")
if separator := strings.IndexByte(withoutPrefix, '/'); separator >= 0 {
dsn.Host = withoutPrefix[:separator]
dsn.Path = withoutPrefix[separator:]
}
} else if !strings.HasPrefix(uriPath, "/") {
dsn.Path = "/" + uriPath
}
}
query := url.Values{}
for _, pragma := range []string{
"busy_timeout(5000)",
"foreign_keys(ON)",
"synchronous(FULL)",
"journal_mode(WAL)",
} {
query.Add("_pragma", pragma)
}
dsn.RawQuery = query.Encode()
return dsn.String()
}
func (s *Store) initialize() error {
if err := s.ensureOpen(); err != nil {
return err
}
ctx := context.Background()
for _, pragma := range []string{
"PRAGMA busy_timeout=5000",
"PRAGMA journal_mode=WAL",
"PRAGMA synchronous=FULL",
"PRAGMA foreign_keys=ON",
} {
if _, err := s.db.ExecContext(ctx, pragma); err != nil {
return fmt.Errorf("configure sql audit database (%s): %w", pragma, err)
}
}
var existingSchemaVersion int
if err := s.db.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&existingSchemaVersion); err != nil {
return fmt.Errorf("read SQL audit schema version: %w", err)
}
if existingSchemaVersion > schemaVersion {
return fmt.Errorf("SQL audit schema version %d is newer than supported version %d", existingSchemaVersion, schemaVersion)
}
statements := []string{
`CREATE TABLE IF NOT EXISTS sql_audit_events (
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
timestamp INTEGER NOT NULL,
event_type TEXT NOT NULL,
status TEXT NOT NULL,
connection_id TEXT NOT NULL DEFAULT '',
connection_fingerprint TEXT NOT NULL,
db_type TEXT NOT NULL DEFAULT '',
database_name TEXT NOT NULL DEFAULT '',
query_id TEXT NOT NULL DEFAULT '',
transaction_id TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '',
boundary_mode TEXT NOT NULL DEFAULT 'unknown',
commit_mode TEXT NOT NULL DEFAULT '',
sql_text TEXT NOT NULL DEFAULT '',
sql_redacted INTEGER NOT NULL DEFAULT 1,
sql_fingerprint TEXT NOT NULL,
statement_index INTEGER NOT NULL DEFAULT 0,
statement_count INTEGER NOT NULL DEFAULT 0,
duration_ms INTEGER NOT NULL DEFAULT 0,
rows_affected INTEGER NOT NULL DEFAULT 0,
rows_returned INTEGER NOT NULL DEFAULT 0,
error_text TEXT NOT NULL DEFAULT '',
prev_hash TEXT NOT NULL DEFAULT '',
record_hash TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_sql_audit_events_timestamp ON sql_audit_events(timestamp DESC, sequence DESC)`,
`CREATE INDEX IF NOT EXISTS idx_sql_audit_events_connection ON sql_audit_events(connection_id, timestamp DESC)`,
`CREATE INDEX IF NOT EXISTS idx_sql_audit_events_transaction ON sql_audit_events(transaction_id, sequence)`,
`CREATE INDEX IF NOT EXISTS idx_sql_audit_events_type_status ON sql_audit_events(event_type, status, timestamp DESC)`,
`CREATE TABLE IF NOT EXISTS sql_audit_settings (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
enabled INTEGER NOT NULL,
capture_mode TEXT NOT NULL,
retention_days INTEGER NOT NULL,
max_records INTEGER NOT NULL
)`,
`INSERT OR IGNORE INTO sql_audit_settings(singleton, enabled, capture_mode, retention_days, max_records)
VALUES(1, 1, 'redacted', 30, 100000)`,
fmt.Sprintf("PRAGMA user_version=%d", schemaVersion),
}
for _, statement := range statements {
if _, err := s.db.ExecContext(ctx, statement); err != nil {
return fmt.Errorf("initialize sql audit database: %w", err)
}
}
return nil
}
// Append adds one terminal audit fact. When auditing is disabled this method is
// a successful no-op. SQL and error text are sanitized before the transaction.
func (s *Store) Append(event Event) error {
return s.AppendBatch([]Event{event})
}
// AppendControl persists an audit-control event even when ordinary collection
// is disabled. It is reserved for settings and purge boundaries so disabling
// or clearing the audit stream cannot create an unmarked control-plane window.
func (s *Store) AppendControl(event Event) error {
return s.appendBatch([]Event{event}, true)
}
// AppendBatch persists related facts in one durable SQLite transaction. It is
// primarily used for the statements executed inside one managed transaction,
// avoiding one FULL-sync round trip per statement while keeping the complete
// batch visible before the database transaction is returned to the caller.
func (s *Store) AppendBatch(events []Event) error {
return s.appendBatch(events, false)
}
func (s *Store) appendBatch(events []Event, bypassDisabled bool) error {
if err := s.ensureOpen(); err != nil {
return err
}
if len(events) == 0 {
return nil
}
inputEvents := events
return s.withImmediate(func(conn *sql.Conn) error {
settings, err := getSettingsFrom(conn)
if err != nil {
return err
}
if !settings.Enabled && !bypassDisabled {
return nil
}
return appendEventsLocked(conn, settings, inputEvents)
})
}
func appendEventsLocked(conn *sql.Conn, settings Settings, inputEvents []Event) error {
if len(inputEvents) > settings.MaxRecords {
return fmt.Errorf(
"%w: batch=%d limit=%d",
ErrBatchExceedsMaxRecords,
len(inputEvents),
settings.MaxRecords,
)
}
prepared := append([]Event(nil), inputEvents...)
now := time.Now().UnixMilli()
for index := range prepared {
event := &prepared[index]
if event.Timestamp <= 0 {
event.Timestamp = now
}
if strings.TrimSpace(event.ID) == "" {
event.ID = uuid.NewString()
} else {
event.ID = sanitizeLabel(event.ID, 256)
}
if strings.TrimSpace(event.EventType) == "" {
return fmt.Errorf("sql audit event %d type is required", index+1)
}
if strings.TrimSpace(event.Status) == "" {
return fmt.Errorf("sql audit event %d status is required", index+1)
}
}
if _, err := pruneLocked(conn, settings, time.Now(), int64(len(prepared))); err != nil {
return err
}
var previousHash string
err := conn.QueryRowContext(context.Background(),
`SELECT record_hash FROM sql_audit_events ORDER BY sequence DESC LIMIT 1`,
).Scan(&previousHash)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("read sql audit chain head: %w", err)
}
for index := range prepared {
event := sanitizeEvent(prepared[index], settings)
event.PrevHash = previousHash
result, err := conn.ExecContext(context.Background(), `INSERT INTO sql_audit_events(
id, timestamp, event_type, status, connection_id, connection_fingerprint,
db_type, database_name, query_id, transaction_id, source, boundary_mode,
commit_mode, sql_text, sql_redacted, sql_fingerprint, statement_index,
statement_count, duration_ms, rows_affected, rows_returned, error_text,
prev_hash, record_hash
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
event.ID, event.Timestamp, event.EventType, event.Status, event.ConnectionID,
event.ConnectionFingerprint, event.DBType, event.Database, event.QueryID,
event.TransactionID, event.Source, event.BoundaryMode, event.CommitMode,
event.SQLText, boolToInt(event.SQLRedacted), event.SQLFingerprint,
event.StatementIndex, event.StatementCount, event.DurationMs,
event.RowsAffected, event.RowsReturned, event.Error, event.PrevHash, "",
)
if err != nil {
return fmt.Errorf("append sql audit event %d: %w", index+1, err)
}
event.Sequence, err = result.LastInsertId()
if err != nil {
return fmt.Errorf("read sql audit sequence for event %d: %w", index+1, err)
}
event.Hash, err = calculateEventHash(event)
if err != nil {
return err
}
if _, err := conn.ExecContext(context.Background(),
`UPDATE sql_audit_events SET record_hash=? WHERE sequence=?`, event.Hash, event.Sequence,
); err != nil {
return fmt.Errorf("finalize sql audit event %d hash: %w", index+1, err)
}
previousHash = event.Hash
}
return nil
}
func (s *Store) Query(filter Filter) (Page, error) {
if err := s.ensureOpen(); err != nil {
return Page{}, err
}
filter = normalizeFilter(filter)
where, args := buildFilterWhere(filter)
page := Page{Items: []Event{}, Page: filter.Page, PageSize: filter.PageSize}
summarySQL := `SELECT COUNT(*),
COALESCE(SUM(CASE WHEN status='success' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status='error' THEN 1 ELSE 0 END), 0),
COUNT(DISTINCT CASE WHEN transaction_id <> '' THEN transaction_id END)
FROM sql_audit_events` + where
if err := s.db.QueryRowContext(context.Background(), summarySQL, args...).Scan(
&page.Summary.TotalEvents,
&page.Summary.SuccessCount,
&page.Summary.ErrorCount,
&page.Summary.TransactionCount,
); err != nil {
return Page{}, fmt.Errorf("summarize sql audit events: %w", err)
}
page.Total = page.Summary.TotalEvents
queryArgs := append(append([]any{}, args...), filter.PageSize, (filter.Page-1)*filter.PageSize)
rows, err := s.db.QueryContext(context.Background(), selectEventColumns+where+
` ORDER BY timestamp DESC, sequence DESC LIMIT ? OFFSET ?`, queryArgs...)
if err != nil {
return Page{}, fmt.Errorf("query sql audit events: %w", err)
}
defer rows.Close()
for rows.Next() {
event, err := scanEvent(rows)
if err != nil {
return Page{}, err
}
page.Items = append(page.Items, event)
}
if err := rows.Err(); err != nil {
return Page{}, fmt.Errorf("iterate sql audit events: %w", err)
}
return page, nil
}
func (s *Store) UpdateSettings(settings Settings) error {
if err := s.ensureOpen(); err != nil {
return err
}
normalized, err := normalizeSettings(settings)
if err != nil {
return err
}
return s.withImmediate(func(conn *sql.Conn) error {
if _, err := conn.ExecContext(context.Background(), `UPDATE sql_audit_settings
SET enabled=?, capture_mode=?, retention_days=?, max_records=? WHERE singleton=1`,
boolToInt(normalized.Enabled), normalized.CaptureMode,
normalized.RetentionDays, normalized.MaxRecords,
); err != nil {
return fmt.Errorf("update sql audit settings: %w", err)
}
_, err := pruneLocked(conn, normalized, time.Now(), 0)
return err
})
}
// UpdateSettingsWithControl atomically applies settings and appends a
// non-disableable control boundary describing the old and new safe values.
func (s *Store) UpdateSettingsWithControl(settings Settings, control Event) error {
if err := s.ensureOpen(); err != nil {
return err
}
normalized, err := normalizeSettings(settings)
if err != nil {
return err
}
return s.withImmediate(func(conn *sql.Conn) error {
previous, err := getSettingsFrom(conn)
if err != nil {
return err
}
if _, err := conn.ExecContext(context.Background(), `UPDATE sql_audit_settings
SET enabled=?, capture_mode=?, retention_days=?, max_records=? WHERE singleton=1`,
boolToInt(normalized.Enabled), normalized.CaptureMode,
normalized.RetentionDays, normalized.MaxRecords,
); err != nil {
return fmt.Errorf("update sql audit settings: %w", err)
}
control.DBType = "gonavi"
control.SQLText = settingsControlDescriptor(previous, normalized)
return appendEventsLocked(conn, controlAuditSettings(normalized), []Event{control})
})
}
// Control descriptors contain only GoNavi-owned structural metadata. Keep
// them visible even when ordinary events use metadata-only capture, otherwise
// disable/retention/purge boundaries would lose the values needed to explain
// an audit window.
func controlAuditSettings(settings Settings) Settings {
settings.CaptureMode = CaptureModeRedacted
return settings
}
func settingsControlDescriptor(previous, next Settings) string {
return fmt.Sprintf(
"AUDIT SETTINGS FROM_ENABLED_%s TO_ENABLED_%s FROM_CAPTURE_%s TO_CAPTURE_%s FROM_RETENTION_DAYS_%d TO_RETENTION_DAYS_%d FROM_MAX_RECORDS_%d TO_MAX_RECORDS_%d",
settingsBoolToken(previous.Enabled),
settingsBoolToken(next.Enabled),
strings.ToUpper(previous.CaptureMode),
strings.ToUpper(next.CaptureMode),
previous.RetentionDays,
next.RetentionDays,
previous.MaxRecords,
next.MaxRecords,
)
}
func settingsBoolToken(value bool) string {
if value {
return "ON"
}
return "OFF"
}
func (s *Store) GetSettings() (Settings, error) {
if err := s.ensureOpen(); err != nil {
return Settings{}, err
}
return getSettingsFrom(s.db)
}
// Clear deletes the oldest contiguous sequence prefix whose timestamps are
// older than beforeTimestamp. A non-positive timestamp clears all events.
// Remaining records and their hashes are never rewritten; integrity checks
// report a truncated/partial chain when a retained first record still points
// at a deleted predecessor.
func (s *Store) Clear(beforeTimestamp int64) (int64, error) {
if err := s.ensureOpen(); err != nil {
return 0, err
}
var deleted int64
err := s.withImmediate(func(conn *sql.Conn) error {
var err error
if beforeTimestamp <= 0 {
deleted, err = deleteAllLocked(conn)
} else {
deleted, err = deleteTimestampPrefixLocked(conn, beforeTimestamp)
}
return err
})
return deleted, err
}
// ClearWithControl atomically removes the selected prefix and appends a control
// boundary that remains visible even when ordinary collection is disabled.
func (s *Store) ClearWithControl(beforeTimestamp int64, control Event) (int64, error) {
if err := s.ensureOpen(); err != nil {
return 0, err
}
var deleted int64
err := s.withImmediate(func(conn *sql.Conn) error {
settings, err := getSettingsFrom(conn)
if err != nil {
return err
}
if beforeTimestamp <= 0 {
deleted, err = deleteAllLocked(conn)
control.SQLText = "AUDIT CLEAR ALL"
} else {
deleted, err = deleteTimestampPrefixLocked(conn, beforeTimestamp)
control.SQLText = fmt.Sprintf("AUDIT CLEAR BEFORE_TIMESTAMP_%d", beforeTimestamp)
}
if err != nil {
return err
}
control.DBType = "gonavi"
control.RowsAffected = deleted
return appendEventsLocked(conn, controlAuditSettings(settings), []Event{control})
})
return deleted, err
}
func (s *Store) VerifyIntegrity() (IntegrityReport, error) {
report := IntegrityReport{
Valid: true,
WeakValidation: true,
Algorithm: integrityAlgorithm,
Message: "ok (unkeyed local hash chain; weak validation)",
}
if err := s.ensureOpen(); err != nil {
return report, err
}
rows, err := s.db.QueryContext(context.Background(), selectEventColumns+` ORDER BY sequence ASC`)
if err != nil {
return report, fmt.Errorf("read sql audit chain: %w", err)
}
defer rows.Close()
previousHash := ""
var previousSequence int64
firstRecord := true
for rows.Next() {
event, err := scanEvent(rows)
if err != nil {
return report, err
}
report.CheckedRecords++
if firstRecord {
report.FirstSequence = event.Sequence
}
report.LastSequence = event.Sequence
if event.Sequence <= 0 {
return invalidIntegrityReport(report, event.Sequence, "sequence must be positive"), nil
}
isFirst := firstRecord
if isFirst {
report.PartialChain = event.Sequence > 1 || event.PrevHash != ""
report.TruncatedPrefix = report.PartialChain
if report.PartialChain {
report.Message = "ok (partial chain after prefix retention; unkeyed weak validation)"
}
} else if event.Sequence <= previousSequence {
return invalidIntegrityReport(report, event.Sequence, "sequence order is invalid"), nil
}
if !isFirst && event.PrevHash != previousHash {
return invalidIntegrityReport(report, event.Sequence, "previous hash does not match"), nil
}
expected, err := calculateEventHash(event)
if err != nil {
return report, err
}
if event.Hash != expected {
return invalidIntegrityReport(report, event.Sequence, "record hash does not match"), nil
}
previousSequence = event.Sequence
previousHash = event.Hash
firstRecord = false
}
if err := rows.Err(); err != nil {
return report, fmt.Errorf("iterate sql audit chain: %w", err)
}
return report, nil
}
func (s *Store) ensureOpen() error {
if s == nil || s.db == nil {
return ErrClosed
}
return nil
}
func (s *Store) withImmediate(operation func(*sql.Conn) error) (resultErr error) {
if err := s.ensureOpen(); err != nil {
return err
}
ctx := context.Background()
conn, err := s.db.Conn(ctx)
if err != nil {
return fmt.Errorf("acquire sql audit connection: %w", err)
}
defer func() { resultErr = errors.Join(resultErr, conn.Close()) }()
if _, err := conn.ExecContext(ctx, "BEGIN IMMEDIATE"); err != nil {
return fmt.Errorf("begin sql audit transaction: %w", err)
}
committed := false
defer func() {
if !committed {
_, rollbackErr := conn.ExecContext(context.Background(), "ROLLBACK")
resultErr = errors.Join(resultErr, rollbackErr)
}
}()
if operation != nil {
if err := operation(conn); err != nil {
return err
}
}
if _, err := conn.ExecContext(ctx, "COMMIT"); err != nil {
return fmt.Errorf("commit sql audit transaction: %w", err)
}
committed = true
return nil
}
type queryRower interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}
func getSettingsFrom(queryer queryRower) (Settings, error) {
var enabled int
var settings Settings
err := queryer.QueryRowContext(context.Background(), `SELECT enabled, capture_mode, retention_days, max_records
FROM sql_audit_settings WHERE singleton=1`).Scan(
&enabled, &settings.CaptureMode, &settings.RetentionDays, &settings.MaxRecords,
)
if err != nil {
return Settings{}, fmt.Errorf("read sql audit settings: %w", err)
}
settings.Enabled = enabled != 0
normalized, err := normalizeSettings(settings)
if err != nil {
return Settings{}, fmt.Errorf("invalid persisted sql audit settings: %w", err)
}
return normalized, nil
}
func normalizeSettings(settings Settings) (Settings, error) {
settings.CaptureMode = strings.ToLower(strings.TrimSpace(settings.CaptureMode))
if settings.CaptureMode == "" {
settings.CaptureMode = CaptureModeRedacted
}
if settings.CaptureMode != CaptureModeRedacted && settings.CaptureMode != CaptureModeMetadata {
return Settings{}, fmt.Errorf("unsupported SQL audit capture mode %q", settings.CaptureMode)
}
if settings.RetentionDays <= 0 {
settings.RetentionDays = defaultRetentionDays
}
if settings.RetentionDays > maxRetentionDays {
return Settings{}, fmt.Errorf("SQL audit retention days cannot exceed %d", maxRetentionDays)
}
if settings.MaxRecords <= 0 {
settings.MaxRecords = defaultMaxRecords
}
if settings.MaxRecords > maxConfiguredEvents {
return Settings{}, fmt.Errorf("SQL audit max records cannot exceed %d", maxConfiguredEvents)
}
return settings, nil
}
func normalizeFilter(filter Filter) Filter {
filter.Search = truncateRunes(strings.TrimSpace(filter.Search), 512)
filter.ConnectionID = strings.TrimSpace(filter.ConnectionID)
filter.Database = strings.TrimSpace(filter.Database)
filter.DBType = strings.ToLower(strings.TrimSpace(filter.DBType))
filter.EventType = strings.ToLower(strings.TrimSpace(filter.EventType))
filter.Status = strings.ToLower(strings.TrimSpace(filter.Status))
filter.TransactionID = strings.TrimSpace(filter.TransactionID)
filter.Source = strings.ToLower(strings.TrimSpace(filter.Source))
if filter.Page <= 0 {
filter.Page = 1
}
if filter.PageSize <= 0 {
filter.PageSize = defaultPageSize
}
if filter.PageSize > maxPageSize {
filter.PageSize = maxPageSize
}
return filter
}
func buildFilterWhere(filter Filter) (string, []any) {
conditions := make([]string, 0, 10)
args := make([]any, 0, 12)
if filter.Search != "" {
pattern := "%" + escapeLike(filter.Search) + "%"
conditions = append(conditions, `(sql_text LIKE ? ESCAPE '\' OR error_text LIKE ? ESCAPE '\'
OR connection_id LIKE ? ESCAPE '\' OR connection_fingerprint LIKE ? ESCAPE '\'
OR database_name LIKE ? ESCAPE '\' OR db_type LIKE ? ESCAPE '\'
OR query_id LIKE ? ESCAPE '\' OR transaction_id LIKE ? ESCAPE '\'
OR sql_fingerprint LIKE ? ESCAPE '\' OR source LIKE ? ESCAPE '\')`)
for range 10 {
args = append(args, pattern)
}
}
appendExact := func(column string, value string) {
if value == "" {
return
}
conditions = append(conditions, column+" = ?")
args = append(args, value)
}
appendExact("connection_id", filter.ConnectionID)
appendExact("database_name", filter.Database)
appendExact("db_type", filter.DBType)
appendExact("event_type", filter.EventType)
appendExact("status", filter.Status)
appendExact("transaction_id", filter.TransactionID)
appendExact("source", filter.Source)
if filter.FromTimestamp > 0 {
conditions = append(conditions, "timestamp >= ?")
args = append(args, filter.FromTimestamp)
}
if filter.ToTimestamp > 0 {
conditions = append(conditions, "timestamp <= ?")
args = append(args, filter.ToTimestamp)
}
if len(conditions) == 0 {
return "", args
}
return " WHERE " + strings.Join(conditions, " AND "), args
}
func escapeLike(value string) string {
value = strings.ReplaceAll(value, `\`, `\\`)
value = strings.ReplaceAll(value, `%`, `\%`)
return strings.ReplaceAll(value, `_`, `\_`)
}
func pruneLocked(conn *sql.Conn, settings Settings, now time.Time, reserve int64) (int64, error) {
var deleted int64
cutoff := now.Add(-time.Duration(settings.RetentionDays) * 24 * time.Hour).UnixMilli()
expired, err := deleteTimestampPrefixLocked(conn, cutoff)
if err != nil {
return 0, fmt.Errorf("prune expired sql audit events: %w", err)
}
deleted += expired
var current int64
if err := conn.QueryRowContext(context.Background(), `SELECT COUNT(*) FROM sql_audit_events`).Scan(&current); err != nil {
return 0, fmt.Errorf("count sql audit events: %w", err)
}
target := int64(settings.MaxRecords) - reserve
if target < 0 {
target = 0
}
if current > target {
removeCount := current - target
result, err := conn.ExecContext(context.Background(), `DELETE FROM sql_audit_events WHERE sequence IN (
SELECT sequence FROM sql_audit_events ORDER BY sequence ASC LIMIT ?
)`, removeCount)
if err != nil {
return 0, fmt.Errorf("enforce sql audit record limit: %w", err)
}
if count, err := result.RowsAffected(); err == nil {
deleted += count
}
}
return deleted, nil
}
func deleteAllLocked(conn *sql.Conn) (int64, error) {
result, err := conn.ExecContext(context.Background(), `DELETE FROM sql_audit_events`)
if err != nil {
return 0, fmt.Errorf("clear SQL audit events: %w", err)
}
deleted, err := result.RowsAffected()
if err != nil {
return 0, fmt.Errorf("count cleared SQL audit events: %w", err)
}
return deleted, nil
}
// deleteTimestampPrefixLocked never removes an event after the first retained
// sequence, even when a caller supplied an out-of-order timestamp. This keeps
// every remaining adjacency and record hash untouched.
func deleteTimestampPrefixLocked(conn *sql.Conn, beforeTimestamp int64) (int64, error) {
var firstRetainedSequence int64
err := conn.QueryRowContext(context.Background(), `SELECT sequence FROM sql_audit_events
WHERE timestamp >= ? ORDER BY sequence ASC LIMIT 1`, beforeTimestamp).Scan(&firstRetainedSequence)
if errors.Is(err, sql.ErrNoRows) {
return deleteAllLocked(conn)
}
if err != nil {
return 0, fmt.Errorf("locate SQL audit retention boundary: %w", err)
}
result, err := conn.ExecContext(context.Background(),
`DELETE FROM sql_audit_events WHERE sequence < ?`, firstRetainedSequence)
if err != nil {
return 0, fmt.Errorf("delete SQL audit sequence prefix: %w", err)
}
deleted, err := result.RowsAffected()
if err != nil {
return 0, fmt.Errorf("count deleted SQL audit sequence prefix: %w", err)
}
return deleted, nil
}
type canonicalEvent struct {
Version string `json:"version"`
ID string `json:"id"`
Sequence int64 `json:"sequence"`
Timestamp int64 `json:"timestamp"`
EventType string `json:"eventType"`
Status string `json:"status"`
ConnectionID string `json:"connectionId"`
ConnectionFingerprint string `json:"connectionFingerprint"`
DBType string `json:"dbType"`
Database string `json:"database"`
QueryID string `json:"queryId"`
TransactionID string `json:"transactionId"`
Source string `json:"source"`
BoundaryMode string `json:"boundaryMode"`
CommitMode string `json:"commitMode"`
SQLText string `json:"sqlText"`
SQLRedacted bool `json:"sqlRedacted"`
SQLFingerprint string `json:"sqlFingerprint"`
StatementIndex int `json:"statementIndex"`
StatementCount int `json:"statementCount"`
DurationMs int64 `json:"durationMs"`
RowsAffected int64 `json:"rowsAffected"`
RowsReturned int64 `json:"rowsReturned"`
Error string `json:"error"`
PrevHash string `json:"prevHash"`
}
func calculateEventHash(event Event) (string, error) {
payload, err := json.Marshal(canonicalEvent{
Version: chainVersion,
ID: event.ID,
Sequence: event.Sequence,
Timestamp: event.Timestamp,
EventType: event.EventType,
Status: event.Status,
ConnectionID: event.ConnectionID,
ConnectionFingerprint: event.ConnectionFingerprint,
DBType: event.DBType,
Database: event.Database,
QueryID: event.QueryID,
TransactionID: event.TransactionID,
Source: event.Source,
BoundaryMode: event.BoundaryMode,
CommitMode: event.CommitMode,
SQLText: event.SQLText,
SQLRedacted: event.SQLRedacted,
SQLFingerprint: event.SQLFingerprint,
StatementIndex: event.StatementIndex,
StatementCount: event.StatementCount,
DurationMs: event.DurationMs,
RowsAffected: event.RowsAffected,
RowsReturned: event.RowsReturned,
Error: event.Error,
PrevHash: event.PrevHash,
})
if err != nil {
return "", fmt.Errorf("encode canonical SQL audit event: %w", err)
}
digest := sha256.Sum256(payload)
return hex.EncodeToString(digest[:]), nil
}
func invalidIntegrityReport(report IntegrityReport, sequence int64, message string) IntegrityReport {
report.Valid = false
report.InvalidSequence = sequence
report.Message = message + " (unkeyed local hash chain; weak validation)"
return report
}
const selectEventColumns = `SELECT sequence, id, timestamp, event_type, status,
connection_id, connection_fingerprint, db_type, database_name, query_id,
transaction_id, source, boundary_mode, commit_mode, sql_text, sql_redacted,
sql_fingerprint, statement_index, statement_count, duration_ms, rows_affected,
rows_returned, error_text, prev_hash, record_hash FROM sql_audit_events`
type rowScanner interface {
Scan(...any) error
}
func scanEvent(scanner rowScanner) (Event, error) {
var event Event
var sqlRedacted int
if err := scanner.Scan(
&event.Sequence, &event.ID, &event.Timestamp, &event.EventType, &event.Status,
&event.ConnectionID, &event.ConnectionFingerprint, &event.DBType, &event.Database,
&event.QueryID, &event.TransactionID, &event.Source, &event.BoundaryMode,
&event.CommitMode, &event.SQLText, &sqlRedacted, &event.SQLFingerprint,
&event.StatementIndex, &event.StatementCount, &event.DurationMs,
&event.RowsAffected, &event.RowsReturned, &event.Error, &event.PrevHash, &event.Hash,
); err != nil {
return Event{}, fmt.Errorf("scan SQL audit event: %w", err)
}
event.SQLRedacted = sqlRedacted != 0
return event, nil
}
func boolToInt(value bool) int {
if value {
return 1
}
return 0
}