Lenguajes ideales para definir reglas de análisis estático de código

El análisis estático de programas es una técnica que examina el código fuente sin ejecutarlo, con el objetivo de identificar errores potenciales, vulnerabilidades de seguridad, problemas de rendimiento y desviaciones de los estándares de codificación. Esta técnica juega un papel fundamental en la seguridad moderna del software. Entre sus aplicaciones clave se incluyen:

  • Garantía de calidad del código: Ayuda a verificar que el código cumple con las prácticas de codificación segura y las mejores prácticas, mejorando así su calidad y seguridad. (Referencia: revisión de seguridad del código).
  • Verificación de cumplimiento normativo: Muchos estándares y regulaciones industriales exigen controles de seguridad y conformidad del software. Las herramientas de aálisis estático ayudan a las organizaciones a garantizar que sus productos cumplan con dichos requisitos. (Referencia: visión general de los elementos a revisar en defectos de software).
  • Detección de vulnerabilidades: Pueden identificar vulnerabilidades como inyección SQL, cross-site scripting (XSS), desbordamiento de búfer, etc., durante la fase de escritura del código. (Referencia: CWE Top 25 2023).
  • Reducción de costos de desarrollo: Al detectar problemas en etapas tempranas, se minimizan los costos y el tiempo de corrección en fases posteriores. (Referencia: construcción de un sistema de defensa de tres capas para el código en DevSecOps).
  1. Problemas actuales en las herramientas de análisis estático

Con el crecimiento masivo de los proyectos y la rápida evolución de los frameworks, las herramientas de análisis estático necesitan cubrir cada vez más escenarios. Sin embargo, suelen ofrecer capacidades de verificación genéricas y una velocidad de actualización limitada, lo que no satisface las necesidades diferenciadas de los usuarios. Los principales desafíos son:

2.1. Imposibilidad de crear reglas personalizadas

Muchas herramientas fueron diseñadas originalmente para resolver problemas específicos de codificación, sin considerar la extensibilidad futura ni la creación de reglas por parte del usuario. Para ofrecer capacidades de personalización, se requeriría rediseñar la arquitectura, o bien la eficiencia de la verificación impide proporcionar configuraciones genéricas y personalización. Como resultado, los usuarios no pueden abordar rápidamente sus necesidades particulares y deben esperar a la siguiente versión de la herramienta, lo que alarga el ciclo de retroalimentación.

2.2. Incapacidad para modificar rápidamente reglas con falsos positivos o falsos negativos

Debido a la naturaleza incierta de la entrada en el análisis estático, las herramientas buscan un equilibrio entre la sobre-aproximación (over-approximation), la sub-aproximación (under-approximation) y la eficiencia. Estos tres factores se influyen mutuamente:

  • Sobre-aproximación: La herramienta puede marcar erróneamente comportamientos que en realidad no ocurren como posibles, generando falsos positivos (marcar código seguro como problemático).
  • Sub-aproximación: Puede no detectar comportamientos que sí ocurren, generando falsos negativos (no identificar problemas reales).
  • Eficiencia: Todos los usuarios desean rapidez, pero la velocidad a menudo entra en conflicto con la precisión.

Por estas razones, las herramientas suelen ofrecer reglas genéricas que no se ajustan a escenarios específicos, y los usuarios no pueden modificar las reglas rápidamente, lo que genera molestias por falsos positivos o falsos negativos.

2.3. Dificultad para desarrollar reglas personalizadas

Incluso cuando el motor de análisis proporciona un kit de desarrollo personalizado, el desarrollador de reglas necesita dominar las técnicas de análisis estático, lo que eleva la curva de aprendizaje. Además, la capacidad de personalización está limitada por la encapsulación de la API del motor.

Ante estos problemas, buscamos un lenguaje adecuado para escribir reglas de análisis estático que facilite la creación de reglas personalizadas, permita al usuario controlar y resolver falsos positivos y falsos negativos en gran medida.

  1. En busca del lenguaje ideal para reglas de análisis estático

Para encontrar ese lenguaje, observamos dos paradigmas comunes: el lenguaje declarativo y el lenguaje imperativo. Se diferencian fundamentalmente en cómo describen el comportamiento del programa y resuelven problemas, pero ambos tienen sus ventajas y ámbitos de aplicación. Muchos lenguajes modernos admiten ambos paradigmas.

Comparación Lenguaje Declarativo Lenguaje Imperativo
Forma de expresar el problema Se centra en "qué hacer" (What to do), describiendo el resultado deseado o el estado objetivo, sin especificar los pasos para lograrlo. Se centra en "cómo hacerlo" (How to do it), describiendo una secuencia de pasos o comandos que cambian el estado del sistema.
Control de flujo Normalmente oculta los detalles del flujo de control; el intérprete o compilador decide cómo alcanzar el estado deseado. El programador debe escribir explícitamente la lógica de control (bucles, condicionales, etc.).
Mentalidad de programación Fomenta la abstracción de alto nivel; el programador se enfoca en el problema sin preocuparse por los detalles de implementación. Requiere un pensamiento más detallado, incluyendo estructuras de datos, algoritmos y gestión del estado.
Estructura del programa Generalmente basada en declaraciones, reglas, restricciones o patrones. Basada en operaciones y cambios de estado: variables, asignaciones, flujo de control.
Manejo de errores y depuración Al ocultar detalles de implementación, la depuración puede ser más desafiante. Al ser explícito cada paso, es más fácil seguir la ejecución y localizar errores.
Escenarios de aplicación Ideal para escenarios basados en reglas, configuración intensiva o consultas de datos. Ideal para escenarios que requieren control fino del flujo de ejecución y cambios de estado.
Ejemplos SQL, HTML, CSS, Haskell (funcional). C, Java, Python (aunque Python también soporta características funcionales).

Veamos un ejemplo concreto. Problema: seleccionar personas mayores de edad (≥18) de una lista.

  • Lenguaje imperativo (Java):
public List<Person> selectAdults(List<Person> persons){
    List<Person> result = new ArrayList<>(); 
    for (Person person : persons) {
        if (person.getAge() >= 18) {
            result.add(person); 
        }
    }
    return result; 
}

  • Lenguaje declarativo (SQL):
SELECT * FROM Persons WHERE Age >= 18;

Como se observa, el lenguaje declarativo es más adecuado para el usuario, razón por la cual SQL se popularizó rápidamente. Las características del lenguaje declarativo son exactamente las que buscamos para escribir reglas de análisis estático: el usuario solo debe especificar "qué hacer", es decir, describir el resultado deseado o la condición a verificar, sin indicar los pasos para alcanzarlo.

Este lenguaje de verificación puede considerarse un Lenguaje Específico del Dominio (DSL), diseñado para un dominio particular: escribir reglas de análisis estático de programas.

No se usa lenguaje natural directamente debido a ambigüedades y falta de precisión. Sin embargo, con el avance de los grandes modelos de lenguaje, la escritura de reglas mediante lenguaje natural está cada vez más cerca. Pero aún así, una vez identificadas las condiciones de verificación, se necesita un motor que convierta esas restricciones en consultas concretas sobre el código, similar a cómo SQL necesita un motor de consulta para analizar y ejecutar las sentencias.

  1. Ejemplos de aplicación del DSL en análisis estático

4.1. Escribir una regla de verificación

Problema: No debe haber código de depuración en el entorno de producción.

Condiciones:

  • Buscar todas las declaraciones de funciones.
  • Y (and): el nombre de la función comienza con "debug".
  • Y (and): la función tiene exactamente un parámetro.
  • Y (and): el tipo del parámetro es "java.util.List".

Código de ejemplo problemático:

package com.dsl;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.List;

public class CheckDebug {
    private static final Logger LOG = LogManager.getLogger(CheckDebug.class);

    // Función problemática
    public void debugFunction(List<String> msgs) {
        for (String msg : msgs) {
            LOG.error("print debug info: {}", msg);
        }
    }
}

Regla en DSL:

/**
 * Problema: No debe haber código de depuración en producción.
 * Condiciones:
 * - Buscar declaraciones de funciones
 * - Y: nombre empieza con "debug"
 * - Y: solo un parámetro
 * - Y: tipo del parámetro es java.util.List
 */
functionDeclaration fd where
    and(
        fd.name startWith "debug",
        fd.parameters.size() == 1,
        fd.parameters[0].type.name == "java.util.List"
    );

4.1.1. Explicación de la regla

En el análisis de programas, los tokens son las unidades mínimas con significado (palabras clave, identificadores, literales, operadores, etc.). El analizador léxico convierte el código fuente en tokens, y el analizador sintáctico construye un árbol sintáctico abstracto (AST). Cada nodo del AST tiene tipo, atributos y valores.

  • Tipo de nodo, atributo, valor: En la regla, functionDeclaration es el nodo que representa una declaración de función. Se accede a sus atributos mediante punto: fd.name, fd.parameters, etc.
  • Alias: fd es un alias para la declaración de función, lo que simplifica la escritura.
  • Colecciones: parameters es una colección. Se accede a elementos por índice: parameters[0].
  • Funciones incorporadas: startWith("debug") es una función de cadena que verifica si la cadena comienza con el prefijo dado.
  • Operadores y expresiones condicionales: == es un operador de igualdad. Se combinan con atributos y valores para formar condiciones.
  • Combinación de condiciones: and combina varias condiciones lógicas.

Conclusión: El DSL es muy cercano a la expresión natural del problema. El usuario puede desarrollar rápidamente reglas que satisfagan sus necesidades y concentrarse en describir el problema, no en la implementación de la herramienta.

4.2. Reemplazar una regla existente y ampliarla

4.2.1. Implementar la regla original

Problema original: Una clase que hereda de java.util.TimerTask y sobrescribe el método run debe tener un bloque try-catch en su implementación.

Condiciones:

  • Buscar clases que hereden de java.util.TimerTask.
  • Y (and): sobrescriben el método run.
  • Y (and): el método run no contiene un bloque try-catch.

Código problemático:

package com.dsl;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.TimerTask;

public class CheckTimerTask extends TimerTask {
    private static final Logger LOG = LogManager.getLogger(CheckTimerTask.class);

    @Override
    public void run() {
        LOG.info("do some thing");
    }
}

Regla en DSL:

/**
 * Problema: Clase que extiende TimerTask y sobrescribe run sin try-catch.
 */
functionDeclaration fd where
    and(
        fd.enclosingClass.superTypes contain parType where
            parType.name == "java.util.TimerTask",
        fd.name == "run",
        fd notContain exceptionBlock
    );

4.2.2. Ampliar la regla con condiciones adicionales

Nuevo problema: Además de tener try-catch, en el bloque de captura debe llamarse a una función que registre un error o advertencia (por ejemplo, error o warn).

Condiciones actualizadas:

  • Buscar clases que hereden de java.util.TimerTask.
  • Y (and): sobrescriben el método run.
  • Y (and): el método run no tiene try-catch, O bien tiene try-catch pero dentro del bloque catch no hay una llamada a función cuyo nombre sea "error" o "warn".

Código problemático actualizado:

package com.dsl;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.TimerTask;

public class CheckTimerTaskEnhance extends TimerTask {
    private static final Logger LOG = LogManager.getLogger(CheckTimerTaskEnhance.class);

    @Override
    public void run() {
        try {
            LOG.info("do some thing");
        } catch (Exception e) {
            LOG.info("do some thing");  // No llama a error ni warn
        }        
    }
}

Regla ampliada en DSL:

/**
 * Problema: Clase que extiende TimerTask y sobrescribe run.
 * - Si no tiene try-catch, es problema.
 * - Si tiene try-catch, pero en el bloque catch no hay llamada a error o warn, también es problema.
 */
functionDeclaration fd where
    and(
        fd.enclosingClass.superTypes contain parType where
            parType.name == "java.util.TimerTask",
        fd.name == "run",
        or(
            fd notContain exceptionBlock,
            fd contain exceptionBlock eb where
                eb contain functionCall fc where
                    fc.name notMatch "error|warn"
        )
    );

Conclusión: El DSL permite reemplazar reglas existentes rápidamente y añadir condiciones para reducir tanto falsos negativos como falsos positivos, mejorando la precisión de la verificación.

  1. Conclusión

  • A través de los ejemplos, vemos que el DSL para reglas personalizadas permite:
    • Escribir reglas de verificación.
    • Modificar y mejorar reglas existentes para reducir falsos positivos y falsos negativos.
    • Reducir la dificultad de desarrollo de reglas.
  • En el artículo "Estructura sintáctica de las reglas de CodeNavi" se profundizará en la gramática de este lenguaje.
  • Invitamos a probar el plugin (buscar "codenavi" en el mercado de extensiones de VS Code) y compartir comentarios.

Etiquetas: análisis estático DSL reglas personalizadas seguridad de software CodeNavi

Publicado el 6-16 22:32