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
25 changes: 20 additions & 5 deletions .sqlguard.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ rules:
disable:
- orderby-without-limit

# Whitelist mode: when non-empty, ONLY these rules run (disable is ignored).
# Whitelist mode: when non-empty, a rule must be listed to run. It narrows
# the rules evaluated against a statement — the scanner and the runtime
# statement rules — and does NOT reach slow-query, n-plus-one or the EXPLAIN
# plan rules; switch one of those off by naming it in `disable` above.
# `disable` still applies to the rules listed here, so listing and disabling
# the same rule disables it.
# only:
# - delete-without-where
# - update-without-where
Expand All @@ -42,17 +47,27 @@ rules:
# Parameterized offsets (OFFSET $1 / ?) can't be evaluated statically.
threshold: 1000

# Runtime (middleware/integrations). These two rules are not evaluated
# against parsed SQL, so they never fire during `sqlguard scan` — but they
# are ordinary rule names, so `disable`, `only` and `severity` above reach
# them too.
slow-query:
# Flag a query whose driver-measured latency reaches this. Default
# 200ms. WithSlowQueryThreshold in Go wins over this.
threshold: 200ms
n-plus-one:
# Setting BOTH of these turns N+1 detection on; it is off otherwise.
# WithN1Detection in Go wins over this.
threshold: 10 # this many executions of one fingerprint...
window: 1m # ...within this window

# Redact literal values (strings/numbers) out of Result.Query before it
# reaches any reporter/log. ON by default — leave it on so customer data in
# query literals never lands in your logs. Result.Fingerprint (a PII-free,
# value-free query identity) is emitted regardless. Set to false ONLY for
# local debugging where the query text is trusted.
redact: true

# Runtime slow-query threshold (middleware). Go duration string.
slow-query:
threshold: 200ms

# Runtime de-duplication of repeated static findings (middleware). The same
# finding (rule + query fingerprint) is reported at most once per window, so a
# recurring query doesn't flood your logs. Default 1m. Set "0" to disable
Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ When changing the public API or Go version, update all nine `go.mod` files and `

**Rules self-register; config is resolved once, never per query.** Built-in rules call `analyzer.Register(RuleSpec{...})` from `init()` in `analyzer/rules.go` — a stable name, default severity, and a settings-aware `Factory`. To add a rule, write it and add one `Register` call; **do not** hand-maintain a rule list in `Default()`. Being addressable by name is what makes enable/disable, severity overrides, per-rule `Settings`, and suppressions work uniformly. `analyzer.Profile` (disabled set, `only` whitelist, severity map, per-rule settings) is applied in `DefaultWithProfile` at construction; the per-query `Analyze` path does no config work (it runs on every query through the driver — keep it allocation-light). `analyzer` must stay free of `config`/YAML imports.

**Not every registered rule is evaluated by the Analyzer.** A `RuleSpec` with a nil `Factory` registers a name without a rule body — `RuleSpec.Evaluated()` reports the difference, and `DefaultWithProfile` skips those when binding. Seven use this: `slow-query` and `n-plus-one`, which `middleware` derives from latency and fingerprint counts, and `seq-scan` / `high-cost` / `full-table-scan` / `no-index-used` / `filesort`, which `explain` derives from the database's own plan. None can be run against a parsed `Statement`, but all seven are documented as rules, so they register to be **addressable**: `disable`, `only`, `severity` and `settings` must mean the same thing on every surface. Their owners ask `Analyzer.RuleEnabled` / `RuleSeverity` / `RuleSettings` for the resolved decision. **`RuleEnabled` answers `disable:` and `severity: off` but deliberately ignores the `only:` whitelist**, because `only:` selects which rules are evaluated _against a statement_ and none of these seven are: a list written to focus `sqlguard scan` would otherwise switch off latency and N+1 reporting in a running application and blank out `sqlguard explain`, none of which it names, and none of which warns. Switching one off takes naming it, which is why `disable:` reaches every surface and `only:` reaches one (pinned by `TestRuleEnabledIgnoresOnly`, `TestRuleEnabledHonoursDisableInsideAnOnlyList` and `TestApplyProfile_IgnoresOnly`). `Guard` resolves its two once in `NewGuard` (the per-query path stays config-free), and `explain` filters centrally in `applyProfile` rather than at each of the five sites that build a finding, so a plan rule added later cannot forget the check. All seven are registered in `analyzer/rules.go`, **not** in the packages that own them: `config` validates names against `analyzer.RuleNames()` while importing only `analyzer`, so registering `seq-scan` from `explain`'s `init()` would make a config naming it fail for anyone who doesn't link that package. Per-rule tunables live in `rules.settings` for these too (`slow-query.threshold`, `n-plus-one.threshold`/`window`, the latter pair also being what turns N+1 on from a file); an explicit Go option (`WithSlowQueryThreshold`, `WithN1Detection`) outranks the file. `config.checkSettings` validates the whole settings block, because every read path — `Settings.Duration`, `Settings.Int` — falls back to the caller's default rather than failing, so anything it cannot use becomes a silently wrong threshold (or, for `n-plus-one`, detection that never switches on). It checks the key against `ruleSettings`, which is the complete tunable set by design: a key absent from a listed rule is a misspelling, and any key on an unlisted rule is a mistake, since that rule reads no settings. It also checks the value's type, rejects a non-positive `n-plus-one.threshold`, and reports half a paired block. `config.checkScanHasRules` covers the shapes that only became expressible once the seven were registered, and asks `Profile.Skip` — the same question `DefaultWithProfile` asks — rather than testing the list against `EvaluatedRuleNames()`, which was blind to a name `only:` selects and `disable:` or `severity: off` then takes away again. It is gated on `only:` being configured, because disabling every rule without one is a legitimate runtime-only setup. Validation reads values through `Settings.LookupDuration`/`LookupInt`, the accessors `Duration`/`Int` themselves delegate to, so a value the loader accepts can never be one the reader silently replaces with a default.

**`config` is the only YAML-aware package.** It loads `.sqlguard.yml` (`Load`/`Discover` walks up to the git root), translates it to an `analyzer.Profile`, and exposes `MiddlewareOptions()`/`Middleware()` helpers. It depends on `analyzer` (and `middleware` for the helper); nothing depends on `config`. This keeps `gopkg.in/yaml.v3` out of the `analyzer`/`middleware` import graph for library users who don't opt into file config. Parsing is lenient by default (unknown keys/rules warn); `strict: true` makes them fatal — so a newer config still loads on an older binary.

**Suppression has two layers** (`analyzer/suppress.go`): in-SQL `-- sqlguard:ignore` / `/* sqlguard:ignore:rule-a,rule-b */`, parsed from raw SQL with a **marker-anchored** regex (avoids string-literal false positives), honored at runtime _and_ statically; and Go-source `// sqlguard:ignore[:rules]` via `ParseIgnoreComment`, which uses a **separate marker-less** regex because go/ast already strips the `//`. The scanner (`cmd/sqlguard/scan.go`) applies it against the AST comment map for the call line and the line directly above.
Expand Down
64 changes: 64 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,70 @@ the same version in lockstep.

## [Unreleased]

### Changed

- **Every documented rule is now addressable in `.sqlguard.yml`.** The rules
reference lists 21 rules, but only the 14 statement rules were registered:
naming `slow-query`, `n-plus-one`, `seq-scan`, `high-cost`,
`full-table-scan`, `no-index-used` or `filesort` under `disable`, `only`,
`severity` or `settings` warned with `unknown rule`, and failed outright
under `strict: true`. All seven are registered now, and the profile reaches
the runtime findings in middleware and the plan findings in `explain`
exactly as it reaches a statement rule. They are still never evaluated
against parsed SQL, so they do not fire during `sqlguard scan`.
- **Breaking (Go API): `middleware.NewQueryTracker` takes a severity.** The
signature is now `NewQueryTracker(threshold, window, severity, reportFn)`.
It is exported, so a caller constructing a tracker directly will not
compile until the argument is added; pass `analyzer.SeverityWarning` for
the previous behaviour. Guard resolves it from the profile, which is what
makes a `severity:` override on `n-plus-one` reach the finding.
- **Breaking (config): the slow-query threshold moved** from the top-level
`slow-query.threshold` key to `rules.settings.slow-query.threshold`, so
every per-rule tunable lives in one place. `Config.SlowQueryThreshold` and
the `SlowQueryConfig` type are gone with it. An explicit
`WithSlowQueryThreshold` in Go still wins over the file.
- **`explain` now honors `rules:` config.** It previously ignored it by
design, which is what its docs said. A `severity` override also beats
`seq-scan`'s row-count-derived severity.
- **`only:` is scoped to the rules evaluated against a statement.** It
narrows the scanner and the statement rules at runtime, and deliberately
does not reach `slow-query`, `n-plus-one` or the five plan rules. A
whitelist is written to focus a scan and names statement rules; if it
reached the rest, `only: [select-star]` in a repository's config would also
switch off latency and N+1 reporting in the running application and make
`sqlguard explain` report nothing, without naming any of them and without
warning. `disable:` and `severity: off` reach every surface.
- **N+1 detection can be enabled from the config file.** Setting both
`rules.settings.n-plus-one.threshold` and `.window` turns it on; it was
previously reachable only from Go via `WithN1Detection`, which still takes
precedence.
- A value in `rules.settings` that will not read back as its type is now
reported **and dropped**, so the rule falls back to its built-in default
rather than acting on the rejected value. In lenient mode the load continues
after a warning, so reporting alone was half an answer:
`slow-query.threshold: 0` warned and then matched every successful query
anyway, flooding the reporter it exists to protect. This
covers durations and numbers, and a half-specified `n-plus-one` block —
a quoted `threshold: "10"` is a string in YAML, read back as 0, which would
have left N+1 detection off with no indication.
- An `only:` list that names no rule the scanner runs — `only: [slow-query]`,
now that it is a valid name — is reported. It would otherwise leave
`sqlguard scan` finding nothing on any codebase, which reads as a clean run.
- A non-positive `slow-query.threshold` or `n-plus-one.window` is reported. A
threshold of `0` matches every successful query, so it would have flooded
the reporter with `slow-query` on every statement; a zero window leaves N+1
off. A fractional-millisecond threshold such as `0.5` also truncated to zero
when read, which produced the same flood — it now scales before converting.
- A misspelled setting **key** is reported too, not just a bad value: the rule
name is known and the value well-formed, so the setting is simply absent and
the built-in default stands. A `settings` block on a rule with no tunables
(the five plan rules) is reported the same way.
- An unknown rule name in `disable:` / `only:` / `severity:` / `settings:` is
now warned about **and ignored**, rather than warned about and honored. One
typo in `only:` acted as a whitelist matching nothing, which since every
rule became addressable would have silenced the runtime and plan findings
as well as the static scan.

## [0.3.0] - 2026-09-25

### Added
Expand Down
71 changes: 68 additions & 3 deletions analyzer/analyzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ type Analyzer struct {
rules []boundRule
parser Parser
severity map[string]Severity
// disabled holds the rules turned off by name — `disable:`, or
// `severity: off`. It is what RuleEnabled answers from, and it does not
// fold in the `only` whitelist; see RuleEnabled for why.
disabled map[string]bool
// settings holds per-rule tunables for the same audience; the statement
// rules have theirs baked in by their factory at construction.
settings map[string]Settings
// rawQuery, when true, leaves Result.Query unredacted. Default is false
// (redact): the safe default for a tool whose findings flow into logs.
rawQuery bool
Expand Down Expand Up @@ -103,9 +110,19 @@ func Default() *Analyzer {
// The config package uses this to turn a .sqlguard.yml into an Analyzer
// without analyzer ever importing config or YAML.
func DefaultWithProfile(p Profile) *Analyzer {
all := specs()
var bound []boundRule
for _, spec := range specs() {
if p.skip(spec.Name) {
disabled := make(map[string]bool, len(p.Disabled))
for _, spec := range all {
if p.Disabled[spec.Name] {
disabled[spec.Name] = true
}
if p.Skip(spec.Name) {
continue
}
// Registered for addressability only — middleware and explain build
// these findings themselves and consult the decisions below.
if !spec.Evaluated() {
continue
}
bound = append(bound, boundRule{
Expand All @@ -120,9 +137,57 @@ func DefaultWithProfile(p Profile) *Analyzer {
sev = make(map[string]Severity, len(p.Severity))
maps.Copy(sev, p.Severity)
}
return &Analyzer{rules: bound, parser: NewFallbackParser(), severity: sev, rawQuery: p.RawQuery}
var settings map[string]Settings
if len(p.Settings) > 0 {
settings = make(map[string]Settings, len(p.Settings))
maps.Copy(settings, p.Settings)
}
return &Analyzer{
rules: bound,
parser: NewFallbackParser(),
severity: sev,
disabled: disabled,
settings: settings,
rawQuery: p.RawQuery,
}
}

// RuleEnabled reports whether the profile leaves the named rule on, for a
// finding built outside the statement path: middleware's `slow-query` and
// `n-plus-one`, and the plan rules `explain` derives. Those have no Factory,
// so the Analyzer never runs them and their owners ask here instead.
//
// It answers `disable:` and `severity: off`. An `only:` whitelist is
// deliberately **not** consulted: `only:` selects which rules the Analyzer
// evaluates over a statement, and these are not evaluated at all. A list
// written to focus `sqlguard scan` — overwhelmingly what `only:` is for —
// would otherwise switch off slow-query and N+1 in a running application and
// blank out `sqlguard explain`, none of which it mentions. Turning one of
// these off takes naming it.
//
// For an evaluated rule the whitelist has already been applied: a rule it
// excludes was never bound, so nothing asks this about it.
//
// An unregistered name is reported as enabled. An Analyzer built with New has
// no profile, and a caller's own rule is not the profile's to turn off.
func (a *Analyzer) RuleEnabled(name string) bool { return !a.disabled[name] }

// RuleSeverity returns the severity to report for name, applying a profile
// override to def when one is set.
func (a *Analyzer) RuleSeverity(name string, def Severity) Severity {
if a.severity != nil {
if s, has := a.severity[name]; has {
return s
}
}
return def
}

// RuleSettings returns the profile settings for name, or nil when none were
// configured. Settings.Int / .Duration treat a nil Settings as "use the
// default", so a caller can read straight through without a nil check.
func (a *Analyzer) RuleSettings(name string) Settings { return a.settings[name] }

// Analyze parses the query once and runs all rules against it. If the
// configured parser returns an error, it degrades to the FallbackParser so
// analysis never breaks the caller's query path. Findings for rules named in
Expand Down
Loading
Loading