Files
cairnobs/api/internal/querylang/planner/planner.go
T
jcoffey-dev 4b5dae5879 Add local login, agent extra log paths, IPv4/IPv6 metrics; remediate security audit findings
This is a large squashed commit covering two batches of prior uncommitted
work plus a full security-audit remediation pass, kept together because
go.mod/go.sum and several shared files (main.go, handler.go) were touched
by both and splitting risked non-building intermediate commits.

Features (built earlier, previously uncommitted):
- Local username/password login for single-tenant deployments with no
  SSO configured (api/localauth, alerting/internal/sessioncheck,
  sentryctl users, web/src/routes/login, metadata migrations 0040/0041).
- Remotely-editable additional log file paths for agents, on top of
  their existing primary source (api/agents, agent/sentry-agent
  extra-file-path diffing, web agent config UI).
- IPv4/IPv6 addresses reported alongside other host system metrics.

Security audit remediation (this pass, all live-verified in production):
- Critical: block ClickHouse SSRF table functions (url/remote/file/s3/...)
  in the raw-SQL query escape hatch.
- High: deny sensitive paths and require Admin to add agent
  extra_file_paths (Editor could previously point an agent at /etc/shadow
  or an SSH key); alerting webhook targets now validate against
  internal/metadata/loopback addresses, both at creation and send time;
  alerting's session middleware now enforces an Editor+ floor on
  mutating requests instead of "any authenticated session"; bumped
  goxmldsig to close a SAML signature-verification bypass (GO-2026-4753).
- Medium: per-IP login rate limiting; security response headers
  (HSTS/CSP/nosniff/X-Frame-Options/Referrer-Policy/Permissions-Policy)
  on web/nginx.conf; a DevCredentialWarnings check in every Go service's
  config loader, logging loudly at startup if a deployment is still on
  docker-compose.yml's literal dev-only credentials; dependency bumps
  (golang.org/x/text, grpc, x/net, quick-xml, h2) across every affected
  Go module and both Rust crates, including a previously-uncovered x/net
  vulnerability in deploy/operator; a new security-scan.yml CI workflow
  running cargo-deny/govulncheck/npm-audit, mirroring the existing
  license-compliance.yml matrix shape.
- Low: removed sentryctl's plaintext --password flag (shell
  history/`ps` exposure) in favor of stdin and a --password-stdin flag
  for reset-password's optional specific-password path; a dummy bcrypt
  comparison closes a login response-time username-enumeration
  side-channel.
2026-08-18 23:53:20 -07:00

308 lines
9.6 KiB
Go

// Package planner compiles a query string (either syntax) into ir.Plan.
// This is the single entry point querylang exposes to callers (the /query
// HTTP handler) -- see Compile.
package planner
import (
"fmt"
"regexp"
"strings"
"time"
"github.com/sentry/sentry/api/internal/querylang/ast"
"github.com/sentry/sentry/api/internal/querylang/ir"
"github.com/sentry/sentry/api/internal/querylang/parser"
)
// Language selects which syntax a query is written in.
type Language string
const (
Auto Language = "" // detect from the query text (default)
SQL Language = "sql"
SPL Language = "spl" // the pipe syntax; named to match the query-language-reference doc
)
const defaultLimit = 100
// Compile turns a query string into a Plan. language overrides
// auto-detection; pass Auto to use the SELECT-prefix heuristic (see
// /docs/query-language-design.md's "Detection" section) -- this exists
// for the rare case a pipe query legitimately starts with the literal
// word "select" as a bare search term.
func Compile(query string, language Language, now time.Time) (*ir.Plan, error) {
isSQL := language == SQL
if language == Auto {
isSQL = looksLikeSQL(query)
}
if isSQL {
if err := validateSelectOnly(query); err != nil {
return nil, err
}
return &ir.Plan{RawSQL: strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(query), ";"))}, nil
}
q, err := parser.Parse(query)
if err != nil {
return nil, err
}
return compileQuery(q, now)
}
func looksLikeSQL(query string) bool {
trimmed := strings.TrimSpace(query)
if trimmed == "" {
return false
}
fields := strings.Fields(trimmed)
return len(fields) > 0 && strings.EqualFold(fields[0], "SELECT")
}
func compileQuery(q *ast.Query, now time.Time) (*ir.Plan, error) {
plan := &ir.Plan{}
textParts, err := compileBoolExpr(q.Base, plan, now)
if err != nil {
return nil, err
}
for _, stage := range q.Pipes {
switch s := stage.(type) {
case ast.WhereStage:
parts, err := compileBoolExpr(s.Expr, plan, now)
if err != nil {
return nil, err
}
textParts = append(textParts, parts...)
case ast.StatsStage:
agg, err := compileStats(s)
if err != nil {
return nil, err
}
plan.Aggregation = agg
case ast.SortStage:
for _, f := range s.Fields {
plan.Sort = append(plan.Sort, ir.SortField{Field: f.Field, Desc: f.Desc})
}
case ast.FieldsStage:
plan.Fields = append(plan.Fields, s.Fields...)
case ast.HeadStage:
n := defaultLimit
if s.HasN {
n = s.N
}
plan.Limit = &ir.Limit{N: n, Tail: false}
case ast.TailStage:
n := defaultLimit
if s.HasN {
n = s.N
}
plan.Limit = &ir.Limit{N: n, Tail: true}
default:
return nil, fmt.Errorf("internal error: unhandled pipe stage %T", stage)
}
}
if len(textParts) > 0 {
plan.TextSearch = []ir.TextPredicate{{Query: strings.Join(textParts, " ")}}
}
return plan, nil
}
// textPart is one piece of a combined Tantivy query string, tagged with
// the conjunction that precedes it (empty for the first piece).
type textPart struct {
conj string // "", "and", "or"
query string
}
// compileBoolExpr walks one bool_expr (the base search, or a `where`
// stage's expression), populating plan.Filters and plan.TimeRange
// directly, and returning free-text pieces for the caller to fold into
// the combined Tantivy query string.
//
// Scope decision: "or" is only supported between free-text terms, which
// Tantivy's own query parser handles natively once composed into one
// string. "or" between structured comparisons/time-bounds is rejected
// with a clear error rather than silently compiled as "and" -- see
// /docs/query-language-reference.md's limitations section. This keeps
// the executor's generated SQL a flat AND-only WHERE clause, which is
// most of what real queries need; full boolean-tree support for
// structured filters is future work if usage shows it's needed.
func compileBoolExpr(expr ast.BoolExpr, plan *ir.Plan, now time.Time) ([]string, error) {
var textParts []string
for i, term := range expr.Terms {
conj := ""
if i > 0 {
conj = expr.Conjs[i-1]
}
switch t := term.(type) {
case ast.Comparison:
if conj == "or" {
return nil, fmt.Errorf("query error: \"or\" is not supported between structured filters (%q) in Phase 2 -- only between free-text search terms", t.Field)
}
plan.Filters = append(plan.Filters, ir.FilterPredicate{Field: t.Field, Op: t.Op, Value: t.Value})
case ast.TimeBound:
if conj == "or" {
return nil, fmt.Errorf("query error: \"or\" is not supported on time bounds (%s) in Phase 2", t.Kind)
}
if err := applyTimeBound(plan, t, now); err != nil {
return nil, err
}
case ast.FreeText:
q := t.Query
if strings.ContainsAny(q, " \t") {
q = `"` + strings.ReplaceAll(q, `"`, `\"`) + `"`
}
if conj == "or" {
textParts = append(textParts, "OR", q)
} else if len(textParts) > 0 {
textParts = append(textParts, "AND", q)
} else {
textParts = append(textParts, q)
}
default:
return nil, fmt.Errorf("internal error: unhandled term %T", term)
}
}
return textParts, nil
}
func applyTimeBound(plan *ir.Plan, t ast.TimeBound, now time.Time) error {
when, err := resolveTimeExpr(t.Expr, now)
if err != nil {
return err
}
if plan.TimeRange == nil {
plan.TimeRange = &ir.TimeRange{}
}
switch t.Kind {
case "earliest":
plan.TimeRange.From = when
case "latest":
plan.TimeRange.To = when
}
return nil
}
func resolveTimeExpr(e ast.TimeExpr, now time.Time) (time.Time, error) {
if !e.IsRelative {
t, err := time.Parse(time.RFC3339, e.Absolute)
if err != nil {
return time.Time{}, fmt.Errorf("query error: invalid absolute timestamp %q, want RFC3339 (e.g. 2026-08-14T00:00:00Z): %w", e.Absolute, err)
}
return t, nil
}
var d time.Duration
switch e.RelativeUnit {
case "s":
d = time.Duration(e.RelativeN) * time.Second
case "m":
d = time.Duration(e.RelativeN) * time.Minute
case "h":
d = time.Duration(e.RelativeN) * time.Hour
case "d":
d = time.Duration(e.RelativeN) * 24 * time.Hour
case "w":
d = time.Duration(e.RelativeN) * 7 * 24 * time.Hour
default:
return time.Time{}, fmt.Errorf("internal error: unknown time unit %q", e.RelativeUnit)
}
if e.RelativeSign < 0 {
d = -d
}
return now.Add(d), nil
}
func compileStats(s ast.StatsStage) (*ir.Aggregation, error) {
agg := &ir.Aggregation{GroupBy: s.By}
seen := map[string]bool{}
for _, a := range s.Aggs {
if a.Func != "count" && a.Field == "" {
return nil, fmt.Errorf("query error: %s() requires a field, e.g. %s(latency_ms)", a.Func, a.Func)
}
alias := a.Alias
if alias == "" {
alias = defaultAggAlias(a)
}
if seen[alias] && a.Alias == "" {
// Two unnamed aggs of the same shape would otherwise collide
// (e.g. `stats sum(a), sum(b)` both defaulting to "sum") --
// disambiguate by field name.
alias = alias + "_" + a.Field
}
seen[alias] = true
agg.Funcs = append(agg.Funcs, ir.AggFunc{Func: a.Func, Field: a.Field, Alias: alias})
}
return agg, nil
}
func defaultAggAlias(a ast.AggCall) string {
if a.Func == "count" {
return "count"
}
return a.Func
}
// --- SQL escape hatch validation, ported from the Phase 0/1
// api/internal/queryapi/validate.go guard it replaces (see task 4) ---
// disallowedKeyword is defense-in-depth on top of the SELECT-only gate:
// it catches mutating/administrative statements appearing anywhere in
// the query, not just at the start. Word-boundary matching, not a real
// SQL parser -- same tradeoffs as the Phase 0/1 version this replaces.
var disallowedKeyword = regexp.MustCompile(`(?i)\b(insert|update|delete|alter|drop|truncate|create|grant|revoke|attach|detach|rename|kill|optimize|system|set|exchange|watch)\b`)
// disallowedTableFunction blocks ClickHouse's built-in table functions
// that reach outside ClickHouse itself -- a keyword blocklist for
// mutating statements (above) doesn't touch these at all, since
// `SELECT * FROM url(...)` is a perfectly ordinary read-only SELECT as
// far as validateSelectOnly's other checks are concerned. Every
// function here lets a SELECT-only, RoleViewer-gated query make
// ClickHouse itself issue an outbound request or read a local file on
// the caller's behalf -- cloud-metadata SSRF via url(), a proxy into
// other internal ClickHouse/MySQL/Postgres instances via
// remote()/remoteSecure()/mysql()/postgresql(), and local/object-storage
// file reads via file()/hdfs()/s3()/azureBlobStorage()/deltaLake()/
// iceberg()/hudi(). Same word-boundary-regex tradeoff as
// disallowedKeyword above: this is a blocklist, not a real SQL parser,
// so it can't be the only control -- see the ClickHouse-grant-level
// hardening this should be paired with (table-function usage revoked
// for the role api's raw-SQL path connects as).
var disallowedTableFunction = regexp.MustCompile(`(?i)\b(url|remote|remoteSecure|mysql|postgresql|s3|s3Cluster|hdfs|hdfsCluster|file|odbc|jdbc|executable|cluster|clusterAllReplicas|azureBlobStorage|deltaLake|iceberg|hudi|redis|mongodb)\s*\(`)
func validateSelectOnly(sql string) error {
trimmed := strings.TrimSpace(sql)
if trimmed == "" {
return fmt.Errorf("query must not be empty")
}
trimmed = strings.TrimSpace(strings.TrimSuffix(trimmed, ";"))
if trimmed == "" {
return fmt.Errorf("query must not be empty")
}
if strings.Contains(trimmed, ";") {
return fmt.Errorf("only a single statement is allowed")
}
firstWord := strings.ToUpper(strings.Fields(trimmed)[0])
if firstWord != "SELECT" {
return fmt.Errorf("only SELECT queries are allowed")
}
if disallowedKeyword.MatchString(trimmed) {
return fmt.Errorf("query contains a disallowed keyword")
}
if disallowedTableFunction.MatchString(trimmed) {
return fmt.Errorf("query contains a disallowed table function")
}
return nil
}