What is an AST and why it matters for security
An Abstract Syntax Tree is the structural representation of a program after it has been parsed. For SQL, the AST turns the text of a query into a hierarchical data structure where each element has a type, properties and relationships with other elements.
The difference from text analysis is fundamental: DELETE FROM users WHERE id = 1 and DELETE FROM users are almost identical as strings, but as ASTs they are completely different: one has a WhereClause node with a predicate, the other has WhereClause = NULL.
That difference is exactly what lets Vericto tell a safe operation from a destructive one with mathematical certainty.
pg_query: PostgreSQL's official parser
pg_query is a binding for libpg_query, a C library that extracts the parser from the PostgreSQL source code (in Vericto, PostgreSQL 17's). It is the same code the server uses to turn every query into a tree before running it.
Using the same parser as the database engine gives an important guarantee: Vericto reads the query the way Postgres reads it. If Vericto says a query has WhereClause = NULL, Postgres would also run it without a WHERE. There is no ambiguity.
Simplified Rust example
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 parser returns a Protocol Buffer with the full AST. Vericto walks that tree looking for nodes that violate the active rules. The process is deterministic: the same input produces exactly the same output on every run.
sqlparser-rs for MySQL, Oracle and SQL Server
For non-Postgres dialects, Vericto uses sqlparser-rs, a multi-dialect SQL parser written in Rust that is now part of the Apache DataFusion project. It is the same parser DataFusion and other Rust query engines use.
Splitting by dialect matters because the syntax is not identical. The clearest example: DELETE FROM table LIMIT 10 is valid in MySQL but not in Postgres. A LIMIT bounds how many rows are deleted, not which ones, so Vericto still blocks a DELETE without WHERE even when it has a LIMIT (VERICTO-001). Separately, VERICTO-002 catches DELETE … LIMIT 0, which deletes nothing and usually points to a misconfigured scope.
use sqlparser::ast::Statement;
use sqlparser::dialect::MySqlDialect;
use sqlparser::parser::Parser;
// Simplificado: el engine real cubre más casos (WHERE siempre verdadero, CTEs, subqueries)
fn analyze_mysql_delete(sql: &str) -> AnalysisResult {
let ast = match Parser::parse_sql(&MySqlDialect {}, sql) {
Ok(ast) => ast,
Err(_) => return AnalysisResult::ParseError,
};
for stmt in &ast {
if let Statement::Delete(delete) = stmt {
// Un LIMIT acota cuántas filas se borran, no cuáles: sin WHERE se bloquea igual
if delete.selection.is_none() {
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 the ones that hide destructive operations in subqueries or CTEs. An allowlist that only checks the first token of the query does not catch them. A depth-first walk of the AST does.
Consider this query:
WITH cleanup AS (
UPDATE sessions SET status = 'expired'
RETURNING id
)
DELETE FROM audit_log
WHERE session_id NOT IN (SELECT id FROM cleanup);
The AST of this query has two write operations:
- The
UpdateStmtinside the CTE, without WHERE → VERICTO-031 (UPDATE in a CTE without WHERE), plus VERICTO-042 - The main
DeleteStmt, with a WHERE → safe
Vericto blocks the whole query because the UpdateStmt in the CTE has no WHERE. In the tree, the CTE hangs off the main statement:
DeleteStmt
├── relation: "audit_log"
├── withClause: WithClause
│ └── CommonTableExpr
│ ├── ctename: "cleanup"
│ └── ctequery: UpdateStmt ← nodo infractor encontrado aquí
│ ├── relation: "sessions"
│ ├── targetList: [ResTarget(status = 'expired')]
│ └── whereClause: NULL ← VERICTO-031 activado
└── whereClause: NOT IN (SubLink) ← seguro, pero la query entera se bloquea
Why <2ms is achievable
AST parsing can sound expensive: building a hierarchical tree for every query that goes through the proxy. Vericto's design target is for parsing and rule evaluation to take under 2 ms at p99.
There are three reasons that target is realistic:
- The parser is in Rust. No garbage-collection pauses, no runtime overhead.
pg_querycalls Postgres's C parser directly.sqlparser-rsis pure Rust. - There is no I/O in the parsing path. The AST is built entirely in memory. No network calls, no disk access, no database queries.
- The audit trail is asynchronous. Recording to the audit trail happens outside the query's critical path: the decision to block or allow does not wait for the event to be written.
Every audit trail event stores the latency of its evaluation (latency_ms), so you can measure the overhead with your own traffic instead of relying on a benchmark.
Rules as predicates on the AST
Vericto's rules engine evaluates every active rule as a predicate over the AST. Standard rules are compiled into Rust. Custom rules are defined in YAML as conditions over the AST and are evaluated over the same tree.
The architecture composes: a query can violate several rules at once. In that case every violation is reported with its AST node, ordered by descending severity; the verdict comes from the most severe one.
The code of Vericto's AST engine, with the 31 rules of the standard catalogue, is source-available under the Elastic License 2.0 (ELv2) at github.com/vericto/vericto-engine (LICENSE). You can inspect exactly what is evaluated and how.
Honest limitations of AST parsing
AST parsing is deterministic and, for the implemented rules, has no heuristic false positives, but it has limitations worth understanding:
- It does not analyze the data. Vericto does not know whether an UPDATE with a valid WHERE clause will affect 1 row or 500,000. To the rules engine,
UPDATE users SET role = 'admin' WHERE country = 'ES'is syntactically safe (it has a WHERE) even though it may affect many users. - It does not use table statistics. VERICTO-050 (SELECT without LIMIT) flags every top-level SELECT without a LIMIT, whether the table is small or huge, because the engine does not know its size. It is a MEDIUM rule: it flags, it does not block.
- It does not catch every SQL injection. It catches the classic tautology (
… OR 1=1, VERICTO-090) and the variants that end in a destructive operation. But if an injection produces a valid query that only reads another user's data, the AST sees a legitimate query. Protection against SQL injection has to happen earlier, with parameterized queries in the ORM or by validating the agent's inputs.