Skip to content

fix(security): close eight PII-denylist bypasses (v0.2.0) - #1

Merged
andrewjstevens merged 1 commit into
mainfrom
security/pii-scope-bypasses
Aug 17, 2026
Merged

fix(security): close eight PII-denylist bypasses (v0.2.0)#1
andrewjstevens merged 1 commit into
mainfrom
security/pii-scope-bypasses

Conversation

@andrewjstevens

Copy link
Copy Markdown
Member

Summary

Closes eight ways the PII denylist could be defeated. Two came from the
adversarial review of the deployed agent; six more surfaced while fixing those.
Every one was reproduced against 0.1.1 before being fixed, and every one has a
regression test.

The most severe was not in the original report: SELECT c FROM tbl AS c
returned every column of every row, PII included, with auto_execute=True.

The two reported issues

  1. Scope aliasingPiiProjectionRule checked only the outermost select
    list, so a CTE / derived table / UNION arm / multi-hop chain could rename a
    denied column and launder it. Now checked across every SELECT scope,
    matching on the underlying column names in the scope that names them.
  2. Value probing — the denylist gated projection only, so
    WHERE BillingCity = 'Columbus', GROUP BY, HAVING and ORDER BY passed.
    New pii_mode ("reference" default | "project") denies any reference.

Found while fixing them

Bypass Effect
SELECT c FROM tbl AS c Whole row incl. all PII, auto-executed
NATURAL JOIN Joins on columns the guard can't enumerate
JOIN ... USING (email) Zero exp.Column nodes → invisible to reference mode
OBJECT_CONSTRUCT(*), COLUMNS(*), * APPLY(f), ROW(c.*) Star check only saw the projection root
SELECT t.* Parses as Column wrapping Star; missed even at top level
MAX(email), ARRAY_AGG(email) Blanket AggFunc exemption returned real values

Breaking changes

Queries 0.1.1 allowed are now denied. The bypasses have no supported way back.
Denied columns in predicates can be restored with pii_mode="project" — which
re-opens the probing oracle. The bundled Q1 identity-resolution query is now
denied in both modes; rationale is in the test and the CHANGELOG.

Minimum Python is now 3.11 (the code has required it since 0.1.0, so the
3.10 CI job could never have passed).

Review notes

  • NoUnresolvableColumnsRule resolves ambiguity from the AST rather than
    denying on a bare name collision. WITH revenue AS (... SUM(x) AS revenue) SELECT revenue FROM revenue is correctly read as a column reference — an
    earlier cut denied it, which would have got the rule switched off in prod.
  • Bare UNNEST aliases are deliberately not covered: indistinguishable at parse
    time from the legitimate scalar-array form, and no worse than selecting the
    struct column directly. Documented as a denylist-config concern.
  • Known limits now stated in the README: JSON/VARIANT/STRUCT payloads,
    re-identification, side channels.

Verification

ruff clean, mypy src clean, 204 tests passing (was 74), all three examples
run. Consumer repos untouched — they pin sql-guard and pick this up via
uv lock --upgrade-package sql-guard separately.

Still open (pre-existing, not fixed here)

  • SelectOnlyRule rejects top-level EXCEPT / INTERSECT (sqlglot derives
    them from SetOperation, not Union). Fails closed.
  • AllowedTablesRule runs last, so allowlist breaches are under-reported in
    decision.reason telemetry.

The denylist could be defeated eight ways. Two were reported from an
adversarial review of a deployed agent; the other six surfaced while
fixing them. Each was confirmed against 0.1.1 with a reproducing query
before being fixed, and each has a regression test.

Scope aliasing. PiiProjectionRule checked only the outermost select list
via outermost_projection_names(), so any inner scope that renamed a
denied column laundered it:

    WITH c AS (SELECT BillingCity AS city FROM t) SELECT city FROM c

Derived tables, UNION arms and multi-hop alias chains worked the same
way. Checking now runs over every SELECT scope and matches the
underlying column names in the scope that names them, so an alias cannot
launder a denied column.

Value probing. The denylist gated projection only, so WHERE, GROUP BY,
HAVING and ORDER BY references passed. None return the column, but each
answers a yes/no question about its value, and enough queries
reconstruct it. New pii_mode config ("reference" | "project", default
"reference") denies any reference to a denied column. "project" is the
documented loosening path and is still all-scope.

Found while fixing the above:

- SELECT c FROM tbl AS c returned every column of every row, PII
  included, and the guard auto-executed it. A bare table alias in a
  value position expands to the whole row; it parses as an ordinary
  column, so the denylist had nothing to match. Strictly worse than the
  SELECT * already blocked. New NoUnresolvableColumnsRule.
- NATURAL JOIN joins on whichever columns the tables share, which the
  guard cannot enumerate without a schema. Now denied.
- JOIN ... USING (email) produced zero exp.Column nodes, so reference
  mode never saw it - a working single-query value oracle. Same for
  AS g(email) column aliases and STRUCT('x' AS email) field names; all
  three carry names as bare exp.Identifier and are now harvested.
- The star check only inspected the projection's root node, so
  OBJECT_CONSTRUCT(*), COLUMNS(*), * APPLY(f) and ROW(c.*) passed on
  non-BigQuery dialects. It is now a deep walk with COUNT(*) as the
  explicit carve-out. ClickHouse COLUMNS('regex'), which has no Star
  node at all, is matched on node type.
- Qualified t.* bypassed the star rule even at top level: it parses as
  an exp.Column wrapping a Star.
- Every exp.AggFunc counted as PII-neutralising, so MAX(email),
  ARRAY_AGG(email) and STRING_AGG(email) returned real values through
  project mode. Only aggregates reducing to a derived statistic qualify
  now.

NoSelectStarRule replaces NoTopLevelStarRule, which stays importable as
an alias. default_rules ordering semantics are unchanged.

BREAKING: queries 0.1.1 allowed are now denied. Alias laundering,
inner-scope and nested stars, whole-row aliases, NATURAL JOIN,
identifier-only references and PII through value-preserving aggregates
have no supported way back - that is the point. Denied columns in
predicates can be restored with pii_mode="project", which re-opens the
value-probing oracle. The bundled Q1 identity-resolution query is now
denied in both modes: its CTE projects the denied columns, and
COUNTIF(email_norm = 'target') is itself an oracle.

Also: minimum Python is now 3.11. The package has imported enum.StrEnum
since 0.1.0 while advertising >=3.10, so import failed on 3.10 and that
CI job could never have passed. Metadata, classifiers, ruff
target-version, mypy python_version and the CI matrix now agree.

Cleared two pre-existing CI failures: a redundant int() around
math.floor in format_cost (RUF046), and a strict-mypy no-untyped-call on
sqlglot's unalias(), replaced with an equivalent .this read.

Tests: 74 -> 204.
@andrewjstevens
andrewjstevens merged commit b8bfc21 into main Aug 17, 2026
3 checks passed
@andrewjstevens
andrewjstevens deleted the security/pii-scope-bypasses branch August 17, 2026 13:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant