Skip to main content
Version: Next (unreleased)

Analyzer API

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

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

Analyze a query

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

FieldMeaning
RuleName stringStable rule identifier, e.g. select-star.
Severity analyzer.SeveritySeverityInfo, SeverityWarning, SeverityCritical. String() gives INFO / WARNING / CRITICAL.
Query stringThe offending SQL, redacted unless the analyzer was built WithRawQuery().
Fingerprint stringAlways set. PII-free, low-cardinality query identity.
Message stringWhat was detected.
Suggestion stringHow to fix it. May be empty.
File string, Line intSet only by the static scanner.

Constructors

ConstructorRulesConfigurable by name?
analyzer.Default()every registered ruleyes
analyzer.DefaultWithProfile(p analyzer.Profile)registered rules filtered/tuned by the profileyes
analyzer.New(rules ...analyzer.Rule)exactly the functions you passno — anonymous rules have no name to configure

And two copying modifiers:

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:

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:

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.

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

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, sqlguard:ignore:<name> in 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:

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

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

FunctionUse
analyzer.Redact(sql string) stringLiterals → ?, comments stripped, structure kept.
analyzer.Fingerprint(sql string) stringRedact + whitespace collapse + list fold.
analyzer.IsMultiStatement(sql string) boolComment- 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() []stringEvery registered rule name, sorted.
analyzer.NewFallbackParser() *FallbackParserThe 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

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

type Reporter interface {
Report(results []analyzer.Result)
}
ReporterOutput
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:

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