Skip to content

Security: elixir-ai-tools/spire

Security

SECURITY.md

Security

Spire is designed to safely execute untrusted SQL — queries provided by your customers, by an LLM, or by anyone else you don't fully trust — against your own data sources. This document describes the threat model: what Spire defends against, what it doesn't, and what's on you.

If you find a vulnerability, please open a private GitHub Security Advisory rather than a public issue.

What Spire defends against

Data access

  • Cross-tenant readsSpire.Policy.Table.enforced_where is injected as an {:enforced_filter, _} op immediately after every matching scan. The op is structurally distinct from :filter and per SPEC.md §8.3 cannot be removed, merged, or reordered by any optimizer. In JOINs the scope applies on every side. Verified by bench/multi_tenant_demo.exs.
  • Column exfiltration — only columns in Policy.Table.columns may appear in SELECT contexts. filter_only_columns may appear in WHERE / GROUP BY / HAVING / ORDER BY but are rejected from SELECT.
  • Table / function escalation — positive allowlist for both. A table or function not in the policy is rejected before the engine or the SQL backend ever sees it.

Resource exhaustion

  • Atom-table DoS — the lexer stores customer-supplied parameter names and CAST types as binaries, not atoms. Tested in test/spire/red_team_test.exs: 100 distinct param names and 100 distinct CAST types each grow the atom table by at most ~10.
  • Regex backtracking on LIKESpire.Engine.Null uses a hand-rolled two-pointer matcher rather than :re. Worst-case O(|str| · |pattern|) on adversarial patterns; no exponential blowup.
  • Query complexityPolicy.max_joins, max_result_limit, require_limit are enforced at analyze time.
  • Deep AST nesting — 200-deep NOT chains and 200-clause AND chains compile in milliseconds; verified in the red-team corpus.
  • 1000-element IN lists — verified to compile and execute in bounded time.

Injection

  • SQL injection via identifiers — every column / table / alias name reaching the SQL emitter is double-quoted with embedded quotes doubled. Identifiers from the customer query are always validated against the policy allowlist before they reach the emitter; identifiers from the policy itself are dev-controlled.
  • String literal injection — string literals are passed as parameters (?N placeholders), never concatenated into SQL. The test-only ClickHouse source inlines params as quote-doubled SQL literals; this path is safe because params here are policy-resolved values, not raw SQL strings.

Three-valued logic

  • All comparisons, logical operators, and arithmetic route through Spire.Engine.Null. Elixir's two-valued == / and / or / not are never used to evaluate SQL expressions. The behavior is proven exhaustively over {true, false, nil} in test/spire/engine/null_truth_tables_test.exs.
  • WHERE NULL, WHERE x = NULL, WHERE NOT NULL all reject rows. Verified in the red-team corpus.

Typed value comparison (Date / DateTime / Decimal)

  • Spire.Engine.Null.term_compare/2 is the engine's single three-way comparator. Every SQL compare op (sql_eq, sql_lt, sql_in, sql_between…) and Spire.Engine.Sort.compare_one/4 go through it.
  • For same-type Date / Time / DateTime / NaiveDateTime / Decimal values it dispatches to Module.compare/2 — comparing by value, not by Erlang's struct-field-alphabetical term ordering. Without this dispatch, Decimal.new("10") sorted before Decimal.new("2.50") (term order on the coef field) and 2024-12-31 sorted after 2025-01-01 (term order on day alphabetically before year).
  • Integer/float values are lifted into Decimal for cross-type compare so WHERE amt > 5 works against a Decimal column.
  • Pinned by test/spire/property/typed_compare_test.exs.

Engine boundary safety nets

  • Custom function crashes — functions passed via Spire.query(..., functions: %{...}) are user code. If they raise / throw / exit, the engine surfaces %Spire.Error{kind: :engine, code: :function_crashed, meta: %{function: name, reason: …}} rather than letting the exception escape past the engine Task. Pinned by test/spire/property/function_crash_test.exs.
  • Arithmetic domain errorssql_pow(0, -1), sql_pow(-1, 0.5), and overflow return nil (matching sql_div(_, 0) → nil) instead of raising ArithmeticError. Pinned by test/spire/property/pow_edge_cases_test.exs.
  • Engine row-materialization budget — sort / aggregate / join are blocking ops that pull the full upstream into memory. Policy.max_engine_rows (default 1_000_000, :infinity in Policy.permissive/0) caps it and surfaces %Spire.Error{code: :row_budget_exceeded} rather than OOMing the BEAM.
  • Runtime LIMIT / OFFSET bounds — negative param values resolved through LIMIT :n no longer slip past Stream.take(-1) (which silently returns the last row); rejected at plan time with %Spire.Error{code: :invalid_limit}.

PII data-flow safety

Spire is intended to hold real customer data — names, emails, dates, monetary values. Every boundary where data might leak into Logger / telemetry / external observability is scrubbed:

Parameter values

  • params: %{s: :active} (an atom value, not a SQL type) raises %Spire.Error{kind: :param, code: :invalid_param_value}. The raise was added because the prior behavior silently mismatched every comparison and returned zero rows.
  • The error message and meta name the param key only (e.g. "s"). The bound value is never echoed; only its Elixir type class (value_type: :atom).

Telemetry metadata

  • [:spire, :query, :start | :stop | :exception], [:spire, :pushdown], and [:spire, :engine, :scan] events carry :source_module (the source struct's module name — an atom) but NEVER the source struct or the plan. The full source holds rows; the plan embeds literal values from the SQL (WHERE name = 'Alice' becomes %Lit{value: "Alice"}). Handlers commonly ship metadata to third-party services unchanged.
  • [:spire, :engine, :materialize] has empty metadata; only :operation and :row_count measurements.
  • [:spire, :query, :exception] still carries :kind, :reason, :stacktrace from :telemetry.span/3 itself — see "Handler hygiene" below.

Parser errors

  • Token values for :string, :int, and :float literals are reported as type only: expected '(', got string literal, not expected '(', got string "alice@example.com". meta.got for literal tokens is {type, :redacted}.
  • Identifier and keyword token values still appear — they're SQL syntax / schema-public table and column names, and the user needs them to diagnose the error.
  • Pinned by test/spire/property/parser_no_pii_test.exs.

Source error reasons

  • A source's scan/2 or pushdown/3 returning {:error, reason} surfaces only :reason_module (the reason struct's module name, if any) and a reason-class string ("map", "tuple", etc.). Adapter exceptions like %Postgrex.Error{} carry the failed SQL with literals inlined plus bound params; the entire struct is no longer echoed into the engine error.
  • Pinned by test/spire/property/source_error_no_pii_test.exs.

Struct rows (Ecto schemas)

  • A source returning [%MyApp.User{}, …] (the default Ecto.Repo.all/1 shape) used to crash normalize_keys/1 with Protocol.UndefinedError, AND the crash trace echoed the entire struct (including unused PII fields) to Logger.
  • Struct rows are now treated as bare maps with __struct__ and __meta__ (Ecto's per-row schema metadata) stripped; other atom keys are converted to strings as for any bare map.
  • Pinned by test/spire/property/struct_rows_test.exs.

Handler hygiene (caller responsibility)

:telemetry.span/3 emits its own standard fields on the :exception event — :kind, :reason, :stacktrace — which Spire does not control. If a custom function (passed via Spire.query(..., functions: %{...})) raises with a PII-bearing message, that message appears in :reason. Use a wrapping handler if you forward telemetry to a third-party service and need to redact further.

What's still on you

  • tenant_id bindingenforced_where: tenant_id = :tenant_id injects the predicate, but YOU choose what value to bind :tenant_id to. Pull it from your session / JWT / auth context; never from the customer's SQL or request body.
  • Policy completeness — a new table without a Policy.Table entry is automatically rejected (positive allowlist). But if you add a table to the policy and forget the enforced_where, that table returns all tenants' rows. The compiler can't infer scoping.
  • columns: :all literally means any column. Use explicit column lists for tables that might gain columns over time.
  • policy: Spire.Policy.permissive() turns off the table/column/function allowlists and raises the complexity caps to :infinity. NEVER use it for SQL from end-users. It exists for dev-written SQL only (scripts, internal tools, code-generated SQL).
  • require_limit is not on by default. Enable it for customer-facing endpoints; without it a customer can submit a query that returns the entire policy-scoped result set.

What's NOT in scope

These are real attack categories Spire does not mitigate. Use infrastructure-layer controls.

  • Timing side channels — a customer can craft a query whose execution time reveals data (e.g., conditional sleep-like patterns via heavy aggregates over filtered subsets). Spire's max_joins and max_result_limit reduce surface area but don't eliminate it.
  • DoS via expensive-but-legal queries — a customer can submit a query that's policy-compliant but generates a massive ClickHouse / Postgres scan. Use database-side query timeouts, memory limits, and resource quotas.
  • Connection / transaction exhaustion — Spire uses your repo's connection pool; ensure pool sizing and per-tenant rate limiting are appropriate.
  • Information disclosure via errors — Spire's Spire.Error and Spire.AnalyzerError messages are descriptive ("column X not allowed"). If you proxy these directly to customers, you may reveal the existence of columns / tables they didn't know about. For production, log the detailed error and return a generic one.
  • Network / transport security — outside Spire's scope; use TLS, network policies, etc. as normal.

Reporting vulnerabilities

Open a private GitHub Security Advisory on the repository. Include a reproducer (a SQL string, a policy, expected vs actual behavior). We aim to acknowledge within 48 hours.

There aren't any published security advisories