mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-14 08:53:04 +08:00
- 新增脱敏审计存储、筛选、保留策略、完整性校验与 JSON/CSV 导出 - 覆盖查询编辑器、事务、导入同步、对象操作、AI/MCP 与 Web 运行时入口 - 完善事务完整语句日志、Windows 快捷键映射及 SQL 分析布局 - 补充多语言、健康状态、数据目录迁移和回归测试
881 lines
29 KiB
Go
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(¤t); 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
|
|
}
|