Qué es un AST y por qué importa para seguridad
Un Abstract Syntax Tree (árbol sintáctico abstracto) es la representación estructural de un programa después de ser parseado. Para SQL, el AST convierte el texto de una query en una estructura de datos jerárquica donde cada elemento tiene un tipo, propiedades, y relaciones con otros elementos.
La diferencia con un análisis de texto es fundamental: el texto DELETE FROM users WHERE id = 1 y DELETE FROM users son casi idénticos como strings, pero como árboles AST son completamente diferentes — uno tiene un nodo WhereClause con un predicado, el otro tiene WhereClause = NULL.
Esta diferencia es exactamente lo que permite a Vetro distinguir entre una operación segura y una destructiva con certeza matemática.
pg_query: el parser oficial de PostgreSQL
pg_query es un binding de libpg_query — la biblioteca C que PostgreSQL usa internamente para parsear todas las queries antes de ejecutarlas. Cuando envías una query a Postgres, lo primero que hace el motor es construir el AST con exactamente esta biblioteca.
Usar el mismo parser que el motor de base de datos tiene una garantía crítica: no puede haber discrepancias entre lo que Vetro analiza y lo que Postgres ejecuta. Si Vetro dice que una query tiene WhereClause = NULL, Postgres también la ejecutaría sin WHERE. No hay ambigüedad.
Ejemplo en Rust (cómo lo usa Vetro)
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());
El parser devuelve un Protocol Buffer con el árbol AST completo. Vetro recorre este árbol buscando nodos que violen las reglas activas. El proceso es determinístico: el mismo input produce exactamente el mismo output en todas las ejecuciones.
sqlparser-rs para MySQL, SQLite y Snowflake
Para los dialectos que no son Postgres, Vetro usa sqlparser-rs — un parser SQL multi-dialecto escrito en Rust mantenido por la comunidad de Apache Arrow. Es el mismo parser que usan DataFusion, Ballista y otros motores de consulta en el ecosistema Rust.
La separación por dialectos es importante porque las reglas no son idénticas entre dialectos. El ejemplo más claro: DELETE FROM tabla LIMIT 10 es válido en MySQL y SQLite, pero no en Postgres. En MySQL, el LIMIT previene la eliminación masiva — Vetro lo reconoce como un predicado efectivo y permite la query. En Postgres, esta sintaxis simplemente no existe.
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: "VETRO-001",
node_path: "DeleteStmt > WhereClause = NULL",
};
}
}
}
AnalysisResult::Allowed
}
Cómo Vetro recorre el árbol en profundidad
Las queries más peligrosas son las que esconden operaciones destructivas en subqueries o CTEs. Un allowlist que solo verifica el primer token de la query no las detecta. El recorrido en profundidad del AST sí.
Considera esta query:
WITH cleanup AS (
UPDATE sessions SET status = 'expired'
RETURNING id
)
DELETE FROM audit_log
WHERE session_id NOT IN (SELECT id FROM cleanup);
El árbol AST de esta query tiene dos operaciones de escritura:
- El
UpdateStmtdentro del CTE — sin WHERE clause → VETRO-042 activado - El
DeleteStmten el cuerpo principal — con WHERE clause → seguro
Vetro bloquea la query entera porque el UpdateStmt en el CTE carece de WHERE. El árbol completo:
SelectStmt
└── withClause: CommonTableExpr
├── ctename: "cleanup"
└── ctequery: UpdateStmt ← nodo infractor encontrado aquí
├── relation: "sessions"
├── targetList: [ResTarget(status = 'expired')]
└── whereClause: NULL ← VETRO-042 activado
DeleteStmt
├── relation: "audit_log"
└── whereClause: NOT IN (SubLink) ← seguro, pero la query entera se bloquea
Por qué <2ms es alcanzable
El parsing AST puede sonar computacionalmente costoso — construir un árbol jerárquico para cada query que pasa por el proxy. En práctica, para queries típicas de aplicaciones web (<500 tokens), el proceso completo ocurre en menos de 0.4ms.
Hay tres razones por las que la latencia es tan baja:
- El parser está en Rust. Sin garbage collection pauses, sin overhead de runtime.
pg_queryllama directamente a la biblioteca C de Postgres.sqlparser-rses pure Rust con zero-cost abstractions. - No hay I/O en el path de parsing. El árbol AST se construye completamente en memoria. No hay llamadas de red, no hay acceso a disco, no hay consultas a la base de datos.
- El audit trail es asíncrono. El registro en el audit trail ocurre fuera del path crítico de la query. La decisión de bloquear o permitir se toma en <2ms; el registro ocurre en paralelo.
El benchmark de latencia que publicamos usa criterion (Rust benchmarking framework) con 10,000 iteraciones:
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_vetro001
time: [41 µs 43 µs 45 µs]
El parsing de una query simple es ~318µs. La evaluación de reglas sobre el AST ya construido es ~43µs adicionales. El overhead total del proxy (parsing + reglas + TLS + forwarding) en p99 con queries reales de producción es inferior a 2ms.
Las reglas como predicados sobre el AST
El engine de reglas de Vetro evalúa cada regla activa como un predicado sobre el árbol AST. Las reglas built-in están compiladas en Rust y tienen latencia de microsegundos. Las reglas custom (YAML) se compilan en predicados equivalentes al inicio y se aplican con la misma eficiencia.
La arquitectura permite composición: una query puede violar múltiples reglas simultáneamente. En ese caso, se reportan todas las violaciones con sus nodos AST respectivos, ordenadas por severidad descendente.
El engine de reglas AST de Vetro es open-source bajo licencia MIT. Puedes ver el código, las 20 reglas built-in, y contribuir reglas nuevas en github.com/vetro-dev.
Limitaciones honestas del AST parsing
El AST parsing es determinístico y tiene cero falsos positivos — pero tiene limitaciones que vale la pena entender:
- No analiza semántica de datos. Vetro no sabe si un UPDATE con WHERE clause válida va a afectar 1 fila o 500,000 filas. Para el engine de reglas,
UPDATE users SET role = 'admin' WHERE country = 'ES'es seguro sintácticamente (tiene WHERE) aunque potencialmente afecte a muchos usuarios. - Las reglas MEDIUM requieren estadísticas de tabla. Las reglas como VETRO-050 (SELECT sin LIMIT en tabla grande) necesitan estadísticas de la base de datos para estimar el tamaño de la tabla. Si esas estadísticas no están disponibles, la regla no se activa.
- No detecta inyección SQL. Si el agente de IA genera una query con SQL injection dentro de un parámetro literalmente incrustado, el AST lo ve como una query válida. La protección contra SQL injection debe ocurrir antes del proxy, en el ORM o en la validación de inputs del agente.