AST parsing para seguridad SQL: cómo funciona pg_query bajo el capó

pg_query es el mismo parser que usa PostgreSQL internamente. En este artículo mostramos cómo Vetro lo usa para construir el árbol sintáctico completo de cualquier query y evaluar condiciones de seguridad determinísticas en menos de 2ms.

10 min de lectura
TÉCNICO AST PARSING

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:

  1. El UpdateStmt dentro del CTE — sin WHERE clause → VETRO-042 activado
  2. El DeleteStmt en 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_query llama directamente a la biblioteca C de Postgres. sqlparser-rs es 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.