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

pg_query empaqueta el parser del código fuente de PostgreSQL. En este artículo mostramos cómo Vericto lo usa para construir el árbol sintáctico completo de cada query y evaluar condiciones de seguridad determinísticas, con un objetivo de menos de 2 ms en el p99.

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 Vericto 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, una biblioteca C que extrae el parser del código fuente de PostgreSQL (en Vericto, el de PostgreSQL 17). Es el mismo código que usa el servidor para convertir cada query en un árbol antes de ejecutarla.

Usar el mismo parser que el motor de base de datos da una garantía importante: Vericto lee la query como la lee Postgres. Si Vericto dice que una query tiene WhereClause = NULL, Postgres también la ejecutaría sin WHERE. No hay ambigüedad.

Ejemplo simplificado en Rust

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. Vericto 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, Oracle y SQL Server

Para los dialectos que no son Postgres, Vericto usa sqlparser-rs, un parser SQL multi-dialecto escrito en Rust que hoy forma parte del proyecto Apache DataFusion. Es el mismo parser que usan DataFusion y otros motores de consulta del ecosistema Rust.

La separación por dialectos importa porque la sintaxis no es idéntica. El ejemplo más claro: DELETE FROM tabla LIMIT 10 es válido en MySQL, pero no en Postgres. Un LIMIT acota cuántas filas se borran, no cuáles, así que Vericto sigue bloqueando un DELETE sin WHERE aunque tenga LIMIT (VERICTO-001). Aparte, VERICTO-002 detecta DELETE … LIMIT 0, que no borra nada y suele indicar un alcance mal configurado.

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
}

Cómo Vericto 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 → VERICTO-031 (UPDATE en CTE sin WHERE), además de VERICTO-042
  2. El DeleteStmt principal, con WHERE → seguro

Vericto bloquea la query entera porque el UpdateStmt del CTE no tiene WHERE. En el árbol, el CTE cuelga de la sentencia principal:

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

Por qué <2ms es alcanzable

El parsing AST puede sonar costoso: construir un árbol jerárquico para cada query que pasa por el proxy. El objetivo de diseño de Vericto es que el parsing y la evaluación de reglas tomen menos de 2 ms en el p99.

Hay tres razones por las que ese objetivo es realista:

  • El parser está en Rust. Sin pausas de garbage collection ni overhead de runtime. pg_query llama directamente al parser en C de Postgres. sqlparser-rs es Rust puro.
  • 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 no espera a que se escriba el evento.

Cada evento del audit trail guarda la latencia de su evaluación (latency_ms), así que puedes medir el overhead con tu propio tráfico en lugar de fiarte de un benchmark.

Las reglas como predicados sobre el AST

El engine de reglas de Vericto evalúa cada regla activa como un predicado sobre el árbol AST. Las reglas estándar están compiladas en Rust. Las reglas custom se definen en YAML como condiciones sobre el AST y se evalúan sobre el mismo árbol.

La arquitectura permite composición: una query puede violar varias reglas a la vez. En ese caso se reportan todas las violaciones con sus nodos AST, ordenadas por severidad descendente; el veredicto lo decide la de mayor severidad.

El código del motor AST de Vericto, con las 31 reglas del catálogo estándar, es código fuente disponible bajo la Elastic License 2.0 (ELv2) en github.com/vericto/vericto-engine (LICENSE). Puedes inspeccionar exactamente qué se evalúa y cómo.

Limitaciones honestas del AST parsing

El AST parsing es determinístico y, para las reglas implementadas, no tiene falsos positivos por heurística, pero tiene limitaciones que vale la pena entender:

  • No analiza semántica de datos. Vericto 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.
  • No usa estadísticas de tabla. VERICTO-050 (SELECT sin LIMIT) marca cualquier SELECT de nivel superior sin LIMIT, sea la tabla pequeña o enorme, porque el engine no conoce su tamaño. Es una regla MEDIUM: marca, no bloquea.
  • No detecta toda inyección SQL. Detecta la tautología clásica (… OR 1=1, VERICTO-090) y las variantes que terminan en una operación destructiva. Pero si una inyección produce una query válida que solo lee datos de otro usuario, el AST la ve como una query legítima. La protección contra SQL injection debe ocurrir antes, con queries parametrizadas en el ORM o validando los inputs del agente.