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