Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ the same version in lockstep.

## [Unreleased]

## [0.3.0] - 2026-09-25

### Added

- **Documentation site** at <https://kartikrocks.github.io/sqlguard/>, built
Expand Down Expand Up @@ -212,7 +214,8 @@ Initial public release.
`integrations/sqlxguard`, `integrations/pgxguard` (native pgx / pgxpool),
`integrations/bunguard`, `integrations/xormguard`, `integrations/entguard`.

[Unreleased]: https://github.com/KARTIKrocks/sqlguard/compare/v0.2.0...HEAD
[Unreleased]: https://github.com/KARTIKrocks/sqlguard/compare/v0.3.0...HEAD
[0.3.0]: https://github.com/KARTIKrocks/sqlguard/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/KARTIKrocks/sqlguard/compare/v0.1.1...v0.2.0
[0.1.1]: https://github.com/KARTIKrocks/sqlguard/compare/v0.1.0...v0.1.1
[0.1.0]: https://github.com/KARTIKrocks/sqlguard/releases/tag/v0.1.0
219 changes: 219 additions & 0 deletions website/versioned_docs/version-0.3/analyzer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
---
id: analyzer
title: Analyzer API
description: Use the analyzer directly, pick a rule subset, write your own rules against the normalized Statement, register them by name, and write a custom reporter.
---

# Analyzer API

Everything above the driver is ordinary Go you can call yourself. The
`analyzer` package has no dependencies outside the standard library.

```go
import "github.com/KARTIKrocks/sqlguard/analyzer"
```

## Analyze a query

```go
a := analyzer.Default() // every built-in rule at its default severity

for _, r := range a.Analyze("DELETE FROM users") {
fmt.Printf("[%s] %s: %s\n", r.Severity, r.RuleName, r.Message)
}
// [CRITICAL] delete-without-where: DELETE without WHERE clause detected. This will delete all rows.
```

`Analyze(query string) []analyzer.Result` parses once, runs every rule,
applies in-SQL [suppressions](suppressions) and severity overrides, and
sets `Query` (redacted) and `Fingerprint` on each result. It never errors
or panics; SQL the parser cannot understand still gets a best-effort pass
through the fallback parser.

### `Result`

| Field | Meaning |
| --- | --- |
| `RuleName string` | Stable rule identifier, e.g. `select-star`. |
| `Severity analyzer.Severity` | `SeverityInfo`, `SeverityWarning`, `SeverityCritical`. `String()` gives `INFO` / `WARNING` / `CRITICAL`. |
| `Query string` | The offending SQL, [redacted](redaction) unless the analyzer was built `WithRawQuery()`. |
| `Fingerprint string` | Always set. PII-free, low-cardinality query identity. |
| `Message string` | What was detected. |
| `Suggestion string` | How to fix it. May be empty. |
| `File string`, `Line int` | Set only by the static scanner. |

## Constructors

| Constructor | Rules | Configurable by name? |
| --- | --- | --- |
| `analyzer.Default()` | every registered rule | yes |
| `analyzer.DefaultWithProfile(p analyzer.Profile)` | registered rules filtered/tuned by the profile | yes |
| `analyzer.New(rules ...analyzer.Rule)` | exactly the functions you pass | no — anonymous rules have no name to configure |

And two copying modifiers:

```go
a = a.WithParser(pgparser.New()) // swap the parser; nil resets to the fallback
a = a.WithRawQuery() // keep literals in Result.Query — local debugging only
```

### `Profile`

`Profile` is the resolved, parser-independent view of configuration. The
`config` package builds one from `.sqlguard.yml`; you can build one by
hand:

```go
p := analyzer.Profile{
Disabled: map[string]bool{"orderby-without-limit": true},
Severity: map[string]analyzer.Severity{"select-star": analyzer.SeverityInfo},
Settings: map[string]analyzer.Settings{
"large-offset": {"threshold": 5000},
},
}
a := analyzer.DefaultWithProfile(p)
```

`Only` (a whitelist) and `RawQuery` are the other fields. Everything in
the profile is resolved once at construction — the per-query path does no
configuration work, which is what keeps it cheap.

## Writing a rule

A rule is a function over the normalized statement:

```go
type Rule func(s *analyzer.Statement) (analyzer.Result, bool)
```

It returns `(result, true)` to report, `(Result{}, false)` to stay quiet.
Rules read `Statement` fields; they never re-parse or pattern-match the raw
SQL — that is the parser's job, and it is what keeps rules correct across
the fallback and the real grammars.

```go
func checkSelectForUpdateWithoutLimit(s *analyzer.Statement) (analyzer.Result, bool) {
if s.Kind == analyzer.StmtSelect && s.HasOrderBy && !s.HasLimit && !s.HasWhere {
return analyzer.Result{
RuleName: "unbounded-sorted-select",
Message: "Sorted SELECT with no WHERE and no LIMIT sorts the whole table.",
Suggestion: "Add a WHERE filter or a LIMIT.",
}, true
}
return analyzer.Result{}, false
}
```

Leave `Severity`, `Query` and `Fingerprint` unset when the rule is
registered (below): the registry's default severity, profile overrides
and the redaction policy are applied centrally. Set `Severity` yourself
only for anonymous rules passed to `analyzer.New`.

Treat a `false` boolean as "not detected", not "proven absent" — the
fallback parser leaves a field `false` when it genuinely cannot tell, and
a rule that assumes otherwise produces false positives. `Statement.Exact`
tells you whether a real grammar produced the structural fields.

### Registering it by name

```go
func init() {
analyzer.Register(analyzer.RuleSpec{
Name: "unbounded-sorted-select",
DefaultSeverity: analyzer.SeverityWarning,
Factory: func(s analyzer.Settings) analyzer.Rule {
return checkSelectForUpdateWithoutLimit
},
})
}
```

Once registered, the rule is part of `analyzer.Default()` and is
addressable by name everywhere: `rules.disable` / `rules.severity` /
`rules.settings` in [config](configuration), `sqlguard:ignore:<name>` in
[suppressions](suppressions), and `analyzer.RuleNames()`. Registering a
name that already exists **replaces** the built-in, so you can override
one.

The `Factory` receives the rule's `Settings` map (from
`rules.settings.<name>` in config). Nil-safe accessors — `Int`, `Bool`,
`String`, `Duration`, each with a default — let a rule take tunables
without touching the config schema:

```go
Factory: func(s analyzer.Settings) analyzer.Rule {
max := s.Int("max-rows", 10000)
return func(st *analyzer.Statement) (analyzer.Result, bool) { /* use max */ }
},
```

### Using a subset

```go
a := analyzer.New(
analyzer.CheckDeleteWithoutWhere,
analyzer.CheckUpdateWithoutWhere,
)
```

The built-in rule functions (`CheckSelectStar`, `CheckLeadingWildcard`,
`CheckDeleteWithoutWhere`, `CheckUpdateWithoutWhere`,
`CheckInsertWithoutColumns`, `CheckSelectWithoutLimit`,
`CheckOrderByWithoutLimit`, `CheckNonSargablePredicate`,
`CheckAddNotNullWithoutDefault`, `CheckImplicitJoin`,
`CheckCartesianJoin`, `CheckInListTooLarge`, `CheckLargeOffset`,
`CheckSelectDistinct`) are exported. Rules passed to `New` are anonymous:
profile overrides do not apply, and the tunable ones run at their
defaults. Prefer `DefaultWithProfile` with `Only` when you want a named,
configurable subset.

## Helpers

| Function | Use |
| --- | --- |
| `analyzer.Redact(sql string) string` | Literals → `?`, comments stripped, structure kept. |
| `analyzer.Fingerprint(sql string) string` | `Redact` + whitespace collapse + list fold. |
| `analyzer.IsMultiStatement(sql string) bool` | Comment- and string-aware `;` check. |
| `analyzer.ParseIgnoreComment(text string) (all bool, rules map[string]bool, found bool)` | Parse a Go comment for a suppression directive. |
| `analyzer.RuleNames() []string` | Every registered rule name, sorted. |
| `analyzer.NewFallbackParser() *FallbackParser` | The zero-dependency parser, for delegation. |
| `(*Analyzer).PrepareQuery(raw string) (display, fingerprint string)` | Apply this analyzer's redaction policy to a query outside the rule path. |

## Reporters

```go
import "github.com/KARTIKrocks/sqlguard/reporter"

type Reporter interface {
Report(results []analyzer.Result)
}
```

| Reporter | Output |
| --- | --- |
| `reporter.NewConsoleReporter()` / `NewConsoleReporterTo(w)` | Colored, human-readable blocks; stderr by default. |
| `reporter.NewJSONReporter()` / `NewJSONReporterTo(w)` | A JSON array; stderr by default. |

`Report` must be safe for concurrent calls — the middleware invokes it
from whichever goroutine ran the query. The result slice handed to you may
be shared with the analysis cache; treat it as read-only.

A reporter that forwards to `slog`:

```go
type slogReporter struct{ l *slog.Logger }

func (s *slogReporter) Report(rs []analyzer.Result) {
for _, r := range rs {
s.l.Warn("sqlguard finding",
"rule", r.RuleName,
"severity", r.Severity.String(),
"fingerprint", r.Fingerprint,
"query", r.Query,
"message", r.Message,
)
}
}

sqlguard.Register("sqlguard-pg", "pgx", middleware.WithReporter(&slogReporter{l: slog.Default()}))
```
61 changes: 61 additions & 0 deletions website/versioned_docs/version-0.3/bun.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
---
id: bun
title: bun
description: bunguard — a bun.QueryHook that analyzes every statement bun renders.
---

# bun

`bunguard` is a `bun.QueryHook`. bun renders SQL through its own query
builder and exposes the final text and a start timestamp in `AfterQuery`,
which is where the hook runs the static rules and the latency check.

```bash
go get github.com/KARTIKrocks/sqlguard/integrations/bunguard
```

```go
import (
"github.com/KARTIKrocks/sqlguard/integrations/bunguard"
"github.com/KARTIKrocks/sqlguard/middleware"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect/pgdialect"
"github.com/uptrace/bun/driver/pgdriver"
)

sqldb := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(dsn)))
db := bun.NewDB(sqldb, pgdialect.New())

db.AddQueryHook(bunguard.New(
middleware.WithSlowQueryThreshold(500*time.Millisecond),
middleware.WithN1Detection(10, time.Second),
))
```

## API

| Symbol | Use |
| --- | --- |
| `bunguard.New(opts ...middleware.Option) *QueryHook` | Build the hook. Pass to `db.AddQueryHook`. |
| `(*QueryHook).ResetN1()` | Clear N+1 state at a request boundary. |
| `BeforeQuery`, `AfterQuery` | The `bun.QueryHook` methods; you do not call them. |

```go
hook := bunguard.New(middleware.WithN1Detection(10, time.Second))
db.AddQueryHook(hook)

func handler(w http.ResponseWriter, r *http.Request) {
defer hook.ResetN1()
// ...
}
```

## Notes

- Static rules run on every query; latency is reported only when the
query succeeded (`event.Err == nil`). Same semantics as every other
surface.
- bun's `pgdriver` is not a `database/sql` driver name you can wrap with
`sqlguard.Register`, but `sqlguard.OpenDB(pgdriver.NewConnector(...))`
works if you prefer driver-level coverage over `ResetN1()`. Do not use
both on one connection.
Loading
Loading