mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-21 04:41:48 +08:00
✨ feat(sql-audit): 新增 SQL 审计中心并完善事务追踪
- 新增脱敏审计存储、筛选、保留策略、完整性校验与 JSON/CSV 导出 - 覆盖查询编辑器、事务、导入同步、对象操作、AI/MCP 与 Web 运行时入口 - 完善事务完整语句日志、Windows 快捷键映射及 SQL 分析布局 - 补充多语言、健康状态、数据目录迁移和回归测试
This commit is contained in:
@@ -24,6 +24,7 @@ import (
|
||||
redisbackend "GoNavi-Wails/internal/redis"
|
||||
"GoNavi-Wails/internal/resultdiff"
|
||||
"GoNavi-Wails/internal/secretstore"
|
||||
"GoNavi-Wails/internal/sqlaudit"
|
||||
syncbackend "GoNavi-Wails/internal/sync"
|
||||
"GoNavi-Wails/shared/i18n"
|
||||
"github.com/google/uuid"
|
||||
@@ -68,20 +69,24 @@ type queryContext struct {
|
||||
}
|
||||
|
||||
type managedSQLTransaction struct {
|
||||
id string
|
||||
execer db.StatementExecer
|
||||
transactor db.TransactionExecer
|
||||
cancel context.CancelFunc
|
||||
config connection.ConnectionConfig
|
||||
dbType string
|
||||
commitSQL string
|
||||
rollbackSQL string
|
||||
createdAt time.Time
|
||||
mu sync.Mutex
|
||||
id string
|
||||
execer db.StatementExecer
|
||||
transactor db.TransactionExecer
|
||||
cancel context.CancelFunc
|
||||
config connection.ConnectionConfig
|
||||
dbType string
|
||||
boundaryMode string
|
||||
commitSQL string
|
||||
rollbackSQL string
|
||||
createdAt time.Time
|
||||
finished bool
|
||||
}
|
||||
|
||||
// App struct
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
webRuntime bool
|
||||
startedAt time.Time
|
||||
dbCache map[string]cachedDatabase // Cache for DB connections
|
||||
connectFailures map[string]cachedConnectFailure
|
||||
@@ -94,11 +99,26 @@ type App struct {
|
||||
allowApplicationQuit bool
|
||||
applicationQuitPromptInFlight bool
|
||||
queryMu sync.RWMutex
|
||||
dataRootApplyMu sync.Mutex
|
||||
configDir string
|
||||
secretStore secretstore.SecretStore
|
||||
runningQueries map[string]queryContext // queryID -> cancelFunc and start time
|
||||
sqlTransactionMu sync.Mutex
|
||||
sqlTransactions map[string]*managedSQLTransaction
|
||||
sqlAuditMu sync.RWMutex
|
||||
sqlAuditStore *sqlaudit.Store
|
||||
sqlAuditStorePath string
|
||||
sqlAuditRuntimeActive bool
|
||||
sqlAuditSuspended bool
|
||||
sqlAuditAppendMu sync.Mutex
|
||||
sqlAuditHealthMu sync.RWMutex
|
||||
sqlAuditHealth sqlAuditHealthState
|
||||
sqlAuditHealthPath string
|
||||
sqlAuditHealthRevision uint64
|
||||
sqlAuditSuspensionDropped int64
|
||||
sqlAuditSuspensionFirstAt int64
|
||||
sqlAuditSuspensionLastAt int64
|
||||
sqlAuditSuspensionLastError string
|
||||
jvmPreviewTokenMu sync.Mutex
|
||||
jvmPreviewTokens map[string]jvmPreviewConfirmationToken
|
||||
jvmPreviewTokenTTL time.Duration
|
||||
@@ -112,6 +132,15 @@ func NewApp() *App {
|
||||
return NewAppWithSecretStore(secretstore.NewKeyringStore())
|
||||
}
|
||||
|
||||
// NewWebApp creates the backend used by the authenticated browser server.
|
||||
// The immutable runtime marker keeps desktop-only Wails APIs from being
|
||||
// reached through the reflective Web RPC bridge.
|
||||
func NewWebApp() *App {
|
||||
app := NewApp()
|
||||
app.webRuntime = true
|
||||
return app
|
||||
}
|
||||
|
||||
func NewAppWithSecretStore(store secretstore.SecretStore) *App {
|
||||
if store == nil {
|
||||
store = secretstore.NewUnavailableStore("secret store unavailable")
|
||||
@@ -244,6 +273,7 @@ func (a *App) startup(ctx context.Context) {
|
||||
if err := migrateLegacyWebKitStorageIfNeeded(a); err != nil {
|
||||
logger.Warnf("迁移旧 WebKit 连接存储失败:%v", err)
|
||||
}
|
||||
a.activateSQLAudit()
|
||||
if shouldInstallMacNativeWindowDiagnostics() {
|
||||
installMacNativeWindowDiagnostics(logger.Path())
|
||||
}
|
||||
@@ -299,6 +329,7 @@ func (a *App) Shutdown() {
|
||||
logger.Infof("应用开始关闭,准备释放资源")
|
||||
a.stopConnectionKeepAliveLoop()
|
||||
a.rollbackPendingSQLTransactionsOnShutdown()
|
||||
a.closeSQLAuditStore()
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
for _, dbInst := range a.dbCache {
|
||||
|
||||
@@ -318,10 +318,40 @@ func isReadOnlyMongoCommand(query string) bool {
|
||||
if _, blocked := mongoWriteCommands[commandKey]; blocked {
|
||||
return false
|
||||
}
|
||||
if commandKey == "aggregate" && mongoAggregateHasWriteStage(doc) {
|
||||
return false
|
||||
}
|
||||
_, allowed := mongoReadOnlyCommands[commandKey]
|
||||
return allowed
|
||||
}
|
||||
|
||||
func mongoAggregateHasWriteStage(doc map[string]interface{}) bool {
|
||||
var pipeline interface{}
|
||||
for key, value := range doc {
|
||||
if strings.EqualFold(strings.TrimSpace(key), "pipeline") {
|
||||
pipeline = value
|
||||
break
|
||||
}
|
||||
}
|
||||
stages, ok := pipeline.([]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, rawStage := range stages {
|
||||
stage, ok := rawStage.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for key := range stage {
|
||||
switch strings.ToLower(strings.TrimSpace(key)) {
|
||||
case "$out", "$merge":
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isReadOnlyMilvusCommand(query string) bool {
|
||||
trimmed := strings.TrimSpace(query)
|
||||
if !strings.HasPrefix(trimmed, "{") {
|
||||
|
||||
@@ -3,6 +3,7 @@ package app
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -124,6 +125,9 @@ func (a *App) SelectDataRootDirectory(currentDir string) connection.QueryResult
|
||||
}
|
||||
|
||||
func (a *App) ApplyDataRootDirectory(directory string, migrate bool) connection.QueryResult {
|
||||
a.dataRootApplyMu.Lock()
|
||||
defer a.dataRootApplyMu.Unlock()
|
||||
|
||||
currentRoot := appdata.MustResolveActiveRoot()
|
||||
targetRoot, err := appdata.ResolveRoot(directory)
|
||||
if err != nil {
|
||||
@@ -139,6 +143,25 @@ func (a *App) ApplyDataRootDirectory(directory string, migrate bool) connection.
|
||||
}
|
||||
}
|
||||
|
||||
// The audit database uses SQLite WAL journaling. Pause it and checkpoint/close
|
||||
// every audit connection before copying or switching the data root so the
|
||||
// migration never copies a live database or an incomplete WAL sidecar.
|
||||
resumeSQLAudit, suspendErr := a.suspendSQLAudit()
|
||||
if suspendErr != nil {
|
||||
a.resumeSQLAudit(resumeSQLAudit)
|
||||
return connection.QueryResult{
|
||||
Success: false,
|
||||
Message: dataRootErrorWithDetail(
|
||||
a.appText,
|
||||
"app.data_root.backend.error.migrate_directory_failed",
|
||||
suspendErr.Error(),
|
||||
suspendErr,
|
||||
map[string]any{"entry": "audit"},
|
||||
).Error(),
|
||||
}
|
||||
}
|
||||
defer a.resumeSQLAudit(resumeSQLAudit)
|
||||
|
||||
if migrate {
|
||||
if err := migrateDataRootContentsWithText(currentRoot, targetRoot, a.appText); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
@@ -222,6 +245,12 @@ func migrateDataRootContentsWithText(sourceRoot string, targetRoot string, text
|
||||
if _, excluded := dataRootMigrationExcludedEntries[name]; excluded {
|
||||
continue
|
||||
}
|
||||
if name == "audit" {
|
||||
// The SQLite audit store is copied as an exact directory snapshot
|
||||
// after every other migration step succeeds. Merging it would leave
|
||||
// stale WAL/SHM or health sidecars from an existing target root.
|
||||
continue
|
||||
}
|
||||
sourcePath := filepath.Join(sourceRoot, name)
|
||||
targetPath := filepath.Join(targetRoot, name)
|
||||
info, err := entry.Info()
|
||||
@@ -241,6 +270,84 @@ func migrateDataRootContentsWithText(sourceRoot string, targetRoot string, text
|
||||
if err := rewriteMigratedDataRootStateWithText(targetRoot, text); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := replaceMigratedAuditDirectory(sourceRoot, targetRoot); err != nil {
|
||||
return dataRootWrapError(text, "app.data_root.backend.error.migrate_directory_failed", err, map[string]any{"entry": "audit"})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func replaceMigratedAuditDirectory(sourceRoot string, targetRoot string) error {
|
||||
sourceAudit := filepath.Join(sourceRoot, "audit")
|
||||
targetAudit := filepath.Join(targetRoot, "audit")
|
||||
|
||||
sourceInfo, sourceErr := os.Stat(sourceAudit)
|
||||
sourceExists := sourceErr == nil
|
||||
if sourceErr != nil && !os.IsNotExist(sourceErr) {
|
||||
return fmt.Errorf("inspect source audit directory: %w", sourceErr)
|
||||
}
|
||||
if sourceExists && !sourceInfo.IsDir() {
|
||||
return errors.New("source audit path is not a directory")
|
||||
}
|
||||
|
||||
stagePath := ""
|
||||
if sourceExists {
|
||||
var err error
|
||||
stagePath, err = os.MkdirTemp(targetRoot, ".gonavi-audit-stage-")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create audit migration stage: %w", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(stagePath) }()
|
||||
if err := copyDir(sourceAudit, stagePath); err != nil {
|
||||
return fmt.Errorf("stage audit directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
targetInfo, targetErr := os.Lstat(targetAudit)
|
||||
targetExists := targetErr == nil
|
||||
if targetErr != nil && !os.IsNotExist(targetErr) {
|
||||
return fmt.Errorf("inspect target audit directory: %w", targetErr)
|
||||
}
|
||||
if targetExists && targetInfo == nil {
|
||||
return errors.New("target audit path is unavailable")
|
||||
}
|
||||
|
||||
backupPath := ""
|
||||
if targetExists {
|
||||
reserved, err := os.MkdirTemp(targetRoot, ".gonavi-audit-backup-")
|
||||
if err != nil {
|
||||
return fmt.Errorf("reserve audit migration backup: %w", err)
|
||||
}
|
||||
if err := os.Remove(reserved); err != nil {
|
||||
return fmt.Errorf("prepare audit migration backup: %w", err)
|
||||
}
|
||||
backupPath = reserved
|
||||
if err := os.Rename(targetAudit, backupPath); err != nil {
|
||||
return fmt.Errorf("backup target audit directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
rollback := func(cause error) error {
|
||||
if backupPath == "" {
|
||||
return cause
|
||||
}
|
||||
if restoreErr := os.Rename(backupPath, targetAudit); restoreErr != nil {
|
||||
return errors.Join(cause, fmt.Errorf("restore target audit directory: %w", restoreErr))
|
||||
}
|
||||
backupPath = ""
|
||||
return cause
|
||||
}
|
||||
|
||||
if sourceExists {
|
||||
if err := os.Rename(stagePath, targetAudit); err != nil {
|
||||
return rollback(fmt.Errorf("activate staged audit directory: %w", err))
|
||||
}
|
||||
stagePath = ""
|
||||
}
|
||||
if backupPath != "" {
|
||||
if err := os.RemoveAll(backupPath); err != nil {
|
||||
logger.Warnf("清理数据目录迁移的旧审计备份失败:path=%s err=%v", backupPath, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,38 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/appdata"
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
func TestApplyDataRootDirectorySerializesConcurrentRequests(t *testing.T) {
|
||||
app := NewApp()
|
||||
app.dataRootApplyMu.Lock()
|
||||
done := make(chan connection.QueryResult, 1)
|
||||
go func() {
|
||||
done <- app.ApplyDataRootDirectory(appdata.MustResolveActiveRoot(), false)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
app.dataRootApplyMu.Unlock()
|
||||
t.Fatal("ApplyDataRootDirectory bypassed the application-wide serialization lock")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
app.dataRootApplyMu.Unlock()
|
||||
|
||||
select {
|
||||
case result := <-done:
|
||||
if !result.Success {
|
||||
t.Fatalf("serialized ApplyDataRootDirectory returned failure: %s", result.Message)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("serialized ApplyDataRootDirectory did not resume")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateDataRootContentsCopiesKnownFilesAndDirectories(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
targetRoot := filepath.Join(t.TempDir(), "gonavi-data")
|
||||
@@ -46,6 +74,64 @@ func TestMigrateDataRootContentsCopiesKnownFilesAndDirectories(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateDataRootContentsReplacesAuditDirectoryWithoutStaleSidecars(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
targetRoot := t.TempDir()
|
||||
sourceAudit := filepath.Join(sourceRoot, "audit")
|
||||
targetAudit := filepath.Join(targetRoot, "audit")
|
||||
if err := os.MkdirAll(sourceAudit, 0o700); err != nil {
|
||||
t.Fatalf("create source audit directory: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(targetAudit, 0o700); err != nil {
|
||||
t.Fatalf("create target audit directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sourceAudit, "sql_audit.db"), []byte("source-db"), 0o600); err != nil {
|
||||
t.Fatalf("write source audit database: %v", err)
|
||||
}
|
||||
for name, content := range map[string]string{
|
||||
"sql_audit.db": "old-db",
|
||||
"sql_audit.db-wal": "stale-wal",
|
||||
"sql_audit.db-shm": "stale-shm",
|
||||
"sql_audit_health.json": "stale-health",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(targetAudit, name), []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write target audit artifact %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := migrateDataRootContents(sourceRoot, targetRoot); err != nil {
|
||||
t.Fatalf("migrateDataRootContents returned error: %v", err)
|
||||
}
|
||||
payload, err := os.ReadFile(filepath.Join(targetAudit, "sql_audit.db"))
|
||||
if err != nil || string(payload) != "source-db" {
|
||||
t.Fatalf("target audit database = %q err=%v, want exact source snapshot", payload, err)
|
||||
}
|
||||
for _, name := range []string{"sql_audit.db-wal", "sql_audit.db-shm", "sql_audit_health.json"} {
|
||||
if _, err := os.Stat(filepath.Join(targetAudit, name)); !os.IsNotExist(err) {
|
||||
t.Fatalf("stale target audit artifact %s survived exact replacement: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateDataRootContentsRemovesTargetAuditWhenSourceHasNone(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
targetRoot := t.TempDir()
|
||||
targetAudit := filepath.Join(targetRoot, "audit")
|
||||
if err := os.MkdirAll(targetAudit, 0o700); err != nil {
|
||||
t.Fatalf("create target audit directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(targetAudit, "sql_audit.db-wal"), []byte("stale"), 0o600); err != nil {
|
||||
t.Fatalf("write stale target audit artifact: %v", err)
|
||||
}
|
||||
|
||||
if err := migrateDataRootContents(sourceRoot, targetRoot); err != nil {
|
||||
t.Fatalf("migrateDataRootContents returned error: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(targetAudit); !os.IsNotExist(err) {
|
||||
t.Fatalf("target audit directory survived despite absent source snapshot: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateDataRootContentsCopiesSecurityUpdateStateAndRewritesBackupPaths(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
targetRoot := filepath.Join(t.TempDir(), "gonavi-data")
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/db"
|
||||
"GoNavi-Wails/internal/logger"
|
||||
"GoNavi-Wails/internal/sqlaudit"
|
||||
"GoNavi-Wails/internal/utils"
|
||||
"GoNavi-Wails/shared/i18n"
|
||||
)
|
||||
@@ -191,7 +192,9 @@ func (a *App) MongoDiscoverMembers(config connection.ConnectionConfig) connectio
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) CreateDatabase(config connection.ConnectionConfig, dbName string) connection.QueryResult {
|
||||
func (a *App) CreateDatabase(config connection.ConnectionConfig, dbName string) (result connection.QueryResult) {
|
||||
auditSQL := fmt.Sprintf("CREATE DATABASE %s", strings.TrimSpace(dbName))
|
||||
defer a.beginSQLAuditUserAction(config, dbName, "object_editor", &auditSQL, &result)()
|
||||
dbName = strings.TrimSpace(dbName)
|
||||
if dbName == "" {
|
||||
return connection.QueryResult{Success: false, Message: a.appText("db.backend.error.database_name_required", nil)}
|
||||
@@ -368,7 +371,9 @@ func resolveSchemaDDLTargetDatabaseWithText(config connection.ConnectionConfig,
|
||||
return targetDbName, nil
|
||||
}
|
||||
|
||||
func (a *App) CreateSchema(config connection.ConnectionConfig, dbName string, schemaName string) connection.QueryResult {
|
||||
func (a *App) CreateSchema(config connection.ConnectionConfig, dbName string, schemaName string) (result connection.QueryResult) {
|
||||
auditSQL := fmt.Sprintf("CREATE SCHEMA %s", strings.TrimSpace(schemaName))
|
||||
defer a.beginSQLAuditUserAction(config, dbName, "object_editor", &auditSQL, &result)()
|
||||
if err := ensureConnectionAllowsStructureEdit(config, "connection.backend.action.create_schema"); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
@@ -396,7 +401,9 @@ func (a *App) CreateSchema(config connection.ConnectionConfig, dbName string, sc
|
||||
return connection.QueryResult{Success: true, Message: a.appText("db.backend.message.schema_created", nil)}
|
||||
}
|
||||
|
||||
func (a *App) RenameSchema(config connection.ConnectionConfig, dbName string, oldSchemaName string, newSchemaName string) connection.QueryResult {
|
||||
func (a *App) RenameSchema(config connection.ConnectionConfig, dbName string, oldSchemaName string, newSchemaName string) (result connection.QueryResult) {
|
||||
auditSQL := fmt.Sprintf("ALTER SCHEMA %s RENAME TO %s", strings.TrimSpace(oldSchemaName), strings.TrimSpace(newSchemaName))
|
||||
defer a.beginSQLAuditUserAction(config, dbName, "object_editor", &auditSQL, &result)()
|
||||
if err := ensureConnectionAllowsStructureEdit(config, "connection.backend.action.rename_schema"); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
@@ -422,7 +429,9 @@ func (a *App) RenameSchema(config connection.ConnectionConfig, dbName string, ol
|
||||
return connection.QueryResult{Success: true, Message: a.appText("db.backend.message.schema_renamed", nil)}
|
||||
}
|
||||
|
||||
func (a *App) DropSchema(config connection.ConnectionConfig, dbName string, schemaName string) connection.QueryResult {
|
||||
func (a *App) DropSchema(config connection.ConnectionConfig, dbName string, schemaName string) (result connection.QueryResult) {
|
||||
auditSQL := fmt.Sprintf("DROP SCHEMA %s", strings.TrimSpace(schemaName))
|
||||
defer a.beginSQLAuditUserAction(config, dbName, "object_editor", &auditSQL, &result)()
|
||||
if err := ensureConnectionAllowsStructureEdit(config, "connection.backend.action.drop_schema"); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
@@ -680,7 +689,9 @@ func buildRunConfigForDDL(config connection.ConnectionConfig, dbType string, dbN
|
||||
return runConfig
|
||||
}
|
||||
|
||||
func (a *App) RenameDatabase(config connection.ConnectionConfig, oldName string, newName string) connection.QueryResult {
|
||||
func (a *App) RenameDatabase(config connection.ConnectionConfig, oldName string, newName string) (result connection.QueryResult) {
|
||||
auditSQL := fmt.Sprintf("ALTER DATABASE %s RENAME TO %s", strings.TrimSpace(oldName), strings.TrimSpace(newName))
|
||||
defer a.beginSQLAuditUserAction(config, oldName, "object_editor", &auditSQL, &result)()
|
||||
oldName = strings.TrimSpace(oldName)
|
||||
newName = strings.TrimSpace(newName)
|
||||
if oldName == "" || newName == "" {
|
||||
@@ -727,7 +738,9 @@ func (a *App) RenameDatabase(config connection.ConnectionConfig, oldName string,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) DropDatabase(config connection.ConnectionConfig, dbName string) connection.QueryResult {
|
||||
func (a *App) DropDatabase(config connection.ConnectionConfig, dbName string) (result connection.QueryResult) {
|
||||
auditSQL := fmt.Sprintf("DROP DATABASE %s", strings.TrimSpace(dbName))
|
||||
defer a.beginSQLAuditUserAction(config, dbName, "object_editor", &auditSQL, &result)()
|
||||
dbName = strings.TrimSpace(dbName)
|
||||
if dbName == "" {
|
||||
return connection.QueryResult{Success: false, Message: a.appText("db.backend.error.database_name_required", nil)}
|
||||
@@ -763,7 +776,9 @@ func (a *App) DropDatabase(config connection.ConnectionConfig, dbName string) co
|
||||
return connection.QueryResult{Success: true, Message: a.appText("db.backend.message.database_dropped", nil)}
|
||||
}
|
||||
|
||||
func (a *App) RenameTable(config connection.ConnectionConfig, dbName string, oldTableName string, newTableName string) connection.QueryResult {
|
||||
func (a *App) RenameTable(config connection.ConnectionConfig, dbName string, oldTableName string, newTableName string) (result connection.QueryResult) {
|
||||
auditSQL := fmt.Sprintf("ALTER TABLE %s RENAME TO %s", strings.TrimSpace(oldTableName), strings.TrimSpace(newTableName))
|
||||
defer a.beginSQLAuditUserAction(config, dbName, "object_editor", &auditSQL, &result)()
|
||||
oldTableName = strings.TrimSpace(oldTableName)
|
||||
newTableName = strings.TrimSpace(newTableName)
|
||||
if oldTableName == "" || newTableName == "" {
|
||||
@@ -819,7 +834,9 @@ func (a *App) RenameTable(config connection.ConnectionConfig, dbName string, old
|
||||
return connection.QueryResult{Success: true, Message: a.appText("db.backend.message.table_renamed", nil)}
|
||||
}
|
||||
|
||||
func (a *App) DropTable(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
|
||||
func (a *App) DropTable(config connection.ConnectionConfig, dbName string, tableName string) (result connection.QueryResult) {
|
||||
auditSQL := fmt.Sprintf("DROP TABLE %s", strings.TrimSpace(tableName))
|
||||
defer a.beginSQLAuditUserAction(config, dbName, "object_editor", &auditSQL, &result)()
|
||||
tableName = strings.TrimSpace(tableName)
|
||||
if tableName == "" {
|
||||
return connection.QueryResult{Success: false, Message: a.appText("db.backend.error.table_name_required", nil)}
|
||||
@@ -878,16 +895,86 @@ func (a *App) MySQLShowCreateTable(config connection.ConnectionConfig, dbName st
|
||||
return a.DBShowCreateTable(config, dbName, tableName)
|
||||
}
|
||||
|
||||
func (a *App) DBQuery(config connection.ConnectionConfig, dbName string, query string) connection.QueryResult {
|
||||
return a.DBQueryWithCancel(config, dbName, query, "")
|
||||
type dbQueryAuditOptions struct {
|
||||
trackHistory bool
|
||||
auditAll bool
|
||||
auditWrites bool
|
||||
source string
|
||||
}
|
||||
|
||||
func (a *App) DBQueryWithCancel(config connection.ConnectionConfig, dbName string, query string, queryID string) (result connection.QueryResult) {
|
||||
// DBQuery() 以及后台元数据读取会传空 queryID;只记录 SQL 编辑器显式传入 ID 的查询,
|
||||
// 避免把表结构探测等内部查询混入用户慢 SQL 历史。
|
||||
trackQueryHistory := strings.TrimSpace(queryID) != ""
|
||||
type dbQueryMultiAuditOptions struct {
|
||||
auditAll bool
|
||||
auditWrites bool
|
||||
source string
|
||||
}
|
||||
|
||||
func containsSQLAuditWrite(dbType string, query string) bool {
|
||||
statements := splitSQLStatements(query)
|
||||
if len(statements) == 0 {
|
||||
return !isReadOnlySQLQuery(dbType, query)
|
||||
}
|
||||
for _, statement := range statements {
|
||||
statement = strings.TrimSpace(statement)
|
||||
if statement != "" && !isReadOnlySQLQuery(dbType, statement) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *App) DBQuery(config connection.ConnectionConfig, dbName string, query string) connection.QueryResult {
|
||||
return a.dbQueryWithCancel(config, dbName, query, "", dbQueryAuditOptions{
|
||||
auditAll: a.webRuntime,
|
||||
auditWrites: true,
|
||||
source: "application_api",
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) DBQueryWithCancel(config connection.ConnectionConfig, dbName string, query string, queryID string) connection.QueryResult {
|
||||
explicitQuery := strings.TrimSpace(queryID) != ""
|
||||
auditSource := "query_editor"
|
||||
if !explicitQuery {
|
||||
auditSource = "application_api"
|
||||
}
|
||||
return a.dbQueryWithCancel(config, dbName, query, queryID, dbQueryAuditOptions{
|
||||
trackHistory: explicitQuery,
|
||||
auditAll: explicitQuery || a.webRuntime,
|
||||
auditWrites: true,
|
||||
source: auditSource,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) dbQueryWithCancel(
|
||||
config connection.ConnectionConfig,
|
||||
dbName string,
|
||||
query string,
|
||||
queryID string,
|
||||
auditOptions dbQueryAuditOptions,
|
||||
) (result connection.QueryResult) {
|
||||
trackQueryHistory := auditOptions.trackHistory
|
||||
auditStartedAt := time.Now()
|
||||
var queryExecutionDuration time.Duration
|
||||
runConfig := normalizeRunConfig(config, dbName)
|
||||
if queryID == "" {
|
||||
queryID = generateQueryID()
|
||||
}
|
||||
query = sanitizeSQLForPgLike(resolveDDLDBType(config), query)
|
||||
trackSQLAudit := auditOptions.auditAll || (auditOptions.auditWrites && containsSQLAuditWrite(resolveDDLDBType(runConfig), query))
|
||||
if trackSQLAudit {
|
||||
defer func() {
|
||||
a.recordSQLAuditQuery(sqlAuditQueryInput{
|
||||
Config: runConfig,
|
||||
Database: dbName,
|
||||
DBType: resolveDDLDBType(runConfig),
|
||||
QueryID: queryID,
|
||||
SQL: query,
|
||||
Source: normalizeSQLAuditSource(auditOptions.source),
|
||||
CommitMode: "auto",
|
||||
Duration: time.Since(auditStartedAt),
|
||||
Result: result,
|
||||
})
|
||||
}()
|
||||
}
|
||||
if trackQueryHistory {
|
||||
defer func() {
|
||||
if !result.Success {
|
||||
@@ -898,12 +985,6 @@ func (a *App) DBQueryWithCancel(config connection.ConnectionConfig, dbName strin
|
||||
}()
|
||||
}
|
||||
|
||||
// Generate query ID if not provided
|
||||
if queryID == "" {
|
||||
queryID = generateQueryID()
|
||||
}
|
||||
|
||||
query = sanitizeSQLForPgLike(resolveDDLDBType(config), query)
|
||||
if err := ensureConnectionAllowsQuery(config, query); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error(), QueryID: queryID}
|
||||
}
|
||||
@@ -1010,9 +1091,50 @@ func (a *App) DBQueryWithCancel(config connection.ConnectionConfig, dbName strin
|
||||
// DBQueryMulti 执行可能包含多条 SQL 语句的查询,返回多个结果集。
|
||||
// 如果底层驱动支持 MultiResultQuerier,一次性执行所有语句;
|
||||
// 否则按分号拆分后逐条执行,模拟多结果集。
|
||||
func (a *App) DBQueryMulti(config connection.ConnectionConfig, dbName string, query string, queryID string) (result connection.QueryResult) {
|
||||
func (a *App) DBQueryMulti(config connection.ConnectionConfig, dbName string, query string, queryID string) connection.QueryResult {
|
||||
explicitQuery := strings.TrimSpace(queryID) != ""
|
||||
auditSource := "query_editor"
|
||||
if !explicitQuery {
|
||||
auditSource = "application_api"
|
||||
}
|
||||
return a.dbQueryMulti(config, dbName, query, queryID, dbQueryMultiAuditOptions{
|
||||
auditAll: explicitQuery || a.webRuntime,
|
||||
auditWrites: true,
|
||||
source: auditSource,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) dbQueryMulti(
|
||||
config connection.ConnectionConfig,
|
||||
dbName string,
|
||||
query string,
|
||||
queryID string,
|
||||
auditOptions dbQueryMultiAuditOptions,
|
||||
) (result connection.QueryResult) {
|
||||
runConfig := normalizeRunConfig(config, dbName)
|
||||
resolvedDBType := resolveDDLDBType(runConfig)
|
||||
trackSQLAudit := auditOptions.auditAll || (auditOptions.auditWrites && containsSQLAuditWrite(resolvedDBType, query))
|
||||
auditSource := normalizeSQLAuditSource(auditOptions.source)
|
||||
auditStartedAt := time.Now()
|
||||
var statementAuditEvents []sqlaudit.Event
|
||||
if trackSQLAudit {
|
||||
defer func() {
|
||||
a.recordSQLAuditQuery(sqlAuditQueryInput{
|
||||
Config: runConfig,
|
||||
Database: dbName,
|
||||
DBType: resolvedDBType,
|
||||
QueryID: queryID,
|
||||
SQL: query,
|
||||
Source: auditSource,
|
||||
CommitMode: "auto",
|
||||
Duration: time.Since(auditStartedAt),
|
||||
Result: result,
|
||||
})
|
||||
}()
|
||||
defer func() {
|
||||
a.appendSQLAuditEvents(statementAuditEvents)
|
||||
}()
|
||||
}
|
||||
// 慢 SQL 埋点:成功执行后记录(低于阈值 500ms 自动跳过)。
|
||||
// 用 named return + defer 覆盖所有 return path,避免遗漏。
|
||||
var queryExecutionDuration time.Duration
|
||||
@@ -1083,6 +1205,46 @@ func (a *App) DBQueryMulti(config connection.ConnectionConfig, dbName string, qu
|
||||
// sql.Rows 不暴露 RowsAffected,导致影响行数丢失。
|
||||
// 因此仅在全部语句皆为读操作时才使用原生路径。
|
||||
statements := splitSQLStatements(query)
|
||||
statementCount := 0
|
||||
for _, statement := range statements {
|
||||
if strings.TrimSpace(statement) != "" {
|
||||
statementCount++
|
||||
}
|
||||
}
|
||||
auditSequentialStatements := trackSQLAudit && statementCount > 1
|
||||
appendStatementAudit := func(
|
||||
statement string,
|
||||
statementIndex int,
|
||||
startedAt time.Time,
|
||||
rowsAffected int64,
|
||||
rowsReturned int64,
|
||||
statementErr error,
|
||||
) {
|
||||
if !auditSequentialStatements {
|
||||
return
|
||||
}
|
||||
completedAt := time.Now()
|
||||
event := buildSQLAuditTransactionEvent(sqlAuditTransactionEventInput{
|
||||
Config: runConfig,
|
||||
Database: dbName,
|
||||
DBType: resolvedDBType,
|
||||
QueryID: queryID,
|
||||
EventType: "query_statement",
|
||||
Status: sqlAuditStatusFromError(statementErr),
|
||||
Source: auditSource,
|
||||
CommitMode: "auto",
|
||||
BoundaryMode: "unknown",
|
||||
SQL: statement,
|
||||
StatementIndex: statementIndex,
|
||||
StatementCount: statementCount,
|
||||
Duration: completedAt.Sub(startedAt),
|
||||
RowsAffected: rowsAffected,
|
||||
RowsReturned: rowsReturned,
|
||||
Err: statementErr,
|
||||
})
|
||||
event.Timestamp = completedAt.UnixMilli()
|
||||
statementAuditEvents = append(statementAuditEvents, event)
|
||||
}
|
||||
allReadOnly := true
|
||||
for _, stmt := range statements {
|
||||
if strings.TrimSpace(stmt) != "" && !isReadOnlySQLQuery(runConfig.Type, stmt) {
|
||||
@@ -1273,6 +1435,7 @@ func (a *App) DBQueryMulti(config connection.ConnectionConfig, dbName string, qu
|
||||
if stmt == "" {
|
||||
continue
|
||||
}
|
||||
statementStartedAt := time.Now()
|
||||
|
||||
isReadStmt := isReadOnlySQLQuery(runConfig.Type, stmt)
|
||||
tryQueryStmtFirst := shouldTryQueryResultFirst(runConfig.Type, stmt)
|
||||
@@ -1344,6 +1507,7 @@ func (a *App) DBQueryMulti(config connection.ConnectionConfig, dbName string, qu
|
||||
}
|
||||
if err == nil {
|
||||
if usedMultiResult {
|
||||
var rowsAffected, rowsReturned int64
|
||||
if len(statementResults) == 0 && len(messages) > 0 {
|
||||
statementResults = []connection.ResultSetData{{
|
||||
Rows: []map[string]interface{}{},
|
||||
@@ -1359,8 +1523,12 @@ func (a *App) DBQueryMulti(config connection.ConnectionConfig, dbName string, qu
|
||||
statementResult.Columns = []string{}
|
||||
}
|
||||
statementResult.StatementIndex = idx + 1
|
||||
affected, returned := summarizeManagedSQLResultSet(statementResult)
|
||||
rowsAffected += affected
|
||||
rowsReturned += returned
|
||||
resultSets = append(resultSets, statementResult)
|
||||
}
|
||||
appendStatementAudit(stmt, idx+1, statementStartedAt, rowsAffected, rowsReturned, nil)
|
||||
continue
|
||||
}
|
||||
if data == nil {
|
||||
@@ -1375,11 +1543,13 @@ func (a *App) DBQueryMulti(config connection.ConnectionConfig, dbName string, qu
|
||||
Messages: messages,
|
||||
StatementIndex: idx + 1,
|
||||
})
|
||||
appendStatementAudit(stmt, idx+1, statementStartedAt, 0, int64(len(data)), nil)
|
||||
continue
|
||||
}
|
||||
if isReadStmt {
|
||||
logger.Error(err, "DBQueryMulti 逐条查询失败(第 %d/%d 条):%s SQL片段=%q", idx+1, len(statements), formatConnSummary(runConfig), sqlSnippet(stmt))
|
||||
errMsg := buildStatementExecutionFailedMessage(idx+1, err, len(resultSets))
|
||||
appendStatementAudit(stmt, idx+1, statementStartedAt, 0, 0, err)
|
||||
return connection.QueryResult{Success: false, Message: errMsg, QueryID: queryID}
|
||||
}
|
||||
}
|
||||
@@ -1399,6 +1569,7 @@ func (a *App) DBQueryMulti(config connection.ConnectionConfig, dbName string, qu
|
||||
if err != nil {
|
||||
logger.Error(err, "DBQueryMulti 逐条执行失败(第 %d/%d 条):%s SQL片段=%q", idx+1, len(statements), formatConnSummary(runConfig), sqlSnippet(stmt))
|
||||
errMsg := buildStatementExecutionFailedMessage(idx+1, err, len(resultSets))
|
||||
appendStatementAudit(stmt, idx+1, statementStartedAt, 0, 0, err)
|
||||
return connection.QueryResult{Success: false, Message: errMsg, QueryID: queryID}
|
||||
}
|
||||
resultSets = append(resultSets, connection.ResultSetData{
|
||||
@@ -1406,6 +1577,7 @@ func (a *App) DBQueryMulti(config connection.ConnectionConfig, dbName string, qu
|
||||
Columns: []string{"affectedRows"},
|
||||
StatementIndex: idx + 1,
|
||||
})
|
||||
appendStatementAudit(stmt, idx+1, statementStartedAt, affected, 0, nil)
|
||||
}
|
||||
|
||||
if resultSets == nil {
|
||||
@@ -1518,6 +1690,8 @@ func shouldTryQueryResultFirst(dbType string, query string) bool {
|
||||
}
|
||||
keyword := leadingSQLKeyword(query)
|
||||
switch keyword {
|
||||
case "explain", "pragma":
|
||||
return true
|
||||
case "exec", "execute", "call":
|
||||
return true
|
||||
case "set", "print":
|
||||
@@ -2653,7 +2827,9 @@ func (a *App) DBGetTriggers(config connection.ConnectionConfig, dbName string, t
|
||||
return connection.QueryResult{Success: true, Data: ensureNonNilSlice(triggers)}
|
||||
}
|
||||
|
||||
func (a *App) DropView(config connection.ConnectionConfig, dbName string, viewName string) connection.QueryResult {
|
||||
func (a *App) DropView(config connection.ConnectionConfig, dbName string, viewName string) (result connection.QueryResult) {
|
||||
auditSQL := fmt.Sprintf("DROP VIEW %s", strings.TrimSpace(viewName))
|
||||
defer a.beginSQLAuditUserAction(config, dbName, "object_editor", &auditSQL, &result)()
|
||||
viewName = strings.TrimSpace(viewName)
|
||||
if viewName == "" {
|
||||
return connection.QueryResult{Success: false, Message: a.appText("db.backend.error.view_name_required", nil)}
|
||||
@@ -2687,7 +2863,9 @@ func (a *App) DropView(config connection.ConnectionConfig, dbName string, viewNa
|
||||
return connection.QueryResult{Success: true, Message: a.appText("db.backend.message.view_dropped", nil)}
|
||||
}
|
||||
|
||||
func (a *App) DropFunction(config connection.ConnectionConfig, dbName string, routineName string, routineType string) connection.QueryResult {
|
||||
func (a *App) DropFunction(config connection.ConnectionConfig, dbName string, routineName string, routineType string) (result connection.QueryResult) {
|
||||
auditSQL := fmt.Sprintf("DROP %s %s", strings.ToUpper(strings.TrimSpace(routineType)), strings.TrimSpace(routineName))
|
||||
defer a.beginSQLAuditUserAction(config, dbName, "object_editor", &auditSQL, &result)()
|
||||
routineName = strings.TrimSpace(routineName)
|
||||
routineType = strings.TrimSpace(strings.ToUpper(routineType))
|
||||
if routineName == "" {
|
||||
@@ -2732,7 +2910,9 @@ func (a *App) DropFunction(config connection.ConnectionConfig, dbName string, ro
|
||||
return connection.QueryResult{Success: true, Message: a.appText("db.backend.message.function_dropped", nil)}
|
||||
}
|
||||
|
||||
func (a *App) RenameView(config connection.ConnectionConfig, dbName string, oldName string, newName string) connection.QueryResult {
|
||||
func (a *App) RenameView(config connection.ConnectionConfig, dbName string, oldName string, newName string) (result connection.QueryResult) {
|
||||
auditSQL := fmt.Sprintf("ALTER VIEW %s RENAME TO %s", strings.TrimSpace(oldName), strings.TrimSpace(newName))
|
||||
defer a.beginSQLAuditUserAction(config, dbName, "object_editor", &auditSQL, &result)()
|
||||
oldName = strings.TrimSpace(oldName)
|
||||
newName = strings.TrimSpace(newName)
|
||||
if oldName == "" || newName == "" {
|
||||
|
||||
140
internal/app/methods_db_audited.go
Normal file
140
internal/app/methods_db_audited.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
type sqlAuditUserActionOptions struct {
|
||||
StatementCount *int
|
||||
SafeError *string
|
||||
}
|
||||
|
||||
// beginSQLAuditUserAction returns a deferred recorder for exported user-facing
|
||||
// write helpers that already own execution, validation, and dialect handling.
|
||||
// The supplied text is an operation descriptor or generated statement and must
|
||||
// never contain row values or message payloads.
|
||||
func (a *App) beginSQLAuditUserAction(
|
||||
config connection.ConnectionConfig,
|
||||
dbName string,
|
||||
source string,
|
||||
sqlText *string,
|
||||
result *connection.QueryResult,
|
||||
statementCount ...*int,
|
||||
) func() {
|
||||
options := sqlAuditUserActionOptions{}
|
||||
if len(statementCount) > 0 {
|
||||
options.StatementCount = statementCount[0]
|
||||
}
|
||||
return a.beginSQLAuditUserActionWithOptions(config, dbName, source, sqlText, result, options)
|
||||
}
|
||||
|
||||
func (a *App) beginSQLAuditUserActionWithOptions(
|
||||
config connection.ConnectionConfig,
|
||||
dbName string,
|
||||
source string,
|
||||
sqlText *string,
|
||||
result *connection.QueryResult,
|
||||
options sqlAuditUserActionOptions,
|
||||
) func() {
|
||||
startedAt := time.Now()
|
||||
queryID := generateQueryID()
|
||||
runConfig := normalizeRunConfig(config, dbName)
|
||||
return func() {
|
||||
if result == nil {
|
||||
return
|
||||
}
|
||||
statement := ""
|
||||
if sqlText != nil {
|
||||
statement = strings.TrimSpace(*sqlText)
|
||||
}
|
||||
resolvedStatementCount := 0
|
||||
if options.StatementCount != nil {
|
||||
resolvedStatementCount = *options.StatementCount
|
||||
}
|
||||
auditResult := *result
|
||||
if !auditResult.Success && options.SafeError != nil {
|
||||
auditResult.Message = strings.TrimSpace(*options.SafeError)
|
||||
}
|
||||
a.recordSQLAuditQuery(sqlAuditQueryInput{
|
||||
Config: runConfig,
|
||||
Database: dbName,
|
||||
DBType: resolveDDLDBType(runConfig),
|
||||
QueryID: queryID,
|
||||
SQL: statement,
|
||||
Source: source,
|
||||
CommitMode: "auto",
|
||||
Duration: time.Since(startedAt),
|
||||
StatementCount: resolvedStatementCount,
|
||||
Result: auditResult,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// DBQueryAudited executes one explicit application-level user action and writes
|
||||
// exactly one SQL audit event without adding it to slow-query history.
|
||||
func (a *App) DBQueryAudited(
|
||||
config connection.ConnectionConfig,
|
||||
dbName string,
|
||||
query string,
|
||||
source string,
|
||||
) connection.QueryResult {
|
||||
queryID := generateQueryID()
|
||||
return a.dbQueryWithCancel(config, dbName, query, queryID, dbQueryAuditOptions{
|
||||
auditAll: true,
|
||||
auditWrites: true,
|
||||
source: normalizeSQLAuditUserActionSource(source),
|
||||
})
|
||||
}
|
||||
|
||||
// DBQueryAI is the dedicated entry point used by GoNavi's built-in AI tool
|
||||
// runtime. The audit source describes this called entry point; it is not an
|
||||
// unforgeable actor identity or security provenance claim.
|
||||
func (a *App) DBQueryAI(
|
||||
config connection.ConnectionConfig,
|
||||
dbName string,
|
||||
query string,
|
||||
) connection.QueryResult {
|
||||
return a.dbQueryWithCancel(config, dbName, query, "", dbQueryAuditOptions{
|
||||
auditAll: true,
|
||||
auditWrites: true,
|
||||
source: "ai_action",
|
||||
})
|
||||
}
|
||||
|
||||
// MCPQueryExecutor is a narrow adapter for the MCP server. Keeping it separate
|
||||
// from App's Wails-bound method set makes the mcp source a backend-owned fact
|
||||
// rather than a source string accepted from a browser or desktop caller.
|
||||
type MCPQueryExecutor struct {
|
||||
app *App
|
||||
}
|
||||
|
||||
func NewMCPQueryExecutor(app *App) *MCPQueryExecutor {
|
||||
return &MCPQueryExecutor{app: app}
|
||||
}
|
||||
|
||||
func (executor *MCPQueryExecutor) DBQueryMulti(
|
||||
config connection.ConnectionConfig,
|
||||
dbName string,
|
||||
query string,
|
||||
) connection.QueryResult {
|
||||
if executor == nil || executor.app == nil {
|
||||
return connection.QueryResult{Success: false, Message: "MCP query executor is unavailable"}
|
||||
}
|
||||
return executor.app.dbQueryMulti(config, dbName, query, "", dbQueryMultiAuditOptions{
|
||||
auditAll: true,
|
||||
auditWrites: true,
|
||||
source: "mcp",
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeSQLAuditUserActionSource(source string) string {
|
||||
switch normalized := strings.ToLower(strings.TrimSpace(source)); normalized {
|
||||
case "data_editor", "table_designer", "object_editor", "message_publish":
|
||||
return normalized
|
||||
default:
|
||||
return "application_api"
|
||||
}
|
||||
}
|
||||
384
internal/app/methods_db_audited_test.go
Normal file
384
internal/app/methods_db_audited_test.go
Normal file
@@ -0,0 +1,384 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/db"
|
||||
"GoNavi-Wails/internal/sqlaudit"
|
||||
datasync "GoNavi-Wails/internal/sync"
|
||||
)
|
||||
|
||||
func TestDBQueryAuditedWritesOneUserActionEventWithoutSlowQueryHistory(t *testing.T) {
|
||||
originalNewDatabaseFunc := newDatabaseFunc
|
||||
t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc })
|
||||
query := "UPDATE users SET display_name = 'private-name' WHERE id = 7"
|
||||
database := &fakeBatchWriteDB{
|
||||
execAffected: map[string]int64{query: 2},
|
||||
execDelay: map[string]time.Duration{query: time.Duration(queryHistorySlowThresholdMs)*time.Millisecond + 25*time.Millisecond},
|
||||
}
|
||||
newDatabaseFunc = func(string) (db.Database, error) { return database, nil }
|
||||
app := newSQLAuditTestApp(t)
|
||||
config := connection.ConnectionConfig{Type: "postgres", Host: "127.0.0.1", Port: 5432, Database: "app"}
|
||||
|
||||
result := app.DBQueryAudited(config, "app", query, "table_designer")
|
||||
if !result.Success {
|
||||
t.Fatalf("DBQueryAudited returned failure: %s", result.Message)
|
||||
}
|
||||
if !strings.HasPrefix(result.QueryID, "query-") {
|
||||
t.Fatalf("query ID = %q, want generated query ID", result.QueryID)
|
||||
}
|
||||
events := loadSQLAuditEvents(t, app, sqlaudit.Filter{Search: result.QueryID})
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("audit event count = %d, want 1: %#v", len(events), events)
|
||||
}
|
||||
event := events[0]
|
||||
if event.QueryID != result.QueryID || event.Source != "table_designer" || event.Status != "success" || event.RowsAffected != 2 {
|
||||
t.Fatalf("unexpected application audit event: %#v", event)
|
||||
}
|
||||
if strings.Contains(event.SQLText, "private-name") || strings.Contains(event.SQLText, " 7") || !event.SQLRedacted {
|
||||
t.Fatalf("application audit SQL was not redacted: %#v", event)
|
||||
}
|
||||
|
||||
history := app.GetSlowQueries(config, "app", "recent", 10)
|
||||
if !history.Success {
|
||||
t.Fatalf("GetSlowQueries returned failure: %s", history.Message)
|
||||
}
|
||||
records, ok := history.Data.([]connection.QueryExecutionRecord)
|
||||
if !ok {
|
||||
t.Fatalf("GetSlowQueries data type = %T, want []connection.QueryExecutionRecord", history.Data)
|
||||
}
|
||||
if len(records) != 0 {
|
||||
t.Fatalf("DBQueryAudited must not write slow query history: %#v", records)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBQueryAuditedRecordsProtectionDenial(t *testing.T) {
|
||||
app := newSQLAuditTestApp(t)
|
||||
config := connection.ConnectionConfig{Type: "postgres", Host: "127.0.0.1", Port: 5432, Database: "app", ReadOnly: true}
|
||||
query := "DELETE FROM users WHERE email = 'private@example.test'"
|
||||
|
||||
result := app.DBQueryAudited(config, "app", query, "object_editor")
|
||||
if result.Success || strings.TrimSpace(result.Message) == "" {
|
||||
t.Fatalf("protected action result = %#v, want rejected error", result)
|
||||
}
|
||||
if !strings.HasPrefix(result.QueryID, "query-") {
|
||||
t.Fatalf("query ID = %q, want generated query ID", result.QueryID)
|
||||
}
|
||||
events := loadSQLAuditEvents(t, app, sqlaudit.Filter{Search: result.QueryID})
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("audit event count = %d, want 1: %#v", len(events), events)
|
||||
}
|
||||
event := events[0]
|
||||
if event.Status != "error" || event.Source != "object_editor" || strings.TrimSpace(event.Error) == "" {
|
||||
t.Fatalf("protection denial was not fully audited: %#v", event)
|
||||
}
|
||||
if strings.Contains(event.SQLText, "private@example.test") || !event.SQLRedacted {
|
||||
t.Fatalf("denied audit SQL was not redacted: %#v", event)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectDBQueryCannotBypassWriteAuditWithEmptyQueryID(t *testing.T) {
|
||||
originalNewDatabaseFunc := newDatabaseFunc
|
||||
t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc })
|
||||
writeSQL := "DELETE FROM users WHERE id = 7"
|
||||
readSQL := "SELECT id FROM users"
|
||||
database := &fakeBatchWriteDB{
|
||||
execAffected: map[string]int64{writeSQL: 1},
|
||||
queryMap: map[string][]map[string]interface{}{readSQL: {{"id": 7}}},
|
||||
fieldMap: map[string][]string{readSQL: {"id"}},
|
||||
}
|
||||
newDatabaseFunc = func(string) (db.Database, error) { return database, nil }
|
||||
app := newSQLAuditTestApp(t)
|
||||
config := connection.ConnectionConfig{Type: "mysql", Host: "127.0.0.1", Port: 3306, Database: "app"}
|
||||
|
||||
if result := app.DBQuery(config, "app", writeSQL); !result.Success {
|
||||
t.Fatalf("direct DBQuery write returned failure: %s", result.Message)
|
||||
}
|
||||
if result := app.DBQuery(config, "app", readSQL); !result.Success {
|
||||
t.Fatalf("direct DBQuery metadata read returned failure: %s", result.Message)
|
||||
}
|
||||
events := loadSQLAuditEvents(t, app, sqlaudit.Filter{})
|
||||
if len(events) != 1 || events[0].Source != "application_api" || events[0].RowsAffected != 1 {
|
||||
t.Fatalf("direct write was not uniquely audited: %#v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectDBQueryCannotBypassWriteAuditWhenBatchStartsWithRead(t *testing.T) {
|
||||
originalNewDatabaseFunc := newDatabaseFunc
|
||||
t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc })
|
||||
query := "SELECT 1; UPDATE users SET enabled = false WHERE id = 7"
|
||||
database := &fakeBatchWriteDB{
|
||||
queryMap: map[string][]map[string]interface{}{query: {{"value": 1}}},
|
||||
fieldMap: map[string][]string{query: {"value"}},
|
||||
}
|
||||
newDatabaseFunc = func(string) (db.Database, error) { return database, nil }
|
||||
app := newSQLAuditTestApp(t)
|
||||
config := connection.ConnectionConfig{Type: "mysql", Host: "127.0.0.1", Port: 3306, Database: "app"}
|
||||
|
||||
result := app.DBQuery(config, "app", query)
|
||||
if !result.Success {
|
||||
t.Fatalf("direct mixed batch returned failure: %s", result.Message)
|
||||
}
|
||||
events := loadSQLAuditEvents(t, app, sqlaudit.Filter{})
|
||||
if len(events) != 1 || events[0].Source != "application_api" {
|
||||
t.Fatalf("read-first write batch bypassed audit: %#v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectDBQueryCannotBypassAuditWithNestedWriteSyntax(t *testing.T) {
|
||||
originalNewDatabaseFunc := newDatabaseFunc
|
||||
t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc })
|
||||
explainWrite := "EXPLAIN ANALYZE UPDATE users SET enabled = false WHERE id = 7"
|
||||
pragmaWrite := "PRAGMA user_version = 7"
|
||||
mongoWrite := `{"aggregate":"users","pipeline":[{"$merge":{"into":"users_archive"}}],"cursor":{}}`
|
||||
database := &fakeBatchWriteDB{
|
||||
queryMap: map[string][]map[string]interface{}{explainWrite: {{"plan": "ok"}}},
|
||||
fieldMap: map[string][]string{explainWrite: {"plan"}},
|
||||
execAffected: map[string]int64{pragmaWrite: 0, mongoWrite: 1},
|
||||
}
|
||||
newDatabaseFunc = func(string) (db.Database, error) { return database, nil }
|
||||
app := newSQLAuditTestApp(t)
|
||||
|
||||
for _, testCase := range []struct {
|
||||
dbType string
|
||||
query string
|
||||
}{
|
||||
{dbType: "postgres", query: explainWrite},
|
||||
{dbType: "sqlite", query: pragmaWrite},
|
||||
{dbType: "mongodb", query: mongoWrite},
|
||||
} {
|
||||
config := connection.ConnectionConfig{Type: testCase.dbType, Host: "127.0.0.1", Database: "app"}
|
||||
if result := app.DBQuery(config, "app", testCase.query); !result.Success {
|
||||
t.Fatalf("direct %s nested write returned failure: %s", testCase.dbType, result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
events := loadSQLAuditEvents(t, app, sqlaudit.Filter{})
|
||||
if len(events) != 3 {
|
||||
t.Fatalf("nested write syntax bypassed audit: %#v", events)
|
||||
}
|
||||
for _, event := range events {
|
||||
if event.Source != "application_api" {
|
||||
t.Fatalf("unexpected nested write audit source: %#v", event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebRuntimeAuditsGenericReadQueriesWithoutTrustingSQLClassification(t *testing.T) {
|
||||
originalNewDatabaseFunc := newDatabaseFunc
|
||||
t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc })
|
||||
query := "SELECT mutating_function(7)"
|
||||
database := &fakeBatchWriteDB{
|
||||
queryMap: map[string][]map[string]interface{}{query: {{"result": 1}}},
|
||||
fieldMap: map[string][]string{query: {"result"}},
|
||||
}
|
||||
newDatabaseFunc = func(string) (db.Database, error) { return database, nil }
|
||||
app := newSQLAuditTestApp(t)
|
||||
app.webRuntime = true
|
||||
config := connection.ConnectionConfig{Type: "postgres", Host: "127.0.0.1", Port: 5432, Database: "app"}
|
||||
|
||||
result := app.DBQuery(config, "app", query)
|
||||
if !result.Success {
|
||||
t.Fatalf("web generic read returned failure: %s", result.Message)
|
||||
}
|
||||
events := loadSQLAuditEvents(t, app, sqlaudit.Filter{})
|
||||
if len(events) != 1 || events[0].Source != "application_api" {
|
||||
t.Fatalf("web generic query was not audit-all: %#v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObjectDDLHelperRecordsProtectionDenial(t *testing.T) {
|
||||
app := newSQLAuditTestApp(t)
|
||||
config := connection.ConnectionConfig{Type: "mysql", Host: "127.0.0.1", Port: 3306, Database: "app", ReadOnly: true}
|
||||
|
||||
result := app.DropTable(config, "app", "private_orders")
|
||||
if result.Success {
|
||||
t.Fatalf("DropTable on protected connection returned success: %#v", result)
|
||||
}
|
||||
events := loadSQLAuditEvents(t, app, sqlaudit.Filter{Source: "object_editor"})
|
||||
if len(events) != 1 || events[0].Status != "error" || events[0].Source != "object_editor" {
|
||||
t.Fatalf("protected object DDL was not audited: %#v", events)
|
||||
}
|
||||
if !strings.Contains(events[0].SQLText, "DROP TABLE") {
|
||||
t.Fatalf("object DDL audit lost operation structure: %#v", events[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyChangesRecordsMetadataWithoutRowValues(t *testing.T) {
|
||||
originalNewDatabaseFunc := newDatabaseFunc
|
||||
t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc })
|
||||
fakeDB := &fakeCreateDatabaseDB{}
|
||||
newDatabaseFunc = func(string) (db.Database, error) { return fakeDB, nil }
|
||||
app := newSQLAuditTestApp(t)
|
||||
config := connection.ConnectionConfig{Type: "mysql", Host: "127.0.0.1", Port: 3306, Database: "app"}
|
||||
|
||||
result := app.ApplyChanges(config, "app", "users", connection.ChangeSet{
|
||||
Updates: []connection.UpdateRow{{
|
||||
Keys: map[string]interface{}{"id": 7},
|
||||
Values: map[string]interface{}{"email": "private@example.test"},
|
||||
}},
|
||||
})
|
||||
if !result.Success {
|
||||
t.Fatalf("ApplyChanges returned failure: %s", result.Message)
|
||||
}
|
||||
events := loadSQLAuditEvents(t, app, sqlaudit.Filter{Source: "data_editor"})
|
||||
if len(events) != 1 || events[0].Status != "success" || events[0].Source != "data_editor" {
|
||||
t.Fatalf("data editor action was not audited: %#v", events)
|
||||
}
|
||||
if strings.Contains(events[0].SQLText, "private@example.test") || strings.Contains(events[0].SQLText, " 7") {
|
||||
t.Fatalf("data editor audit leaked row values: %#v", events[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendWriteWorkflowsRecordFixedAuditSources(t *testing.T) {
|
||||
protectedConfig := connection.ConnectionConfig{
|
||||
Type: "mysql",
|
||||
Host: "127.0.0.1",
|
||||
Port: 3306,
|
||||
Database: "app",
|
||||
ReadOnly: true,
|
||||
}
|
||||
|
||||
t.Run("sql_file", func(t *testing.T) {
|
||||
originalNewDatabaseFunc := newDatabaseFunc
|
||||
t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc })
|
||||
newDatabaseFunc = func(string) (db.Database, error) { return &fakeSQLFileBatchDB{}, nil }
|
||||
app := newSQLAuditTestApp(t)
|
||||
filePath := filepath.Join(t.TempDir(), "private-job.sql")
|
||||
if err := os.WriteFile(filePath, []byte("INSERT INTO users(email) VALUES ('private@example.test');"), 0o600); err != nil {
|
||||
t.Fatalf("write SQL file fixture: %v", err)
|
||||
}
|
||||
config := protectedConfig
|
||||
config.ReadOnly = false
|
||||
result := app.ExecuteSQLFile(config, "app", filePath, "sql-file-audit-test")
|
||||
if !result.Success {
|
||||
t.Fatalf("SQL file execution returned failure: %#v", result)
|
||||
}
|
||||
events := loadSQLAuditEvents(t, app, sqlaudit.Filter{Source: "sql_file"})
|
||||
if len(events) != 1 || events[0].Source != "sql_file" || events[0].Status != "success" {
|
||||
t.Fatalf("SQL file execution was not audited with its fixed source: %#v", events)
|
||||
}
|
||||
if strings.Contains(events[0].SQLText, "private@example.test") || strings.Contains(events[0].SQLText, "private-job.sql") {
|
||||
t.Fatalf("SQL file audit leaked file contents or path: %#v", events[0])
|
||||
}
|
||||
if !strings.Contains(events[0].SQLText, "SHA256_") || !strings.Contains(events[0].SQLText, "EXECUTED_1") || events[0].StatementCount != 1 {
|
||||
t.Fatalf("SQL file audit lacks a safe content identity or execution counts: %#v", events[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sql_file_failure_redaction", func(t *testing.T) {
|
||||
originalNewDatabaseFunc := newDatabaseFunc
|
||||
t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc })
|
||||
newDatabaseFunc = func(string) (db.Database, error) {
|
||||
return &fakeSQLFileBatchDB{failExecSQL: "CALL broken_proc"}, nil
|
||||
}
|
||||
app := newSQLAuditTestApp(t)
|
||||
filePath := filepath.Join(t.TempDir(), "private-failure.sql")
|
||||
if err := os.WriteFile(filePath, []byte("CALL broken_proc('private-secret', 777);"), 0o600); err != nil {
|
||||
t.Fatalf("write failing SQL file fixture: %v", err)
|
||||
}
|
||||
config := protectedConfig
|
||||
config.ReadOnly = false
|
||||
result := app.ExecuteSQLFile(config, "app", filePath, "sql-file-failure-audit-test")
|
||||
if result.Success {
|
||||
t.Fatalf("failing SQL file execution returned success: %#v", result)
|
||||
}
|
||||
events := loadSQLAuditEvents(t, app, sqlaudit.Filter{Source: "sql_file"})
|
||||
if len(events) != 1 || events[0].Status != "error" {
|
||||
t.Fatalf("failing SQL file execution was not audited: %#v", events)
|
||||
}
|
||||
serialized, _ := json.Marshal(events[0])
|
||||
for _, secret := range []string{"private-secret", "777", "private-failure.sql", "broken_proc"} {
|
||||
if bytes.Contains(serialized, []byte(secret)) {
|
||||
t.Fatalf("failing SQL file audit leaked %q: %s", secret, serialized)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("data_import", func(t *testing.T) {
|
||||
app := newSQLAuditTestApp(t)
|
||||
result := app.ImportDataWithProgress(protectedConfig, "app", "users", "private.csv")
|
||||
if result.Success {
|
||||
t.Fatalf("protected import returned success: %#v", result)
|
||||
}
|
||||
events := loadSQLAuditEvents(t, app, sqlaudit.Filter{Source: "data_import"})
|
||||
if len(events) != 1 || events[0].Source != "data_import" || events[0].Status != "error" {
|
||||
t.Fatalf("data import attempt was not audited with its fixed source: %#v", events)
|
||||
}
|
||||
if strings.Contains(events[0].SQLText, "private.csv") {
|
||||
t.Fatalf("data import audit leaked the local file path: %#v", events[0])
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(events[0].SQLText), "users") {
|
||||
t.Fatalf("data import audit lost the safe target table: %#v", events[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sync", func(t *testing.T) {
|
||||
app := newSQLAuditTestApp(t)
|
||||
result := app.DataSync(datasync.SyncConfig{
|
||||
TargetConfig: protectedConfig,
|
||||
TargetDatabase: "app",
|
||||
Tables: []string{"users"},
|
||||
Content: "data",
|
||||
})
|
||||
if result.Success {
|
||||
t.Fatalf("protected data sync returned success: %#v", result)
|
||||
}
|
||||
events := loadSQLAuditEvents(t, app, sqlaudit.Filter{Source: "sync"})
|
||||
if len(events) != 1 || events[0].Source != "sync" || events[0].Status != "error" {
|
||||
t.Fatalf("data sync attempt was not audited with its fixed source: %#v", events)
|
||||
}
|
||||
if events[0].StatementCount != 1 || !strings.Contains(events[0].SQLText, "TABLES_1") {
|
||||
t.Fatalf("data sync audit lacks its safe task summary: %#v", events[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAIEntryPointAndMCPExecutorRecordAuditSources(t *testing.T) {
|
||||
originalNewDatabaseFunc := newDatabaseFunc
|
||||
t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc })
|
||||
database := &sqlAuditTestDatabase{
|
||||
rows: []map[string]interface{}{{"id": 7}},
|
||||
columns: []string{"id"},
|
||||
affected: 1,
|
||||
}
|
||||
newDatabaseFunc = func(string) (db.Database, error) { return database, nil }
|
||||
app := newSQLAuditTestApp(t)
|
||||
config := connection.ConnectionConfig{Type: "postgres", Host: "127.0.0.1", Port: 5432, Database: "app"}
|
||||
|
||||
aiResult := app.DBQueryAI(config, "app", "SELECT id FROM users WHERE email = 'private@example.test'")
|
||||
if !aiResult.Success {
|
||||
t.Fatalf("DBQueryAI returned failure: %s", aiResult.Message)
|
||||
}
|
||||
mcpResult := NewMCPQueryExecutor(app).DBQueryMulti(config, "app", "SELECT id FROM users")
|
||||
if !mcpResult.Success {
|
||||
t.Fatalf("MCPQueryExecutor returned failure: %s", mcpResult.Message)
|
||||
}
|
||||
spoofedResult := app.DBQueryAudited(config, "app", "UPDATE users SET email = 'private@example.test' WHERE id = 7", "mcp")
|
||||
if !spoofedResult.Success {
|
||||
t.Fatalf("DBQueryAudited returned failure: %s", spoofedResult.Message)
|
||||
}
|
||||
|
||||
aiEvents := loadSQLAuditEvents(t, app, sqlaudit.Filter{Search: aiResult.QueryID})
|
||||
if len(aiEvents) != 1 || aiEvents[0].Source != "ai_action" {
|
||||
t.Fatalf("AI query did not retain its entry-point source: %#v", aiEvents)
|
||||
}
|
||||
if strings.Contains(aiEvents[0].SQLText, "private@example.test") {
|
||||
t.Fatalf("AI query audit leaked a literal: %#v", aiEvents[0])
|
||||
}
|
||||
mcpEvents := loadSQLAuditEvents(t, app, sqlaudit.Filter{Search: mcpResult.QueryID})
|
||||
if len(mcpEvents) != 1 || mcpEvents[0].Source != "mcp" {
|
||||
t.Fatalf("MCP query did not retain its backend-owned source: %#v", mcpEvents)
|
||||
}
|
||||
spoofedEvents := loadSQLAuditEvents(t, app, sqlaudit.Filter{Search: spoofedResult.QueryID})
|
||||
if len(spoofedEvents) != 1 || spoofedEvents[0].Source != "application_api" {
|
||||
t.Fatalf("public audited query was able to spoof a privileged source: %#v", spoofedEvents)
|
||||
}
|
||||
}
|
||||
@@ -525,7 +525,9 @@ func TestMethodsDBQueryMultiMessagesUseLocalizedText(t *testing.T) {
|
||||
}
|
||||
source := string(sourceBytes)
|
||||
|
||||
functionSource := methodsDBFunctionSource(t, source, "func (a *App) DBQueryMulti")
|
||||
// DBQueryMulti only selects the audit policy; the shared execution path owns
|
||||
// all user-facing multi-statement messages.
|
||||
functionSource := methodsDBFunctionSource(t, source, "func (a *App) dbQueryMulti")
|
||||
rawMessages := []string{
|
||||
`fmt.Sprintf("第 %d 条语句执行失败: %v", idx+1, err)`,
|
||||
`fmt.Sprintf("(前 %d 条已执行成功)", len(resultSets))`,
|
||||
@@ -696,7 +698,7 @@ func TestMethodsDBManagedTransactionExecutionMessagesUseLocalizedText(t *testing
|
||||
"db.backend.error.transaction_rollback_failed",
|
||||
},
|
||||
},
|
||||
"func executeManagedSQLTransactionStatements": {
|
||||
"func executeManagedSQLTransactionStatementsWithObserver": {
|
||||
rawMessages: []string{
|
||||
`fmt.Errorf("当前事务会话不支持查询语句")`,
|
||||
`fmt.Errorf("第 %d 条语句执行失败: %w", idx+1, err)`,
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/db"
|
||||
@@ -27,6 +28,9 @@ type fakeBatchWriteDB struct {
|
||||
queryErr map[string]error
|
||||
execErr map[string]error
|
||||
execAffected map[string]int64
|
||||
execDelay map[string]time.Duration
|
||||
execStarted chan<- string
|
||||
execRelease <-chan struct{}
|
||||
session *fakeBatchWriteSession
|
||||
}
|
||||
|
||||
@@ -173,6 +177,29 @@ func (f *fakeBatchWriteDB) ExecContext(ctx context.Context, query string) (int64
|
||||
f.lastCtx = ctx
|
||||
f.execCalls++
|
||||
f.execQueries = append(f.execQueries, query)
|
||||
if f.execStarted != nil {
|
||||
select {
|
||||
case f.execStarted <- query:
|
||||
case <-ctx.Done():
|
||||
return 0, ctx.Err()
|
||||
}
|
||||
}
|
||||
if f.execRelease != nil {
|
||||
select {
|
||||
case <-f.execRelease:
|
||||
case <-ctx.Done():
|
||||
return 0, ctx.Err()
|
||||
}
|
||||
}
|
||||
if delay := f.execDelay[query]; delay > 0 {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
case <-ctx.Done():
|
||||
return 0, ctx.Err()
|
||||
}
|
||||
}
|
||||
if err := f.execErr[query]; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -356,6 +383,126 @@ func cloneResultSets(input []connection.ResultSetData) []connection.ResultSetDat
|
||||
return cloned
|
||||
}
|
||||
|
||||
func TestDBQueryMultiInTransactionSerializesCommitWithInFlightStatement(t *testing.T) {
|
||||
originalNewDatabaseFunc := newDatabaseFunc
|
||||
t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc })
|
||||
|
||||
initialStatement := "UPDATE users SET active = 1 WHERE id = 1"
|
||||
followUpStatement := "UPDATE users SET active = 0 WHERE id = 2"
|
||||
fakeDB := &fakeTransactionalDB{fakeBatchWriteDB: fakeBatchWriteDB{
|
||||
execAffected: map[string]int64{initialStatement: 1, followUpStatement: 1},
|
||||
}}
|
||||
newDatabaseFunc = func(string) (db.Database, error) { return fakeDB, nil }
|
||||
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
|
||||
config := connection.ConnectionConfig{Type: "mysql", Host: "127.0.0.1", Port: 3306, Database: "main"}
|
||||
|
||||
started := app.DBQueryMultiTransactional(config, "main", initialStatement, "tx-serialize-start")
|
||||
if !started.Success || started.TransactionID == "" {
|
||||
t.Fatalf("start managed transaction: %#v", started)
|
||||
}
|
||||
|
||||
execStarted := make(chan string, 1)
|
||||
execRelease := make(chan struct{})
|
||||
fakeDB.execStarted = execStarted
|
||||
fakeDB.execRelease = execRelease
|
||||
queryDone := make(chan connection.QueryResult, 1)
|
||||
go func() {
|
||||
queryDone <- app.DBQueryMultiInTransaction(started.TransactionID, followUpStatement, "tx-serialize-follow-up")
|
||||
}()
|
||||
|
||||
select {
|
||||
case statement := <-execStarted:
|
||||
if statement != followUpStatement {
|
||||
close(execRelease)
|
||||
t.Fatalf("blocked statement = %q, want %q", statement, followUpStatement)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
close(execRelease)
|
||||
t.Fatal("follow-up statement did not start")
|
||||
}
|
||||
|
||||
commitDone := make(chan connection.QueryResult, 1)
|
||||
go func() {
|
||||
commitDone <- app.DBCommitTransaction(started.TransactionID)
|
||||
}()
|
||||
select {
|
||||
case result := <-commitDone:
|
||||
close(execRelease)
|
||||
t.Fatalf("commit completed while statement was still in flight: %#v", result)
|
||||
case <-time.After(75 * time.Millisecond):
|
||||
}
|
||||
|
||||
close(execRelease)
|
||||
if result := <-queryDone; !result.Success {
|
||||
t.Fatalf("follow-up statement failed: %#v", result)
|
||||
}
|
||||
if result := <-commitDone; !result.Success {
|
||||
t.Fatalf("commit after follow-up failed: %#v", result)
|
||||
}
|
||||
if fakeDB.txSession.commitCalls != 1 || !fakeDB.txSession.closed {
|
||||
t.Fatalf("transaction was not committed exactly once after execution: commitCalls=%d closed=%v", fakeDB.txSession.commitCalls, fakeDB.txSession.closed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackPendingSQLTransactionsWaitsForInFlightStatement(t *testing.T) {
|
||||
originalNewDatabaseFunc := newDatabaseFunc
|
||||
t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc })
|
||||
|
||||
initialStatement := "UPDATE users SET active = 1 WHERE id = 1"
|
||||
followUpStatement := "UPDATE users SET active = 0 WHERE id = 2"
|
||||
fakeDB := &fakeTransactionalDB{fakeBatchWriteDB: fakeBatchWriteDB{
|
||||
execAffected: map[string]int64{initialStatement: 1, followUpStatement: 1},
|
||||
}}
|
||||
newDatabaseFunc = func(string) (db.Database, error) { return fakeDB, nil }
|
||||
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
|
||||
config := connection.ConnectionConfig{Type: "mysql", Host: "127.0.0.1", Port: 3306, Database: "main"}
|
||||
|
||||
started := app.DBQueryMultiTransactional(config, "main", initialStatement, "tx-shutdown-start")
|
||||
if !started.Success || started.TransactionID == "" {
|
||||
t.Fatalf("start managed transaction: %#v", started)
|
||||
}
|
||||
|
||||
execStarted := make(chan string, 1)
|
||||
execRelease := make(chan struct{})
|
||||
fakeDB.execStarted = execStarted
|
||||
fakeDB.execRelease = execRelease
|
||||
queryDone := make(chan connection.QueryResult, 1)
|
||||
go func() {
|
||||
queryDone <- app.DBQueryMultiInTransaction(started.TransactionID, followUpStatement, "tx-shutdown-follow-up")
|
||||
}()
|
||||
select {
|
||||
case <-execStarted:
|
||||
case <-time.After(2 * time.Second):
|
||||
close(execRelease)
|
||||
t.Fatal("follow-up statement did not start")
|
||||
}
|
||||
|
||||
shutdownDone := make(chan struct{})
|
||||
go func() {
|
||||
app.rollbackPendingSQLTransactionsOnShutdown()
|
||||
close(shutdownDone)
|
||||
}()
|
||||
select {
|
||||
case <-shutdownDone:
|
||||
close(execRelease)
|
||||
t.Fatal("shutdown rollback completed while statement was still in flight")
|
||||
case <-time.After(75 * time.Millisecond):
|
||||
}
|
||||
|
||||
close(execRelease)
|
||||
if result := <-queryDone; !result.Success {
|
||||
t.Fatalf("follow-up statement failed: %#v", result)
|
||||
}
|
||||
select {
|
||||
case <-shutdownDone:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("shutdown rollback did not finish after statement completed")
|
||||
}
|
||||
if fakeDB.txSession.rollbackCalls != 1 || !fakeDB.txSession.closed {
|
||||
t.Fatalf("transaction was not rolled back exactly once after execution: rollbackCalls=%d closed=%v", fakeDB.txSession.rollbackCalls, fakeDB.txSession.closed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBQueryMultiKeepsOracleAnonymousBlockAsSingleStatement(t *testing.T) {
|
||||
originalNewDatabaseFunc := newDatabaseFunc
|
||||
t.Cleanup(func() {
|
||||
|
||||
@@ -9,11 +9,48 @@ import (
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/db"
|
||||
"GoNavi-Wails/internal/logger"
|
||||
"GoNavi-Wails/internal/sqlaudit"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const sqlEditorTransactionFinishTimeout = 30 * time.Second
|
||||
|
||||
type managedSQLStatementObservation struct {
|
||||
Statement string
|
||||
StatementIndex int
|
||||
StatementCount int
|
||||
StartedAt time.Time
|
||||
CompletedAt time.Time
|
||||
Duration time.Duration
|
||||
RowsAffected int64
|
||||
RowsReturned int64
|
||||
Err error
|
||||
}
|
||||
|
||||
type managedSQLStatementObserver func(managedSQLStatementObservation)
|
||||
|
||||
func withManagedSQLStatementAuditTimestamp(
|
||||
observer managedSQLStatementObserver,
|
||||
events *[]sqlaudit.Event,
|
||||
) managedSQLStatementObserver {
|
||||
if observer == nil {
|
||||
return nil
|
||||
}
|
||||
return func(observation managedSQLStatementObservation) {
|
||||
before := 0
|
||||
if events != nil {
|
||||
before = len(*events)
|
||||
}
|
||||
observer(observation)
|
||||
if events == nil || observation.CompletedAt.IsZero() {
|
||||
return
|
||||
}
|
||||
for index := before; index < len(*events); index++ {
|
||||
(*events)[index].Timestamp = observation.CompletedAt.UnixMilli()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DBQueryMultiTransactional executes SQL editor DML in a managed transaction.
|
||||
// The transaction stays open until DBCommitTransaction or DBRollbackTransaction
|
||||
// is called by the SQL editor UI.
|
||||
@@ -45,12 +82,35 @@ func (a *App) DBQueryMultiTransactional(config connection.ConnectionConfig, dbNa
|
||||
}
|
||||
|
||||
query = sanitizeSQLForPgLike(transactionDBType, query)
|
||||
if err := ensureConnectionAllowsQuery(config, query); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error(), QueryID: queryID}
|
||||
}
|
||||
if !shouldUseManagedSQLTransaction(transactionDBType, query) {
|
||||
return a.DBQueryMulti(config, dbName, query, queryID)
|
||||
}
|
||||
transactionID := "sql-editor-" + uuid.NewString()
|
||||
transactionAuditOpened := false
|
||||
transactionBoundaryMode := "unknown"
|
||||
defer func() {
|
||||
if transactionAuditOpened {
|
||||
return
|
||||
}
|
||||
a.recordSQLAuditTransactionEvent(sqlAuditTransactionEventInput{
|
||||
Config: transactionConfig,
|
||||
Database: dbName,
|
||||
DBType: transactionDBType,
|
||||
QueryID: queryID,
|
||||
TransactionID: transactionID,
|
||||
EventType: "transaction_begin",
|
||||
Status: sqlAuditStatusFromResult(result),
|
||||
Source: "query_editor",
|
||||
CommitMode: "pending",
|
||||
BoundaryMode: transactionBoundaryMode,
|
||||
SQL: query,
|
||||
StatementCount: countSQLAuditStatements(query),
|
||||
Err: sqlAuditErrorFromResult(result),
|
||||
})
|
||||
}()
|
||||
if err := ensureConnectionAllowsQuery(config, query); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error(), QueryID: queryID}
|
||||
}
|
||||
var queryExecutionDuration time.Duration
|
||||
defer func() {
|
||||
if !result.Success {
|
||||
@@ -97,6 +157,7 @@ func (a *App) DBQueryMultiTransactional(config connection.ConnectionConfig, dbNa
|
||||
startTextTransaction bool
|
||||
)
|
||||
if provider, ok := dbInst.(db.TransactionExecerProvider); ok {
|
||||
transactionBoundaryMode = "driver_api"
|
||||
// database/sql rolls back a BeginTx transaction when its context is cancelled.
|
||||
// SQL editor transactions must outlive the execution RPC and be ended only by
|
||||
// explicit commit, rollback, or shutdown cleanup.
|
||||
@@ -111,6 +172,7 @@ func (a *App) DBQueryMultiTransactional(config connection.ConnectionConfig, dbNa
|
||||
sessionExecer = transactionExecer
|
||||
transactor = transactionExecer
|
||||
} else if implicitTextTransaction {
|
||||
transactionBoundaryMode = "implicit"
|
||||
provider, ok := dbInst.(db.SessionExecerProvider)
|
||||
if !ok {
|
||||
return connection.QueryResult{
|
||||
@@ -125,6 +187,7 @@ func (a *App) DBQueryMultiTransactional(config connection.ConnectionConfig, dbNa
|
||||
return connection.QueryResult{Success: false, Message: err.Error(), QueryID: queryID}
|
||||
}
|
||||
} else {
|
||||
transactionBoundaryMode = "text_sql"
|
||||
if !hasTextTransaction {
|
||||
return connection.QueryResult{
|
||||
Success: false,
|
||||
@@ -166,12 +229,49 @@ func (a *App) DBQueryMultiTransactional(config connection.ConnectionConfig, dbNa
|
||||
return connection.QueryResult{Success: false, Message: err.Error(), QueryID: queryID}
|
||||
}
|
||||
}
|
||||
transactionAuditOpened = true
|
||||
a.recordSQLAuditTransactionEvent(sqlAuditTransactionEventInput{
|
||||
Config: transactionConfig,
|
||||
Database: dbName,
|
||||
DBType: transactionDBType,
|
||||
QueryID: queryID,
|
||||
TransactionID: transactionID,
|
||||
EventType: "transaction_begin",
|
||||
Status: "success",
|
||||
Source: "query_editor",
|
||||
CommitMode: "pending",
|
||||
BoundaryMode: transactionBoundaryMode,
|
||||
})
|
||||
|
||||
statements := splitSQLStatements(query)
|
||||
queryStartedAt := time.Now()
|
||||
resultSets, err := executeManagedSQLTransactionStatements(ctx, sessionExecer, transactionConfig, statements, a.appText)
|
||||
statementAuditEvents := make([]sqlaudit.Event, 0, len(statements))
|
||||
resultSets, err := executeManagedSQLTransactionStatementsWithObserver(
|
||||
ctx,
|
||||
sessionExecer,
|
||||
transactionConfig,
|
||||
statements,
|
||||
a.appText,
|
||||
withManagedSQLStatementAuditTimestamp(
|
||||
a.sqlAuditTransactionStatementObserver(transactionConfig, dbName, transactionDBType, queryID, transactionID, transactionBoundaryMode, &statementAuditEvents),
|
||||
&statementAuditEvents,
|
||||
),
|
||||
)
|
||||
queryExecutionDuration += time.Since(queryStartedAt)
|
||||
a.appendSQLAuditEvents(statementAuditEvents)
|
||||
if err != nil {
|
||||
a.recordSQLAuditTransactionEvent(sqlAuditTransactionEventInput{
|
||||
Config: transactionConfig,
|
||||
Database: dbName,
|
||||
DBType: transactionDBType,
|
||||
QueryID: queryID,
|
||||
TransactionID: transactionID,
|
||||
EventType: "transaction_rollback_requested",
|
||||
Status: "success",
|
||||
Source: "query_editor",
|
||||
CommitMode: "auto",
|
||||
BoundaryMode: transactionBoundaryMode,
|
||||
})
|
||||
var rollbackErr error
|
||||
if transactor != nil {
|
||||
rollbackErr = transactor.Rollback()
|
||||
@@ -182,25 +282,38 @@ func (a *App) DBQueryMultiTransactional(config connection.ConnectionConfig, dbNa
|
||||
logger.Error(rollbackErr, "DBQueryMultiTransactional 执行失败后回滚失败:%s SQL片段=%q", formatConnSummary(runConfig), sqlSnippet(query))
|
||||
err = appendRollbackFailureMessage(err, rollbackErr)
|
||||
}
|
||||
a.recordSQLAuditTransactionEvent(sqlAuditTransactionEventInput{
|
||||
Config: transactionConfig,
|
||||
Database: dbName,
|
||||
DBType: transactionDBType,
|
||||
QueryID: queryID,
|
||||
TransactionID: transactionID,
|
||||
EventType: "transaction_auto_rollback",
|
||||
Status: sqlAuditStatusFromError(rollbackErr),
|
||||
Source: "query_editor",
|
||||
CommitMode: "auto",
|
||||
BoundaryMode: transactionBoundaryMode,
|
||||
Err: rollbackErr,
|
||||
})
|
||||
logger.Error(err, "DBQueryMultiTransactional 执行失败:%s SQL片段=%q", formatConnSummary(runConfig), sqlSnippet(query))
|
||||
return connection.QueryResult{Success: false, Message: err.Error(), QueryID: queryID}
|
||||
}
|
||||
|
||||
transactionID := "sql-editor-" + uuid.NewString()
|
||||
a.sqlTransactionMu.Lock()
|
||||
if a.sqlTransactions == nil {
|
||||
a.sqlTransactions = make(map[string]*managedSQLTransaction)
|
||||
}
|
||||
a.sqlTransactions[transactionID] = &managedSQLTransaction{
|
||||
id: transactionID,
|
||||
execer: sessionExecer,
|
||||
transactor: transactor,
|
||||
cancel: transactionCancel,
|
||||
config: runConfig,
|
||||
dbType: transactionDBType,
|
||||
commitSQL: commitSQL,
|
||||
rollbackSQL: rollbackSQL,
|
||||
createdAt: time.Now(),
|
||||
id: transactionID,
|
||||
execer: sessionExecer,
|
||||
transactor: transactor,
|
||||
cancel: transactionCancel,
|
||||
config: runConfig,
|
||||
dbType: transactionDBType,
|
||||
boundaryMode: transactionBoundaryMode,
|
||||
commitSQL: commitSQL,
|
||||
rollbackSQL: rollbackSQL,
|
||||
createdAt: time.Now(),
|
||||
}
|
||||
a.sqlTransactionMu.Unlock()
|
||||
|
||||
@@ -231,6 +344,11 @@ func (a *App) DBQueryMultiInTransaction(transactionID string, query string, quer
|
||||
if !ok || tx == nil || tx.execer == nil {
|
||||
return connection.QueryResult{Success: false, Message: a.appText("db.backend.error.transaction_not_found", nil), QueryID: queryID}
|
||||
}
|
||||
tx.mu.Lock()
|
||||
defer tx.mu.Unlock()
|
||||
if tx.finished || tx.execer == nil {
|
||||
return connection.QueryResult{Success: false, Message: a.appText("db.backend.error.transaction_not_found", nil), QueryID: queryID}
|
||||
}
|
||||
|
||||
runConfig := tx.config
|
||||
if strings.TrimSpace(runConfig.Type) == "" {
|
||||
@@ -263,8 +381,20 @@ func (a *App) DBQueryMultiInTransaction(transactionID string, query string, quer
|
||||
}()
|
||||
|
||||
queryStartedAt := time.Now()
|
||||
resultSets, err := executeManagedSQLTransactionStatements(ctx, tx.execer, runConfig, statements, a.appText)
|
||||
statementAuditEvents := make([]sqlaudit.Event, 0, len(statements))
|
||||
resultSets, err := executeManagedSQLTransactionStatementsWithObserver(
|
||||
ctx,
|
||||
tx.execer,
|
||||
runConfig,
|
||||
statements,
|
||||
a.appText,
|
||||
withManagedSQLStatementAuditTimestamp(
|
||||
a.sqlAuditTransactionStatementObserver(runConfig, runConfig.Database, tx.dbType, queryID, transactionID, tx.boundaryMode, &statementAuditEvents),
|
||||
&statementAuditEvents,
|
||||
),
|
||||
)
|
||||
queryExecutionDuration += time.Since(queryStartedAt)
|
||||
a.appendSQLAuditEvents(statementAuditEvents)
|
||||
if err != nil {
|
||||
logger.Error(err, "DBQueryMultiInTransaction 执行失败:id=%s dbType=%s SQL片段=%q", transactionID, tx.dbType, sqlSnippet(query))
|
||||
return connection.QueryResult{
|
||||
@@ -286,6 +416,17 @@ func (a *App) DBQueryMultiInTransaction(transactionID string, query string, quer
|
||||
}
|
||||
|
||||
func executeManagedSQLTransactionStatements(ctx context.Context, session db.StatementExecer, runConfig connection.ConnectionConfig, statements []string, text func(string, map[string]any) string) ([]connection.ResultSetData, error) {
|
||||
return executeManagedSQLTransactionStatementsWithObserver(ctx, session, runConfig, statements, text, nil)
|
||||
}
|
||||
|
||||
func executeManagedSQLTransactionStatementsWithObserver(
|
||||
ctx context.Context,
|
||||
session db.StatementExecer,
|
||||
runConfig connection.ConnectionConfig,
|
||||
statements []string,
|
||||
text func(string, map[string]any) string,
|
||||
observer managedSQLStatementObserver,
|
||||
) ([]connection.ResultSetData, error) {
|
||||
if text == nil {
|
||||
text = defaultDBBackendText
|
||||
}
|
||||
@@ -306,11 +447,37 @@ func executeManagedSQLTransactionStatements(ctx context.Context, session db.Stat
|
||||
sessionMultiQueryTarget, _ := session.(db.StatementMultiResultQueryExecer)
|
||||
sessionMultiQueryMessageTarget, _ := session.(db.StatementMultiResultQueryMessageExecer)
|
||||
|
||||
for idx, stmt := range statements {
|
||||
statementCount := 0
|
||||
for _, statement := range statements {
|
||||
if strings.TrimSpace(statement) != "" {
|
||||
statementCount++
|
||||
}
|
||||
}
|
||||
statementIndex := 0
|
||||
for _, stmt := range statements {
|
||||
stmt = strings.TrimSpace(stmt)
|
||||
if stmt == "" {
|
||||
continue
|
||||
}
|
||||
statementIndex++
|
||||
statementStartedAt := time.Now()
|
||||
emitObservation := func(rowsAffected, rowsReturned int64, err error) {
|
||||
if observer == nil {
|
||||
return
|
||||
}
|
||||
completedAt := time.Now()
|
||||
observer(managedSQLStatementObservation{
|
||||
Statement: stmt,
|
||||
StatementIndex: statementIndex,
|
||||
StatementCount: statementCount,
|
||||
StartedAt: statementStartedAt,
|
||||
CompletedAt: completedAt,
|
||||
Duration: completedAt.Sub(statementStartedAt),
|
||||
RowsAffected: rowsAffected,
|
||||
RowsReturned: rowsReturned,
|
||||
Err: err,
|
||||
})
|
||||
}
|
||||
|
||||
isReadStmt := isReadOnlySQLQuery(runConfig.Type, stmt)
|
||||
tryQueryStmtFirst := shouldTryQueryResultFirst(runConfig.Type, stmt)
|
||||
@@ -346,6 +513,7 @@ func executeManagedSQLTransactionStatements(ctx context.Context, session db.Stat
|
||||
}
|
||||
if err == nil {
|
||||
if usedMultiResult {
|
||||
var rowsAffected, rowsReturned int64
|
||||
if len(statementResults) == 0 && len(messages) > 0 {
|
||||
statementResults = []connection.ResultSetData{{
|
||||
Rows: []map[string]interface{}{},
|
||||
@@ -360,9 +528,13 @@ func executeManagedSQLTransactionStatements(ctx context.Context, session db.Stat
|
||||
if statementResult.Columns == nil {
|
||||
statementResult.Columns = []string{}
|
||||
}
|
||||
statementResult.StatementIndex = idx + 1
|
||||
statementResult.StatementIndex = statementIndex
|
||||
affected, returned := summarizeManagedSQLResultSet(statementResult)
|
||||
rowsAffected += affected
|
||||
rowsReturned += returned
|
||||
resultSets = append(resultSets, statementResult)
|
||||
}
|
||||
emitObservation(rowsAffected, rowsReturned, nil)
|
||||
continue
|
||||
}
|
||||
if data == nil {
|
||||
@@ -375,24 +547,30 @@ func executeManagedSQLTransactionStatements(ctx context.Context, session db.Stat
|
||||
Rows: data,
|
||||
Columns: columns,
|
||||
Messages: messages,
|
||||
StatementIndex: idx + 1,
|
||||
StatementIndex: statementIndex,
|
||||
})
|
||||
emitObservation(0, int64(len(data)), nil)
|
||||
continue
|
||||
}
|
||||
if isReadStmt {
|
||||
return nil, buildStatementExecutionFailedError(idx+1, err)
|
||||
statementErr := buildStatementExecutionFailedError(statementIndex, err)
|
||||
emitObservation(0, 0, statementErr)
|
||||
return nil, statementErr
|
||||
}
|
||||
}
|
||||
|
||||
affected, err := session.ExecContext(ctx, stmt)
|
||||
if err != nil {
|
||||
return nil, buildStatementExecutionFailedError(idx+1, err)
|
||||
statementErr := buildStatementExecutionFailedError(statementIndex, err)
|
||||
emitObservation(0, 0, statementErr)
|
||||
return nil, statementErr
|
||||
}
|
||||
resultSets = append(resultSets, connection.ResultSetData{
|
||||
Rows: []map[string]interface{}{{"affectedRows": affected}},
|
||||
Columns: []string{"affectedRows"},
|
||||
StatementIndex: idx + 1,
|
||||
StatementIndex: statementIndex,
|
||||
})
|
||||
emitObservation(affected, 0, nil)
|
||||
}
|
||||
|
||||
if resultSets == nil {
|
||||
@@ -401,6 +579,46 @@ func executeManagedSQLTransactionStatements(ctx context.Context, session db.Stat
|
||||
return resultSets, nil
|
||||
}
|
||||
|
||||
func summarizeManagedSQLResultSet(resultSet connection.ResultSetData) (rowsAffected, rowsReturned int64) {
|
||||
if !isAffectedRowsResultSet(resultSet) {
|
||||
return 0, int64(len(resultSet.Rows))
|
||||
}
|
||||
for _, row := range resultSet.Rows {
|
||||
value, ok := row["affectedRows"]
|
||||
if !ok {
|
||||
for key, candidate := range row {
|
||||
if strings.EqualFold(strings.TrimSpace(key), "affectedRows") {
|
||||
value = candidate
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
rowsAffected += int64(typed)
|
||||
case int32:
|
||||
rowsAffected += int64(typed)
|
||||
case int64:
|
||||
rowsAffected += typed
|
||||
case uint:
|
||||
rowsAffected += int64(typed)
|
||||
case uint32:
|
||||
rowsAffected += int64(typed)
|
||||
case uint64:
|
||||
if typed <= uint64(^uint64(0)>>1) {
|
||||
rowsAffected += int64(typed)
|
||||
}
|
||||
case float64:
|
||||
rowsAffected += int64(typed)
|
||||
}
|
||||
}
|
||||
return rowsAffected, 0
|
||||
}
|
||||
|
||||
func shouldUseManagedSQLTransaction(dbType string, query string) bool {
|
||||
if isManagedSQLTransactionUnsupportedType(dbType) {
|
||||
return false
|
||||
@@ -500,15 +718,24 @@ func isOracleLikeAnonymousBlockManagedWrite(dbType string, stmt string) bool {
|
||||
}
|
||||
|
||||
func (a *App) DBCommitTransaction(transactionID string) connection.QueryResult {
|
||||
return a.finishManagedSQLTransaction(transactionID, true)
|
||||
return a.finishManagedSQLTransaction(transactionID, true, "manual")
|
||||
}
|
||||
|
||||
func (a *App) DBRollbackTransaction(transactionID string) connection.QueryResult {
|
||||
return a.finishManagedSQLTransaction(transactionID, false)
|
||||
return a.finishManagedSQLTransaction(transactionID, false, "manual")
|
||||
}
|
||||
|
||||
func (a *App) finishManagedSQLTransaction(transactionID string, commit bool) connection.QueryResult {
|
||||
func (a *App) DBCommitTransactionWithTrigger(transactionID string, trigger string) connection.QueryResult {
|
||||
return a.finishManagedSQLTransaction(transactionID, true, trigger)
|
||||
}
|
||||
|
||||
func (a *App) DBRollbackTransactionWithTrigger(transactionID string, trigger string) connection.QueryResult {
|
||||
return a.finishManagedSQLTransaction(transactionID, false, trigger)
|
||||
}
|
||||
|
||||
func (a *App) finishManagedSQLTransaction(transactionID string, commit bool, trigger string) connection.QueryResult {
|
||||
transactionID = strings.TrimSpace(transactionID)
|
||||
trigger = normalizeSQLTransactionFinishTrigger(trigger)
|
||||
if transactionID == "" {
|
||||
return connection.QueryResult{Success: false, Message: a.appText("db.backend.error.transaction_id_required", nil)}
|
||||
}
|
||||
@@ -522,19 +749,53 @@ func (a *App) finishManagedSQLTransaction(transactionID string, commit bool) con
|
||||
if !ok || tx == nil || tx.execer == nil {
|
||||
return connection.QueryResult{Success: false, Message: a.appText("db.backend.error.transaction_not_found", nil)}
|
||||
}
|
||||
tx.mu.Lock()
|
||||
defer tx.mu.Unlock()
|
||||
if tx.finished || tx.execer == nil {
|
||||
return connection.QueryResult{Success: false, Message: a.appText("db.backend.error.transaction_not_found", nil)}
|
||||
}
|
||||
tx.finished = true
|
||||
if tx.cancel != nil {
|
||||
defer tx.cancel()
|
||||
}
|
||||
|
||||
actionCode := "rollback"
|
||||
sqlText := tx.rollbackSQL
|
||||
eventType := "transaction_rollback"
|
||||
if commit {
|
||||
actionCode = "commit"
|
||||
sqlText = tx.commitSQL
|
||||
eventType = "transaction_commit"
|
||||
} else if trigger == "tab_close" || trigger == "auto" {
|
||||
eventType = "transaction_auto_rollback"
|
||||
}
|
||||
auditSource := "query_editor"
|
||||
if trigger == "tab_close" {
|
||||
auditSource = "tab_close"
|
||||
}
|
||||
commitMode := "manual"
|
||||
if trigger == "auto" {
|
||||
commitMode = "auto"
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), sqlEditorTransactionFinishTimeout)
|
||||
defer cancel()
|
||||
requestedEventType := "transaction_rollback_requested"
|
||||
if commit {
|
||||
requestedEventType = "transaction_commit_requested"
|
||||
}
|
||||
a.recordSQLAuditTransactionEvent(sqlAuditTransactionEventInput{
|
||||
Config: tx.config,
|
||||
Database: tx.config.Database,
|
||||
DBType: tx.dbType,
|
||||
TransactionID: transactionID,
|
||||
EventType: requestedEventType,
|
||||
Status: "success",
|
||||
Source: auditSource,
|
||||
CommitMode: commitMode,
|
||||
BoundaryMode: tx.boundaryMode,
|
||||
})
|
||||
startedAt := time.Now()
|
||||
|
||||
var execErr error
|
||||
if tx.transactor != nil {
|
||||
@@ -548,6 +809,19 @@ func (a *App) finishManagedSQLTransaction(transactionID string, commit bool) con
|
||||
}
|
||||
closeErr := tx.execer.Close()
|
||||
if execErr != nil {
|
||||
a.recordSQLAuditTransactionEvent(sqlAuditTransactionEventInput{
|
||||
Config: tx.config,
|
||||
Database: tx.config.Database,
|
||||
DBType: tx.dbType,
|
||||
TransactionID: transactionID,
|
||||
EventType: eventType,
|
||||
Status: "error",
|
||||
Source: auditSource,
|
||||
CommitMode: commitMode,
|
||||
BoundaryMode: tx.boundaryMode,
|
||||
Duration: time.Since(startedAt),
|
||||
Err: execErr,
|
||||
})
|
||||
logger.Error(execErr, "SQL 编辑器事务%s失败:id=%s dbType=%s", actionCode, transactionID, tx.dbType)
|
||||
key := "db.backend.error.transaction_rollback_failed"
|
||||
if commit {
|
||||
@@ -556,6 +830,21 @@ func (a *App) finishManagedSQLTransaction(transactionID string, commit bool) con
|
||||
return connection.QueryResult{Success: false, Message: a.appText(key, map[string]any{"detail": execErr.Error()})}
|
||||
}
|
||||
if closeErr != nil {
|
||||
// Commit/Rollback has already succeeded at the database boundary. Record that
|
||||
// outcome as success while retaining the local session cleanup error.
|
||||
a.recordSQLAuditTransactionEvent(sqlAuditTransactionEventInput{
|
||||
Config: tx.config,
|
||||
Database: tx.config.Database,
|
||||
DBType: tx.dbType,
|
||||
TransactionID: transactionID,
|
||||
EventType: eventType,
|
||||
Status: "success",
|
||||
Source: auditSource,
|
||||
CommitMode: commitMode,
|
||||
BoundaryMode: tx.boundaryMode,
|
||||
Duration: time.Since(startedAt),
|
||||
Err: closeErr,
|
||||
})
|
||||
logger.Error(closeErr, "SQL 编辑器事务%s后关闭会话失败:id=%s dbType=%s", actionCode, transactionID, tx.dbType)
|
||||
key := "db.backend.error.transaction_rollback_close_failed"
|
||||
if commit {
|
||||
@@ -563,6 +852,18 @@ func (a *App) finishManagedSQLTransaction(transactionID string, commit bool) con
|
||||
}
|
||||
return connection.QueryResult{Success: false, Message: a.appText(key, map[string]any{"detail": closeErr.Error()})}
|
||||
}
|
||||
a.recordSQLAuditTransactionEvent(sqlAuditTransactionEventInput{
|
||||
Config: tx.config,
|
||||
Database: tx.config.Database,
|
||||
DBType: tx.dbType,
|
||||
TransactionID: transactionID,
|
||||
EventType: eventType,
|
||||
Status: "success",
|
||||
Source: auditSource,
|
||||
CommitMode: commitMode,
|
||||
BoundaryMode: tx.boundaryMode,
|
||||
Duration: time.Since(startedAt),
|
||||
})
|
||||
|
||||
if commit {
|
||||
return connection.QueryResult{Success: true, Message: a.appText("db.backend.message.transaction_committed", nil)}
|
||||
@@ -570,6 +871,15 @@ func (a *App) finishManagedSQLTransaction(transactionID string, commit bool) con
|
||||
return connection.QueryResult{Success: true, Message: a.appText("db.backend.message.transaction_rolled_back", nil)}
|
||||
}
|
||||
|
||||
func normalizeSQLTransactionFinishTrigger(trigger string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(trigger)) {
|
||||
case "auto", "tab_close":
|
||||
return strings.ToLower(strings.TrimSpace(trigger))
|
||||
default:
|
||||
return "manual"
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) rollbackPendingSQLTransactionsOnShutdown() {
|
||||
a.sqlTransactionMu.Lock()
|
||||
pending := make([]*managedSQLTransaction, 0, len(a.sqlTransactions))
|
||||
@@ -582,13 +892,34 @@ func (a *App) rollbackPendingSQLTransactionsOnShutdown() {
|
||||
a.sqlTransactionMu.Unlock()
|
||||
|
||||
for _, tx := range pending {
|
||||
tx.mu.Lock()
|
||||
if tx.finished {
|
||||
tx.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
tx.finished = true
|
||||
ctx, cancel := context.WithTimeout(context.Background(), sqlEditorTransactionFinishTimeout)
|
||||
a.recordSQLAuditTransactionEvent(sqlAuditTransactionEventInput{
|
||||
Config: tx.config,
|
||||
Database: tx.config.Database,
|
||||
DBType: tx.dbType,
|
||||
TransactionID: tx.id,
|
||||
EventType: "transaction_rollback_requested",
|
||||
Status: "success",
|
||||
Source: "app_shutdown",
|
||||
CommitMode: "auto",
|
||||
BoundaryMode: tx.boundaryMode,
|
||||
})
|
||||
startedAt := time.Now()
|
||||
var rollbackErr error
|
||||
if tx.transactor != nil {
|
||||
if err := tx.transactor.Rollback(); err != nil {
|
||||
rollbackErr = err
|
||||
logger.Warnf("关闭应用时回滚 SQL 编辑器事务失败:id=%s dbType=%s err=%v", tx.id, tx.dbType, err)
|
||||
}
|
||||
} else if strings.TrimSpace(tx.rollbackSQL) != "" && tx.execer != nil {
|
||||
if _, err := tx.execer.ExecContext(ctx, tx.rollbackSQL); err != nil {
|
||||
rollbackErr = err
|
||||
logger.Warnf("关闭应用时回滚 SQL 编辑器事务失败:id=%s dbType=%s err=%v", tx.id, tx.dbType, err)
|
||||
}
|
||||
}
|
||||
@@ -596,10 +927,32 @@ func (a *App) rollbackPendingSQLTransactionsOnShutdown() {
|
||||
if tx.cancel != nil {
|
||||
tx.cancel()
|
||||
}
|
||||
var closeErr error
|
||||
if tx.execer != nil {
|
||||
if err := tx.execer.Close(); err != nil {
|
||||
closeErr = err
|
||||
logger.Warnf("关闭应用时关闭 SQL 编辑器事务会话失败:id=%s dbType=%s err=%v", tx.id, tx.dbType, err)
|
||||
}
|
||||
}
|
||||
auditErr := rollbackErr
|
||||
status := sqlAuditStatusFromError(rollbackErr)
|
||||
if auditErr == nil && closeErr != nil {
|
||||
// The database rollback succeeded; retain only the cleanup warning.
|
||||
auditErr = closeErr
|
||||
}
|
||||
a.recordSQLAuditTransactionEvent(sqlAuditTransactionEventInput{
|
||||
Config: tx.config,
|
||||
Database: tx.config.Database,
|
||||
DBType: tx.dbType,
|
||||
TransactionID: tx.id,
|
||||
EventType: "transaction_auto_rollback",
|
||||
Status: status,
|
||||
Source: "app_shutdown",
|
||||
CommitMode: "auto",
|
||||
BoundaryMode: tx.boundaryMode,
|
||||
Duration: time.Since(startedAt),
|
||||
Err: auditErr,
|
||||
})
|
||||
tx.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ package app
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/csv"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -1548,7 +1550,14 @@ func executeSQLFileStream(ctx context.Context, dbInst db.Database, reader io.Rea
|
||||
|
||||
// ExecuteSQLFile 在后端流式读取并执行大 SQL 文件,通过事件推送进度。
|
||||
// 前端通过 EventsOn("sqlfile:progress", ...) 监听进度。
|
||||
func (a *App) ExecuteSQLFile(config connection.ConnectionConfig, dbName string, filePath string, jobID string) connection.QueryResult {
|
||||
func (a *App) ExecuteSQLFile(config connection.ConnectionConfig, dbName string, filePath string, jobID string) (result connection.QueryResult) {
|
||||
auditSQL := "EXECUTE SQL FILE"
|
||||
auditStatementCount := 0
|
||||
auditSafeError := "SQL file task failed before an execution summary was available"
|
||||
defer a.beginSQLAuditUserActionWithOptions(config, dbName, "sql_file", &auditSQL, &result, sqlAuditUserActionOptions{
|
||||
StatementCount: &auditStatementCount,
|
||||
SafeError: &auditSafeError,
|
||||
})()
|
||||
if strings.TrimSpace(filePath) == "" {
|
||||
return connection.QueryResult{Success: false, Message: a.appText("file.backend.error.file_path_empty", nil)}
|
||||
}
|
||||
@@ -1574,8 +1583,12 @@ func (a *App) ExecuteSQLFile(config connection.ConnectionConfig, dbName string,
|
||||
defer f.Close()
|
||||
|
||||
// 获取文件大小用于计算进度
|
||||
fi, _ := f.Stat()
|
||||
totalSize := fi.Size()
|
||||
var totalSize int64
|
||||
totalSizeKnown := false
|
||||
if fi, statErr := f.Stat(); statErr == nil {
|
||||
totalSize = fi.Size()
|
||||
totalSizeKnown = true
|
||||
}
|
||||
|
||||
// 设置取消上下文
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
@@ -1619,7 +1632,8 @@ func (a *App) ExecuteSQLFile(config connection.ConnectionConfig, dbName string,
|
||||
emitProgress("running", 0, 0, 0, 0, "", "")
|
||||
|
||||
// 使用 countingReader 追踪已读取字节数
|
||||
cr := &countingReader{r: f}
|
||||
fileDigest := sha256.New()
|
||||
cr := &countingReader{r: io.TeeReader(f, fileDigest)}
|
||||
|
||||
startTime := time.Now()
|
||||
execResult, streamErr := executeSQLFileStream(ctx, dbInst, cr, sqlFileExecutionOptions{
|
||||
@@ -1644,6 +1658,12 @@ func (a *App) ExecuteSQLFile(config connection.ConnectionConfig, dbName string,
|
||||
executedCount := execResult.Executed
|
||||
failedCount := execResult.Failed
|
||||
errorLogs := execResult.Errors
|
||||
auditStatementCount = executedCount + failedCount
|
||||
auditSQL = fmt.Sprintf("EXECUTE SQL FILE EXECUTED_%d FAILED_%d", executedCount, failedCount)
|
||||
auditSafeError = fmt.Sprintf("SQL file task failed after executing %d statement(s); %d statement(s) failed", executedCount, failedCount)
|
||||
if totalSizeKnown && cr.n == totalSize {
|
||||
auditSQL += " SHA256_" + hex.EncodeToString(fileDigest.Sum(nil))
|
||||
}
|
||||
|
||||
if streamErr != nil && streamErr.Error() == "已取消" {
|
||||
emitProgress("cancelled", executedCount, failedCount, executedCount+failedCount, cr.n, "", a.appText("file.backend.message.user_cancelled", nil))
|
||||
@@ -2364,7 +2384,21 @@ func formatImportSQLValue(dbType, columnType string, value interface{}) string {
|
||||
}
|
||||
|
||||
// ImportDataWithProgress 执行导入并发送进度事件
|
||||
func (a *App) ImportDataWithProgress(config connection.ConnectionConfig, dbName, tableName, filePath string) connection.QueryResult {
|
||||
func (a *App) ImportDataWithProgress(config connection.ConnectionConfig, dbName, tableName, filePath string) (result connection.QueryResult) {
|
||||
dbType := resolveDDLDBType(config)
|
||||
schemaName, pureTableName := normalizeSchemaAndTable(config, dbName, tableName)
|
||||
auditTarget := strings.TrimSpace(tableName)
|
||||
if pureTableName != "" {
|
||||
auditTarget = quoteTableIdentByType(dbType, schemaName, pureTableName)
|
||||
}
|
||||
if auditTarget == "" {
|
||||
auditTarget = "TARGET_TABLE"
|
||||
}
|
||||
auditSQL := "IMPORT DATA INTO " + auditTarget
|
||||
auditSafeError := "data import task failed"
|
||||
defer a.beginSQLAuditUserActionWithOptions(config, dbName, "data_import", &auditSQL, &result, sqlAuditUserActionOptions{
|
||||
SafeError: &auditSafeError,
|
||||
})()
|
||||
if err := ensureConnectionAllowsDataImport(config, "connection.backend.action.import_data"); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
@@ -2374,8 +2408,6 @@ func (a *App) ImportDataWithProgress(config connection.ConnectionConfig, dbName,
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
dbType := resolveDDLDBType(config)
|
||||
schemaName, pureTableName := normalizeSchemaAndTable(config, dbName, tableName)
|
||||
columnTypeMap := map[string]string{}
|
||||
if defs, colErr := dbInst.GetColumns(schemaName, pureTableName); colErr == nil {
|
||||
columnTypeMap = buildImportColumnTypeMap(defs)
|
||||
@@ -2406,19 +2438,22 @@ func (a *App) ImportDataWithProgress(config connection.ConnectionConfig, dbName,
|
||||
"imported": resultData.Success,
|
||||
"failed": resultData.Failed,
|
||||
})
|
||||
result := map[string]interface{}{
|
||||
resultPayload := map[string]interface{}{
|
||||
"success": resultData.Success,
|
||||
"failed": resultData.Failed,
|
||||
"total": resultData.Total,
|
||||
"affectedRows": int64(resultData.Success),
|
||||
"errorLogs": resultData.ErrorLogs,
|
||||
"errorSummary": summary,
|
||||
}
|
||||
|
||||
maybeReleaseFileTransferMemory("import-finished", int64(resultData.Total), filePath)
|
||||
return connection.QueryResult{Success: true, Data: result, Message: summary}
|
||||
return connection.QueryResult{Success: true, Data: resultPayload, Message: summary}
|
||||
}
|
||||
|
||||
func (a *App) ApplyChanges(config connection.ConnectionConfig, dbName, tableName string, changes connection.ChangeSet) connection.QueryResult {
|
||||
func (a *App) ApplyChanges(config connection.ConnectionConfig, dbName, tableName string, changes connection.ChangeSet) (result connection.QueryResult) {
|
||||
auditSQL := fmt.Sprintf("APPLY CHANGES TO %s", strings.TrimSpace(tableName))
|
||||
defer a.beginSQLAuditUserAction(config, dbName, "data_editor", &auditSQL, &result)()
|
||||
if err := ensureConnectionAllowsDataEdit(config, "connection.backend.action.apply_result_changes"); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
@@ -2983,7 +3018,13 @@ func tableDataClearMessageKeys(mode tableDataClearMode, partial bool) (failureKe
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) runTableDataClear(config connection.ConnectionConfig, dbName string, tableNames []string, mode tableDataClearMode) connection.QueryResult {
|
||||
func (a *App) runTableDataClear(config connection.ConnectionConfig, dbName string, tableNames []string, mode tableDataClearMode) (result connection.QueryResult) {
|
||||
auditAction := "DELETE TABLE DATA"
|
||||
if mode == tableDataClearModeTruncate {
|
||||
auditAction = "TRUNCATE TABLE DATA"
|
||||
}
|
||||
auditSQL := auditAction + " " + strings.Join(tableNames, ", ")
|
||||
defer a.beginSQLAuditUserAction(config, dbName, "object_editor", &auditSQL, &result)()
|
||||
actionLabel, progressLabel := tableDataClearActionLabels(mode)
|
||||
if err := ensureConnectionAllowsDataEdit(config, actionLabel); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
|
||||
1161
internal/app/methods_sql_audit.go
Normal file
1161
internal/app/methods_sql_audit.go
Normal file
File diff suppressed because it is too large
Load Diff
678
internal/app/methods_sql_audit_test.go
Normal file
678
internal/app/methods_sql_audit_test.go
Normal file
@@ -0,0 +1,678 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/db"
|
||||
"GoNavi-Wails/internal/secretstore"
|
||||
"GoNavi-Wails/internal/sqlaudit"
|
||||
)
|
||||
|
||||
type sqlAuditTestDatabase struct {
|
||||
rows []map[string]interface{}
|
||||
columns []string
|
||||
queryErr error
|
||||
affected int64
|
||||
connected bool
|
||||
}
|
||||
|
||||
func (database *sqlAuditTestDatabase) Connect(connection.ConnectionConfig) error {
|
||||
database.connected = true
|
||||
return nil
|
||||
}
|
||||
func (database *sqlAuditTestDatabase) Close() error { return nil }
|
||||
func (database *sqlAuditTestDatabase) Ping() error { return nil }
|
||||
func (database *sqlAuditTestDatabase) Query(string) ([]map[string]interface{}, []string, error) {
|
||||
return database.rows, database.columns, database.queryErr
|
||||
}
|
||||
func (database *sqlAuditTestDatabase) Exec(string) (int64, error) {
|
||||
return database.affected, database.queryErr
|
||||
}
|
||||
func (database *sqlAuditTestDatabase) GetDatabases() ([]string, error) { return nil, nil }
|
||||
func (database *sqlAuditTestDatabase) GetTables(string) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (database *sqlAuditTestDatabase) GetCreateStatement(string, string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
func (database *sqlAuditTestDatabase) GetColumns(string, string) ([]connection.ColumnDefinition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (database *sqlAuditTestDatabase) GetAllColumns(string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (database *sqlAuditTestDatabase) GetIndexes(string, string) ([]connection.IndexDefinition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (database *sqlAuditTestDatabase) GetForeignKeys(string, string) ([]connection.ForeignKeyDefinition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (database *sqlAuditTestDatabase) GetTriggers(string, string) ([]connection.TriggerDefinition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func newSQLAuditTestApp(t *testing.T) *App {
|
||||
t.Helper()
|
||||
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
|
||||
app.configDir = t.TempDir()
|
||||
app.activateSQLAudit()
|
||||
t.Cleanup(func() { app.closeSQLAuditStore() })
|
||||
return app
|
||||
}
|
||||
|
||||
func loadSQLAuditEvents(t *testing.T, app *App, filter sqlaudit.Filter) []sqlaudit.Event {
|
||||
t.Helper()
|
||||
filter.PageSize = 500
|
||||
result := app.GetSQLAuditEvents(filter)
|
||||
if !result.Success {
|
||||
t.Fatalf("GetSQLAuditEvents returned failure: %s", result.Message)
|
||||
}
|
||||
page, ok := result.Data.(sqlaudit.Page)
|
||||
if !ok {
|
||||
t.Fatalf("GetSQLAuditEvents data type = %T, want sqlaudit.Page", result.Data)
|
||||
}
|
||||
sort.Slice(page.Items, func(left, right int) bool {
|
||||
return page.Items[left].Sequence < page.Items[right].Sequence
|
||||
})
|
||||
return page.Items
|
||||
}
|
||||
|
||||
func TestSQLAuditConnectionFingerprintExcludesSecrets(t *testing.T) {
|
||||
base := connection.ConnectionConfig{
|
||||
Type: "postgres",
|
||||
Host: "db.internal",
|
||||
Port: 5432,
|
||||
User: "alice",
|
||||
Password: "primary-secret",
|
||||
Database: "app",
|
||||
DSN: "postgres://alice:dsn-secret@db.internal/app",
|
||||
URI: "postgres://alice:uri-secret@db.internal/app",
|
||||
UseSSH: true,
|
||||
SSH: connection.SSHConfig{Host: "jump.internal", User: "ops", Password: "ssh-secret"},
|
||||
UseProxy: true,
|
||||
Proxy: connection.ProxyConfig{Host: "proxy.internal", User: "proxy", Password: "proxy-secret"},
|
||||
UseHTTPTunnel: true,
|
||||
HTTPTunnel: connection.HTTPTunnelConfig{Host: "tunnel.internal", User: "tunnel", Password: "tunnel-secret"},
|
||||
}
|
||||
changedSecrets := base
|
||||
changedSecrets.User = "bob"
|
||||
changedSecrets.Password = "changed-primary"
|
||||
changedSecrets.DSN = "postgres://bob:changed-dsn@db.internal/app"
|
||||
changedSecrets.URI = "postgres://bob:changed-uri@db.internal/app"
|
||||
changedSecrets.SSH.Password = "changed-ssh"
|
||||
changedSecrets.Proxy.Password = "changed-proxy"
|
||||
changedSecrets.HTTPTunnel.Password = "changed-tunnel"
|
||||
|
||||
baseFingerprint := buildSQLAuditConnectionFingerprint(base, "app")
|
||||
if got := buildSQLAuditConnectionFingerprint(changedSecrets, "app"); got != baseFingerprint {
|
||||
t.Fatalf("secret-only changes altered audit fingerprint: base=%s changed=%s", baseFingerprint, got)
|
||||
}
|
||||
changedEndpoint := base
|
||||
changedEndpoint.Host = "other.internal"
|
||||
if got := buildSQLAuditConnectionFingerprint(changedEndpoint, "app"); got == baseFingerprint {
|
||||
t.Fatal("endpoint change should alter audit fingerprint")
|
||||
}
|
||||
saved := base
|
||||
saved.ID = "connection-1"
|
||||
savedFingerprint := buildSQLAuditConnectionFingerprint(saved, "app")
|
||||
savedChangedEndpoint := saved
|
||||
savedChangedEndpoint.Host = "moved.internal"
|
||||
if got := buildSQLAuditConnectionFingerprint(savedChangedEndpoint, "app"); got != savedFingerprint {
|
||||
t.Fatal("saved connection endpoint edit should retain its audit fingerprint")
|
||||
}
|
||||
if got := buildSQLAuditConnectionFingerprint(saved, "analytics"); got == savedFingerprint {
|
||||
t.Fatal("logical database change should alter a saved connection audit fingerprint")
|
||||
}
|
||||
dsnOnly := connection.ConnectionConfig{Type: "custom", Driver: "postgres", DSN: "host=opaque-a port=5432 user=alice password=first dbname=app"}
|
||||
dsnSecretChanged := dsnOnly
|
||||
dsnSecretChanged.DSN = "host=opaque-a port=5432 user=bob password=second dbname=app"
|
||||
if got, want := buildSQLAuditConnectionFingerprint(dsnSecretChanged, "app"), buildSQLAuditConnectionFingerprint(dsnOnly, "app"); got != want {
|
||||
t.Fatal("DSN credential change should not alter temporary connection fingerprint")
|
||||
}
|
||||
dsnEndpointChanged := dsnOnly
|
||||
dsnEndpointChanged.DSN = "host=opaque-b port=5432 user=alice password=first dbname=app"
|
||||
if got, want := buildSQLAuditConnectionFingerprint(dsnEndpointChanged, "app"), buildSQLAuditConnectionFingerprint(dsnOnly, "app"); got == want {
|
||||
t.Fatal("safe DSN endpoint change should alter temporary connection fingerprint")
|
||||
}
|
||||
uriOnly := connection.ConnectionConfig{Type: "postgres", URI: "postgres://alice:first@db.internal/app?pass=first&key=first"}
|
||||
uriSecretsChanged := uriOnly
|
||||
uriSecretsChanged.URI = "postgres://bob:second@db.internal/app?pass=second&key=second&custom_secret=third"
|
||||
if got, want := buildSQLAuditConnectionFingerprint(uriSecretsChanged, "app"), buildSQLAuditConnectionFingerprint(uriOnly, "app"); got != want {
|
||||
t.Fatal("URI credentials and query parameters must not alter temporary connection fingerprint")
|
||||
}
|
||||
uriEndpointChanged := uriOnly
|
||||
uriEndpointChanged.URI = "postgres://alice:first@other.internal/app?pass=first"
|
||||
if got, want := buildSQLAuditConnectionFingerprint(uriEndpointChanged, "app"), buildSQLAuditConnectionFingerprint(uriOnly, "app"); got == want {
|
||||
t.Fatal("URI authority change should alter temporary connection fingerprint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBQueryWithCancelWritesRedactedSQLAudit(t *testing.T) {
|
||||
originalNewDatabaseFunc := newDatabaseFunc
|
||||
t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc })
|
||||
database := &sqlAuditTestDatabase{
|
||||
rows: []map[string]interface{}{{"id": int64(7)}},
|
||||
columns: []string{"id"},
|
||||
}
|
||||
newDatabaseFunc = func(string) (db.Database, error) { return database, nil }
|
||||
app := newSQLAuditTestApp(t)
|
||||
config := connection.ConnectionConfig{Type: "postgres", Host: "127.0.0.1", Port: 5432, Database: "app"}
|
||||
|
||||
result := app.DBQueryWithCancel(config, "app", "SELECT * FROM users WHERE email = 'secret@example.test' AND id = 7", "query-audit-1")
|
||||
if !result.Success {
|
||||
t.Fatalf("DBQueryWithCancel returned failure: %s", result.Message)
|
||||
}
|
||||
events := loadSQLAuditEvents(t, app, sqlaudit.Filter{Search: "query-audit-1"})
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("audit event count = %d, want 1: %#v", len(events), events)
|
||||
}
|
||||
event := events[0]
|
||||
if event.EventType != "query" || event.Status != "success" || event.RowsReturned != 1 {
|
||||
t.Fatalf("unexpected query audit event: %#v", event)
|
||||
}
|
||||
if strings.Contains(event.SQLText, "secret@example.test") || strings.Contains(event.SQLText, " 7") {
|
||||
t.Fatalf("audit SQL leaked literals: %q", event.SQLText)
|
||||
}
|
||||
if !event.SQLRedacted || event.ConnectionFingerprint == "" {
|
||||
t.Fatalf("expected redacted event with connection identity: %#v", event)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedSQLTransactionWritesCompleteAuditTimeline(t *testing.T) {
|
||||
originalNewDatabaseFunc := newDatabaseFunc
|
||||
t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc })
|
||||
firstStatement := "UPDATE users SET name = 'private-name' WHERE id = 1"
|
||||
secondStatement := "DELETE FROM audit_logs WHERE user_id = 1"
|
||||
database := &fakeBatchWriteDB{execAffected: map[string]int64{
|
||||
firstStatement: 1,
|
||||
secondStatement: 3,
|
||||
}}
|
||||
newDatabaseFunc = func(string) (db.Database, error) { return database, nil }
|
||||
app := newSQLAuditTestApp(t)
|
||||
config := connection.ConnectionConfig{Type: "mysql", Host: "127.0.0.1", Port: 3306, Database: "main"}
|
||||
|
||||
started := app.DBQueryMultiTransactional(config, "main", firstStatement+";\n"+secondStatement+";", "query-tx-audit")
|
||||
if !started.Success || started.TransactionID == "" {
|
||||
t.Fatalf("DBQueryMultiTransactional returned %#v", started)
|
||||
}
|
||||
committed := app.DBCommitTransactionWithTrigger(started.TransactionID, "auto")
|
||||
if !committed.Success {
|
||||
t.Fatalf("DBCommitTransactionWithTrigger returned failure: %s", committed.Message)
|
||||
}
|
||||
|
||||
events := loadSQLAuditEvents(t, app, sqlaudit.Filter{TransactionID: started.TransactionID})
|
||||
if len(events) != 5 {
|
||||
t.Fatalf("transaction audit event count = %d, want 5: %#v", len(events), events)
|
||||
}
|
||||
wantTypes := []string{"transaction_begin", "transaction_statement", "transaction_statement", "transaction_commit_requested", "transaction_commit"}
|
||||
for index, wantType := range wantTypes {
|
||||
if events[index].EventType != wantType || events[index].Status != "success" {
|
||||
t.Fatalf("event %d = %#v, want type=%s success", index, events[index], wantType)
|
||||
}
|
||||
}
|
||||
if events[1].StatementIndex != 1 || events[1].StatementCount != 2 || events[1].RowsAffected != 1 {
|
||||
t.Fatalf("unexpected first statement audit metrics: %#v", events[1])
|
||||
}
|
||||
if events[2].StatementIndex != 2 || events[2].RowsAffected != 3 {
|
||||
t.Fatalf("unexpected second statement audit metrics: %#v", events[2])
|
||||
}
|
||||
if strings.Contains(events[1].SQLText, "private-name") {
|
||||
t.Fatalf("transaction audit leaked SQL literal: %q", events[1].SQLText)
|
||||
}
|
||||
if events[3].CommitMode != "auto" || events[3].BoundaryMode != "text_sql" {
|
||||
t.Fatalf("commit request audit lost trigger/boundary metadata: %#v", events[3])
|
||||
}
|
||||
if events[4].CommitMode != "auto" || events[4].BoundaryMode != "text_sql" {
|
||||
t.Fatalf("commit audit lost trigger/boundary metadata: %#v", events[4])
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedSQLTransactionFailureAuditsStatementAndAutomaticRollback(t *testing.T) {
|
||||
originalNewDatabaseFunc := newDatabaseFunc
|
||||
t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc })
|
||||
firstStatement := "UPDATE users SET active = 1 WHERE id = 1"
|
||||
secondStatement := "DELETE FROM missing_table WHERE id = 1"
|
||||
database := &fakeBatchWriteDB{
|
||||
execAffected: map[string]int64{firstStatement: 1},
|
||||
execErr: map[string]error{secondStatement: errors.New("table 'private_table_name' does not exist")},
|
||||
}
|
||||
newDatabaseFunc = func(string) (db.Database, error) { return database, nil }
|
||||
app := newSQLAuditTestApp(t)
|
||||
config := connection.ConnectionConfig{Type: "mysql", Host: "127.0.0.1", Port: 3306, Database: "main"}
|
||||
|
||||
result := app.DBQueryMultiTransactional(config, "main", firstStatement+";\n"+secondStatement+";", "query-tx-failed")
|
||||
if result.Success {
|
||||
t.Fatalf("expected transaction failure, got %#v", result)
|
||||
}
|
||||
events := loadSQLAuditEvents(t, app, sqlaudit.Filter{Search: "query-tx-failed"})
|
||||
if len(events) != 5 {
|
||||
t.Fatalf("failed transaction audit event count = %d, want 5: %#v", len(events), events)
|
||||
}
|
||||
if events[2].EventType != "transaction_statement" || events[2].Status != "error" {
|
||||
t.Fatalf("failed statement was not audited: %#v", events[2])
|
||||
}
|
||||
if strings.Contains(events[2].Error, "private_table_name") {
|
||||
t.Fatalf("audit error leaked quoted driver detail: %q", events[2].Error)
|
||||
}
|
||||
if events[3].EventType != "transaction_rollback_requested" || events[3].Status != "success" {
|
||||
t.Fatalf("automatic rollback request was not audited: %#v", events[3])
|
||||
}
|
||||
if events[4].EventType != "transaction_auto_rollback" || events[4].Status != "success" {
|
||||
t.Fatalf("automatic rollback was not audited: %#v", events[4])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLAuditRecordsRealSQLiteExecutions(t *testing.T) {
|
||||
app := newSQLAuditTestApp(t)
|
||||
databasePath := filepath.Join(t.TempDir(), "audit-target.sqlite")
|
||||
config := connection.ConnectionConfig{
|
||||
Type: "custom",
|
||||
Driver: "sqlite",
|
||||
DSN: databasePath,
|
||||
Database: databasePath,
|
||||
}
|
||||
t.Cleanup(func() { app.DBReleaseConnection(config) })
|
||||
queries := []struct {
|
||||
id string
|
||||
sql string
|
||||
}{
|
||||
{id: "real-sqlite-create", sql: "CREATE TABLE audit_users (id INTEGER PRIMARY KEY, email TEXT)"},
|
||||
{id: "real-sqlite-insert", sql: "INSERT INTO audit_users(id, email) VALUES (1, 'private@example.test')"},
|
||||
{id: "real-sqlite-select", sql: "SELECT id, email FROM audit_users WHERE id = 1"},
|
||||
}
|
||||
for _, query := range queries {
|
||||
result := app.DBQueryMulti(config, databasePath, query.sql, query.id)
|
||||
if !result.Success {
|
||||
t.Fatalf("DBQueryMulti(%s) returned failure: %s", query.id, result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
events := loadSQLAuditEvents(t, app, sqlaudit.Filter{Search: "real-sqlite-", PageSize: 10})
|
||||
if len(events) != len(queries) {
|
||||
t.Fatalf("real SQLite audit event count = %d, want %d: %#v", len(events), len(queries), events)
|
||||
}
|
||||
for _, event := range events {
|
||||
if event.Status != "success" || event.DBType != "sqlite" {
|
||||
t.Fatalf("unexpected real SQLite audit event: %#v", event)
|
||||
}
|
||||
if strings.Contains(event.SQLText, "private@example.test") {
|
||||
t.Fatalf("real SQLite audit leaked literal: %q", event.SQLText)
|
||||
}
|
||||
}
|
||||
if events[1].RowsAffected != 1 {
|
||||
t.Fatalf("real SQLite INSERT affected rows = %d, want 1", events[1].RowsAffected)
|
||||
}
|
||||
if events[2].RowsReturned != 1 {
|
||||
t.Fatalf("real SQLite SELECT returned rows = %d, want 1", events[2].RowsReturned)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLAuditHealthRecordsAndClosesPersistenceGap(t *testing.T) {
|
||||
app := newSQLAuditTestApp(t)
|
||||
wasActive, suspendErr := app.suspendSQLAudit()
|
||||
if suspendErr != nil {
|
||||
t.Fatalf("suspendSQLAudit returned error: %v", suspendErr)
|
||||
}
|
||||
if !wasActive {
|
||||
t.Fatal("expected SQL audit runtime to be active before suspension")
|
||||
}
|
||||
app.appendSQLAuditEvent(sqlaudit.Event{EventType: "query", Status: "success", QueryID: "lost-query"})
|
||||
|
||||
degradedResult := app.GetSQLAuditHealth()
|
||||
degraded, ok := degradedResult.Data.(sqlAuditHealthState)
|
||||
if !ok {
|
||||
t.Fatalf("GetSQLAuditHealth data type = %T, want sqlAuditHealthState", degradedResult.Data)
|
||||
}
|
||||
if degraded.Status != sqlAuditHealthStatusDegraded || degraded.DroppedEvents != 1 {
|
||||
t.Fatalf("unexpected degraded SQL audit health: %#v", degraded)
|
||||
}
|
||||
|
||||
app.resumeSQLAudit(wasActive)
|
||||
app.appendSQLAuditEvent(sqlaudit.Event{EventType: "query", Status: "success", QueryID: "recovered-query"})
|
||||
healthResult := app.GetSQLAuditHealth()
|
||||
health := healthResult.Data.(sqlAuditHealthState)
|
||||
if health.Status != sqlAuditHealthStatusHealthy || health.DroppedEvents != 1 || health.LastSuccessAt == 0 {
|
||||
t.Fatalf("unexpected recovered SQL audit health: %#v", health)
|
||||
}
|
||||
events := loadSQLAuditEvents(t, app, sqlaudit.Filter{})
|
||||
if len(events) != 2 || events[0].QueryID != "recovered-query" || events[1].EventType != "audit_gap" {
|
||||
t.Fatalf("recovered audit timeline did not persist gap marker and next event: %#v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuspendSQLAuditReturnsCheckpointFailureAndCanResume(t *testing.T) {
|
||||
app := newSQLAuditTestApp(t)
|
||||
originalClose := closeSQLAuditStoreHandle
|
||||
closeSQLAuditStoreHandle = func(store *sqlaudit.Store) error {
|
||||
return errors.Join(originalClose(store), errors.New("simulated checkpoint failure"))
|
||||
}
|
||||
wasActive, err := app.suspendSQLAudit()
|
||||
closeSQLAuditStoreHandle = originalClose
|
||||
if err == nil || !strings.Contains(err.Error(), "simulated checkpoint failure") {
|
||||
t.Fatalf("suspendSQLAudit error = %v, want checkpoint failure", err)
|
||||
}
|
||||
if !wasActive {
|
||||
t.Fatal("failed suspension lost the prior active state")
|
||||
}
|
||||
|
||||
app.resumeSQLAudit(wasActive)
|
||||
app.appendSQLAuditEvent(sqlaudit.Event{EventType: "query", Status: "success", QueryID: "after-failed-suspend"})
|
||||
events := loadSQLAuditEvents(t, app, sqlaudit.Filter{Search: "after-failed-suspend"})
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("audit store did not resume after failed suspension: %#v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLAuditHealthReportsCaptureStateAndMode(t *testing.T) {
|
||||
app := newSQLAuditTestApp(t)
|
||||
|
||||
health := app.GetSQLAuditHealth().Data.(sqlAuditHealthState)
|
||||
if health.CaptureEnabled == nil || !*health.CaptureEnabled {
|
||||
t.Fatalf("default capture state was not reported as enabled: %#v", health)
|
||||
}
|
||||
if health.CaptureMode != sqlaudit.CaptureModeRedacted {
|
||||
t.Fatalf("default capture mode = %q, want %q", health.CaptureMode, sqlaudit.CaptureModeRedacted)
|
||||
}
|
||||
|
||||
settings := sqlaudit.DefaultSettings()
|
||||
settings.Enabled = false
|
||||
settings.CaptureMode = sqlaudit.CaptureModeMetadata
|
||||
if result := app.UpdateSQLAuditSettings(settings); !result.Success {
|
||||
t.Fatalf("UpdateSQLAuditSettings returned failure: %s", result.Message)
|
||||
}
|
||||
health = app.GetSQLAuditHealth().Data.(sqlAuditHealthState)
|
||||
if health.CaptureEnabled == nil || *health.CaptureEnabled {
|
||||
t.Fatalf("disabled capture state was not reported explicitly: %#v", health)
|
||||
}
|
||||
if health.CaptureMode != sqlaudit.CaptureModeMetadata {
|
||||
t.Fatalf("capture mode = %q, want %q", health.CaptureMode, sqlaudit.CaptureModeMetadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLAuditRecoveryRetainsGapMarkerAtMinimumRecordLimit(t *testing.T) {
|
||||
app := newSQLAuditTestApp(t)
|
||||
settingsResult := app.UpdateSQLAuditSettings(sqlaudit.Settings{
|
||||
Enabled: true,
|
||||
CaptureMode: sqlaudit.CaptureModeRedacted,
|
||||
RetentionDays: 30,
|
||||
MaxRecords: 1,
|
||||
})
|
||||
if !settingsResult.Success {
|
||||
t.Fatalf("UpdateSQLAuditSettings returned failure: %s", settingsResult.Message)
|
||||
}
|
||||
|
||||
wasActive, suspendErr := app.suspendSQLAudit()
|
||||
if suspendErr != nil {
|
||||
t.Fatalf("suspendSQLAudit returned error: %v", suspendErr)
|
||||
}
|
||||
app.appendSQLAuditEvent(sqlaudit.Event{EventType: "query", Status: "success", QueryID: "lost-at-minimum-limit"})
|
||||
app.resumeSQLAudit(wasActive)
|
||||
app.appendSQLAuditEvent(sqlaudit.Event{EventType: "query", Status: "success", QueryID: "current-at-minimum-limit"})
|
||||
|
||||
events := loadSQLAuditEvents(t, app, sqlaudit.Filter{})
|
||||
if len(events) != 1 || events[0].EventType != "audit_gap" {
|
||||
t.Fatalf("minimum retention limit must keep the recovery marker: %#v", events)
|
||||
}
|
||||
health := app.GetSQLAuditHealth().Data.(sqlAuditHealthState)
|
||||
if health.Status != sqlAuditHealthStatusHealthy || health.DroppedEvents != 1 {
|
||||
t.Fatalf("unexpected health after durable minimum-limit marker: %#v", health)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLAuditOversizedBatchCreatesVisibleHealthGapInsteadOfSilentTail(t *testing.T) {
|
||||
app := newSQLAuditTestApp(t)
|
||||
settingsResult := app.UpdateSQLAuditSettings(sqlaudit.Settings{
|
||||
Enabled: true,
|
||||
CaptureMode: sqlaudit.CaptureModeRedacted,
|
||||
RetentionDays: 30,
|
||||
MaxRecords: 1,
|
||||
})
|
||||
if !settingsResult.Success {
|
||||
t.Fatalf("UpdateSQLAuditSettings returned failure: %s", settingsResult.Message)
|
||||
}
|
||||
|
||||
app.appendSQLAuditEvents([]sqlaudit.Event{
|
||||
{EventType: "transaction_statement", Status: "success", QueryID: "oversized-1"},
|
||||
{EventType: "transaction_statement", Status: "success", QueryID: "oversized-2"},
|
||||
})
|
||||
events := loadSQLAuditEvents(t, app, sqlaudit.Filter{})
|
||||
if len(events) != 1 || events[0].EventType != "audit_settings_change" {
|
||||
t.Fatalf("oversized batch must fail atomically while retaining its settings boundary, got %#v", events)
|
||||
}
|
||||
health := app.GetSQLAuditHealth().Data.(sqlAuditHealthState)
|
||||
if health.Status != sqlAuditHealthStatusDegraded || health.DroppedEvents != 2 {
|
||||
t.Fatalf("oversized batch was not exposed as a health gap: %#v", health)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLAuditControlEventsSurviveDisableAndClear(t *testing.T) {
|
||||
app := newSQLAuditTestApp(t)
|
||||
disabled := sqlaudit.Settings{
|
||||
Enabled: false,
|
||||
CaptureMode: sqlaudit.CaptureModeRedacted,
|
||||
RetentionDays: 30,
|
||||
MaxRecords: 100,
|
||||
}
|
||||
if result := app.UpdateSQLAuditSettings(disabled); !result.Success {
|
||||
t.Fatalf("disable SQL audit returned failure: %s", result.Message)
|
||||
}
|
||||
app.appendSQLAuditEvent(sqlaudit.Event{EventType: "query", Status: "success", QueryID: "disabled-query"})
|
||||
|
||||
enabled := disabled
|
||||
enabled.Enabled = true
|
||||
if result := app.UpdateSQLAuditSettings(enabled); !result.Success {
|
||||
t.Fatalf("enable SQL audit returned failure: %s", result.Message)
|
||||
}
|
||||
events := loadSQLAuditEvents(t, app, sqlaudit.Filter{})
|
||||
if len(events) != 2 || events[0].EventType != "audit_settings_change" || events[1].EventType != "audit_settings_change" {
|
||||
t.Fatalf("disable/enable control boundaries were not persisted: %#v", events)
|
||||
}
|
||||
for _, event := range events {
|
||||
if event.QueryID == "disabled-query" {
|
||||
t.Fatalf("ordinary event was persisted while auditing was disabled: %#v", events)
|
||||
}
|
||||
}
|
||||
|
||||
if result := app.ClearSQLAuditEvents(0); !result.Success {
|
||||
t.Fatalf("ClearSQLAuditEvents returned failure: %s", result.Message)
|
||||
}
|
||||
events = loadSQLAuditEvents(t, app, sqlaudit.Filter{})
|
||||
if len(events) != 1 || events[0].EventType != "audit_clear" || events[0].RowsAffected != 2 {
|
||||
t.Fatalf("clear boundary did not replace deleted history with a control event: %#v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLAuditSettingsControlCanRecoverDegradedWriterWhileDisablingCapture(t *testing.T) {
|
||||
app := newSQLAuditTestApp(t)
|
||||
app.markSQLAuditFailure(1, errors.New("simulated writer failure"))
|
||||
settings := sqlaudit.DefaultSettings()
|
||||
settings.Enabled = false
|
||||
|
||||
if result := app.UpdateSQLAuditSettings(settings); !result.Success {
|
||||
t.Fatalf("disable after writer recovery returned failure: %s", result.Message)
|
||||
}
|
||||
health := app.GetSQLAuditHealth().Data.(sqlAuditHealthState)
|
||||
if health.Status != sqlAuditHealthStatusHealthy || health.DroppedEvents != 1 || health.CaptureEnabled == nil || *health.CaptureEnabled {
|
||||
t.Fatalf("control write did not close the degraded state: %#v", health)
|
||||
}
|
||||
events := loadSQLAuditEvents(t, app, sqlaudit.Filter{})
|
||||
if len(events) != 2 || events[0].EventType != "audit_settings_change" || events[1].EventType != "audit_gap" {
|
||||
t.Fatalf("settings recovery lacks control and gap boundaries: %#v", events)
|
||||
}
|
||||
if result := app.BuildSQLAuditExport(sqlaudit.Filter{}, "json"); !result.Success {
|
||||
t.Fatalf("recovered disabled audit history should remain exportable: %s", result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSQLAuditExportPreservesExistingFileWhenAtomicReplacementFails(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
target := filepath.Join(directory, "audit.json")
|
||||
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
|
||||
t.Fatalf("write original export: %v", err)
|
||||
}
|
||||
|
||||
originalReplace := replaceSQLAuditFile
|
||||
t.Cleanup(func() { replaceSQLAuditFile = originalReplace })
|
||||
replaceSQLAuditFile = func(_, _ string) error {
|
||||
return errors.New("simulated atomic replacement failure")
|
||||
}
|
||||
|
||||
if err := writeSQLAuditExportAtomically(target, []byte("replacement")); err == nil {
|
||||
t.Fatal("expected replacement failure")
|
||||
}
|
||||
content, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
t.Fatalf("read preserved export: %v", err)
|
||||
}
|
||||
if string(content) != "original" {
|
||||
t.Fatalf("existing export was not preserved: %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportSQLAuditFileRejectsWebRuntimeBeforeOpeningDesktopDialog(t *testing.T) {
|
||||
app := NewWebApp()
|
||||
app.configDir = t.TempDir()
|
||||
app.activateSQLAudit()
|
||||
t.Cleanup(func() { app.closeSQLAuditStore() })
|
||||
|
||||
result := app.ExportSQLAuditFile(sqlaudit.Filter{}, "json")
|
||||
if result.Success || !strings.Contains(result.Message, "BuildSQLAuditExport") {
|
||||
t.Fatalf("web runtime desktop export result = %#v, want safe rejection", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLAuditExportTargetRejectsInternalStorageFiles(t *testing.T) {
|
||||
app := newSQLAuditTestApp(t)
|
||||
for _, protectedPath := range []string{
|
||||
app.sqlAuditDatabasePath(),
|
||||
app.sqlAuditDatabasePath() + "-wal",
|
||||
app.sqlAuditDatabasePath() + "-shm",
|
||||
app.sqlAuditHealthFilePath(),
|
||||
} {
|
||||
if err := app.validateSQLAuditExportTarget(protectedPath); err == nil {
|
||||
t.Fatalf("protected audit export target %q was accepted", protectedPath)
|
||||
}
|
||||
}
|
||||
if err := app.validateSQLAuditExportTarget(filepath.Join(t.TempDir(), "safe-export.json")); err != nil {
|
||||
t.Fatalf("safe audit export target was rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLAuditExportTargetRejectsMissingSidecarThroughSymlinkedParent(t *testing.T) {
|
||||
app := newSQLAuditTestApp(t)
|
||||
auditDirectory := filepath.Dir(app.sqlAuditDatabasePath())
|
||||
aliasDirectory := filepath.Join(t.TempDir(), "audit-alias")
|
||||
if err := os.Symlink(auditDirectory, aliasDirectory); err != nil {
|
||||
t.Skipf("creating a directory symlink is unavailable: %v", err)
|
||||
}
|
||||
candidate := filepath.Join(aliasDirectory, filepath.Base(app.sqlAuditHealthFilePath()))
|
||||
_ = os.Remove(app.sqlAuditHealthFilePath())
|
||||
if err := app.validateSQLAuditExportTarget(candidate); err == nil {
|
||||
t.Fatalf("symlinked missing health export target %q was accepted", candidate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBQueryMultiAuditsSuccessfulPrefixBeforeLaterStatementFailure(t *testing.T) {
|
||||
originalNewDatabaseFunc := newDatabaseFunc
|
||||
t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc })
|
||||
firstStatement := "UPDATE users SET active = 1 WHERE id = 1"
|
||||
secondStatement := "DELETE FROM missing_table WHERE id = 2"
|
||||
database := &fakeBatchWriteDB{
|
||||
execAffected: map[string]int64{firstStatement: 3},
|
||||
execErr: map[string]error{secondStatement: errors.New("second statement failed")},
|
||||
}
|
||||
newDatabaseFunc = func(string) (db.Database, error) { return database, nil }
|
||||
app := newSQLAuditTestApp(t)
|
||||
config := connection.ConnectionConfig{Type: "mysql", Host: "127.0.0.1", Port: 3306, Database: "main"}
|
||||
|
||||
result := app.DBQueryMulti(config, "main", firstStatement+";\n"+secondStatement+";", "query-partial-audit")
|
||||
if result.Success {
|
||||
t.Fatalf("expected second statement failure, got %#v", result)
|
||||
}
|
||||
events := loadSQLAuditEvents(t, app, sqlaudit.Filter{Search: "query-partial-audit"})
|
||||
if len(events) != 3 {
|
||||
t.Fatalf("partial batch audit event count = %d, want 3: %#v", len(events), events)
|
||||
}
|
||||
if events[0].EventType != "query_statement" || events[0].Status != "success" ||
|
||||
events[0].StatementIndex != 1 || events[0].RowsAffected != 3 {
|
||||
t.Fatalf("successful committed prefix was not audited: %#v", events[0])
|
||||
}
|
||||
if events[1].EventType != "query_statement" || events[1].Status != "error" ||
|
||||
events[1].StatementIndex != 2 || !strings.Contains(events[1].Error, "second statement failed") {
|
||||
t.Fatalf("failed statement was not audited: %#v", events[1])
|
||||
}
|
||||
if events[2].EventType != "query" || events[2].Status != "error" || events[2].StatementCount != 2 {
|
||||
t.Fatalf("batch summary was not retained after statement events: %#v", events[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedSQLTransactionStatementAuditUsesActualCompletionTimes(t *testing.T) {
|
||||
originalNewDatabaseFunc := newDatabaseFunc
|
||||
t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc })
|
||||
firstStatement := "UPDATE users SET active = 1 WHERE id = 1"
|
||||
secondStatement := "UPDATE users SET active = 0 WHERE id = 2"
|
||||
database := &fakeBatchWriteDB{
|
||||
execAffected: map[string]int64{firstStatement: 1, secondStatement: 1},
|
||||
execDelay: map[string]time.Duration{
|
||||
firstStatement: 25 * time.Millisecond,
|
||||
secondStatement: 25 * time.Millisecond,
|
||||
},
|
||||
}
|
||||
newDatabaseFunc = func(string) (db.Database, error) { return database, nil }
|
||||
app := newSQLAuditTestApp(t)
|
||||
config := connection.ConnectionConfig{Type: "mysql", Host: "127.0.0.1", Port: 3306, Database: "main"}
|
||||
|
||||
started := app.DBQueryMultiTransactional(config, "main", firstStatement+";\n"+secondStatement+";", "query-timestamp-audit")
|
||||
if !started.Success || started.TransactionID == "" {
|
||||
t.Fatalf("start managed transaction: %#v", started)
|
||||
}
|
||||
events := loadSQLAuditEvents(t, app, sqlaudit.Filter{TransactionID: started.TransactionID})
|
||||
statements := make([]sqlaudit.Event, 0, 2)
|
||||
for _, event := range events {
|
||||
if event.EventType == "transaction_statement" {
|
||||
statements = append(statements, event)
|
||||
}
|
||||
}
|
||||
if len(statements) != 2 {
|
||||
t.Fatalf("transaction statement events = %d, want 2: %#v", len(statements), events)
|
||||
}
|
||||
if statements[0].Timestamp <= 0 || statements[1].Timestamp <= statements[0].Timestamp {
|
||||
t.Fatalf("statement completion timestamps were collapsed at batch flush: %#v", statements)
|
||||
}
|
||||
if statements[0].DurationMs < 20 || statements[1].DurationMs < 20 {
|
||||
t.Fatalf("statement durations do not reflect execution time: %#v", statements)
|
||||
}
|
||||
if rollback := app.DBRollbackTransaction(started.TransactionID); !rollback.Success {
|
||||
t.Fatalf("rollback managed transaction: %#v", rollback)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedSQLTransactionProtectionDenialIsAudited(t *testing.T) {
|
||||
app := newSQLAuditTestApp(t)
|
||||
config := connection.ConnectionConfig{
|
||||
Type: "mysql",
|
||||
Host: "127.0.0.1",
|
||||
Port: 3306,
|
||||
Database: "main",
|
||||
ReadOnly: true,
|
||||
}
|
||||
|
||||
result := app.DBQueryMultiTransactional(config, "main", "UPDATE users SET active = 1 WHERE id = 9", "query-denied-audit")
|
||||
if result.Success {
|
||||
t.Fatalf("expected production protection denial, got %#v", result)
|
||||
}
|
||||
events := loadSQLAuditEvents(t, app, sqlaudit.Filter{Search: "query-denied-audit"})
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("denied managed transaction audit events = %d, want 1: %#v", len(events), events)
|
||||
}
|
||||
if events[0].EventType != "transaction_begin" || events[0].Status != "error" || events[0].Error == "" {
|
||||
t.Fatalf("denied managed transaction was not audited: %#v", events[0])
|
||||
}
|
||||
}
|
||||
@@ -81,7 +81,34 @@ func (a *App) resolveDataSyncEndpointConfig(raw connection.ConnectionConfig, sel
|
||||
}
|
||||
|
||||
// DataSync executes a data synchronization task
|
||||
func (a *App) DataSync(config sync.SyncConfig) sync.SyncResult {
|
||||
func (a *App) DataSync(config sync.SyncConfig) (result sync.SyncResult) {
|
||||
auditStartedAt := time.Now()
|
||||
defer func() {
|
||||
runConfig := normalizeRunConfig(config.TargetConfig, config.TargetDatabase)
|
||||
auditMessage := ""
|
||||
if !result.Success {
|
||||
auditMessage = "data synchronization task failed"
|
||||
}
|
||||
auditResult := connection.QueryResult{
|
||||
Success: result.Success,
|
||||
Message: auditMessage,
|
||||
Data: map[string]int64{
|
||||
"affectedRows": int64(result.RowsInserted + result.RowsUpdated + result.RowsDeleted),
|
||||
},
|
||||
}
|
||||
a.recordSQLAuditQuery(sqlAuditQueryInput{
|
||||
Config: runConfig,
|
||||
Database: config.TargetDatabase,
|
||||
DBType: resolveDDLDBType(runConfig),
|
||||
QueryID: generateQueryID(),
|
||||
SQL: fmt.Sprintf("SYNC DATA TABLES_%d", len(config.Tables)),
|
||||
Source: "sync",
|
||||
CommitMode: "auto",
|
||||
Duration: time.Since(auditStartedAt),
|
||||
StatementCount: len(config.Tables),
|
||||
Result: auditResult,
|
||||
})
|
||||
}()
|
||||
if err := ensureDataSyncTargetProtection(config); err != nil {
|
||||
return sync.SyncResult{
|
||||
Success: false,
|
||||
|
||||
20
internal/app/sql_audit_atomic_replace_other.go
Normal file
20
internal/app/sql_audit_atomic_replace_other.go
Normal file
@@ -0,0 +1,20 @@
|
||||
//go:build !windows
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func atomicReplaceSQLAuditFile(source, target string) error {
|
||||
if err := os.Rename(source, target); err != nil {
|
||||
return err
|
||||
}
|
||||
directory, err := os.Open(filepath.Dir(target))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.Join(directory.Sync(), directory.Close())
|
||||
}
|
||||
21
internal/app/sql_audit_atomic_replace_windows.go
Normal file
21
internal/app/sql_audit_atomic_replace_windows.go
Normal file
@@ -0,0 +1,21 @@
|
||||
//go:build windows
|
||||
|
||||
package app
|
||||
|
||||
import "golang.org/x/sys/windows"
|
||||
|
||||
func atomicReplaceSQLAuditFile(source, target string) error {
|
||||
sourcePath, err := windows.UTF16PtrFromString(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targetPath, err := windows.UTF16PtrFromString(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return windows.MoveFileEx(
|
||||
sourcePath,
|
||||
targetPath,
|
||||
windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH,
|
||||
)
|
||||
}
|
||||
@@ -452,14 +452,164 @@ func isReadOnlySQLQuery(dbType string, query string) bool {
|
||||
if keyword == "select" && isSQLSelectIntoStatement(query) {
|
||||
return false
|
||||
}
|
||||
if keyword == "explain" && explainAnalyzeMayWrite(query) {
|
||||
return false
|
||||
}
|
||||
if keyword == "pragma" {
|
||||
return !pragmaMayWrite(query)
|
||||
}
|
||||
switch keyword {
|
||||
case "select", "with", "show", "describe", "desc", "explain", "pragma", "values", "consume":
|
||||
case "select", "with", "show", "describe", "desc", "explain", "values", "consume":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func explainAnalyzeMayWrite(query string) bool {
|
||||
keyword, pos := nextSQLKeyword(query, 0)
|
||||
if keyword != "explain" {
|
||||
return false
|
||||
}
|
||||
pos = skipSQLTrivia(query, pos)
|
||||
analyze := false
|
||||
if pos < len(query) && query[pos] == '(' {
|
||||
next := skipBalancedSQLParens(query, pos)
|
||||
if next < 0 {
|
||||
return false
|
||||
}
|
||||
options := query[pos+1 : next-1]
|
||||
analyze = sqlContainsKeyword(options, "analyze") || sqlContainsKeyword(options, "analyse")
|
||||
pos = next
|
||||
} else {
|
||||
for {
|
||||
option, next := nextSQLKeyword(query, pos)
|
||||
switch option {
|
||||
case "analyze", "analyse":
|
||||
analyze = true
|
||||
pos = next
|
||||
case "verbose":
|
||||
pos = next
|
||||
default:
|
||||
goto optionsDone
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
optionsDone:
|
||||
if !analyze {
|
||||
return false
|
||||
}
|
||||
body := query[skipSQLTrivia(query, pos):]
|
||||
bodyKeyword, withHasWrite := sqlDataOperationInfo(body)
|
||||
if withHasWrite || isSQLDataWriteKeyword(bodyKeyword) {
|
||||
return true
|
||||
}
|
||||
if bodyKeyword == "select" && isSQLSelectIntoStatement(body) {
|
||||
return true
|
||||
}
|
||||
switch bodyKeyword {
|
||||
case "create", "execute", "call":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func pragmaMayWrite(query string) bool {
|
||||
keyword, pos := nextSQLKeyword(query, 0)
|
||||
if keyword != "pragma" {
|
||||
return false
|
||||
}
|
||||
name, next, ok := readSQLIdentifierName(query, pos)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
pos = skipSQLTrivia(query, next)
|
||||
if pos < len(query) && query[pos] == '.' {
|
||||
name, next, ok = readSQLIdentifierName(query, pos+1)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
pos = skipSQLTrivia(query, next)
|
||||
}
|
||||
if pos < len(query) && query[pos] == '=' {
|
||||
return true
|
||||
}
|
||||
if pos < len(query) && query[pos] == '(' {
|
||||
return !isReadOnlyPragmaWithArgument(name)
|
||||
}
|
||||
for {
|
||||
pos = skipSQLTrivia(query, pos)
|
||||
if pos < len(query) && query[pos] == ';' {
|
||||
pos++
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if pos < len(query) {
|
||||
return true
|
||||
}
|
||||
return !isReadOnlyPragmaWithoutArgument(name)
|
||||
}
|
||||
|
||||
func readSQLIdentifierName(text string, start int) (string, int, bool) {
|
||||
pos := skipSQLTrivia(text, start)
|
||||
end, ok := skipSQLIdentifierToken(text, pos)
|
||||
if !ok || end <= pos {
|
||||
return "", pos, false
|
||||
}
|
||||
token := text[pos:end]
|
||||
switch token[0] {
|
||||
case '"', '`':
|
||||
if len(token) < 2 {
|
||||
return "", end, false
|
||||
}
|
||||
delimiter := string(token[0])
|
||||
token = strings.ReplaceAll(token[1:len(token)-1], delimiter+delimiter, delimiter)
|
||||
case '[':
|
||||
if len(token) < 2 || token[len(token)-1] != ']' {
|
||||
return "", end, false
|
||||
}
|
||||
token = token[1 : len(token)-1]
|
||||
}
|
||||
token = strings.ToLower(strings.TrimSpace(token))
|
||||
return token, end, token != ""
|
||||
}
|
||||
|
||||
func isReadOnlyPragmaWithArgument(name string) bool {
|
||||
switch name {
|
||||
case "foreign_key_check", "foreign_key_list", "index_info", "index_xinfo", "index_list",
|
||||
"integrity_check", "quick_check", "table_info", "table_xinfo":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isReadOnlyPragmaWithoutArgument(name string) bool {
|
||||
switch name {
|
||||
case "analysis_limit", "application_id", "auto_vacuum", "automatic_index", "busy_timeout",
|
||||
"cache_size", "cache_spill", "case_sensitive_like", "cell_size_check", "checkpoint_fullfsync",
|
||||
"collation_list", "compile_options", "data_version", "database_list", "defer_foreign_keys",
|
||||
"encoding", "foreign_key_check", "foreign_key_list", "foreign_keys", "freelist_count",
|
||||
"full_column_names", "fullfsync", "function_list", "hard_heap_limit", "ignore_check_constraints",
|
||||
"index_info", "index_list", "index_xinfo", "integrity_check", "journal_mode", "journal_size_limit",
|
||||
"legacy_alter_table", "legacy_file_format", "locking_mode", "max_page_count", "mmap_size",
|
||||
"module_list", "page_count", "page_size", "pragma_list", "query_only", "quick_check",
|
||||
"read_uncommitted", "recursive_triggers", "reverse_unordered_selects", "schema_version", "secure_delete",
|
||||
"short_column_names", "soft_heap_limit", "stats", "synchronous", "table_info", "table_list",
|
||||
"table_xinfo", "temp_store", "threads", "trusted_schema", "user_version", "wal_autocheckpoint",
|
||||
"writable_schema":
|
||||
return true
|
||||
default:
|
||||
// Unknown/action pragmas are conservative writes. This covers
|
||||
// no-argument operations such as optimize, incremental_vacuum and
|
||||
// wal_checkpoint without depending on a perpetually complete list.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isBatchableWriteSQLStatement(dbType string, query string) bool {
|
||||
if isReadOnlySQLQuery(dbType, query) {
|
||||
return false
|
||||
|
||||
@@ -111,6 +111,61 @@ func TestIsReadOnlySQLQuery_TreatsMongoDeleteAsWrite(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsReadOnlySQLQuery_TreatsMongoAggregateOutputStagesAsWrites(t *testing.T) {
|
||||
for _, query := range []string{
|
||||
`{"aggregate":"users","pipeline":[{"$match":{"active":true}},{"$out":"active_users"}],"cursor":{}}`,
|
||||
`{"aggregate":"users","pipeline":[{"$merge":{"into":"active_users"}}],"cursor":{}}`,
|
||||
} {
|
||||
if isReadOnlySQLQuery("mongodb", query) {
|
||||
t.Fatalf("Mongo aggregate write stage was classified read-only: %s", query)
|
||||
}
|
||||
}
|
||||
if !isReadOnlySQLQuery("mongodb", `{"aggregate":"users","pipeline":[{"$match":{"active":true}}],"cursor":{}}`) {
|
||||
t.Fatal("read-only Mongo aggregate was classified as write")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsReadOnlySQLQuery_TreatsExecutingExplainWritesAsWrites(t *testing.T) {
|
||||
for _, query := range []string{
|
||||
"EXPLAIN ANALYZE UPDATE users SET active = false",
|
||||
"EXPLAIN (ANALYZE true, BUFFERS true) DELETE FROM users",
|
||||
"EXPLAIN ANALYSE WITH removed AS (DELETE FROM users RETURNING id) SELECT * FROM removed",
|
||||
} {
|
||||
if isReadOnlySQLQuery("postgres", query) {
|
||||
t.Fatalf("executing EXPLAIN write was classified read-only: %s", query)
|
||||
}
|
||||
}
|
||||
if !isReadOnlySQLQuery("postgres", "EXPLAIN UPDATE users SET active = false") {
|
||||
t.Fatal("non-executing EXPLAIN was classified as write")
|
||||
}
|
||||
if !isReadOnlySQLQuery("postgres", "EXPLAIN ANALYZE SELECT * FROM users") {
|
||||
t.Fatal("EXPLAIN ANALYZE SELECT was classified as write")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsReadOnlySQLQuery_TreatsMutablePragmasAsWrites(t *testing.T) {
|
||||
for _, query := range []string{
|
||||
"PRAGMA user_version = 7",
|
||||
"PRAGMA main.application_id(42)",
|
||||
`PRAGMA "main".user_version = 123`,
|
||||
"PRAGMA [main].user_version = 124",
|
||||
"PRAGMA `main`.user_version = 125",
|
||||
"PRAGMA optimize",
|
||||
"PRAGMA incremental_vacuum",
|
||||
"PRAGMA wal_checkpoint",
|
||||
} {
|
||||
if isReadOnlySQLQuery("sqlite", query) {
|
||||
t.Fatalf("mutable PRAGMA was classified read-only: %s", query)
|
||||
}
|
||||
}
|
||||
if !isReadOnlySQLQuery("sqlite", "PRAGMA table_info('users')") {
|
||||
t.Fatal("metadata PRAGMA was classified as write")
|
||||
}
|
||||
if !isReadOnlySQLQuery("sqlite", "PRAGMA database_list") {
|
||||
t.Fatal("read-only no-argument PRAGMA was classified as write")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsReadOnlySQLQuery_TreatsMilvusJSONQueriesAsReadOnly(t *testing.T) {
|
||||
for _, query := range []string{
|
||||
`{"list_collections":true}`,
|
||||
|
||||
@@ -26,14 +26,15 @@ type Backend interface {
|
||||
DBGetForeignKeys(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult
|
||||
DBGetTriggers(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult
|
||||
DBShowCreateTable(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult
|
||||
DBQueryMulti(config connection.ConnectionConfig, dbName string, query string, queryID string) connection.QueryResult
|
||||
ExecuteSQLFromMCP(config connection.ConnectionConfig, dbName string, query string) connection.QueryResult
|
||||
InspectSQL(dbType string, sql string) appcore.SQLInspection
|
||||
GetSQLSafetyLevel() ai.SQLPermissionLevel
|
||||
}
|
||||
|
||||
// AppBackend 基于现有 internal/app.App 暴露 MCP 所需数据库能力。
|
||||
type AppBackend struct {
|
||||
app *appcore.App
|
||||
app *appcore.App
|
||||
mcpQueryExecutor *appcore.MCPQueryExecutor
|
||||
}
|
||||
|
||||
func NewAppBackend(ctx context.Context) *AppBackend {
|
||||
@@ -42,7 +43,7 @@ func NewAppBackend(ctx context.Context) *AppBackend {
|
||||
}
|
||||
a := appcore.NewApp()
|
||||
appcore.InitializeLifecycle(a, ctx)
|
||||
return &AppBackend{app: a}
|
||||
return &AppBackend{app: a, mcpQueryExecutor: appcore.NewMCPQueryExecutor(a)}
|
||||
}
|
||||
|
||||
func (b *AppBackend) Close(ctx context.Context) error {
|
||||
@@ -101,8 +102,8 @@ func (b *AppBackend) DBShowCreateTable(config connection.ConnectionConfig, dbNam
|
||||
return b.app.DBShowCreateTable(config, dbName, tableName)
|
||||
}
|
||||
|
||||
func (b *AppBackend) DBQueryMulti(config connection.ConnectionConfig, dbName string, query string, queryID string) connection.QueryResult {
|
||||
return b.app.DBQueryMulti(config, dbName, query, queryID)
|
||||
func (b *AppBackend) ExecuteSQLFromMCP(config connection.ConnectionConfig, dbName string, query string) connection.QueryResult {
|
||||
return b.mcpQueryExecutor.DBQueryMulti(config, dbName, query)
|
||||
}
|
||||
|
||||
func (b *AppBackend) InspectSQL(dbType string, sql string) appcore.SQLInspection {
|
||||
|
||||
@@ -546,7 +546,7 @@ func (s *Service) ExecuteSQL(ctx context.Context, req *mcp.CallToolRequest, args
|
||||
}
|
||||
|
||||
dbName := effectiveDBName(args.DBName, view.Config)
|
||||
queryResult := s.backend.DBQueryMulti(view.Config, dbName, sqlText, "")
|
||||
queryResult := s.backend.ExecuteSQLFromMCP(view.Config, dbName, sqlText)
|
||||
if !queryResult.Success {
|
||||
return toolError("SQL 执行失败: %s", strings.TrimSpace(queryResult.Message)), executeSQLResult{}, nil
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ func (f *fakeBackend) DBShowCreateTable(config connection.ConnectionConfig, dbNa
|
||||
return f.ddlResult
|
||||
}
|
||||
|
||||
func (f *fakeBackend) DBQueryMulti(config connection.ConnectionConfig, dbName string, query string, queryID string) connection.QueryResult {
|
||||
func (f *fakeBackend) ExecuteSQLFromMCP(config connection.ConnectionConfig, dbName string, query string) connection.QueryResult {
|
||||
f.queryCalled = true
|
||||
return f.queryResult
|
||||
}
|
||||
|
||||
235
internal/sqlaudit/export.go
Normal file
235
internal/sqlaudit/export.go
Normal file
@@ -0,0 +1,235 @@
|
||||
package sqlaudit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var ErrUnsupportedExportFormat = errors.New("unsupported SQL audit export format")
|
||||
|
||||
var (
|
||||
ErrExportRecordLimit = errors.New("SQL audit export record limit exceeded")
|
||||
ErrExportSizeLimit = errors.New("SQL audit export size limit exceeded")
|
||||
)
|
||||
|
||||
const (
|
||||
maxExportRecords int64 = 100_000
|
||||
maxExportBytes = 64 * 1024 * 1024
|
||||
)
|
||||
|
||||
// BuildExport returns every event matching filter. Pagination fields are
|
||||
// intentionally ignored so exporting from a paged workbench does not silently
|
||||
// export only the visible page.
|
||||
func (s *Store) BuildExport(filter Filter, format string) ([]byte, error) {
|
||||
return s.buildExportWithLimits(filter, format, maxExportRecords, maxExportBytes)
|
||||
}
|
||||
|
||||
// BuildExportWithLimits applies caller-specific resource caps. It is used by
|
||||
// the browser server, whose RPC envelope creates additional copies of content.
|
||||
func (s *Store) BuildExportWithLimits(filter Filter, format string, recordLimit int64, byteLimit int) ([]byte, error) {
|
||||
if recordLimit <= 0 || recordLimit > maxExportRecords {
|
||||
recordLimit = maxExportRecords
|
||||
}
|
||||
if byteLimit <= 0 || byteLimit > maxExportBytes {
|
||||
byteLimit = maxExportBytes
|
||||
}
|
||||
return s.buildExportWithLimits(filter, format, recordLimit, byteLimit)
|
||||
}
|
||||
|
||||
func (s *Store) buildExportWithLimits(filter Filter, format string, recordLimit int64, byteLimit int) ([]byte, error) {
|
||||
if err := s.ensureOpen(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
format = strings.ToLower(strings.TrimSpace(format))
|
||||
if format != "json" && format != "csv" {
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnsupportedExportFormat, format)
|
||||
}
|
||||
filter = normalizeFilter(filter)
|
||||
filter.Page = 0
|
||||
filter.PageSize = 0
|
||||
events, err := s.loadExportEvents(filter, recordLimit, byteLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch format {
|
||||
case "json":
|
||||
output := newBoundedBuffer(byteLimit)
|
||||
if _, err := output.Write([]byte{'['}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for index, event := range events {
|
||||
payload, marshalErr := json.Marshal(event)
|
||||
if marshalErr != nil {
|
||||
return nil, fmt.Errorf("encode SQL audit JSON export: %w", marshalErr)
|
||||
}
|
||||
if index > 0 {
|
||||
if _, err := output.Write([]byte{','}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if _, err := output.Write(payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if _, err := output.Write([]byte("]\n")); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return output.Bytes(), nil
|
||||
|
||||
case "csv":
|
||||
output := newBoundedBuffer(byteLimit)
|
||||
writer := csv.NewWriter(output)
|
||||
if err := writer.Write(exportCSVHeader); err != nil {
|
||||
return nil, fmt.Errorf("write SQL audit CSV header: %w", err)
|
||||
}
|
||||
for _, event := range events {
|
||||
if err := writer.Write(eventCSVRecord(event)); err != nil {
|
||||
return nil, fmt.Errorf("write SQL audit CSV event: %w", err)
|
||||
}
|
||||
}
|
||||
writer.Flush()
|
||||
if err := writer.Error(); err != nil {
|
||||
return nil, fmt.Errorf("flush SQL audit CSV export: %w", err)
|
||||
}
|
||||
return output.Bytes(), nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnsupportedExportFormat, format)
|
||||
}
|
||||
|
||||
func (s *Store) loadExportEvents(filter Filter, recordLimit int64, byteLimit int) ([]Event, error) {
|
||||
where, args := buildFilterWhere(filter)
|
||||
var total int64
|
||||
if err := s.db.QueryRowContext(context.Background(),
|
||||
`SELECT COUNT(*) FROM sql_audit_events`+where, args...,
|
||||
).Scan(&total); err != nil {
|
||||
return nil, fmt.Errorf("count SQL audit export events: %w", err)
|
||||
}
|
||||
if recordLimit > 0 && total > recordLimit {
|
||||
return nil, fmt.Errorf("%w: total=%d limit=%d", ErrExportRecordLimit, total, recordLimit)
|
||||
}
|
||||
rows, err := s.db.QueryContext(context.Background(), selectEventColumns+where+
|
||||
` ORDER BY timestamp DESC, sequence DESC`, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query SQL audit export: %w", err)
|
||||
}
|
||||
events := make([]Event, 0, int(total))
|
||||
retainedStringBytes := 0
|
||||
for rows.Next() {
|
||||
if recordLimit > 0 && int64(len(events)) >= recordLimit {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("%w: limit=%d", ErrExportRecordLimit, recordLimit)
|
||||
}
|
||||
event, scanErr := scanEvent(rows)
|
||||
if scanErr != nil {
|
||||
_ = rows.Close()
|
||||
return nil, scanErr
|
||||
}
|
||||
retainedStringBytes += eventStringBytes(event)
|
||||
if byteLimit > 0 && retainedStringBytes > byteLimit {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("%w: limit=%d", ErrExportSizeLimit, byteLimit)
|
||||
}
|
||||
events = append(events, event)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("iterate SQL audit export: %w", err)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, fmt.Errorf("close SQL audit export rows: %w", err)
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func eventStringBytes(event Event) int {
|
||||
return len(event.ID) + len(event.EventType) + len(event.Status) + len(event.ConnectionID) +
|
||||
len(event.ConnectionFingerprint) + len(event.DBType) + len(event.Database) + len(event.QueryID) +
|
||||
len(event.TransactionID) + len(event.Source) + len(event.BoundaryMode) + len(event.CommitMode) +
|
||||
len(event.SQLText) + len(event.SQLFingerprint) + len(event.Error) + len(event.PrevHash) + len(event.Hash)
|
||||
}
|
||||
|
||||
type boundedBuffer struct {
|
||||
buffer bytes.Buffer
|
||||
limit int
|
||||
}
|
||||
|
||||
func newBoundedBuffer(limit int) *boundedBuffer {
|
||||
return &boundedBuffer{limit: limit}
|
||||
}
|
||||
|
||||
func (b *boundedBuffer) Write(payload []byte) (int, error) {
|
||||
if b.limit > 0 && b.buffer.Len()+len(payload) > b.limit {
|
||||
return 0, fmt.Errorf("%w: limit=%d", ErrExportSizeLimit, b.limit)
|
||||
}
|
||||
return b.buffer.Write(payload)
|
||||
}
|
||||
|
||||
func (b *boundedBuffer) Bytes() []byte {
|
||||
return b.buffer.Bytes()
|
||||
}
|
||||
|
||||
var exportCSVHeader = []string{
|
||||
"id", "sequence", "timestamp", "eventType", "status", "connectionId",
|
||||
"connectionFingerprint", "dbType", "database", "queryId", "transactionId",
|
||||
"source", "boundaryMode", "commitMode", "sqlText", "sqlRedacted",
|
||||
"sqlFingerprint", "statementIndex", "statementCount", "durationMs",
|
||||
"rowsAffected", "rowsReturned", "error", "prevHash", "hash",
|
||||
}
|
||||
|
||||
func eventCSVRecord(event Event) []string {
|
||||
return protectCSVRecord([]string{
|
||||
event.ID,
|
||||
strconv.FormatInt(event.Sequence, 10),
|
||||
strconv.FormatInt(event.Timestamp, 10),
|
||||
event.EventType,
|
||||
event.Status,
|
||||
event.ConnectionID,
|
||||
event.ConnectionFingerprint,
|
||||
event.DBType,
|
||||
event.Database,
|
||||
event.QueryID,
|
||||
event.TransactionID,
|
||||
event.Source,
|
||||
event.BoundaryMode,
|
||||
event.CommitMode,
|
||||
event.SQLText,
|
||||
strconv.FormatBool(event.SQLRedacted),
|
||||
event.SQLFingerprint,
|
||||
strconv.Itoa(event.StatementIndex),
|
||||
strconv.Itoa(event.StatementCount),
|
||||
strconv.FormatInt(event.DurationMs, 10),
|
||||
strconv.FormatInt(event.RowsAffected, 10),
|
||||
strconv.FormatInt(event.RowsReturned, 10),
|
||||
event.Error,
|
||||
event.PrevHash,
|
||||
event.Hash,
|
||||
})
|
||||
}
|
||||
|
||||
func protectCSVRecord(record []string) []string {
|
||||
protected := make([]string, len(record))
|
||||
for index, cell := range record {
|
||||
protected[index] = protectCSVFormula(cell)
|
||||
}
|
||||
return protected
|
||||
}
|
||||
|
||||
func protectCSVFormula(value string) string {
|
||||
trimmed := strings.TrimLeft(value, " \t\r\n")
|
||||
if trimmed == "" {
|
||||
return value
|
||||
}
|
||||
switch trimmed[0] {
|
||||
case '=', '+', '-', '@':
|
||||
return "'" + value
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
156
internal/sqlaudit/export_test.go
Normal file
156
internal/sqlaudit/export_test.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package sqlaudit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBuildExportIgnoresPaginationAndFiltersAllMatches(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
now := time.Now().UnixMilli()
|
||||
for index := 0; index < 3; index++ {
|
||||
event := sampleEvent("export-"+string(rune('a'+index)), now+int64(index))
|
||||
event.ConnectionID = "export-connection"
|
||||
if err := store.Append(event); err != nil {
|
||||
t.Fatalf("Append returned error: %v", err)
|
||||
}
|
||||
}
|
||||
other := sampleEvent("other", now+10)
|
||||
other.ConnectionID = "other-connection"
|
||||
if err := store.Append(other); err != nil {
|
||||
t.Fatalf("Append other returned error: %v", err)
|
||||
}
|
||||
|
||||
payload, err := store.BuildExport(Filter{
|
||||
ConnectionID: "export-connection",
|
||||
Page: 3,
|
||||
PageSize: 1,
|
||||
}, "json")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildExport(json) returned error: %v", err)
|
||||
}
|
||||
var events []Event
|
||||
if err := json.Unmarshal(payload, &events); err != nil {
|
||||
t.Fatalf("decode JSON export: %v\npayload=%s", err, payload)
|
||||
}
|
||||
if len(events) != 3 {
|
||||
t.Fatalf("export should ignore pagination and include 3 filtered events, got %d", len(events))
|
||||
}
|
||||
for _, event := range events {
|
||||
if event.ConnectionID != "export-connection" {
|
||||
t.Fatalf("export ignored non-pagination filter: %#v", event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCSVExportPreventsFormulaInjection(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
event := sampleEvent("csv-formula", time.Now().UnixMilli())
|
||||
event.ConnectionID = "=HYPERLINK(\"https://example.test\")"
|
||||
event.Database = " @SUM(1+1)"
|
||||
if err := store.Append(event); err != nil {
|
||||
t.Fatalf("Append returned error: %v", err)
|
||||
}
|
||||
payload, err := store.BuildExport(Filter{}, "CSV")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildExport(csv) returned error: %v", err)
|
||||
}
|
||||
reader := csv.NewReader(bytes.NewReader(payload))
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
t.Fatalf("read CSV export: %v\npayload=%s", err, payload)
|
||||
}
|
||||
if len(records) != 2 {
|
||||
t.Fatalf("CSV export rows=%d, want header + event", len(records))
|
||||
}
|
||||
connectionColumn := csvHeaderIndex(t, records[0], "connectionId")
|
||||
databaseColumn := csvHeaderIndex(t, records[0], "database")
|
||||
if !strings.HasPrefix(records[1][connectionColumn], "'=") {
|
||||
t.Fatalf("connection ID formula was not escaped: %q", records[1][connectionColumn])
|
||||
}
|
||||
if !strings.HasPrefix(records[1][databaseColumn], "'@") {
|
||||
t.Fatalf("whitespace-prefixed formula was not escaped: %q", records[1][databaseColumn])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExportRejectsUnsupportedFormat(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
_, err := store.BuildExport(Filter{}, "xlsx")
|
||||
if !errors.Is(err, ErrUnsupportedExportFormat) {
|
||||
t.Fatalf("BuildExport error=%v, want ErrUnsupportedExportFormat", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExportReturnsRecognizableRecordLimitError(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
now := time.Now().UnixMilli()
|
||||
if err := store.Append(sampleEvent("limit-a", now)); err != nil {
|
||||
t.Fatalf("Append first returned error: %v", err)
|
||||
}
|
||||
if err := store.Append(sampleEvent("limit-b", now+1)); err != nil {
|
||||
t.Fatalf("Append second returned error: %v", err)
|
||||
}
|
||||
for _, format := range []string{"json", "csv"} {
|
||||
if _, err := store.buildExportWithLimits(Filter{}, format, 1, maxExportBytes); !errors.Is(err, ErrExportRecordLimit) {
|
||||
t.Fatalf("%s export error=%v, want ErrExportRecordLimit", format, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExportReturnsRecognizableSizeLimitError(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
event := sampleEvent("large-export", time.Now().UnixMilli())
|
||||
event.Database = strings.Repeat("database", 30)
|
||||
if err := store.Append(event); err != nil {
|
||||
t.Fatalf("Append returned error: %v", err)
|
||||
}
|
||||
for _, format := range []string{"json", "csv"} {
|
||||
if _, err := store.buildExportWithLimits(Filter{}, format, maxExportRecords, 64); !errors.Is(err, ErrExportSizeLimit) {
|
||||
t.Fatalf("%s export error=%v, want ErrExportSizeLimit", format, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadExportEventsClosesRowsBeforeReturning(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
now := time.Now().UnixMilli()
|
||||
if err := store.Append(sampleEvent("export-connection-release", now)); err != nil {
|
||||
t.Fatalf("Append returned error: %v", err)
|
||||
}
|
||||
events, err := store.loadExportEvents(normalizeFilter(Filter{}), maxExportRecords, maxExportBytes)
|
||||
if err != nil || len(events) != 1 {
|
||||
t.Fatalf("loadExportEvents events=%#v err=%v", events, err)
|
||||
}
|
||||
if inUse := store.db.Stats().InUse; inUse != 0 {
|
||||
t.Fatalf("loadExportEvents returned before releasing SQLite rows: inUse=%d", inUse)
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- store.Append(sampleEvent("append-after-export-load", now+1))
|
||||
}()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("Append after export load returned error: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("export row cursor retained the store's only SQLite connection")
|
||||
}
|
||||
}
|
||||
|
||||
func csvHeaderIndex(t *testing.T, header []string, name string) int {
|
||||
t.Helper()
|
||||
for index, value := range header {
|
||||
if value == name {
|
||||
return index
|
||||
}
|
||||
}
|
||||
t.Fatalf("CSV header %q missing from %#v", name, header)
|
||||
return -1
|
||||
}
|
||||
800
internal/sqlaudit/redact.go
Normal file
800
internal/sqlaudit/redact.go
Normal file
@@ -0,0 +1,800 @@
|
||||
package sqlaudit
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var sqlQueryTypes = map[string]struct{}{
|
||||
"mysql": {}, "mariadb": {}, "goldendb": {}, "greatdb": {}, "gdb": {},
|
||||
"oceanbase": {}, "doris": {}, "diros": {}, "starrocks": {}, "sphinx": {},
|
||||
"postgres": {}, "postgresql": {}, "sqlserver": {}, "mssql": {}, "sqlite": {},
|
||||
"duckdb": {}, "oracle": {}, "dameng": {}, "dm": {}, "kingbase": {}, "highgo": {},
|
||||
"vastbase": {}, "opengauss": {}, "gaussdb": {}, "iris": {}, "intersystems": {},
|
||||
"tdengine": {}, "iotdb": {}, "clickhouse": {}, "trino": {}, "custom": {},
|
||||
"gonavi": {},
|
||||
}
|
||||
|
||||
var redisReadCommands = map[string]struct{}{
|
||||
"GET": {}, "MGET": {}, "GETDEL": {}, "GETEX": {}, "STRLEN": {}, "TYPE": {},
|
||||
"TTL": {}, "PTTL": {}, "EXPIRETIME": {}, "PEXPIRETIME": {}, "EXISTS": {},
|
||||
"DEL": {}, "UNLINK": {}, "TOUCH": {}, "DUMP": {}, "OBJECT": {}, "KEYS": {},
|
||||
"SCAN": {}, "HGET": {}, "HMGET": {}, "HGETALL": {}, "HEXISTS": {}, "HLEN": {},
|
||||
"HKEYS": {}, "HVALS": {}, "HSCAN": {}, "LINDEX": {}, "LLEN": {}, "LRANGE": {},
|
||||
"LPOS": {}, "SCARD": {}, "SMEMBERS": {}, "SISMEMBER": {}, "SMISMEMBER": {},
|
||||
"SSCAN": {}, "ZCARD": {}, "ZCOUNT": {}, "ZLEXCOUNT": {}, "ZRANGE": {},
|
||||
"ZRANGEBYSCORE": {}, "ZRANK": {}, "ZREVRANK": {}, "ZSCORE": {}, "ZMSCORE": {},
|
||||
"ZSCAN": {}, "XRANGE": {}, "XREVRANGE": {}, "XLEN": {}, "XREAD": {}, "XINFO": {},
|
||||
"PFCOUNT": {}, "BITCOUNT": {}, "GETBIT": {}, "GEODIST": {}, "GEOHASH": {},
|
||||
"GEOPOS": {}, "GEOSEARCH": {}, "JSON.GET": {}, "JSON.MGET": {}, "JSON.TYPE": {},
|
||||
"JSON.OBJKEYS": {}, "JSON.OBJLEN": {}, "JSON.ARRLEN": {}, "TS.GET": {},
|
||||
"TS.RANGE": {}, "TS.REVRANGE": {}, "TS.MGET": {}, "TS.MRANGE": {},
|
||||
}
|
||||
|
||||
var redisMultiKeyCommands = map[string]struct{}{
|
||||
"MGET": {}, "DEL": {}, "UNLINK": {}, "EXISTS": {}, "TOUCH": {},
|
||||
}
|
||||
|
||||
// RedactQuery dispatches query sanitization by data-source type. SQL dialects
|
||||
// use the SQL lexer, Redis retains only commands/keys and replaces values, and
|
||||
// known non-SQL/message or unknown query languages default to metadata-only.
|
||||
func RedactQuery(dbType, text string) string {
|
||||
normalizedType := normalizeQueryType(dbType)
|
||||
if normalizedType == "redis" {
|
||||
return redactRedisQuery(text)
|
||||
}
|
||||
if _, ok := sqlQueryTypes[normalizedType]; ok {
|
||||
return RedactSQL(text)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
const (
|
||||
maxSQLRunes = 64 * 1024
|
||||
maxErrorRunes = 4 * 1024
|
||||
)
|
||||
|
||||
var (
|
||||
sensitiveAssignmentPattern = regexp.MustCompile(`(?i)\b(password|passwd|pwd|token|secret|api[_-]?key|access[_-]?key)\b(\s*(?:=|:)\s*)([^\s,;]+)`)
|
||||
identifiedByPattern = regexp.MustCompile(`(?i)\b(identified\s+by)(\s+)([^\s,;]+)`)
|
||||
uriUserInfoPattern = regexp.MustCompile(`(?i)([a-z][a-z0-9+.-]*://)([^\s/@:]+)(?::[^\s/@]*)?@`)
|
||||
bareUserInfoPattern = regexp.MustCompile(`(?i)\b[^\s:@/]+:[^\s@/]+@(\[[^\]\s]+\]|[a-z0-9.-]+)`)
|
||||
slashUserInfoPattern = regexp.MustCompile(`(?i)\b[^\s/@:]+/[^\s@/]+@([a-z0-9.-]+)(?:/[^\s,;]*)?`)
|
||||
bearerPattern = regexp.MustCompile(`(?i)\bbearer\s+[a-z0-9._~+/=-]+`)
|
||||
basicAuthPattern = regexp.MustCompile(`(?i)\bbasic\s+[a-z0-9+/=]+`)
|
||||
postgresKeyDetailPattern = regexp.MustCompile(`(?i)(\bkey\s*\([^\)\r\n]*\)\s*=\s*)\([^\)\r\n]*\)`)
|
||||
duplicateKeyValuePattern = regexp.MustCompile(`(?i)(\bduplicate\s+key\s+value\s+(?:is|was)\s*)\([^\)\r\n]*\)`)
|
||||
failingRowPattern = regexp.MustCompile(`(?i)(\bfailing\s+row\s+contains\s*)\([^\)\r\n]*\)`)
|
||||
)
|
||||
|
||||
// RedactSQL removes comments and literal values while retaining enough SQL
|
||||
// structure for local diagnostics. It intentionally favors confidentiality
|
||||
// over perfectly preserving dialect-specific quoted identifiers.
|
||||
func RedactSQL(sqlText string) string {
|
||||
if strings.TrimSpace(sqlText) == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var out strings.Builder
|
||||
out.Grow(len(sqlText))
|
||||
for index := 0; index < len(sqlText); {
|
||||
if hasPrefixAt(sqlText, index, "--") || sqlText[index] == '#' {
|
||||
index = skipLineComment(sqlText, index)
|
||||
if index < len(sqlText) && (sqlText[index] == '\r' || sqlText[index] == '\n') {
|
||||
out.WriteByte('\n')
|
||||
}
|
||||
continue
|
||||
}
|
||||
if hasPrefixAt(sqlText, index, "/*") {
|
||||
index = skipBlockComment(sqlText, index+2)
|
||||
out.WriteByte(' ')
|
||||
continue
|
||||
}
|
||||
if next, ok := skipOracleQuotedLiteral(sqlText, index); ok {
|
||||
out.WriteByte('?')
|
||||
index = next
|
||||
continue
|
||||
}
|
||||
if sqlText[index] == '$' {
|
||||
if next, ok := skipDollarQuotedLiteral(sqlText, index); ok {
|
||||
out.WriteByte('?')
|
||||
index = next
|
||||
continue
|
||||
}
|
||||
}
|
||||
if sqlText[index] == '\'' || sqlText[index] == '"' {
|
||||
quote := sqlText[index]
|
||||
out.WriteByte(quote)
|
||||
out.WriteByte('?')
|
||||
out.WriteByte(quote)
|
||||
index = skipQuoted(sqlText, index, quote)
|
||||
continue
|
||||
}
|
||||
if isNumberStart(sqlText, index) {
|
||||
out.WriteByte('?')
|
||||
index = skipNumber(sqlText, index)
|
||||
continue
|
||||
}
|
||||
|
||||
r, size := utf8.DecodeRuneInString(sqlText[index:])
|
||||
if r == utf8.RuneError && size == 1 {
|
||||
out.WriteRune(unicode.ReplacementChar)
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if unicode.IsControl(r) && r != '\n' && r != '\r' && r != '\t' {
|
||||
out.WriteByte(' ')
|
||||
} else {
|
||||
out.WriteRune(r)
|
||||
}
|
||||
index += size
|
||||
}
|
||||
|
||||
redacted := sensitiveAssignmentPattern.ReplaceAllString(out.String(), `${1}${2}?`)
|
||||
redacted = identifiedByPattern.ReplaceAllString(redacted, `${1}${2}?`)
|
||||
redacted = uriUserInfoPattern.ReplaceAllString(redacted, `${1}***:***@`)
|
||||
redacted = bearerPattern.ReplaceAllString(redacted, "Bearer ***")
|
||||
return truncateRunes(strings.TrimSpace(redacted), maxSQLRunes)
|
||||
}
|
||||
|
||||
// RedactError removes common credential forms and quoted values from a driver
|
||||
// error before it enters the audit database.
|
||||
func RedactError(message string) string {
|
||||
message = strings.TrimSpace(message)
|
||||
if message == "" {
|
||||
return ""
|
||||
}
|
||||
message = uriUserInfoPattern.ReplaceAllString(message, `${1}***:***@`)
|
||||
message = bareUserInfoPattern.ReplaceAllString(message, `***:***@$1`)
|
||||
message = slashUserInfoPattern.ReplaceAllString(message, `***/***@$1`)
|
||||
message = bearerPattern.ReplaceAllString(message, "Bearer ***")
|
||||
message = basicAuthPattern.ReplaceAllString(message, "Basic ***")
|
||||
message = postgresKeyDetailPattern.ReplaceAllString(message, `${1}(?)`)
|
||||
message = duplicateKeyValuePattern.ReplaceAllString(message, `${1}(?)`)
|
||||
message = failingRowPattern.ReplaceAllString(message, `${1}(?)`)
|
||||
message = sensitiveAssignmentPattern.ReplaceAllString(message, `${1}${2}***`)
|
||||
message = identifiedByPattern.ReplaceAllString(message, `${1}${2}***`)
|
||||
message = redactQuotedSegments(message)
|
||||
message = strings.Map(func(r rune) rune {
|
||||
if unicode.IsControl(r) {
|
||||
return ' '
|
||||
}
|
||||
return r
|
||||
}, message)
|
||||
return truncateRunes(strings.TrimSpace(message), maxErrorRunes)
|
||||
}
|
||||
|
||||
func normalizeQueryType(dbType string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(dbType))
|
||||
switch normalized {
|
||||
case "redis-cluster", "redis_cluster", "redis-sentinel", "redis_sentinel":
|
||||
return "redis"
|
||||
case "rocket-mq", "rocket_mq", "apache-rocketmq", "apache_rocketmq", "rmq":
|
||||
return "rocketmq"
|
||||
case "apache-kafka", "apache_kafka":
|
||||
return "kafka"
|
||||
case "rabbit-mq", "rabbit_mq":
|
||||
return "rabbitmq"
|
||||
case "elastic":
|
||||
return "elasticsearch"
|
||||
case "chromadb", "chroma-db":
|
||||
return "chroma"
|
||||
case "qdrantdb", "qdrant-db":
|
||||
return "qdrant"
|
||||
case "milvusdb", "milvus-db":
|
||||
return "milvus"
|
||||
default:
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
func redactRedisQuery(text string) string {
|
||||
tokens, ok := tokenizeRedisQuery(text)
|
||||
if !ok || len(tokens) == 0 {
|
||||
return ""
|
||||
}
|
||||
command := strings.ToUpper(tokens[0])
|
||||
switch command {
|
||||
case "AUTH":
|
||||
if len(tokens) >= 3 {
|
||||
return "AUTH ? ?"
|
||||
}
|
||||
if len(tokens) == 2 {
|
||||
return "AUTH ?"
|
||||
}
|
||||
return "AUTH"
|
||||
case "HELLO":
|
||||
return redactRedisHello(tokens)
|
||||
case "CONFIG":
|
||||
return redactRedisConfig(tokens)
|
||||
case "ACL":
|
||||
return redactRedisACL(tokens)
|
||||
case "CLIENT":
|
||||
return redactRedisClient(tokens)
|
||||
case "SCRIPT":
|
||||
return redactRedisScript(tokens)
|
||||
case "FUNCTION":
|
||||
return redactRedisFunction(tokens)
|
||||
case "PING", "ECHO":
|
||||
if len(tokens) > 1 {
|
||||
return command + " ?"
|
||||
}
|
||||
return command
|
||||
case "MSET", "MSETNX":
|
||||
return redactRedisAlternatingPairs(command, tokens, 1)
|
||||
case "HSET", "HMSET":
|
||||
return redactRedisHashPairs(command, tokens)
|
||||
case "SET", "SETNX", "GETSET", "APPEND", "SETRANGE", "INCRBY", "INCRBYFLOAT",
|
||||
"DECRBY", "SETBIT", "SETEX", "PSETEX", "RESTORE", "LSET", "LINSERT",
|
||||
"HSETNX", "HINCRBY", "HINCRBYFLOAT", "LPUSH", "LPUSHX", "RPUSH", "RPUSHX",
|
||||
"LREM", "SADD", "SREM", "SMOVE",
|
||||
"ZADD", "ZINCRBY", "ZREM", "GEOADD", "PFADD", "PUBLISH", "SPUBLISH",
|
||||
"XADD", "XACK", "XCLAIM", "XAUTOCLAIM", "XDEL", "XGROUP", "XTRIM",
|
||||
"JSON.SET", "JSON.MERGE", "JSON.ARRAPPEND", "JSON.ARRINSERT", "JSON.ARRTRIM",
|
||||
"JSON.NUMINCRBY", "JSON.NUMMULTBY", "JSON.STRAPPEND", "TS.ADD", "TS.INCRBY",
|
||||
"TS.DECRBY":
|
||||
return redactRedisKeyAndPayload(command, tokens)
|
||||
case "EVAL", "EVALSHA", "FCALL", "FCALL_RO":
|
||||
return redactRedisScriptInvocation(command, tokens)
|
||||
case "MIGRATE", "MODULE":
|
||||
return command + " ?"
|
||||
case "SELECT", "SWAPDB":
|
||||
return command + redactRedisSafeArguments(tokens[1:])
|
||||
default:
|
||||
if _, ok := redisReadCommands[command]; !ok {
|
||||
return ""
|
||||
}
|
||||
if len(tokens) == 1 {
|
||||
return command
|
||||
}
|
||||
if _, ok := redisMultiKeyCommands[command]; ok {
|
||||
return command + redactRedisSafeArguments(tokens[1:])
|
||||
}
|
||||
// Retain the first key/pattern only. Later arguments can be members,
|
||||
// payloads or dialect-specific options and are omitted conservatively.
|
||||
return command + " " + formatRedisToken(tokens[1])
|
||||
}
|
||||
}
|
||||
|
||||
func redactRedisHello(tokens []string) string {
|
||||
parts := []string{"HELLO"}
|
||||
index := 1
|
||||
if index < len(tokens) {
|
||||
if _, err := strconv.Atoi(tokens[index]); err == nil {
|
||||
parts = append(parts, tokens[index])
|
||||
} else {
|
||||
parts = append(parts, "?")
|
||||
}
|
||||
index++
|
||||
}
|
||||
for index < len(tokens) {
|
||||
switch strings.ToUpper(tokens[index]) {
|
||||
case "AUTH":
|
||||
parts = append(parts, "AUTH", "?", "?")
|
||||
index += 3
|
||||
case "SETNAME":
|
||||
parts = append(parts, "SETNAME", "?")
|
||||
index += 2
|
||||
default:
|
||||
// Unknown HELLO options may carry data; retain no further text.
|
||||
index = len(tokens)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func redactRedisConfig(tokens []string) string {
|
||||
if len(tokens) < 2 {
|
||||
return "CONFIG"
|
||||
}
|
||||
subcommand := strings.ToUpper(tokens[1])
|
||||
switch subcommand {
|
||||
case "SET":
|
||||
if len(tokens) >= 3 {
|
||||
return "CONFIG SET " + formatRedisToken(tokens[2]) + " ?"
|
||||
}
|
||||
return "CONFIG SET ?"
|
||||
case "GET":
|
||||
if len(tokens) >= 3 {
|
||||
return "CONFIG GET " + formatRedisToken(tokens[2])
|
||||
}
|
||||
return "CONFIG GET"
|
||||
case "RESETSTAT", "REWRITE":
|
||||
return "CONFIG " + subcommand
|
||||
default:
|
||||
return "CONFIG"
|
||||
}
|
||||
}
|
||||
|
||||
func redactRedisACL(tokens []string) string {
|
||||
if len(tokens) < 2 {
|
||||
return "ACL"
|
||||
}
|
||||
subcommand := strings.ToUpper(tokens[1])
|
||||
switch subcommand {
|
||||
case "SETUSER":
|
||||
if len(tokens) >= 3 {
|
||||
return "ACL SETUSER " + formatRedisToken(tokens[2]) + " ?"
|
||||
}
|
||||
return "ACL SETUSER ?"
|
||||
case "GETUSER", "DELUSER":
|
||||
if len(tokens) >= 3 {
|
||||
return "ACL " + subcommand + " " + formatRedisToken(tokens[2])
|
||||
}
|
||||
return "ACL " + subcommand
|
||||
case "LIST", "USERS", "WHOAMI", "CAT", "GENPASS", "LOG", "SAVE":
|
||||
return "ACL " + subcommand
|
||||
default:
|
||||
return "ACL"
|
||||
}
|
||||
}
|
||||
|
||||
func redactRedisClient(tokens []string) string {
|
||||
if len(tokens) < 2 {
|
||||
return "CLIENT"
|
||||
}
|
||||
subcommand := strings.ToUpper(tokens[1])
|
||||
switch subcommand {
|
||||
case "SETNAME", "SETINFO", "KILL", "UNBLOCK":
|
||||
return "CLIENT " + subcommand + " ?"
|
||||
case "GETNAME", "ID", "INFO", "LIST", "PAUSE", "UNPAUSE", "REPLY", "TRACKING", "CACHING":
|
||||
return "CLIENT " + subcommand
|
||||
default:
|
||||
return "CLIENT"
|
||||
}
|
||||
}
|
||||
|
||||
func redactRedisScript(tokens []string) string {
|
||||
if len(tokens) < 2 {
|
||||
return "SCRIPT"
|
||||
}
|
||||
subcommand := strings.ToUpper(tokens[1])
|
||||
switch subcommand {
|
||||
case "LOAD", "DEBUG":
|
||||
return "SCRIPT " + subcommand + " ?"
|
||||
case "EXISTS", "FLUSH", "KILL", "HELP":
|
||||
return "SCRIPT " + subcommand
|
||||
default:
|
||||
return "SCRIPT"
|
||||
}
|
||||
}
|
||||
|
||||
func redactRedisFunction(tokens []string) string {
|
||||
if len(tokens) < 2 {
|
||||
return "FUNCTION"
|
||||
}
|
||||
subcommand := strings.ToUpper(tokens[1])
|
||||
switch subcommand {
|
||||
case "LOAD", "RESTORE":
|
||||
return "FUNCTION " + subcommand + " ?"
|
||||
case "DELETE":
|
||||
if len(tokens) >= 3 {
|
||||
return "FUNCTION DELETE " + formatRedisToken(tokens[2])
|
||||
}
|
||||
return "FUNCTION DELETE"
|
||||
case "DUMP", "FLUSH", "KILL", "LIST", "STATS", "HELP":
|
||||
return "FUNCTION " + subcommand
|
||||
default:
|
||||
return "FUNCTION"
|
||||
}
|
||||
}
|
||||
|
||||
func redactRedisAlternatingPairs(command string, tokens []string, start int) string {
|
||||
if len(tokens) <= start || (len(tokens)-start)%2 != 0 {
|
||||
return command + " ?"
|
||||
}
|
||||
parts := []string{command}
|
||||
for index := start; index < len(tokens); index += 2 {
|
||||
parts = append(parts, formatRedisToken(tokens[index]))
|
||||
if index+1 < len(tokens) {
|
||||
parts = append(parts, "?")
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func redactRedisHashPairs(command string, tokens []string) string {
|
||||
if len(tokens) < 2 {
|
||||
return command
|
||||
}
|
||||
if (len(tokens)-2)%2 != 0 {
|
||||
return command + " " + formatRedisToken(tokens[1]) + " ?"
|
||||
}
|
||||
parts := []string{command, formatRedisToken(tokens[1])}
|
||||
for index := 2; index < len(tokens); index += 2 {
|
||||
parts = append(parts, formatRedisToken(tokens[index]))
|
||||
if index+1 < len(tokens) {
|
||||
parts = append(parts, "?")
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func redactRedisKeyAndPayload(command string, tokens []string) string {
|
||||
if len(tokens) < 2 {
|
||||
return command
|
||||
}
|
||||
result := command + " " + formatRedisToken(tokens[1])
|
||||
if len(tokens) > 2 {
|
||||
result += " ?"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func redactRedisScriptInvocation(command string, tokens []string) string {
|
||||
if len(tokens) < 3 {
|
||||
return command + " ?"
|
||||
}
|
||||
numKeys, err := strconv.Atoi(tokens[2])
|
||||
if err != nil || numKeys < 0 {
|
||||
return command + " ?"
|
||||
}
|
||||
parts := []string{command, "?", strconv.Itoa(numKeys)}
|
||||
for index := 0; index < numKeys && 3+index < len(tokens); index++ {
|
||||
parts = append(parts, formatRedisToken(tokens[3+index]))
|
||||
}
|
||||
if 3+numKeys < len(tokens) {
|
||||
parts = append(parts, "?")
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func redactRedisSafeArguments(tokens []string) string {
|
||||
if len(tokens) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(tokens))
|
||||
for _, token := range tokens {
|
||||
parts = append(parts, formatRedisToken(token))
|
||||
}
|
||||
return " " + strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func tokenizeRedisQuery(text string) ([]string, bool) {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return nil, true
|
||||
}
|
||||
var tokens []string
|
||||
var current strings.Builder
|
||||
var quote rune
|
||||
escaped := false
|
||||
tokenStarted := false
|
||||
flush := func() {
|
||||
if !tokenStarted {
|
||||
return
|
||||
}
|
||||
tokens = append(tokens, current.String())
|
||||
current.Reset()
|
||||
tokenStarted = false
|
||||
}
|
||||
for _, r := range text {
|
||||
if escaped {
|
||||
current.WriteRune(r)
|
||||
escaped = false
|
||||
tokenStarted = true
|
||||
continue
|
||||
}
|
||||
if r == '\\' {
|
||||
escaped = true
|
||||
tokenStarted = true
|
||||
continue
|
||||
}
|
||||
if quote != 0 {
|
||||
if r == quote {
|
||||
quote = 0
|
||||
} else {
|
||||
current.WriteRune(r)
|
||||
}
|
||||
tokenStarted = true
|
||||
continue
|
||||
}
|
||||
if r == '\'' || r == '"' {
|
||||
quote = r
|
||||
tokenStarted = true
|
||||
continue
|
||||
}
|
||||
if unicode.IsSpace(r) {
|
||||
flush()
|
||||
continue
|
||||
}
|
||||
current.WriteRune(r)
|
||||
tokenStarted = true
|
||||
}
|
||||
if escaped {
|
||||
current.WriteRune('\\')
|
||||
}
|
||||
if quote != 0 {
|
||||
return nil, false
|
||||
}
|
||||
flush()
|
||||
return tokens, true
|
||||
}
|
||||
|
||||
func formatRedisToken(token string) string {
|
||||
token = truncateRunes(strings.Map(func(r rune) rune {
|
||||
if unicode.IsControl(r) {
|
||||
return ' '
|
||||
}
|
||||
return r
|
||||
}, token), 512)
|
||||
if token == "" {
|
||||
return `""`
|
||||
}
|
||||
if strings.IndexFunc(token, unicode.IsSpace) >= 0 {
|
||||
return strconv.Quote(token)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func queryFingerprintSeed(dbType, redactedText string) string {
|
||||
if redactedText != "" {
|
||||
return redactedText
|
||||
}
|
||||
// Metadata-only query languages intentionally do not include raw input in
|
||||
// their fingerprint. Payload-derived hashes can be dictionary-guessed when
|
||||
// messages contain low-entropy tokens, card numbers or common credentials.
|
||||
return "metadata:" + normalizeQueryType(dbType)
|
||||
}
|
||||
|
||||
func sanitizeEvent(event Event, settings Settings) Event {
|
||||
event.Sequence = 0
|
||||
event.PrevHash = ""
|
||||
event.Hash = ""
|
||||
event.EventType = strings.ToLower(sanitizeLabel(event.EventType, 64))
|
||||
event.Status = strings.ToLower(sanitizeLabel(event.Status, 64))
|
||||
event.ConnectionID = sanitizeLabel(event.ConnectionID, 256)
|
||||
event.DBType = strings.ToLower(sanitizeLabel(event.DBType, 64))
|
||||
event.Database = sanitizeLabel(event.Database, 512)
|
||||
event.QueryID = sanitizeLabel(event.QueryID, 256)
|
||||
event.TransactionID = sanitizeLabel(event.TransactionID, 256)
|
||||
event.Source = strings.ToLower(sanitizeLabel(event.Source, 64))
|
||||
event.BoundaryMode = normalizeBoundaryMode(event.BoundaryMode)
|
||||
event.CommitMode = normalizeCommitMode(event.CommitMode)
|
||||
|
||||
rawSQL := event.SQLText
|
||||
redactedSQL := RedactQuery(event.DBType, rawSQL)
|
||||
fingerprintSeed := queryFingerprintSeed(event.DBType, redactedSQL)
|
||||
event.SQLFingerprint = versionedDigest("sqlaudit-sql-fingerprint-v1", fingerprintSeed)
|
||||
event.SQLRedacted = true
|
||||
if settings.CaptureMode == CaptureModeMetadata {
|
||||
event.SQLText = ""
|
||||
} else {
|
||||
event.SQLText = redactedSQL
|
||||
}
|
||||
|
||||
fingerprintSeed = strings.TrimSpace(event.ConnectionFingerprint)
|
||||
if fingerprintSeed == "" {
|
||||
fingerprintSeed = strings.Join([]string{event.ConnectionID, event.DBType, event.Database}, "\x00")
|
||||
}
|
||||
event.ConnectionFingerprint = versionedDigest("sqlaudit-connection-fingerprint-v1", fingerprintSeed)
|
||||
event.Error = RedactError(event.Error)
|
||||
if event.StatementIndex < 0 {
|
||||
event.StatementIndex = 0
|
||||
}
|
||||
if event.StatementCount < 0 {
|
||||
event.StatementCount = 0
|
||||
}
|
||||
if event.DurationMs < 0 {
|
||||
event.DurationMs = 0
|
||||
}
|
||||
if event.RowsAffected < 0 {
|
||||
event.RowsAffected = 0
|
||||
}
|
||||
if event.RowsReturned < 0 {
|
||||
event.RowsReturned = 0
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
func normalizeBoundaryMode(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case BoundaryModeDriverAPI:
|
||||
return BoundaryModeDriverAPI
|
||||
case BoundaryModeTextSQL:
|
||||
return BoundaryModeTextSQL
|
||||
case BoundaryModeImplicit:
|
||||
return BoundaryModeImplicit
|
||||
default:
|
||||
return BoundaryModeUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeCommitMode(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case CommitModeAuto:
|
||||
return CommitModeAuto
|
||||
case CommitModeManual:
|
||||
return CommitModeManual
|
||||
case CommitModePending:
|
||||
return CommitModePending
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func versionedDigest(version, value string) string {
|
||||
digest := sha256.Sum256([]byte(version + "\x00" + value))
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
func sanitizeLabel(value string, maxRunes int) string {
|
||||
value = strings.Map(func(r rune) rune {
|
||||
if unicode.IsControl(r) {
|
||||
return ' '
|
||||
}
|
||||
return r
|
||||
}, strings.TrimSpace(value))
|
||||
return truncateRunes(value, maxRunes)
|
||||
}
|
||||
|
||||
func truncateRunes(value string, max int) string {
|
||||
if max <= 0 || utf8.RuneCountInString(value) <= max {
|
||||
return value
|
||||
}
|
||||
runes := []rune(value)
|
||||
return string(runes[:max])
|
||||
}
|
||||
|
||||
func redactQuotedSegments(value string) string {
|
||||
var out strings.Builder
|
||||
out.Grow(len(value))
|
||||
for index := 0; index < len(value); {
|
||||
if value[index] == '\'' || value[index] == '"' {
|
||||
quote := value[index]
|
||||
out.WriteByte(quote)
|
||||
out.WriteByte('?')
|
||||
out.WriteByte(quote)
|
||||
index = skipQuoted(value, index, quote)
|
||||
continue
|
||||
}
|
||||
out.WriteByte(value[index])
|
||||
index++
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func skipQuoted(value string, start int, quote byte) int {
|
||||
for index := start + 1; index < len(value); index++ {
|
||||
if value[index] == '\\' {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if value[index] != quote {
|
||||
continue
|
||||
}
|
||||
if index+1 < len(value) && value[index+1] == quote {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
return index + 1
|
||||
}
|
||||
return len(value)
|
||||
}
|
||||
|
||||
func skipLineComment(value string, start int) int {
|
||||
for index := start; index < len(value); index++ {
|
||||
if value[index] == '\r' || value[index] == '\n' {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return len(value)
|
||||
}
|
||||
|
||||
func skipBlockComment(value string, start int) int {
|
||||
depth := 1
|
||||
for index := start; index+1 < len(value); {
|
||||
switch value[index : index+2] {
|
||||
case "/*":
|
||||
depth++
|
||||
index += 2
|
||||
case "*/":
|
||||
depth--
|
||||
index += 2
|
||||
if depth == 0 {
|
||||
return index
|
||||
}
|
||||
default:
|
||||
index++
|
||||
}
|
||||
}
|
||||
return len(value)
|
||||
}
|
||||
|
||||
func skipDollarQuotedLiteral(value string, start int) (int, bool) {
|
||||
endTag := start + 1
|
||||
for endTag < len(value) && (value[endTag] == '_' || isASCIILetterOrDigit(value[endTag])) {
|
||||
endTag++
|
||||
}
|
||||
if endTag >= len(value) || value[endTag] != '$' {
|
||||
return start, false
|
||||
}
|
||||
tag := value[start : endTag+1]
|
||||
contentEnd := strings.Index(value[endTag+1:], tag)
|
||||
if contentEnd < 0 {
|
||||
return len(value), true
|
||||
}
|
||||
return endTag + 1 + contentEnd + len(tag), true
|
||||
}
|
||||
|
||||
func skipOracleQuotedLiteral(value string, start int) (int, bool) {
|
||||
if start+3 >= len(value) || (value[start] != 'q' && value[start] != 'Q') || value[start+1] != '\'' {
|
||||
return start, false
|
||||
}
|
||||
opening := value[start+2]
|
||||
closing := opening
|
||||
switch opening {
|
||||
case '[':
|
||||
closing = ']'
|
||||
case '{':
|
||||
closing = '}'
|
||||
case '(':
|
||||
closing = ')'
|
||||
case '<':
|
||||
closing = '>'
|
||||
}
|
||||
for index := start + 3; index+1 < len(value); index++ {
|
||||
if value[index] == closing && value[index+1] == '\'' {
|
||||
return index + 2, true
|
||||
}
|
||||
}
|
||||
return len(value), true
|
||||
}
|
||||
|
||||
func isNumberStart(value string, index int) bool {
|
||||
if index >= len(value) || value[index] < '0' || value[index] > '9' {
|
||||
return false
|
||||
}
|
||||
if index == 0 {
|
||||
return true
|
||||
}
|
||||
previous := value[index-1]
|
||||
return !(previous == '_' || previous == '$' || isASCIILetterOrDigit(previous))
|
||||
}
|
||||
|
||||
func skipNumber(value string, start int) int {
|
||||
index := start
|
||||
if index+2 <= len(value) && index+1 < len(value) && value[index] == '0' && (value[index+1] == 'x' || value[index+1] == 'X') {
|
||||
index += 2
|
||||
for index < len(value) && isASCIIHex(value[index]) {
|
||||
index++
|
||||
}
|
||||
return index
|
||||
}
|
||||
for index < len(value) && value[index] >= '0' && value[index] <= '9' {
|
||||
index++
|
||||
}
|
||||
if index < len(value) && value[index] == '.' {
|
||||
index++
|
||||
for index < len(value) && value[index] >= '0' && value[index] <= '9' {
|
||||
index++
|
||||
}
|
||||
}
|
||||
if index < len(value) && (value[index] == 'e' || value[index] == 'E') {
|
||||
index++
|
||||
if index < len(value) && (value[index] == '+' || value[index] == '-') {
|
||||
index++
|
||||
}
|
||||
for index < len(value) && value[index] >= '0' && value[index] <= '9' {
|
||||
index++
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
func hasPrefixAt(value string, index int, prefix string) bool {
|
||||
return index >= 0 && index+len(prefix) <= len(value) && value[index:index+len(prefix)] == prefix
|
||||
}
|
||||
|
||||
func isASCIILetterOrDigit(value byte) bool {
|
||||
return value >= 'a' && value <= 'z' || value >= 'A' && value <= 'Z' || value >= '0' && value <= '9'
|
||||
}
|
||||
|
||||
func isASCIIHex(value byte) bool {
|
||||
return value >= '0' && value <= '9' || value >= 'a' && value <= 'f' || value >= 'A' && value <= 'F'
|
||||
}
|
||||
167
internal/sqlaudit/redact_test.go
Normal file
167
internal/sqlaudit/redact_test.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package sqlaudit
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRedactSQLRemovesDialectLiteralsCommentsAndCredentials(t *testing.T) {
|
||||
input := `-- comment-secret
|
||||
SELECT 'alice-secret', 12345, $$postgres-secret$$, q'[oracle-secret]'
|
||||
FROM users /* block-secret */
|
||||
WHERE password = bare-secret
|
||||
AND endpoint = 'postgres://alice:db-secret@example.test/app'
|
||||
AND note = "quoted-secret"`
|
||||
|
||||
redacted := RedactSQL(input)
|
||||
for _, secret := range []string{
|
||||
"comment-secret", "alice-secret", "12345", "postgres-secret", "oracle-secret",
|
||||
"block-secret", "bare-secret", "alice", "db-secret", "quoted-secret",
|
||||
} {
|
||||
if strings.Contains(redacted, secret) {
|
||||
t.Fatalf("redacted SQL leaked %q: %s", secret, redacted)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(redacted, "SELECT") || !strings.Contains(redacted, "FROM users") {
|
||||
t.Fatalf("redaction should retain SQL structure: %s", redacted)
|
||||
}
|
||||
if !strings.Contains(redacted, "password = ?") {
|
||||
t.Fatalf("sensitive unquoted assignment should be replaced: %s", redacted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactSQLPreservesIdentifiersAndPlaceholders(t *testing.T) {
|
||||
input := "SELECT report_2026, `OrderID`, [AccountID] FROM events WHERE id = $1 AND name = ?"
|
||||
redacted := RedactSQL(input)
|
||||
for _, fragment := range []string{"report_2026", "`OrderID`", "[AccountID]", "$1", "name = ?"} {
|
||||
if !strings.Contains(redacted, fragment) {
|
||||
t.Fatalf("expected %q to remain in %q", fragment, redacted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactErrorRemovesSecretsAndControlCharacters(t *testing.T) {
|
||||
input := "connect postgres://alice:db-secret@example.test/app password=top-secret " +
|
||||
"Authorization: Bearer abc.def.ghi Basic dXNlcjpwYXNz fallback alice:bare-pass@db.internal oracle scott/tiger@dbhost/service near 'literal-secret'\nnext"
|
||||
redacted := RedactError(input)
|
||||
for _, secret := range []string{"alice", "db-secret", "top-secret", "abc.def.ghi", "dXNlcjpwYXNz", "bare-pass", "scott", "tiger", "service", "literal-secret"} {
|
||||
if strings.Contains(redacted, secret) {
|
||||
t.Fatalf("redacted error leaked %q: %s", secret, redacted)
|
||||
}
|
||||
}
|
||||
if strings.ContainsAny(redacted, "\r\n") {
|
||||
t.Fatalf("redacted error should be single line: %q", redacted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactSQLRemovesNestedBlockComments(t *testing.T) {
|
||||
redacted := RedactSQL("SELECT 1 /* outer /* nested */ private-after-nested */ FROM users")
|
||||
if strings.Contains(redacted, "outer") || strings.Contains(redacted, "nested") || strings.Contains(redacted, "private-after-nested") {
|
||||
t.Fatalf("nested SQL comment leaked into audit text: %q", redacted)
|
||||
}
|
||||
if !strings.Contains(redacted, "FROM users") {
|
||||
t.Fatalf("nested comment redaction removed following SQL structure: %q", redacted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactSQLPreservesSensitiveNamedColumnsWithoutAssignments(t *testing.T) {
|
||||
input := "SELECT password, token, secret FROM sessions"
|
||||
if redacted := RedactSQL(input); redacted != input {
|
||||
t.Fatalf("sensitive-named columns were mistaken for assigned values: %q", redacted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactErrorRemovesUnquotedDuplicateKeyValues(t *testing.T) {
|
||||
input := "ERROR: duplicate key value violates unique constraint; DETAIL: Key (email, tenant_id)=(alice@example.test, 42) already exists; The duplicate key value is (private-token). Failing row contains (7, failing@example.test, row-secret)."
|
||||
redacted := RedactError(input)
|
||||
for _, secret := range []string{"alice@example.test", "42", "private-token", "failing@example.test", "row-secret"} {
|
||||
if strings.Contains(redacted, secret) {
|
||||
t.Fatalf("redacted duplicate-key error leaked %q: %s", secret, redacted)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(redacted, "Key (email, tenant_id)=(?)") {
|
||||
t.Fatalf("redacted error lost safe constraint structure: %s", redacted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactionBoundsStoredText(t *testing.T) {
|
||||
longSQL := "SELECT '" + strings.Repeat("界", maxSQLRunes+100) + "'"
|
||||
if got := len([]rune(RedactSQL(longSQL))); got > maxSQLRunes {
|
||||
t.Fatalf("redacted SQL exceeds limit: %d", got)
|
||||
}
|
||||
longError := strings.Repeat("failure ", maxErrorRunes)
|
||||
if got := len([]rune(RedactError(longError))); got > maxErrorRunes {
|
||||
t.Fatalf("redacted error exceeds limit: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactQueryUsesSQLLexerForSQLTypes(t *testing.T) {
|
||||
query := "SELECT * FROM users WHERE password = 'sql-secret' AND id = 42"
|
||||
for _, dbType := range []string{"mysql", "postgres", "sqlserver", "oracle", "clickhouse", "custom"} {
|
||||
if got, want := RedactQuery(dbType, query), RedactSQL(query); got != want {
|
||||
t.Fatalf("RedactQuery(%s)=%q, want SQL redaction %q", dbType, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactQueryRedactsRedisValuesAndPreservesCommandsAndKeys(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
want string
|
||||
secrets []string
|
||||
}{
|
||||
{name: "auth", query: "AUTH default auth-secret", want: "AUTH ? ?", secrets: []string{"default", "auth-secret"}},
|
||||
{name: "hello auth", query: "HELLO 3 AUTH app hello-secret SETNAME client-secret", want: "HELLO 3 AUTH ? ? SETNAME ?", secrets: []string{"app", "hello-secret", "client-secret"}},
|
||||
{name: "config set", query: "CONFIG SET requirepass config-secret", want: "CONFIG SET requirepass ?", secrets: []string{"config-secret"}},
|
||||
{name: "set", query: "SET session:key set-secret EX 60", want: "SET session:key ?", secrets: []string{"set-secret", "60"}},
|
||||
{name: "mset", query: "MSET key:1 first-secret key:2 second-secret", want: "MSET key:1 ? key:2 ?", secrets: []string{"first-secret", "second-secret"}},
|
||||
{name: "hset", query: "HSET profile:1 email private@example.test token hash-secret", want: "HSET profile:1 email ? token ?", secrets: []string{"private@example.test", "hash-secret"}},
|
||||
{name: "list", query: "LPUSH queue:key payload-one payload-two", want: "LPUSH queue:key ?", secrets: []string{"payload-one", "payload-two"}},
|
||||
{name: "set member", query: "SADD set:key member-secret", want: "SADD set:key ?", secrets: []string{"member-secret"}},
|
||||
{name: "sorted set", query: "ZADD rank:key 12.5 member-secret", want: "ZADD rank:key ?", secrets: []string{"12.5", "member-secret"}},
|
||||
{name: "publish", query: "PUBLISH channel:key message-secret", want: "PUBLISH channel:key ?", secrets: []string{"message-secret"}},
|
||||
{name: "stream", query: "XADD stream:key * field payload-secret", want: "XADD stream:key ?", secrets: []string{"payload-secret", "field"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := RedactQuery("redis", tt.query)
|
||||
if got != tt.want {
|
||||
t.Fatalf("RedactQuery(redis)=%q, want %q", got, tt.want)
|
||||
}
|
||||
for _, secret := range tt.secrets {
|
||||
if strings.Contains(got, secret) {
|
||||
t.Fatalf("Redis redaction leaked %q in %q", secret, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactQueryDefaultsMessageAndUnknownLanguagesToMetadataOnly(t *testing.T) {
|
||||
query := `PUBLISH topic {"token":"message-secret"}`
|
||||
for _, dbType := range []string{"rocketmq", "mqtt", "kafka", "rabbitmq", "mongodb", "elasticsearch", "milvus", "mystery-driver"} {
|
||||
if got := RedactQuery(dbType, query); got != "" {
|
||||
t.Fatalf("RedactQuery(%s)=%q, want metadata-only empty text", dbType, got)
|
||||
}
|
||||
}
|
||||
if got := RedactQuery("redis", `SET key "unterminated-secret`); got != "" {
|
||||
t.Fatalf("unparseable Redis input must fall back to metadata-only, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeEventPreservesPendingCommitMode(t *testing.T) {
|
||||
event := sanitizeEvent(Event{
|
||||
EventType: "transaction_begin",
|
||||
Status: "success",
|
||||
CommitMode: CommitModePending,
|
||||
}, Settings{
|
||||
Enabled: true,
|
||||
CaptureMode: CaptureModeRedacted,
|
||||
RetentionDays: 30,
|
||||
MaxRecords: 100,
|
||||
})
|
||||
if event.CommitMode != CommitModePending {
|
||||
t.Fatalf("pending commit mode = %q, want %q", event.CommitMode, CommitModePending)
|
||||
}
|
||||
}
|
||||
880
internal/sqlaudit/store.go
Normal file
880
internal/sqlaudit/store.go
Normal file
@@ -0,0 +1,880 @@
|
||||
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
|
||||
}
|
||||
652
internal/sqlaudit/store_test.go
Normal file
652
internal/sqlaudit/store_test.go
Normal file
@@ -0,0 +1,652 @@
|
||||
package sqlaudit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func openTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
store, err := Open(filepath.Join(t.TempDir(), "audit", "sql_audit.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Open returned error: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := store.Close(); err != nil && !errors.Is(err, ErrClosed) {
|
||||
t.Errorf("Close returned error: %v", err)
|
||||
}
|
||||
})
|
||||
return store
|
||||
}
|
||||
|
||||
func sampleEvent(id string, timestamp int64) Event {
|
||||
return Event{
|
||||
ID: id,
|
||||
Timestamp: timestamp,
|
||||
EventType: "query",
|
||||
Status: "success",
|
||||
ConnectionID: "conn-main",
|
||||
ConnectionFingerprint: "postgres://admin:raw-secret@db.example/app",
|
||||
DBType: "mysql",
|
||||
Database: "analytics",
|
||||
QueryID: "query-1",
|
||||
Source: "query_editor",
|
||||
BoundaryMode: BoundaryModeDriverAPI,
|
||||
SQLText: "SELECT * FROM users WHERE id = 42 AND token = 'raw-token'",
|
||||
DurationMs: 25,
|
||||
RowsReturned: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenConfiguresSQLiteAndDefaultSettings(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
settings, err := store.GetSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSettings returned error: %v", err)
|
||||
}
|
||||
if settings != DefaultSettings() {
|
||||
t.Fatalf("unexpected defaults: %#v", settings)
|
||||
}
|
||||
|
||||
var journalMode string
|
||||
if err := store.db.QueryRow(`PRAGMA journal_mode`).Scan(&journalMode); err != nil {
|
||||
t.Fatalf("read journal mode: %v", err)
|
||||
}
|
||||
if strings.ToLower(journalMode) != "wal" {
|
||||
t.Fatalf("journal mode must be WAL, got %q", journalMode)
|
||||
}
|
||||
var synchronous int
|
||||
if err := store.db.QueryRow(`PRAGMA synchronous`).Scan(&synchronous); err != nil {
|
||||
t.Fatalf("read synchronous mode: %v", err)
|
||||
}
|
||||
if synchronous != 2 {
|
||||
t.Fatalf("synchronous must be FULL (2), got %d", synchronous)
|
||||
}
|
||||
if got := store.db.Stats().MaxOpenConnections; got != 4 {
|
||||
t.Fatalf("MaxOpenConnections=%d, want 4", got)
|
||||
}
|
||||
|
||||
connections := make([]interface{ Close() error }, 0, 4)
|
||||
for index := 0; index < 4; index++ {
|
||||
conn, err := store.db.Conn(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("acquire pooled connection %d: %v", index+1, err)
|
||||
}
|
||||
connections = append(connections, conn)
|
||||
var busyTimeout, foreignKeys, connectionSynchronous int
|
||||
var connectionJournal string
|
||||
if err := conn.QueryRowContext(context.Background(), `PRAGMA busy_timeout`).Scan(&busyTimeout); err != nil {
|
||||
t.Fatalf("read connection %d busy_timeout: %v", index+1, err)
|
||||
}
|
||||
if err := conn.QueryRowContext(context.Background(), `PRAGMA foreign_keys`).Scan(&foreignKeys); err != nil {
|
||||
t.Fatalf("read connection %d foreign_keys: %v", index+1, err)
|
||||
}
|
||||
if err := conn.QueryRowContext(context.Background(), `PRAGMA synchronous`).Scan(&connectionSynchronous); err != nil {
|
||||
t.Fatalf("read connection %d synchronous: %v", index+1, err)
|
||||
}
|
||||
if err := conn.QueryRowContext(context.Background(), `PRAGMA journal_mode`).Scan(&connectionJournal); err != nil {
|
||||
t.Fatalf("read connection %d journal_mode: %v", index+1, err)
|
||||
}
|
||||
if busyTimeout != 5000 || foreignKeys != 1 || connectionSynchronous != 2 || strings.ToLower(connectionJournal) != "wal" {
|
||||
t.Fatalf("connection %d PRAGMAs unexpected: busy=%d foreign=%d sync=%d journal=%q",
|
||||
index+1, busyTimeout, foreignKeys, connectionSynchronous, connectionJournal)
|
||||
}
|
||||
}
|
||||
for _, conn := range connections {
|
||||
if err := conn.Close(); err != nil {
|
||||
t.Fatalf("close pooled connection: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if runtime.GOOS != "windows" {
|
||||
dirInfo, err := os.Stat(filepath.Dir(store.Path()))
|
||||
if err != nil {
|
||||
t.Fatalf("stat audit directory: %v", err)
|
||||
}
|
||||
if got := dirInfo.Mode().Perm(); got != 0o700 {
|
||||
t.Fatalf("audit directory mode = %#o, want 0700", got)
|
||||
}
|
||||
fileInfo, err := os.Stat(store.Path())
|
||||
if err != nil {
|
||||
t.Fatalf("stat audit database: %v", err)
|
||||
}
|
||||
if got := fileInfo.Mode().Perm(); got != 0o600 {
|
||||
t.Fatalf("audit database mode = %#o, want 0600", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenSupportsURIReservedCharactersInPath(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audit space # percent% amp&", "sql audit.db")
|
||||
store, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Open reserved-character path returned error: %v", err)
|
||||
}
|
||||
if err := store.Append(sampleEvent("reserved-path", time.Now().UnixMilli())); err != nil {
|
||||
_ = store.Close()
|
||||
t.Fatalf("Append reserved-character path returned error: %v", err)
|
||||
}
|
||||
if err := store.Close(); err != nil {
|
||||
t.Fatalf("Close reserved-character path returned error: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("SQLite database was not created at literal path %q: %v", path, err)
|
||||
}
|
||||
if info, err := os.Stat(path + "-wal"); err == nil && info.Size() > 0 {
|
||||
t.Fatalf("Close should checkpoint/truncate WAL before migration, size=%d", info.Size())
|
||||
} else if err != nil && !os.IsNotExist(err) {
|
||||
t.Fatalf("stat WAL after Close: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendSanitizesAndQueryFiltersWithSummary(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
now := time.Now().UnixMilli()
|
||||
first := sampleEvent("event-1", now-100)
|
||||
first.TransactionID = "tx-1"
|
||||
if err := store.Append(first); err != nil {
|
||||
t.Fatalf("Append first returned error: %v", err)
|
||||
}
|
||||
second := sampleEvent("event-2", now)
|
||||
second.Status = "error"
|
||||
second.TransactionID = "tx-2"
|
||||
second.Error = "password=driver-secret failed near 'raw-token'"
|
||||
if err := store.Append(second); err != nil {
|
||||
t.Fatalf("Append second returned error: %v", err)
|
||||
}
|
||||
|
||||
page, err := store.Query(Filter{Database: "analytics", Page: 1, PageSize: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("Query returned error: %v", err)
|
||||
}
|
||||
if page.Total != 2 || len(page.Items) != 1 || page.Items[0].ID != "event-2" {
|
||||
t.Fatalf("unexpected page: %#v", page)
|
||||
}
|
||||
if page.Summary.TotalEvents != 2 || page.Summary.SuccessCount != 1 ||
|
||||
page.Summary.ErrorCount != 1 || page.Summary.TransactionCount != 2 {
|
||||
t.Fatalf("unexpected summary: %#v", page.Summary)
|
||||
}
|
||||
event := page.Items[0]
|
||||
for _, secret := range []string{"42", "raw-token", "driver-secret", "admin", "raw-secret", "db.example"} {
|
||||
if strings.Contains(event.SQLText+event.Error+event.ConnectionFingerprint, secret) {
|
||||
t.Fatalf("stored audit event leaked %q: %#v", secret, event)
|
||||
}
|
||||
}
|
||||
if !event.SQLRedacted || event.SQLFingerprint == "" || len(event.ConnectionFingerprint) != 64 {
|
||||
t.Fatalf("event was not safely normalized: %#v", event)
|
||||
}
|
||||
|
||||
errorPage, err := store.Query(Filter{Status: "error", Search: "password", PageSize: 10})
|
||||
if err != nil || errorPage.Total != 1 || len(errorPage.Items) != 1 {
|
||||
t.Fatalf("filtered query failed: page=%#v err=%v", errorPage, err)
|
||||
}
|
||||
|
||||
assertAuditStorageFilesDoNotContain(t, store.Path(), []string{"raw-token", "driver-secret", "raw-secret"})
|
||||
}
|
||||
|
||||
func TestQuerySupportsContractFiltersAndEscapesSearchWildcards(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
now := time.Now().UnixMilli()
|
||||
target := sampleEvent("filter-target", now)
|
||||
target.EventType = "TRANSACTION_COMMIT"
|
||||
target.Status = "SUCCESS"
|
||||
target.TransactionID = "tx-filter"
|
||||
target.Source = "SYSTEM"
|
||||
target.DBType = "MYSQL"
|
||||
if err := store.Append(target); err != nil {
|
||||
t.Fatalf("Append target returned error: %v", err)
|
||||
}
|
||||
other := sampleEvent("filter-other", now+100)
|
||||
other.ConnectionID = "conn-other"
|
||||
other.Database = "reporting"
|
||||
other.TransactionID = "tx-other"
|
||||
if err := store.Append(other); err != nil {
|
||||
t.Fatalf("Append other returned error: %v", err)
|
||||
}
|
||||
|
||||
page, err := store.Query(Filter{
|
||||
ConnectionID: "conn-main",
|
||||
Database: "analytics",
|
||||
DBType: "MYSQL",
|
||||
EventType: "TRANSACTION_COMMIT",
|
||||
Status: "SUCCESS",
|
||||
TransactionID: "tx-filter",
|
||||
Source: "SYSTEM",
|
||||
FromTimestamp: now - 1,
|
||||
ToTimestamp: now + 1,
|
||||
PageSize: 10,
|
||||
})
|
||||
if err != nil || page.Total != 1 || len(page.Items) != 1 || page.Items[0].ID != "filter-target" {
|
||||
t.Fatalf("contract filters did not isolate target: page=%#v err=%v", page, err)
|
||||
}
|
||||
|
||||
page, err = store.Query(Filter{Search: `%_' OR 1=1 --`, PageSize: 10})
|
||||
if err != nil || page.Total != 0 {
|
||||
t.Fatalf("literal wildcard/injection-like search should not broaden results: page=%#v err=%v", page, err)
|
||||
}
|
||||
|
||||
all, err := store.Query(Filter{PageSize: 10})
|
||||
if err != nil || len(all.Items) == 0 {
|
||||
t.Fatalf("load events for fingerprint search: page=%#v err=%v", all, err)
|
||||
}
|
||||
fingerprintPrefix := all.Items[0].SQLFingerprint[:12]
|
||||
page, err = store.Query(Filter{Search: fingerprintPrefix, PageSize: 10})
|
||||
if err != nil || page.Total == 0 {
|
||||
t.Fatalf("fingerprint search should find matching events: page=%#v err=%v", page, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataAndDisabledCaptureModes(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
settings := DefaultSettings()
|
||||
settings.CaptureMode = CaptureModeMetadata
|
||||
if err := store.UpdateSettings(settings); err != nil {
|
||||
t.Fatalf("UpdateSettings(metadata) returned error: %v", err)
|
||||
}
|
||||
if err := store.Append(sampleEvent("metadata-event", time.Now().UnixMilli())); err != nil {
|
||||
t.Fatalf("Append metadata event returned error: %v", err)
|
||||
}
|
||||
page, err := store.Query(Filter{PageSize: 10})
|
||||
if err != nil || len(page.Items) != 1 {
|
||||
t.Fatalf("query metadata event: page=%#v err=%v", page, err)
|
||||
}
|
||||
if page.Items[0].SQLText != "" || page.Items[0].SQLFingerprint == "" || !page.Items[0].SQLRedacted {
|
||||
t.Fatalf("metadata capture persisted SQL text: %#v", page.Items[0])
|
||||
}
|
||||
|
||||
settings.Enabled = false
|
||||
if err := store.UpdateSettings(settings); err != nil {
|
||||
t.Fatalf("UpdateSettings(disabled) returned error: %v", err)
|
||||
}
|
||||
if err := store.Append(sampleEvent("disabled-event", time.Now().UnixMilli())); err != nil {
|
||||
t.Fatalf("disabled Append should be a no-op: %v", err)
|
||||
}
|
||||
page, err = store.Query(Filter{PageSize: 10})
|
||||
if err != nil || page.Total != 1 {
|
||||
t.Fatalf("disabled capture should not append: page=%#v err=%v", page, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlBoundariesAreAtomicAndBypassDisabledCapture(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
settings := DefaultSettings()
|
||||
settings.Enabled = false
|
||||
settings.CaptureMode = CaptureModeMetadata
|
||||
control := Event{ID: "settings-control", EventType: "audit_settings_change", Status: "success", Source: "audit_control"}
|
||||
if err := store.UpdateSettingsWithControl(settings, control); err != nil {
|
||||
t.Fatalf("UpdateSettingsWithControl returned error: %v", err)
|
||||
}
|
||||
if err := store.Append(sampleEvent("disabled-ordinary", time.Now().UnixMilli())); err != nil {
|
||||
t.Fatalf("disabled ordinary append returned error: %v", err)
|
||||
}
|
||||
page, err := store.Query(Filter{PageSize: 10})
|
||||
if err != nil || page.Total != 1 || page.Items[0].ID != "settings-control" {
|
||||
t.Fatalf("disabled control boundary was not retained: page=%#v err=%v", page, err)
|
||||
}
|
||||
if !strings.Contains(page.Items[0].SQLText, "FROM_ENABLED_ON TO_ENABLED_OFF") {
|
||||
t.Fatalf("settings control boundary lacks safe old/new metadata: %q", page.Items[0].SQLText)
|
||||
}
|
||||
|
||||
deleted, err := store.ClearWithControl(0, Event{ID: "clear-control", EventType: "audit_clear", Status: "success", Source: "audit_control"})
|
||||
if err != nil || deleted != 1 {
|
||||
t.Fatalf("ClearWithControl deleted=%d err=%v, want 1", deleted, err)
|
||||
}
|
||||
page, err = store.Query(Filter{PageSize: 10})
|
||||
if err != nil || page.Total != 1 || page.Items[0].ID != "clear-control" || page.Items[0].RowsAffected != 1 {
|
||||
t.Fatalf("clear control boundary was not atomically retained: page=%#v err=%v", page, err)
|
||||
}
|
||||
if page.Items[0].SQLText != "AUDIT CLEAR ALL" {
|
||||
t.Fatalf("metadata capture erased the clear control descriptor: %q", page.Items[0].SQLText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendNeverPersistsRedisOrMessagePayloadSecrets(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
now := time.Now().UnixMilli()
|
||||
redisEvent := sampleEvent("redis-secret", now)
|
||||
redisEvent.DBType = "redis"
|
||||
redisEvent.SQLText = `HSET account:42 email private@example.test token redis-token-secret`
|
||||
redisEvent.Error = `AUTH failed password=redis-error-secret`
|
||||
if err := store.Append(redisEvent); err != nil {
|
||||
t.Fatalf("Append Redis event returned error: %v", err)
|
||||
}
|
||||
mqEvent := sampleEvent("mq-secret", now+1)
|
||||
mqEvent.DBType = "rocketmq"
|
||||
mqEvent.SQLText = `PUBLISH orders {"token":"mq-payload-secret","card":"4111111111111111"}`
|
||||
mqEvent.Error = `publish rejected token=mq-error-secret`
|
||||
if err := store.Append(mqEvent); err != nil {
|
||||
t.Fatalf("Append MQ event returned error: %v", err)
|
||||
}
|
||||
mqEventSecond := sampleEvent("mq-secret-second", now+2)
|
||||
mqEventSecond.DBType = "rocketmq"
|
||||
mqEventSecond.SQLText = `PUBLISH orders {"token":"different-low-entropy-secret"}`
|
||||
if err := store.Append(mqEventSecond); err != nil {
|
||||
t.Fatalf("Append second MQ event returned error: %v", err)
|
||||
}
|
||||
|
||||
page, err := store.Query(Filter{PageSize: 10})
|
||||
if err != nil || len(page.Items) != 3 {
|
||||
t.Fatalf("query non-SQL audit events: page=%#v err=%v", page, err)
|
||||
}
|
||||
byID := map[string]Event{}
|
||||
for _, event := range page.Items {
|
||||
byID[event.ID] = event
|
||||
}
|
||||
if got := byID["redis-secret"].SQLText; got != "HSET account:42 email ? token ?" {
|
||||
t.Fatalf("unexpected Redis audit text: %q", got)
|
||||
}
|
||||
if got := byID["mq-secret"].SQLText; got != "" {
|
||||
t.Fatalf("message payload must be metadata-only, got %q", got)
|
||||
}
|
||||
if byID["mq-secret"].SQLFingerprint == "" {
|
||||
t.Fatal("metadata-only message event still needs a safe fingerprint")
|
||||
}
|
||||
if byID["mq-secret"].SQLFingerprint != byID["mq-secret-second"].SQLFingerprint {
|
||||
t.Fatalf("metadata-only fingerprint must not depend on message payload: first=%s second=%s",
|
||||
byID["mq-secret"].SQLFingerprint, byID["mq-secret-second"].SQLFingerprint)
|
||||
}
|
||||
|
||||
assertAuditStorageFilesDoNotContain(t, store.Path(), []string{
|
||||
"private@example.test", "redis-token-secret", "redis-error-secret",
|
||||
"mq-payload-secret", "4111111111111111", "mq-error-secret", "different-low-entropy-secret",
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrityDetectsTampering(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
now := time.Now().UnixMilli()
|
||||
for index := 0; index < 2; index++ {
|
||||
if err := store.Append(sampleEvent("integrity-"+string(rune('a'+index)), now+int64(index))); err != nil {
|
||||
t.Fatalf("Append returned error: %v", err)
|
||||
}
|
||||
}
|
||||
report, err := store.VerifyIntegrity()
|
||||
if err != nil || !report.Valid || !report.WeakValidation || report.CheckedRecords != 2 {
|
||||
t.Fatalf("unexpected valid integrity report: %#v err=%v", report, err)
|
||||
}
|
||||
if _, err := store.db.Exec(`UPDATE sql_audit_events SET sql_text='tampered' WHERE sequence=1`); err != nil {
|
||||
t.Fatalf("tamper test database: %v", err)
|
||||
}
|
||||
report, err = store.VerifyIntegrity()
|
||||
if err != nil || report.Valid || report.InvalidSequence != 1 {
|
||||
t.Fatalf("tampering was not detected: %#v err=%v", report, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearAndRecordLimitPreserveRemainingHashes(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
now := time.Now().UnixMilli()
|
||||
for index := 0; index < 3; index++ {
|
||||
if err := store.Append(sampleEvent("clear-"+string(rune('a'+index)), now-3000+int64(index*1000))); err != nil {
|
||||
t.Fatalf("Append returned error: %v", err)
|
||||
}
|
||||
}
|
||||
beforeClear, err := store.Query(Filter{PageSize: 10})
|
||||
if err != nil || len(beforeClear.Items) != 3 {
|
||||
t.Fatalf("query before clear: page=%#v err=%v", beforeClear, err)
|
||||
}
|
||||
retainedHash := beforeClear.Items[0].Hash
|
||||
deleted, err := store.Clear(now - 1500)
|
||||
if err != nil || deleted != 2 {
|
||||
t.Fatalf("Clear deleted=%d err=%v, want 2", deleted, err)
|
||||
}
|
||||
page, err := store.Query(Filter{PageSize: 10})
|
||||
if err != nil || len(page.Items) != 1 || page.Items[0].PrevHash == "" {
|
||||
t.Fatalf("clear rewrote or lost the retained chain anchor: page=%#v err=%v", page, err)
|
||||
}
|
||||
if page.Items[0].Hash != retainedHash {
|
||||
t.Fatalf("clear must not rewrite retained event hash: before=%s after=%s", retainedHash, page.Items[0].Hash)
|
||||
}
|
||||
if report, err := store.VerifyIntegrity(); err != nil || !report.Valid || !report.PartialChain || !report.TruncatedPrefix {
|
||||
t.Fatalf("chain invalid after clear: report=%#v err=%v", report, err)
|
||||
}
|
||||
|
||||
settings := DefaultSettings()
|
||||
settings.MaxRecords = 2
|
||||
if err := store.UpdateSettings(settings); err != nil {
|
||||
t.Fatalf("UpdateSettings returned error: %v", err)
|
||||
}
|
||||
for index := 0; index < 3; index++ {
|
||||
if err := store.Append(sampleEvent("limited-"+string(rune('a'+index)), now+int64(index))); err != nil {
|
||||
t.Fatalf("Append limited event returned error: %v", err)
|
||||
}
|
||||
}
|
||||
page, err = store.Query(Filter{PageSize: 10})
|
||||
if err != nil || page.Total != 2 {
|
||||
t.Fatalf("record limit not enforced: page=%#v err=%v", page, err)
|
||||
}
|
||||
if report, err := store.VerifyIntegrity(); err != nil || !report.Valid || !report.PartialChain {
|
||||
t.Fatalf("chain invalid after record-limit pruning: report=%#v err=%v", report, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearDeletesOnlyContiguousPrefixForOutOfOrderTimestamps(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
now := time.Now().UnixMilli()
|
||||
for _, event := range []Event{
|
||||
sampleEvent("prefix-old", now-10_000),
|
||||
sampleEvent("prefix-new", now),
|
||||
sampleEvent("later-sequence-old-time", now-20_000),
|
||||
} {
|
||||
if err := store.Append(event); err != nil {
|
||||
t.Fatalf("Append returned error: %v", err)
|
||||
}
|
||||
}
|
||||
before, err := store.Query(Filter{PageSize: 10})
|
||||
if err != nil || len(before.Items) != 3 {
|
||||
t.Fatalf("query before clear: page=%#v err=%v", before, err)
|
||||
}
|
||||
hashes := map[string]string{}
|
||||
for _, event := range before.Items {
|
||||
hashes[event.ID] = event.Hash
|
||||
}
|
||||
|
||||
deleted, err := store.Clear(now - 5_000)
|
||||
if err != nil || deleted != 1 {
|
||||
t.Fatalf("Clear should delete only one contiguous prefix event: deleted=%d err=%v", deleted, err)
|
||||
}
|
||||
after, err := store.Query(Filter{PageSize: 10})
|
||||
if err != nil || len(after.Items) != 2 {
|
||||
t.Fatalf("query after clear: page=%#v err=%v", after, err)
|
||||
}
|
||||
for _, event := range after.Items {
|
||||
if event.Hash != hashes[event.ID] {
|
||||
t.Fatalf("retained event %s hash was rewritten", event.ID)
|
||||
}
|
||||
}
|
||||
if report, err := store.VerifyIntegrity(); err != nil || !report.Valid || !report.PartialChain {
|
||||
t.Fatalf("partial chain should remain verifiable: report=%#v err=%v", report, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentAppendKeepsUniqueOrderedChain(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
const count = 32
|
||||
now := time.Now().UnixMilli()
|
||||
var wait sync.WaitGroup
|
||||
errorsCh := make(chan error, count)
|
||||
for index := 0; index < count; index++ {
|
||||
wait.Add(1)
|
||||
go func(index int) {
|
||||
defer wait.Done()
|
||||
event := sampleEvent("concurrent-"+time.UnixMilli(int64(index)).Format("150405.000000000"), now+int64(index))
|
||||
errorsCh <- store.Append(event)
|
||||
}(index)
|
||||
}
|
||||
wait.Wait()
|
||||
close(errorsCh)
|
||||
for err := range errorsCh {
|
||||
if err != nil {
|
||||
t.Fatalf("concurrent Append returned error: %v", err)
|
||||
}
|
||||
}
|
||||
page, err := store.Query(Filter{PageSize: count})
|
||||
if err != nil || page.Total != count {
|
||||
t.Fatalf("concurrent events missing: total=%d err=%v", page.Total, err)
|
||||
}
|
||||
seen := make(map[int64]struct{}, count)
|
||||
for _, event := range page.Items {
|
||||
if _, duplicate := seen[event.Sequence]; duplicate {
|
||||
t.Fatalf("duplicate sequence %d", event.Sequence)
|
||||
}
|
||||
seen[event.Sequence] = struct{}{}
|
||||
}
|
||||
if report, err := store.VerifyIntegrity(); err != nil || !report.Valid || report.CheckedRecords != count {
|
||||
t.Fatalf("concurrent chain invalid: report=%#v err=%v", report, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWALReaderDoesNotBlockSynchronousAppend(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
now := time.Now().UnixMilli()
|
||||
if err := store.Append(sampleEvent("wal-seed", now)); err != nil {
|
||||
t.Fatalf("Append seed returned error: %v", err)
|
||||
}
|
||||
reader, err := store.db.Conn(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("acquire reader connection: %v", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
if _, err := reader.ExecContext(context.Background(), `BEGIN`); err != nil {
|
||||
t.Fatalf("begin reader transaction: %v", err)
|
||||
}
|
||||
rows, err := reader.QueryContext(context.Background(), `SELECT id FROM sql_audit_events ORDER BY sequence`)
|
||||
if err != nil {
|
||||
_, _ = reader.ExecContext(context.Background(), `ROLLBACK`)
|
||||
t.Fatalf("open reader cursor: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
t.Fatal("reader cursor should expose seed event")
|
||||
}
|
||||
var seedID string
|
||||
if err := rows.Scan(&seedID); err != nil || seedID != "wal-seed" {
|
||||
t.Fatalf("scan reader cursor id=%q err=%v", seedID, err)
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- store.Append(sampleEvent("wal-writer", now+1))
|
||||
}()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("Append while WAL reader active returned error: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("active WAL reader blocked synchronous audit append")
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
t.Fatalf("close reader rows: %v", err)
|
||||
}
|
||||
if _, err := reader.ExecContext(context.Background(), `ROLLBACK`); err != nil {
|
||||
t.Fatalf("rollback reader transaction: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendBatchIsAtomicAndKeepsOneContinuousChain(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
now := time.Now().UnixMilli()
|
||||
if err := store.Append(sampleEvent("before-batch", now)); err != nil {
|
||||
t.Fatalf("Append before batch returned error: %v", err)
|
||||
}
|
||||
batch := []Event{
|
||||
sampleEvent("batch-1", now+1),
|
||||
sampleEvent("batch-2", now+2),
|
||||
sampleEvent("batch-3", now+3),
|
||||
}
|
||||
if err := store.AppendBatch(batch); err != nil {
|
||||
t.Fatalf("AppendBatch returned error: %v", err)
|
||||
}
|
||||
page, err := store.Query(Filter{PageSize: 10})
|
||||
if err != nil || page.Total != 4 {
|
||||
t.Fatalf("query after batch: page=%#v err=%v", page, err)
|
||||
}
|
||||
report, err := store.VerifyIntegrity()
|
||||
if err != nil || !report.Valid || report.CheckedRecords != 4 {
|
||||
t.Fatalf("integrity after batch: report=%#v err=%v", report, err)
|
||||
}
|
||||
|
||||
duplicateBatch := []Event{
|
||||
sampleEvent("atomic-new", now+4),
|
||||
sampleEvent("batch-2", now+5),
|
||||
}
|
||||
if err := store.AppendBatch(duplicateBatch); err == nil {
|
||||
t.Fatal("AppendBatch with duplicate ID should fail")
|
||||
}
|
||||
page, err = store.Query(Filter{Search: "atomic-new", PageSize: 10})
|
||||
if err != nil || page.Total != 0 {
|
||||
t.Fatalf("failed batch should roll back every event: page=%#v err=%v", page, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendBatchLargerThanMaxRecordsFailsAtomically(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
settings := DefaultSettings()
|
||||
settings.MaxRecords = 3
|
||||
if err := store.UpdateSettings(settings); err != nil {
|
||||
t.Fatalf("UpdateSettings returned error: %v", err)
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
if err := store.Append(sampleEvent("existing-before-large-batch", now)); err != nil {
|
||||
t.Fatalf("Append existing event returned error: %v", err)
|
||||
}
|
||||
batch := make([]Event, 0, 6)
|
||||
for index := 0; index < 6; index++ {
|
||||
batch = append(batch, sampleEvent(fmt.Sprintf("large-batch-%d", index), now+int64(index+1)))
|
||||
}
|
||||
if err := store.AppendBatch(batch); !errors.Is(err, ErrBatchExceedsMaxRecords) {
|
||||
t.Fatalf("AppendBatch error = %v, want ErrBatchExceedsMaxRecords", err)
|
||||
}
|
||||
page, err := store.Query(Filter{PageSize: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("Query returned error: %v", err)
|
||||
}
|
||||
if page.Total != 1 || len(page.Items) != 1 || page.Items[0].ID != "existing-before-large-batch" {
|
||||
t.Fatalf("oversized batch must not modify existing audit records: %#v", page)
|
||||
}
|
||||
if report, err := store.VerifyIntegrity(); err != nil || !report.Valid || report.CheckedRecords != 1 {
|
||||
t.Fatalf("large-batch chain invalid: report=%#v err=%v", report, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsValidationAndClosedStore(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
invalid := DefaultSettings()
|
||||
invalid.CaptureMode = "full"
|
||||
if err := store.UpdateSettings(invalid); err == nil {
|
||||
t.Fatal("full/raw SQL capture mode must be rejected")
|
||||
}
|
||||
if err := store.Close(); err != nil {
|
||||
t.Fatalf("Close returned error: %v", err)
|
||||
}
|
||||
if err := store.Append(sampleEvent("closed", time.Now().UnixMilli())); !errors.Is(err, ErrClosed) {
|
||||
t.Fatalf("Append after Close error=%v, want ErrClosed", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertAuditStorageFilesDoNotContain(t *testing.T, databasePath string, secrets []string) {
|
||||
t.Helper()
|
||||
for _, path := range []string{databasePath, databasePath + "-wal", databasePath + "-shm"} {
|
||||
payload, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
t.Fatalf("read SQLite audit storage file %q: %v", path, err)
|
||||
}
|
||||
for _, secret := range secrets {
|
||||
if strings.Contains(string(payload), secret) {
|
||||
t.Fatalf("SQLite audit storage file %q contains raw secret %q", path, secret)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
114
internal/sqlaudit/types.go
Normal file
114
internal/sqlaudit/types.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package sqlaudit
|
||||
|
||||
const (
|
||||
CaptureModeRedacted = "redacted"
|
||||
CaptureModeMetadata = "metadata"
|
||||
|
||||
BoundaryModeDriverAPI = "driver_api"
|
||||
BoundaryModeTextSQL = "text_sql"
|
||||
BoundaryModeImplicit = "implicit"
|
||||
BoundaryModeUnknown = "unknown"
|
||||
|
||||
CommitModeAuto = "auto"
|
||||
CommitModeManual = "manual"
|
||||
CommitModePending = "pending"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPageSize = 50
|
||||
maxPageSize = 500
|
||||
defaultRetentionDays = 30
|
||||
defaultMaxRecords = 100_000
|
||||
)
|
||||
|
||||
// Event is one immutable SQL audit fact. SQLText is always sanitized again by
|
||||
// Store.Append; callers cannot use this type to persist raw SQL.
|
||||
type Event struct {
|
||||
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"`
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
|
||||
type Filter struct {
|
||||
Search string `json:"search"`
|
||||
ConnectionID string `json:"connectionId"`
|
||||
Database string `json:"database"`
|
||||
DBType string `json:"dbType"`
|
||||
EventType string `json:"eventType"`
|
||||
Status string `json:"status"`
|
||||
TransactionID string `json:"transactionId"`
|
||||
Source string `json:"source"`
|
||||
FromTimestamp int64 `json:"fromTimestamp"`
|
||||
ToTimestamp int64 `json:"toTimestamp"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
|
||||
type Summary struct {
|
||||
TotalEvents int64 `json:"totalEvents"`
|
||||
SuccessCount int64 `json:"successCount"`
|
||||
ErrorCount int64 `json:"errorCount"`
|
||||
TransactionCount int64 `json:"transactionCount"`
|
||||
}
|
||||
|
||||
type Page struct {
|
||||
Items []Event `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
Summary Summary `json:"summary"`
|
||||
}
|
||||
|
||||
type Settings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
CaptureMode string `json:"captureMode"`
|
||||
RetentionDays int `json:"retentionDays"`
|
||||
MaxRecords int `json:"maxRecords"`
|
||||
}
|
||||
|
||||
func DefaultSettings() Settings {
|
||||
return Settings{
|
||||
Enabled: true,
|
||||
CaptureMode: CaptureModeRedacted,
|
||||
RetentionDays: defaultRetentionDays,
|
||||
MaxRecords: defaultMaxRecords,
|
||||
}
|
||||
}
|
||||
|
||||
// IntegrityReport reports local SHA-256 chain consistency. WeakValidation is
|
||||
// always true because an unkeyed chain detects accidental/local edits but does
|
||||
// not stop an attacker who can rewrite both records and hashes.
|
||||
type IntegrityReport struct {
|
||||
Valid bool `json:"valid"`
|
||||
WeakValidation bool `json:"weakValidation"`
|
||||
PartialChain bool `json:"partialChain"`
|
||||
TruncatedPrefix bool `json:"truncatedPrefix"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
CheckedRecords int64 `json:"checkedRecords"`
|
||||
FirstSequence int64 `json:"firstSequence,omitempty"`
|
||||
LastSequence int64 `json:"lastSequence,omitempty"`
|
||||
InvalidSequence int64 `json:"invalidSequence,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
@@ -29,6 +29,56 @@ const (
|
||||
|
||||
var errorType = reflect.TypeOf((*error)(nil)).Elem()
|
||||
|
||||
var desktopOnlyAppMethods = map[string]struct{}{
|
||||
"Shutdown": {},
|
||||
"SetWindowTranslucency": {},
|
||||
"SetMacNativeWindowControls": {},
|
||||
"ResetWebViewZoom": {},
|
||||
"SelectDataRootDirectory": {},
|
||||
"GetDataRootDirectoryInfo": {},
|
||||
"ApplyDataRootDirectory": {},
|
||||
"OpenDataRootDirectory": {},
|
||||
"SelectDriverDownloadDirectory": {},
|
||||
"SelectDriverPackageFile": {},
|
||||
"SelectDriverPackageDirectory": {},
|
||||
"OpenSQLFile": {},
|
||||
"SelectSQLFileForExecution": {},
|
||||
"SelectSQLDirectory": {},
|
||||
"ListSQLDirectory": {},
|
||||
"ReadSQLFile": {},
|
||||
"WriteSQLFile": {},
|
||||
"CreateSQLFile": {},
|
||||
"CreateSQLDirectory": {},
|
||||
"DeleteSQLFile": {},
|
||||
"DeleteSQLDirectory": {},
|
||||
"RenameSQLFile": {},
|
||||
"RenameSQLDirectory": {},
|
||||
"ExecuteSQLFile": {},
|
||||
"ExportSQLFile": {},
|
||||
"ImportConfigFile": {},
|
||||
"ExportConnectionsPackage": {},
|
||||
"SelectSSHKeyFile": {},
|
||||
"SelectCertificateFile": {},
|
||||
"SelectDatabaseFile": {},
|
||||
"ImportData": {},
|
||||
"PreviewImportFile": {},
|
||||
"ImportDataWithProgress": {},
|
||||
"ExportTable": {},
|
||||
"ExportTableWithOptions": {},
|
||||
"ExportTablesSQL": {},
|
||||
"ExportTablesDataSQL": {},
|
||||
"ExportTablesSQLWithOptions": {},
|
||||
"ExportDatabaseSQL": {},
|
||||
"ExportDatabasesSQLWithOptions": {},
|
||||
"ExportSchemaSQL": {},
|
||||
"ExportData": {},
|
||||
"ExportDataWithOptions": {},
|
||||
"ExportQuery": {},
|
||||
"ExportQueryWithOptions": {},
|
||||
"RedisExportKeys": {},
|
||||
"ExportSQLAuditFile": {},
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
Addr string
|
||||
}
|
||||
@@ -128,6 +178,9 @@ func (i *methodInvoker) Invoke(req invokeRequest) (any, error) {
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unsupported invoke target: %s.%s", namespace, receiver)
|
||||
}
|
||||
if (key == "app" || key == "app.app") && isDesktopOnlyAppMethod(methodName) {
|
||||
return nil, fmt.Errorf("method %s is unavailable in web runtime", methodName)
|
||||
}
|
||||
|
||||
method := target.MethodByName(methodName)
|
||||
if !method.IsValid() {
|
||||
@@ -155,6 +208,11 @@ func (i *methodInvoker) Invoke(req invokeRequest) (any, error) {
|
||||
return unpackResults(results)
|
||||
}
|
||||
|
||||
func isDesktopOnlyAppMethod(methodName string) bool {
|
||||
_, denied := desktopOnlyAppMethods[strings.TrimSpace(methodName)]
|
||||
return denied
|
||||
}
|
||||
|
||||
func decodeArgument(raw json.RawMessage, targetType reflect.Type) (reflect.Value, error) {
|
||||
holder := reflect.New(targetType)
|
||||
if len(raw) == 0 {
|
||||
@@ -194,13 +252,14 @@ func unpackResults(results []reflect.Value) (any, error) {
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
options Options
|
||||
assets fs.FS
|
||||
app *appcore.App
|
||||
ai *aiservice.Service
|
||||
auth *webAuthManager
|
||||
events *eventHub
|
||||
invoker *methodInvoker
|
||||
options Options
|
||||
assets fs.FS
|
||||
app *appcore.App
|
||||
ai *aiservice.Service
|
||||
auth *webAuthManager
|
||||
events *eventHub
|
||||
invoker *methodInvoker
|
||||
auditHeavySem chan struct{}
|
||||
}
|
||||
|
||||
func ParseOptions(args []string) (Options, error) {
|
||||
@@ -246,7 +305,7 @@ func New(ctx context.Context, assetFS fs.FS, options Options) (*Server, error) {
|
||||
events := newEventHub()
|
||||
lifecycleCtx := uievents.WithEmitter(ctx, events)
|
||||
|
||||
app := appcore.NewApp()
|
||||
app := appcore.NewWebApp()
|
||||
appcore.InitializeLifecycle(app, lifecycleCtx)
|
||||
ai := aiservice.NewService()
|
||||
aiservice.InitializeLifecycle(ai, lifecycleCtx)
|
||||
@@ -256,13 +315,14 @@ func New(ctx context.Context, assetFS fs.FS, options Options) (*Server, error) {
|
||||
}
|
||||
|
||||
return &Server{
|
||||
options: options,
|
||||
assets: frontendFS,
|
||||
app: app,
|
||||
ai: ai,
|
||||
auth: auth,
|
||||
events: events,
|
||||
invoker: newMethodInvoker(app, ai),
|
||||
options: options,
|
||||
assets: frontendFS,
|
||||
app: app,
|
||||
ai: ai,
|
||||
auth: auth,
|
||||
events: events,
|
||||
invoker: newMethodInvoker(app, ai),
|
||||
auditHeavySem: make(chan struct{}, 1),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -412,6 +472,15 @@ func (s *Server) handleInvoke(w http.ResponseWriter, r *http.Request) {
|
||||
s.writeInvokeResponse(w, http.StatusBadRequest, invokeResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if isSQLAuditHeavyInvoke(request) && s.auditHeavySem != nil {
|
||||
select {
|
||||
case s.auditHeavySem <- struct{}{}:
|
||||
defer func() { <-s.auditHeavySem }()
|
||||
default:
|
||||
s.writeInvokeResponse(w, http.StatusTooManyRequests, invokeResponse{Error: "another SQL audit export or integrity verification is already in progress"})
|
||||
return
|
||||
}
|
||||
}
|
||||
result, err := s.invoker.Invoke(request)
|
||||
if err != nil {
|
||||
s.writeInvokeResponse(w, http.StatusBadRequest, invokeResponse{Error: err.Error()})
|
||||
@@ -420,6 +489,20 @@ func (s *Server) handleInvoke(w http.ResponseWriter, r *http.Request) {
|
||||
s.writeInvokeResponse(w, http.StatusOK, invokeResponse{Result: result})
|
||||
}
|
||||
|
||||
func isSQLAuditHeavyInvoke(request invokeRequest) bool {
|
||||
namespace := strings.ToLower(strings.TrimSpace(request.Namespace))
|
||||
receiver := strings.ToLower(strings.TrimSpace(request.Receiver))
|
||||
if namespace != "app" || (receiver != "" && receiver != "app") {
|
||||
return false
|
||||
}
|
||||
switch strings.TrimSpace(request.Method) {
|
||||
case "BuildSQLAuditExport", "VerifySQLAuditIntegrity":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) writeInvokeResponse(w http.ResponseWriter, status int, response invokeResponse) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
|
||||
@@ -79,3 +79,33 @@ func TestMethodInvokerInvokeSupportsStructuredReturnValues(t *testing.T) {
|
||||
t.Fatalf("expected echoed value hello, got %#v", payload["value"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMethodInvokerRejectsDesktopOnlyAppMethodsBeforeReflection(t *testing.T) {
|
||||
invoker := &methodInvoker{
|
||||
targets: map[string]reflect.Value{
|
||||
"app.app": reflect.ValueOf(webserverTestReceiver{}),
|
||||
},
|
||||
}
|
||||
|
||||
for _, method := range []string{
|
||||
"Shutdown", "ExportSQLAuditFile", "OpenSQLFile", "ExecuteSQLFile", "ReadSQLFile",
|
||||
"PreviewImportFile", "ImportDataWithProgress", "GetDataRootDirectoryInfo",
|
||||
"ApplyDataRootDirectory", "OpenDataRootDirectory",
|
||||
} {
|
||||
_, err := invoker.Invoke(invokeRequest{Namespace: "app", Receiver: "app", Method: method})
|
||||
if err == nil || !strings.Contains(err.Error(), "unavailable in web runtime") {
|
||||
t.Fatalf("desktop-only method %s error = %v, want web runtime rejection", method, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLAuditHeavyInvokeIncludesExportAndIntegrityVerification(t *testing.T) {
|
||||
for _, method := range []string{"BuildSQLAuditExport", "VerifySQLAuditIntegrity"} {
|
||||
if !isSQLAuditHeavyInvoke(invokeRequest{Namespace: "app", Receiver: "app", Method: method}) {
|
||||
t.Fatalf("%s must share the SQL audit heavy-operation semaphore", method)
|
||||
}
|
||||
}
|
||||
if isSQLAuditHeavyInvoke(invokeRequest{Namespace: "app", Receiver: "app", Method: "GetSQLAuditEvents"}) {
|
||||
t.Fatal("ordinary paged audit reads must not use the heavy-operation semaphore")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user