Documentation
¶
Index ¶
- func EvaluatedRuleNames() []string
- func Fingerprint(sql string) string
- func IsMultiStatement(sql string) bool
- func ParseIgnoreComment(text string) (all bool, rules map[string]bool, found bool)
- func Redact(sql string) string
- func Register(spec RuleSpec)
- func RuleNames() []string
- type Analyzer
- func (a *Analyzer) Analyze(query string) []Result
- func (a *Analyzer) PrepareQuery(raw string) (display, fingerprint string)
- func (a *Analyzer) RuleEnabled(name string) bool
- func (a *Analyzer) RuleSettings(name string) Settings
- func (a *Analyzer) RuleSeverity(name string, def Severity) Severity
- func (a *Analyzer) WithParser(p Parser) *Analyzer
- func (a *Analyzer) WithRawQuery() *Analyzer
- type FallbackParser
- type Parser
- type Profile
- type Result
- func CheckAddNotNullWithoutDefault(s *Statement) (Result, bool)
- func CheckCartesianJoin(s *Statement) (Result, bool)
- func CheckDeleteWithoutWhere(s *Statement) (Result, bool)
- func CheckImplicitJoin(s *Statement) (Result, bool)
- func CheckInListTooLarge(s *Statement) (Result, bool)
- func CheckInsertWithoutColumns(s *Statement) (Result, bool)
- func CheckLargeOffset(s *Statement) (Result, bool)
- func CheckLeadingWildcard(s *Statement) (Result, bool)
- func CheckNonSargablePredicate(s *Statement) (Result, bool)
- func CheckOrderByWithoutLimit(s *Statement) (Result, bool)
- func CheckSelectDistinct(s *Statement) (Result, bool)
- func CheckSelectStar(s *Statement) (Result, bool)
- func CheckSelectWithoutLimit(s *Statement) (Result, bool)
- func CheckUpdateWithoutWhere(s *Statement) (Result, bool)
- type Rule
- type RuleSpec
- type Settings
- func (s Settings) Bool(key string, def bool) bool
- func (s Settings) Duration(key string, def time.Duration) time.Duration
- func (s Settings) Int(key string, def int) int
- func (s Settings) LookupDuration(key string) (time.Duration, bool)
- func (s Settings) LookupInt(key string) (int, bool)
- func (s Settings) String(key, def string) string
- type Severity
- type Statement
- type StmtKind
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func EvaluatedRuleNames ¶ added in v0.4.0
func EvaluatedRuleNames() []string
EvaluatedRuleNames returns the rules the Analyzer runs against a Statement, sorted — the subset of RuleNames() that has a Factory. The config loader uses it to tell an `only:` list that selects nothing runnable from one that narrows the scan, which reads identically in YAML.
func Fingerprint ¶
Fingerprint returns a stable, PII-free identity for sql: it is Redact followed by whitespace collapsing and IN/VALUES-list folding ("(?, ?, ?)" -> "(?)") so that queries differing only in literal values or list length share one fingerprint. A trailing ";" is trimmed.
It is the canonical query identity the runtime, the N+1 tracker and any metrics adapter group on, and is safe as a low-cardinality metric label.
func IsMultiStatement ¶
IsMultiStatement reports whether sql contains more than one SQL statement, i.e. a ";" statement separator followed by further non-whitespace content. Comments and string-literal bodies are removed first (reusing the same comment/literal-aware lexer the parser uses), so a ";" hidden in a -- or /* */ comment, or inside a string literal, cannot defeat it. A single trailing ";" is not multi-statement.
Note that this deliberately uses the *narrowest* reading of a literal, which is the opposite of what Redact does. The two have opposite fail-safe directions: Redact must never leave a byte of a literal in its output, so it over-consumes; IsMultiStatement must never miss a statement separator, so it under-consumes. Concretely:
- Backslash escapes are not honored. Treating "\'" as an escape would let `'a\'; DROP TABLE t; --'` read as one literal and hide the second statement.
- Both readings of "$$" are run and either one finding a separator is enough, because each is blind to a payload the other catches. Reading it as a dollar delimiter hides the ";" in `UPDATE t AS $$ SET id = 1; DROP TABLE t` behind an unterminated body; reading it as ordinary bytes hides the ";" in `SELECT $$'$$; DROP TABLE t` behind an unterminated ordinary literal. Dropping either one is a bypass, pinned by TestIsMultiStatementNeedsBothReadings.
The cost is that a single statement carrying a ";" inside a dollar-quoted body is refused. Over-rejection is the affordable error here.
func ParseIgnoreComment ¶
ParseIgnoreComment parses the text of a single comment for a sqlguard:ignore directive. It is used by the static scanner to honor `// sqlguard:ignore` / `// sqlguard:ignore:rule-a,rule-b` annotations in Go source. found reports whether a directive was present; all is true for a bare directive (suppress every rule); rules holds the named rules otherwise.
func Redact ¶
Redact returns sql with comments stripped and every string literal and numeric literal replaced by a single "?" placeholder. Query structure, keywords, and identifiers (including double-quoted and backtick-quoted identifiers) are preserved, so the result stays readable and analyzable but carries no literal values — no emails, tokens, or other PII reach a log sink.
It covers single-quoted literals (honoring doubled-quote and backslash escapes alike), Postgres dollar-quoted strings ($$…$$ / $tag$…$tag$), and decimal, hex (0x…), binary (0b…) and exponent numeric forms.
It is a zero-dependency lexical pass, not a full parser, and never errors. Where a dialect ambiguity makes the end of a literal uncertain it over-redacts rather than risk a leak; see redactSpans.
One dialect gap is deliberate: a double-quoted run is kept as an identifier, which is right for ANSI/Postgres and for MySQL under ANSI_QUOTES but not for MySQL's default sql_mode, where "…" is also a string literal. On MySQL use '…' or bind parameters for values. Backticks are always identifiers.
Use it whenever a query is about to leave the process.
Types ¶
type Analyzer ¶
type Analyzer struct {
// contains filtered or unexported fields
}
Analyzer holds a set of rules and a Parser, and runs the rules against SQL queries. Configuration (disabled rules, severity overrides, per-rule settings) is resolved once at construction into the bound rule set and the severity map; the per-query Analyze path does no config work.
func Default ¶
func Default() *Analyzer
Default creates an Analyzer with all registered built-in rules and the fallback parser, using each rule's default settings and severity.
func DefaultWithProfile ¶
DefaultWithProfile builds an Analyzer from the rule registry with the given Profile applied: disabled/whitelisted rules are filtered, per-rule settings are passed to each rule's factory, and severity overrides are precomputed. The config package uses this to turn a .sqlguard.yml into an Analyzer without analyzer ever importing config or YAML.
func New ¶
New creates an Analyzer with the given anonymous rules, using the zero-dependency FallbackParser. Use WithParser to supply a real dialect parser. Rules added this way are not subject to profile overrides (they have no registry name); use Default/DefaultWithProfile for configurable built-in rules.
func (*Analyzer) Analyze ¶
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 an in-SQL `sqlguard:ignore` directive are suppressed, and severity overrides from the active Profile are applied.
func (*Analyzer) PrepareQuery ¶
PrepareQuery returns the query field and fingerprint for a Result built outside the rule path (e.g. the runtime slow-query and N+1 findings), applying the same redaction policy as Analyze so every emitted Result is consistent. display is redacted unless the Analyzer was built WithRawQuery; fingerprint is always the PII-free identity.
func (*Analyzer) RuleEnabled ¶ added in v0.4.0
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 (*Analyzer) RuleSettings ¶ added in v0.4.0
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 (*Analyzer) RuleSeverity ¶ added in v0.4.0
RuleSeverity returns the severity to report for name, applying a profile override to def when one is set.
func (*Analyzer) WithParser ¶
WithParser returns a copy of the Analyzer that uses the given Parser. Passing nil resets it to the FallbackParser.
func (*Analyzer) WithRawQuery ¶
WithRawQuery returns a copy of the Analyzer that leaves Result.Query unredacted (the raw SQL, literals and all). Redaction is on by default so literal values never reach a log sink; opt out only for local debugging where the query text is trusted. Fingerprint is always populated either way.
type FallbackParser ¶
type FallbackParser struct{}
FallbackParser is the zero-dependency Parser. It removes SQL comments and string-literal contents before pattern matching, so keywords inside comments or strings (and identifiers like update_at) no longer cause false positives. It is best-effort and never returns an error: SQL it cannot fully understand still yields a usable Statement with Exact=false.
func NewFallbackParser ¶
func NewFallbackParser() *FallbackParser
NewFallbackParser returns the default zero-dependency parser.
type Parser ¶
Parser turns a raw SQL string into sqlguard's normalized Statement.
Implementations:
- FallbackParser (this package): zero-dependency, best-effort, never returns an error.
- parsers/pgparser, parsers/mysqlparser (optional modules): real dialect ASTs, exact analysis, fall back to FallbackParser on parse failure.
A Parser used on the runtime query path MUST NOT panic and SHOULD avoid returning an error for SQL it merely doesn't understand — degrade to a best-effort Statement instead, so analysis never breaks db.Query.
type Profile ¶
type Profile struct {
// Disabled rules are not constructed or run.
Disabled map[string]bool
// Only, when non-empty, is a whitelist: only these rules run.
Only map[string]bool
// Severity overrides a rule's reported severity.
Severity map[string]Severity
// Settings holds per-rule tunables.
Settings map[string]Settings
// RawQuery, when true, disables Result.Query redaction (literals are
// left in the reported SQL). Default (false) redacts — see
// Analyzer.WithRawQuery.
RawQuery bool
}
Profile is the resolved, parser-independent view of configuration applied to an Analyzer at construction time. The config package builds it from .sqlguard.yml; analyzer never imports config or YAML. All maps are keyed by rule name. Resolution happens once here, never on the per-query path.
func (Profile) Skip ¶ added in v0.4.0
Skip reports whether this profile excludes the named rule from evaluation, folding in both the `only` whitelist and the disabled set. It is exported so the config loader can ask the same question the Analyzer answers, rather than reimplementing the precedence and drifting from it.
type Result ¶
type Result struct {
RuleName string
Severity Severity
// Query is the offending SQL as surfaced to reporters. By default it is
// redacted (string/numeric literals replaced with "?") so literal values
// never reach a log sink; an Analyzer built WithRawQuery leaves it raw.
Query string
// Fingerprint is the redacted, whitespace-collapsed, list-folded query
// identity (see analyzer.Fingerprint). It is always set, never carries
// PII, and is safe as a metric label or log key.
Fingerprint string
Message string
Suggestion string
File string // populated only in static analysis mode
Line int // populated only in static analysis mode
}
Result represents a single finding from query analysis.
func CheckAddNotNullWithoutDefault ¶
CheckAddNotNullWithoutDefault detects an ALTER TABLE that adds a NOT NULL column with no DEFAULT, which errors or forces a full table rewrite on a populated table.
func CheckCartesianJoin ¶
CheckCartesianJoin detects a multi-table FROM with no join condition and no WHERE filter — an unconditioned cartesian product (incl. CROSS JOIN).
func CheckDeleteWithoutWhere ¶
CheckDeleteWithoutWhere detects DELETE statements without a WHERE clause.
func CheckImplicitJoin ¶
CheckImplicitJoin detects a FROM clause that joins tables with commas (FROM a, b) instead of explicit JOIN syntax — error-prone because a forgotten join condition silently yields a cartesian product.
func CheckInListTooLarge ¶
CheckInListTooLarge detects an IN (...) value list with more elements than the default threshold (100). Use the registry / config to tune max-length.
func CheckInsertWithoutColumns ¶
CheckInsertWithoutColumns detects INSERT statements without an explicit column list.
func CheckLargeOffset ¶
CheckLargeOffset detects a literal OFFSET larger than the default threshold (1000). Use the registry / config to tune threshold.
func CheckLeadingWildcard ¶
CheckLeadingWildcard detects LIKE patterns with leading wildcards, using the rule's default settings (no minimum term length).
func CheckNonSargablePredicate ¶
CheckNonSargablePredicate detects a function or cast applied to a column on the column side of a WHERE comparison (e.g. WHERE LOWER(email) = ...), which prevents an ordinary index on that column from being used.
func CheckOrderByWithoutLimit ¶
CheckOrderByWithoutLimit detects ORDER BY without LIMIT, which sorts the entire result set.
func CheckSelectDistinct ¶
CheckSelectDistinct detects a select-level DISTINCT, which is often added to hide duplicate rows produced by an unintended join fan-out rather than to express a genuine need for distinct results. INFO by default.
func CheckSelectStar ¶
CheckSelectStar detects SELECT * usage.
func CheckSelectWithoutLimit ¶
CheckSelectWithoutLimit detects SELECT statements without a LIMIT clause. Only flags queries that have a FROM clause (to skip SELECT 1, SELECT version(), etc.) and don't have WHERE, to reduce noise.
func CheckUpdateWithoutWhere ¶
CheckUpdateWithoutWhere detects UPDATE statements without a WHERE clause.
type Rule ¶
Rule checks a normalized Statement and returns a Result if an issue is found. It returns the result and true if an issue was detected, or a zero Result and false otherwise.
Rules operate on the parsed Statement, not the raw SQL string, so a query is parsed once per Analyze call and every rule sees the same dialect- agnostic view.
type RuleSpec ¶
type RuleSpec struct {
Name string
DefaultSeverity Severity
// Factory builds the rule for the statement path. It is nil for findings
// the Analyzer does not produce itself — middleware's `slow-query` and
// `n-plus-one`, and the plan rules `explain` derives from a query plan.
// Those register so they are addressable by name like any other rule;
// their owners ask the Analyzer for the resolved decision before they
// emit. A nil Factory is never called.
Factory func(Settings) Rule
}
RuleSpec describes a built-in rule: its stable name (used in config, suppressions and reports), its default severity, and a factory that builds the rule from its settings. Keeping construction behind a factory is what makes per-rule settings work uniformly for every present and future rule.
type Settings ¶
Settings holds rule-specific configuration as a generic key/value map so new tunables can be added without changing this type or the config schema. Accessors are nil-safe and fall back to the provided default, so a rule can always be constructed even with no settings supplied.
func (Settings) Duration ¶
Duration returns the setting parsed as a time.Duration, or def if missing or unparseable.
func (Settings) Int ¶
Int returns the setting as an int, or def if missing or not numeric. YAML decodes integers as int and JSON as float64, so both are accepted.
func (Settings) LookupDuration ¶ added in v0.4.0
LookupDuration returns the setting parsed as a time.Duration. It accepts a duration string ("200ms") or a number interpreted as milliseconds. ok is false when the key is absent or the value is one Duration cannot use — see LookupInt for why the config loader validates through this.
func (Settings) LookupInt ¶ added in v0.4.0
LookupInt returns the setting as an int. ok is false when the key is absent or the value is one Int cannot use — the same condition under which Int falls back to its default. The config loader validates through this so it flags exactly what the reader will ignore, instead of keeping a second copy of these rules that can drift.
type Severity ¶
type Severity int
Severity represents the importance level of an analysis finding.
const ( // SeverityInfo is an advisory finding worth noting but not necessarily acting on. SeverityInfo Severity = iota // SeverityWarning is a likely problem that should be reviewed. SeverityWarning // SeverityCritical is a serious problem likely to cause incorrect or destructive behavior. SeverityCritical )
func RuleDefaultSeverity ¶ added in v0.4.0
RuleDefaultSeverity returns the severity a rule was registered with. ok is false for an unregistered name. The findings built outside the statement path read this rather than repeating a literal, so the registry entry stays the single source of truth for every rule, not just the evaluated ones.
func RuleDefaultSeverityOr ¶ added in v0.4.0
RuleDefaultSeverityOr returns the severity a rule was registered with, or def when the name is not registered. It is what the findings built outside the statement path use: they cannot reach their RuleSpec any other way, and without one helper each of them repeats the same lookup-and-fall-back.
type Statement ¶
type Statement struct {
// Raw is the original, untouched SQL string. Reported back to users.
Raw string
// Kind is the statement's top-level kind.
Kind StmtKind
// HasWhere reports whether the statement has a WHERE clause.
HasWhere bool
// HasLimit reports whether the statement has a LIMIT clause.
HasLimit bool
// HasOrderBy reports whether the statement has an ORDER BY clause.
HasOrderBy bool
// HasFrom reports whether a SELECT has a FROM clause. Distinguishes
// "SELECT * FROM t" from "SELECT 1" / "SELECT version()".
HasFrom bool
// SelectStar reports an unqualified "SELECT *" / "SELECT t.*" of columns.
// It is false for aggregate forms like COUNT(*).
SelectStar bool
// SelectDistinct reports a select-level DISTINCT (SELECT DISTINCT ...,
// incl. Postgres DISTINCT ON and MySQL DISTINCTROW). It is false for an
// aggregate-level DISTINCT such as COUNT(DISTINCT col), which is unrelated.
// The dialect parsers compute it from the AST; the fallback approximates it
// lexically.
SelectDistinct bool
// InsertColumnsListed reports whether an INSERT names its target columns
// explicitly, or has no columns to name. The second case is what keeps
// PostgreSQL's INSERT INTO t DEFAULT VALUES and MySQL's
// INSERT INTO t SET col = ... out of insert-without-columns: neither binds
// caller-supplied values by position, so no schema change can shift them.
// A parser filling this from an AST must account for both — reading it as
// "the column list is non-empty" alone reports DEFAULT VALUES.
// Only meaningful when Kind == StmtInsert.
InsertColumnsListed bool
// LeadingWildcardLike reports a LIKE pattern beginning with a wildcard
// (e.g. LIKE '%foo'), which prevents index use.
LeadingWildcardLike bool
// NonSargablePredicate reports a function or cast applied to a column on
// the column side of a WHERE comparison (e.g. WHERE LOWER(email) = ...),
// which prevents the use of an ordinary index on that column. Like the
// LIKE fields, this is a literal/text-level heuristic the real parsers'
// ASTs discard, so it is computed by the fallback lexer and preserved by
// the dialect parsers rather than recomputed structurally.
NonSargablePredicate bool
// AddNotNullNoDefault reports an ALTER TABLE that adds a NOT NULL column
// with no DEFAULT (e.g. ALTER TABLE t ADD COLUMN c int NOT NULL), which
// fails or forces a table rewrite on a populated table. Like the other
// text-level fields above, it is computed by the fallback lexer and
// preserved by the dialect parsers.
AddNotNullNoDefault bool
// ImplicitCommaJoin reports a FROM clause that lists multiple tables
// separated by top-level commas (FROM a, b) instead of explicit JOIN
// syntax — the old-style join that silently produces a cartesian product
// when its join condition is forgotten. Computed by the fallback lexer and
// preserved (not recomputed from the AST) by the dialect parsers, so it
// stays a best-effort heuristic even when Exact is true.
ImplicitCommaJoin bool
// CartesianJoin reports a multi-table FROM (comma join, CROSS JOIN, or a
// bare JOIN) with no join condition (ON/USING/NATURAL) and no top-level
// WHERE filter — an unconditioned cartesian product. It is the high-
// confidence subset of ImplicitCommaJoin and also covers CROSS/bare JOIN.
// Like ImplicitCommaJoin, it is a fallback-lexer heuristic preserved by the
// dialect parsers, so it stays best-effort even when Exact is true.
CartesianJoin bool
// MaxInListLen is the largest element count among the statement's IN (...)
// value lists (IN (SELECT ...) subqueries are excluded). It powers the
// in-list-too-large rule's max-length threshold. Zero means no value-list
// IN was found. Like the other counts, rules read it, never raw SQL. It is a
// fallback-lexer heuristic preserved by the dialect parsers (the AST discards
// the literal list it counts), so it stays best-effort even when Exact is true.
MaxInListLen int
// OffsetValue is the largest literal OFFSET seen (standard OFFSET n or
// MySQL's LIMIT offset, count), powering the large-offset rule. Zero means
// no offset, OFFSET 0, or a parameterized offset (OFFSET $1 / ?), which
// cannot be evaluated statically and is therefore never flagged. The dialect
// parsers read it from the AST's limit clause; the fallback scans for it.
OffsetValue int
// LeadingWildcardTermLen is the length of the longest searchable term
// (the literal with surrounding % wildcards trimmed) across all
// leading-wildcard LIKE patterns in the statement. It powers the
// leading-wildcard rule's min-length setting. Zero means "unknown"
// (e.g. produced by a real parser that did not compute it); rules must
// treat zero as unknown and not as "short", to avoid false negatives.
LeadingWildcardTermLen int
// Exact is true when the Statement was produced by a real SQL parser
// (structural analysis), false when produced by the regex fallback
// (best-effort). Rules may use this to suppress lower-confidence findings.
//
// "Exact" covers the structural facts the dialect parsers derive from the
// AST: Kind, HasWhere/HasLimit/HasOrderBy/HasFrom, SelectStar,
// SelectDistinct, OffsetValue, and InsertColumnsListed. A few facts stay
// lexical heuristics even when Exact is true — MaxInListLen,
// ImplicitCommaJoin, CartesianJoin, and the literal/text-level fields
// (LeadingWildcard*, NonSargablePredicate, AddNotNullNoDefault) — because
// they read literal values the AST discards or are intentionally text-level.
// Each such field documents this.
Exact bool
}
Statement is sqlguard's normalized, dialect-agnostic view of a single SQL statement. It carries only the semantic facts the rules need — not a full AST. Every Parser (the zero-dependency fallback and the optional real dialect parsers) populates this same struct, so rules never depend on a particular parser or dialect.
Boolean fields are best-effort: a fallback-produced Statement may leave a field false when it genuinely cannot tell. Rules must treat "false" as "not detected", never as "proven absent", to avoid false positives.
type StmtKind ¶
type StmtKind int
StmtKind is the top-level kind of a SQL statement.
const ( // StmtUnknown means the parser could not determine the statement kind. StmtUnknown StmtKind = iota // StmtSelect is a SELECT (or WITH ... SELECT) query. StmtSelect // StmtInsert is an INSERT statement, or one of the dialect keywords that // inserts rows the same way: MySQL/SQLite's REPLACE and the UPSERT the // CockroachDB-derived grammar behind pgparser accepts. They bind values by // column order exactly as INSERT does, so every rule that targets INSERT // targets them. Note this also makes them DML to explain.validate, which // accepts them under WithAllowDML rather than refusing them as unrecognized. StmtInsert // StmtUpdate is an UPDATE statement. StmtUpdate // StmtDelete is a DELETE statement. StmtDelete // StmtOther is a recognized statement that none of the rules target // (DDL, transaction control, etc.). StmtOther )