feat(sql-audit): 新增 SQL 审计中心并完善事务追踪

- 新增脱敏审计存储、筛选、保留策略、完整性校验与 JSON/CSV 导出
- 覆盖查询编辑器、事务、导入同步、对象操作、AI/MCP 与 Web 运行时入口
- 完善事务完整语句日志、Windows 快捷键映射及 SQL 分析布局
- 补充多语言、健康状态、数据目录迁移和回归测试
This commit is contained in:
Syngnat
2026-07-12 15:24:15 +08:00
parent 0b33b3a683
commit 7c8e8d8dd3
89 changed files with 11421 additions and 194 deletions

View File

@@ -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 {

View File

@@ -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, "{") {

View File

@@ -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
}

View File

@@ -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")

View File

@@ -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 == "" {

View 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"
}
}

View 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)
}
}

View File

@@ -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)`,

View File

@@ -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() {

View File

@@ -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()
}
}

View File

@@ -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()}

File diff suppressed because it is too large Load Diff

View 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])
}
}

View File

@@ -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,

View 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())
}

View 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,
)
}

View File

@@ -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

View File

@@ -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}`,

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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
View 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
}
}

View 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
View 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'
}

View 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
View 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(&current); err != nil {
return 0, fmt.Errorf("count sql audit events: %w", err)
}
target := int64(settings.MaxRecords) - reserve
if target < 0 {
target = 0
}
if current > target {
removeCount := current - target
result, err := conn.ExecContext(context.Background(), `DELETE FROM sql_audit_events WHERE sequence IN (
SELECT sequence FROM sql_audit_events ORDER BY sequence ASC LIMIT ?
)`, removeCount)
if err != nil {
return 0, fmt.Errorf("enforce sql audit record limit: %w", err)
}
if count, err := result.RowsAffected(); err == nil {
deleted += count
}
}
return deleted, nil
}
func deleteAllLocked(conn *sql.Conn) (int64, error) {
result, err := conn.ExecContext(context.Background(), `DELETE FROM sql_audit_events`)
if err != nil {
return 0, fmt.Errorf("clear SQL audit events: %w", err)
}
deleted, err := result.RowsAffected()
if err != nil {
return 0, fmt.Errorf("count cleared SQL audit events: %w", err)
}
return deleted, nil
}
// deleteTimestampPrefixLocked never removes an event after the first retained
// sequence, even when a caller supplied an out-of-order timestamp. This keeps
// every remaining adjacency and record hash untouched.
func deleteTimestampPrefixLocked(conn *sql.Conn, beforeTimestamp int64) (int64, error) {
var firstRetainedSequence int64
err := conn.QueryRowContext(context.Background(), `SELECT sequence FROM sql_audit_events
WHERE timestamp >= ? ORDER BY sequence ASC LIMIT 1`, beforeTimestamp).Scan(&firstRetainedSequence)
if errors.Is(err, sql.ErrNoRows) {
return deleteAllLocked(conn)
}
if err != nil {
return 0, fmt.Errorf("locate SQL audit retention boundary: %w", err)
}
result, err := conn.ExecContext(context.Background(),
`DELETE FROM sql_audit_events WHERE sequence < ?`, firstRetainedSequence)
if err != nil {
return 0, fmt.Errorf("delete SQL audit sequence prefix: %w", err)
}
deleted, err := result.RowsAffected()
if err != nil {
return 0, fmt.Errorf("count deleted SQL audit sequence prefix: %w", err)
}
return deleted, nil
}
type canonicalEvent struct {
Version string `json:"version"`
ID string `json:"id"`
Sequence int64 `json:"sequence"`
Timestamp int64 `json:"timestamp"`
EventType string `json:"eventType"`
Status string `json:"status"`
ConnectionID string `json:"connectionId"`
ConnectionFingerprint string `json:"connectionFingerprint"`
DBType string `json:"dbType"`
Database string `json:"database"`
QueryID string `json:"queryId"`
TransactionID string `json:"transactionId"`
Source string `json:"source"`
BoundaryMode string `json:"boundaryMode"`
CommitMode string `json:"commitMode"`
SQLText string `json:"sqlText"`
SQLRedacted bool `json:"sqlRedacted"`
SQLFingerprint string `json:"sqlFingerprint"`
StatementIndex int `json:"statementIndex"`
StatementCount int `json:"statementCount"`
DurationMs int64 `json:"durationMs"`
RowsAffected int64 `json:"rowsAffected"`
RowsReturned int64 `json:"rowsReturned"`
Error string `json:"error"`
PrevHash string `json:"prevHash"`
}
func calculateEventHash(event Event) (string, error) {
payload, err := json.Marshal(canonicalEvent{
Version: chainVersion,
ID: event.ID,
Sequence: event.Sequence,
Timestamp: event.Timestamp,
EventType: event.EventType,
Status: event.Status,
ConnectionID: event.ConnectionID,
ConnectionFingerprint: event.ConnectionFingerprint,
DBType: event.DBType,
Database: event.Database,
QueryID: event.QueryID,
TransactionID: event.TransactionID,
Source: event.Source,
BoundaryMode: event.BoundaryMode,
CommitMode: event.CommitMode,
SQLText: event.SQLText,
SQLRedacted: event.SQLRedacted,
SQLFingerprint: event.SQLFingerprint,
StatementIndex: event.StatementIndex,
StatementCount: event.StatementCount,
DurationMs: event.DurationMs,
RowsAffected: event.RowsAffected,
RowsReturned: event.RowsReturned,
Error: event.Error,
PrevHash: event.PrevHash,
})
if err != nil {
return "", fmt.Errorf("encode canonical SQL audit event: %w", err)
}
digest := sha256.Sum256(payload)
return hex.EncodeToString(digest[:]), nil
}
func invalidIntegrityReport(report IntegrityReport, sequence int64, message string) IntegrityReport {
report.Valid = false
report.InvalidSequence = sequence
report.Message = message + " (unkeyed local hash chain; weak validation)"
return report
}
const selectEventColumns = `SELECT sequence, id, timestamp, event_type, status,
connection_id, connection_fingerprint, db_type, database_name, query_id,
transaction_id, source, boundary_mode, commit_mode, sql_text, sql_redacted,
sql_fingerprint, statement_index, statement_count, duration_ms, rows_affected,
rows_returned, error_text, prev_hash, record_hash FROM sql_audit_events`
type rowScanner interface {
Scan(...any) error
}
func scanEvent(scanner rowScanner) (Event, error) {
var event Event
var sqlRedacted int
if err := scanner.Scan(
&event.Sequence, &event.ID, &event.Timestamp, &event.EventType, &event.Status,
&event.ConnectionID, &event.ConnectionFingerprint, &event.DBType, &event.Database,
&event.QueryID, &event.TransactionID, &event.Source, &event.BoundaryMode,
&event.CommitMode, &event.SQLText, &sqlRedacted, &event.SQLFingerprint,
&event.StatementIndex, &event.StatementCount, &event.DurationMs,
&event.RowsAffected, &event.RowsReturned, &event.Error, &event.PrevHash, &event.Hash,
); err != nil {
return Event{}, fmt.Errorf("scan SQL audit event: %w", err)
}
event.SQLRedacted = sqlRedacted != 0
return event, nil
}
func boolToInt(value bool) int {
if value {
return 1
}
return 0
}

View 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
View 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"`
}

View File

@@ -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)

View File

@@ -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")
}
}