Skip to main content
Version: 0.3

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