diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..9d72492 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,18 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment include: +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d97ca88 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,17 @@ +# Contributing to SQLens + +Thank you for your interest in SQLens! We welcome contributions from the community. + +## How to Contribute + +1. **Fork the repository.** +2. **Create a branch** for your feature or bug fix: `git checkout -b my-new-feature` +3. **Make your changes.** Ensure code follows standard Go formatting (`go fmt`). +4. **Run tests**: `make test` +5. **Commit your changes**: `git commit -m "feat: my new feature"` +6. **Push to your branch** and open a **Pull Request**. + +## Guidelines +- All new features must include unit tests. +- Keep Pull Requests focused on a single change. +- Follow the [Code of Conduct](CODE_OF_CONDUCT.md). diff --git a/README.md b/README.md index 69999e3..0669e9a 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,18 @@ SQLens is a transparent TCP proxy that intercepts and analyzes SQL queries in real-time. It helps detect **N+1 query patterns**, **slow queries**, and provides a live performance dashboard—**with zero code changes to your application.** +## Demo + +### Dashboard Overview + + + ## Features - **Transparent Proxy:** Just point your app to SQLens instead of your DB. - **N+1 Detection:** Automatically flags inefficient ORM patterns. +- **Performance Guardrails:** Real-time detection of SQL anti-patterns (e.g., SELECT *, missing WHERE). +- **Data Privacy:** Optional PII redaction mode to mask sensitive query values. - **Slow Query Tracking:** Real-time latency measurement and visualization. - **Live Dashboard:** Web-based interface to see what's happening under the hood. @@ -44,6 +52,7 @@ SQLens can be configured via environment variables: - `SQLENS_LISTEN_ADDR`: Proxy listen address (default `:5433`) - `SQLENS_TARGET_ADDR`: Target database address (default `localhost:5432`) - `SQLENS_N1_THRESHOLD`: Number of repeated queries to trigger alert (default `5`) +- `SQLENS_REDACT_SENSITIVE`: Enable to mask sensitive data in logs/dashboard (default `false`) --- Built for SQL performance observability. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..248a040 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,14 @@ +# Security Policy + +## Reporting a Vulnerability + +We take the security of SQLens seriously. If you believe you have found a security vulnerability, please report it to us by following these steps: + +1. open a GitHub issue. +2. Include as much detail as possible: steps to reproduce, impact, and a proof of concept if available. + +We will acknowledge your report within 48 hours and keep you updated on our progress. + +## Redact Mode + +SQLens includes a `RedactSensitive` mode (set via `SQLENS_REDACT_SENSITIVE=true`). When enabled, the proxy will mask all literals in SQL queries before they reach the dashboard or logs. We recommend enabling this in production environments where queries may contain PII. diff --git a/analyzer/analyzer.go b/analyzer/analyzer.go index fea1d3e..e3a6bf0 100644 --- a/analyzer/analyzer.go +++ b/analyzer/analyzer.go @@ -12,6 +12,7 @@ type QueryEvent struct { Timestamp time.Time Fingerprint string N1Flag bool + Violations []string // New: Performance/Safety guardrail warnings } type AnalysisResult struct { @@ -30,16 +31,16 @@ func NewPipeline(analyzers ...Analyzer) *Pipeline { return &Pipeline{analyzers: analyzers} } -func (p *Pipeline) Process(ctx context.Context, event QueryEvent) { +func (p *Pipeline) Process(ctx context.Context, event QueryEvent) QueryEvent { currentEvent := event for _, a := range p.analyzers { result, err := a.Analyze(ctx, currentEvent) if err != nil { - // In a real app we'd log this, but we continue processing continue } if result != nil { currentEvent = result.Event } } + return currentEvent } diff --git a/analyzer/guardrail.go b/analyzer/guardrail.go new file mode 100644 index 0000000..1bc35db --- /dev/null +++ b/analyzer/guardrail.go @@ -0,0 +1,34 @@ +package analyzer + +import ( + "context" + "strings" +) + +type GuardrailAnalyzer struct{} + +func NewGuardrailAnalyzer() *GuardrailAnalyzer { + return &GuardrailAnalyzer{} +} + +func (g *GuardrailAnalyzer) Analyze(ctx context.Context, event QueryEvent) (*AnalysisResult, error) { + upperQuery := strings.ToUpper(event.RawQuery) + + // Guardrail 1: DELETE/UPDATE without WHERE (Dangerous!) + if (strings.Contains(upperQuery, "DELETE") || strings.Contains(upperQuery, "UPDATE")) && + !strings.Contains(upperQuery, "WHERE") { + event.Violations = append(event.Violations, "DANGEROUS: Modification query without WHERE clause detected!") + } + + // Guardrail 2: SELECT * (Performance Antipattern) + if strings.Contains(upperQuery, "SELECT *") { + event.Violations = append(event.Violations, "SLOW: SELECT * usage (fetch only needed columns)") + } + + // Guardrail 3: SELECT without LIMIT (Scale Risk) + if strings.Contains(upperQuery, "SELECT") && !strings.Contains(upperQuery, "LIMIT") { + event.Violations = append(event.Violations, "UNBOUNDED: SELECT without LIMIT (potential large result set)") + } + + return &AnalysisResult{Event: event}, nil +} diff --git a/assets/dashboard.png b/assets/dashboard.png new file mode 100644 index 0000000..5f443d9 Binary files /dev/null and b/assets/dashboard.png differ diff --git a/benchmark.go b/benchmark.go index ba70f05..b22344e 100644 --- a/benchmark.go +++ b/benchmark.go @@ -18,10 +18,10 @@ func main() { } defer db.Close() - fmt.Println("🚀 Starting Realistic SQLens Benchmark...") + fmt.Println("Starting Realistic SQLens Benchmark...") // 1. Setup Table and Seed Data - fmt.Println("\n--- 🛠️ Setup: Creating 'users' table ---") + fmt.Println("\n--- Setup: Creating 'users' table ---") _, err = db.Exec(` DROP TABLE IF EXISTS users; CREATE TABLE users ( @@ -47,7 +47,7 @@ func main() { // 2. Realistic N+1 Simulation // Scenario: Fetching user IDs, then fetching full details for each one individually - fmt.Println("\n--- 🕵️ Scenario: N+1 Detection (Fetching user details one by one) ---") + fmt.Println("\n--- Scenario: N+1 Detection (Fetching user details one by one) ---") rows, err := db.Query("SELECT id FROM users LIMIT 10") if err != nil { log.Fatal(err) @@ -75,7 +75,7 @@ func main() { } // 3. Slow Query Simulation - fmt.Println("\n--- 🐢 Scenario: Slow Query Detection (Complex search) ---") + fmt.Println("\n--- Scenario: Slow Query Detection (Complex search) ---") start := time.Now() var count int // Using pg_sleep to force a slow query report in SQLens @@ -86,12 +86,22 @@ func main() { fmt.Printf("Slow query finished (latency: %v)\n", time.Since(start)) // 4. Batch Updates - fmt.Println("\n--- ⚡ Scenario: Batch Updates ---") + fmt.Println("\n--- Scenario: Batch Updates ---") for i := 0; i < 50; i++ { _, _ = db.Exec("UPDATE users SET created_at = NOW() WHERE id = $1", (i%20)+1) } - fmt.Println("Finished 50 quick updates.") - fmt.Println("\n✅ Benchmark Complete!") + // 5. Performance Guardrails Test + fmt.Println("\n--- Scenario: Performance Guardrails ---") + fmt.Println("Triggering 'SELECT *' warning...") + _, _ = db.Exec("SELECT * FROM users") + + fmt.Println("Triggering 'Missing WHERE' warning...") + _, _ = db.Exec("UPDATE users SET name = 'Ghost'") + + fmt.Println("Triggering 'Missing LIMIT' warning...") + _, _ = db.Exec("SELECT name FROM users") + + fmt.Println("\nBenchmark Complete!") fmt.Println("Check the SQLens Dashboard (http://localhost:8080) for N+1 alerts and latency maps.") } diff --git a/cmd/sqlens/main.go b/cmd/sqlens/main.go index a1047e0..5c5d97b 100644 --- a/cmd/sqlens/main.go +++ b/cmd/sqlens/main.go @@ -28,10 +28,11 @@ func main() { time.Duration(cfg.N1WindowSecs)*time.Second, cfg.N1Threshold, ), + analyzer.NewGuardrailAnalyzer(), // New Feature: Real-time SQL Guardrails ) // Initialize TCP Proxy - proxyServer := proxy.NewServer(cfg.ListenAddr, cfg.TargetAddr, pipeline, memStore) + proxyServer := proxy.NewServer(cfg.ListenAddr, cfg.TargetAddr, pipeline, memStore, cfg.RedactSensitive) // Initialize Web Dashboard webServer := web.NewServer(":8080", memStore) diff --git a/config/config.go b/config/config.go index d6a7bc5..1770fef 100644 --- a/config/config.go +++ b/config/config.go @@ -6,23 +6,33 @@ import ( ) type Config struct { - ListenAddr string - TargetAddr string - SlowQueryMs int - N1WindowSecs int - N1Threshold int + ListenAddr string + TargetAddr string + SlowQueryMs int + N1WindowSecs int + N1Threshold int + RedactSensitive bool } func LoadConfig() Config { return Config{ - ListenAddr: getEnv("SQLENS_LISTEN_ADDR", ":5433"), - TargetAddr: getEnv("SQLENS_TARGET_ADDR", "localhost:5432"), - SlowQueryMs: getEnvAsInt("SQLENS_SLOW_QUERY_MS", 100), - N1WindowSecs: getEnvAsInt("SQLENS_N1_WINDOW_SECS", 10), - N1Threshold: getEnvAsInt("SQLENS_N1_THRESHOLD", 5), + ListenAddr: getEnv("SQLENS_LISTEN_ADDR", ":5433"), + TargetAddr: getEnv("SQLENS_TARGET_ADDR", "localhost:5432"), + SlowQueryMs: getEnvAsInt("SQLENS_SLOW_QUERY_MS", 100), + N1WindowSecs: getEnvAsInt("SQLENS_N1_WINDOW_SECS", 10), + N1Threshold: getEnvAsInt("SQLENS_N1_THRESHOLD", 5), + RedactSensitive: getEnvAsBool("SQLENS_REDACT_SENSITIVE", false), } } +func getEnvAsBool(key string, fallback bool) bool { + strValue := getEnv(key, "") + if value, err := strconv.ParseBool(strValue); err == nil { + return value + } + return fallback +} + func getEnv(key, fallback string) string { if value, exists := os.LookupEnv(key); exists { return value diff --git a/proxy/server.go b/proxy/server.go index 1064c2b..8ef8ccd 100644 --- a/proxy/server.go +++ b/proxy/server.go @@ -11,10 +11,11 @@ import ( ) type Server struct { - listenAddr string - targetAddr string - pipeline *analyzer.Pipeline - store EventStore + listenAddr string + targetAddr string + pipeline *analyzer.Pipeline + store EventStore + redactSensitive bool } // EventStore represents an interface to save and retrieve query events @@ -22,12 +23,13 @@ type EventStore interface { Save(event analyzer.QueryEvent) } -func NewServer(listen, target string, p *analyzer.Pipeline, s EventStore) *Server { +func NewServer(listen, target string, p *analyzer.Pipeline, s EventStore, redact bool) *Server { return &Server{ - listenAddr: listen, - targetAddr: target, - pipeline: p, - store: s, + listenAddr: listen, + targetAddr: target, + pipeline: p, + store: s, + redactSensitive: redact, } } @@ -152,7 +154,15 @@ func (s *Server) handleConnection(ctx context.Context, clientConn net.Conn) { Timestamp: time.Now(), Latency: l, } - s.pipeline.Process(context.Background(), event) + + // Reassign event to the one that's been through the pipeline + event = s.pipeline.Process(context.Background(), event) + + // If redaction is enabled, mask the raw SQL + if s.redactSensitive { + event.RawQuery = event.Fingerprint + } + s.store.Save(event) }(query, latency) } else { diff --git a/web/server.go b/web/server.go index 228acf1..f48a641 100644 --- a/web/server.go +++ b/web/server.go @@ -39,23 +39,30 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {