AST parsing for SQL security: how pg_query works under the hood

How Vericto converts every SQL query into a syntax tree to detect destructive operations with mathematical certainty.

9 min read
TECHNICAL AST PARSING

What is an AST and why it matters for security

An Abstract Syntax Tree is the structured representation of a program. Each node in the tree corresponds to a syntactic construct: a statement, an expression, an operator.

For SQL, this means a query like DELETE FROM users becomes a tree with a root node DeleteStmt that has properties: the affected table, the WHERE clause (absent in this case), and other metadata.

The difference with text-based approaches (regex, simple tokenization, keyword matching) is fundamental: the AST captures the semantics of the query, not just its form.

pg_query: PostgreSQL's official parser

pg_query is a binding of libpg_query, the C library that PostgreSQL uses internally to parse queries. This means the AST that Vericto builds is identical to the one PostgreSQL would build to execute the query.

The security implication is important: there is no divergence between what Vericto analyzes and what the database executes. There are no evasion paths based on parsing differences.

Rust example (how Vericto uses it)

use pg_query::{parse, NodeMut, NodeRef};

fn has_delete_without_where(sql: &str) -> Result<bool, pg_query::Error> {
    let result = parse(sql)?;

    for stmt in result.protobuf.stmts.iter() {
        if let Some(node) = &stmt.stmt {
            if let Some(NodeRef::DeleteStmt(delete)) = node.node.as_ref().map(|n| n.into()) {
                // WhereClause es None si no hay cláusula WHERE
                if delete.where_clause.is_none() {
                    return Ok(true);
                }
            }
        }
    }

    Ok(false)
}

// Test
assert!(has_delete_without_where("DELETE FROM users").unwrap());
assert!(!has_delete_without_where("DELETE FROM users WHERE id = $1").unwrap());

The result of parsing is a tree of protobuf nodes representing each element of the query.

sqlparser-rs for MySQL, Oracle and SQL Server

For non-Postgres dialects, Vericto uses sqlparser-rs (apache/datafusion-sqlparser-rs), a multi-dialect Rust library. Vericto uses it with MySqlDialect for MySQL, MsSqlDialect for SQL Server, and GenericDialect for Oracle.

The same tree traversal and the same rules apply regardless of the dialect. The rules layer is agnostic to the underlying parser.

use sqlparser::dialect::MySqlDialect;
use sqlparser::parser::Parser;
use sqlparser::ast::{Statement, Expr};

fn analyze_mysql_delete(sql: &str) -> AnalysisResult {
    let dialect = MySqlDialect {};
    let ast = Parser::parse_sql(&dialect, sql).unwrap();

    for stmt in &ast {
        if let Statement::Delete { selection, limit, .. } = stmt {
            // En MySQL: LIMIT hace seguro el DELETE
            let has_limit = limit.is_some();
            let has_where = selection.is_some();

            if !has_where && !has_limit {
                return AnalysisResult::Blocked {
                    rule: "VERICTO-001",
                    node_path: "DeleteStmt > WhereClause = NULL",
                };
            }
        }
    }

    AnalysisResult::Allowed
}

How Vericto traverses the tree in depth

The most dangerous queries are those that hide destructive operations in non-obvious nodes. A query that looks like a SELECT can contain a DELETE inside a CTE.

Vericto traverses the AST depth-first (DFS), evaluating each node against the set of active rules. The traversal is complete: no subtree goes unevaluated.

WITH cleanup AS (
  UPDATE sessions SET status = 'expired'
  RETURNING id
)
DELETE FROM audit_log
WHERE session_id NOT IN (SELECT id FROM cleanup);

A query can contain multiple operations. The complete traversal guarantees all of them are evaluated, not just the first.

  1. SELECT * FROM x: tree traversal to verify no nested destructive operations
  2. WITH x AS (DELETE FROM users) SELECT * FROM x: detected by CTE traversal

If any node violates an active rule, the query is blocked. It does not matter at what level of the tree the offending node is.

SelectStmt
└── withClause: CommonTableExpr
    ├── ctename: "cleanup"
    └── ctequery: UpdateStmt   ← nodo infractor encontrado aquí
        ├── relation: "sessions"
        ├── targetList: [ResTarget(status = 'expired')]
        └── whereClause: NULL  ← VERICTO-042 activado

DeleteStmt
├── relation: "audit_log"
└── whereClause: NOT IN (SubLink)  ← seguro, pero la query entera se bloquea

Why <2ms is achievable

AST parsing can sound computationally expensive. In practice, for typical queries (<500 tokens), parsing with pg_query takes between 0.1ms and 0.4ms on modern hardware.

Tree traversal and rule evaluation add another 0.1-0.2ms. The total analysis process rarely exceeds 0.5ms.

  • The parser is implemented in Rust: no GC, no runtime overhead
  • Analysis happens entirely in memory: no I/O
  • Audit trail recording is asynchronous: it does not block the critical path

Benchmarks with 10M queries on Postgres (Apple M2, 16GB RAM):

Benchmarking ast_parse/simple_select
  time: [312 µs 318 µs 325 µs]

Benchmarking ast_parse/complex_cte_with_subquery
  time: [847 µs 863 µs 881 µs]

Benchmarking ast_parse/update_without_where
  time: [298 µs 304 µs 311 µs]

Benchmarking ast_parse/rule_evaluation_vericto001
  time: [41 µs 43 µs 45 µs]

The proxy overhead at p99 is 1.3ms. For 95% of queries, the overhead is imperceptible (<0.5ms).

Rules as predicates on the AST

Vericto's rules engine evaluates predicates on the AST. Each rule is a function that takes a StatementInfo (the normalized representation of the node) and returns true or false.

This design makes rules deterministic by construction: the same input always produces the same result. No state, no randomness, no external context dependency.

Vericto's AST engine for the 28 standard rules is open-source under the MIT license. You can inspect exactly what is evaluated and how.

Honest limitations of AST parsing

AST parsing is deterministic and has zero false positives for the implemented rules. But it has honest limitations worth documenting.

  • It does not understand business context: it cannot know if a specific DELETE is intentional or accidental
  • It has no information about data volume: it cannot know if a SELECT without LIMIT will return 10 or 10M rows
  • It does not detect SQL injection that arrives pre-parsed in parameters (that requires runtime parameter analysis)