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.
- SELECT * FROM x: tree traversal to verify no nested destructive operations
- 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)