Skip to main content

Ubicaciones automáticas del código fuente con Rego

feature Cloud Compliance

12 de febrero de 2024

0 minutos de lectura

En Snyk, somos grandes admiradores de Rego de Open Policy Agent. Snyk IaC se basa en un amplio conjunto de reglas escritas en Rego, y los clientes también pueden agregar sus propias reglas personalizadas.

Hace poco lanzamos una serie de mejoras para Snyk IaC y, en esta publicación del blog, analizamos en profundidad una función particularmente interesante: las ubicaciones automáticas del código fuente para las infracciones de reglas.

Al comprobar los archivos de IaC en busca de problemas conocidos, el comando actualizado `snyk iac test` mostrará información precisa sobre el archivo, la línea y la columna de cada infracción de regla. Esto también funciona con reglas personalizadas, sin que el usuario tenga que hacer nada.

Terminal que muestra problemas de seguridad de alta gravedad en un bucket de S3 relacionados con una ACL pública deshabilitada y la configuración de bloqueo de políticas públicas.

Pero antes de presentar una prueba de concepto independiente para esta técnica, tendremos que hacer algunas simplificaciones. La implementación completa está disponible en nuestro motor de políticas unificado.

Empecemos con un ejemplo de CloudFormation. Aunque nuestro motor de IaC admite muchos formatos y se enfoca especialmente en Terraform, CloudFormation es un buen ejemplo porque podemos analizarlo sin demasiadas dependencias (después de todo, es solo YAML).

Resources:
  Vpc:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.0.0.0/16
  PublicSubnet:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId:
        Ref: Vpc
      CidrBlock: 10.0.0.0/24
  PrivateSubnet:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId:
        Ref: Vpc
      CidrBlock: 10.0.128.0/20

Queremos asegurarnos de que ninguna subred use un bloque CIDR mayor que `/24`, así que escribamos una política de Rego para hacerlo:

package policy

deny[resourceId] {
    resource := input.Resources[resourceId]
    resource.Type == "AWS::EC2::Subnet"
    [_, mask] = split(resource.Properties.CidrBlock, "/")
    to_number(mask) < 24
}

De esta manera, `deny` generará un conjunto de recursos denegados. No explicaremos en detalle cómo funciona Rego, pero si quieres obtener más información, te recomendamos el excelente curso OPA by Example.

Podemos dividir el problema en dos partes:

  1. Queremos inferir que nuestra política usa el atributo `CidrBlock`

  2. Luego, recuperaremos la ubicación en el código fuente

Empecemos por (2), ya que nos permite familiarizarnos con el código.

Recuperación de la ubicación en el código fuente

Una ubicación en el código fuente se ve así:

type Location struct {
    File   string
    Line   int
    Column int
}
func (loc Location) String() string {
    return fmt.Sprintf("%s:%d:%d", loc.File, loc.Line, loc.Column)
}

También presentaremos un tipo auxiliar para representar rutas en YAML. En YAML, hay dos tipos de documentos anidados: arreglos y objetos.

some_array:
- hello
- world
some_object:
  foo: bar

Si quisiéramos poder hacer referencia a cualquier subdocumento, podríamos usar algo similar a las rutas JSON. En el ejemplo anterior, [`"some_array", 1`] apuntaría a `"word"`. Pero como no admitiremos arreglos en nuestra prueba de concepto, podemos conformarnos con usar un arreglo de cadenas.

type Path []string

Un ejemplo de una ruta sería algo como:

Path{"Resources", "PrivateSubnet", "Properties", "CidrBlock"}

Ahora podemos ofrecer un tipo práctico para cargar YAML e indicarnos la `Location` de ciertas `Paths`.

type Source struct {
    file string
    root *yaml.Node
}
func NewSource(file string) (*Source, error) {
    bytes, err := ioutil.ReadFile(file)
    if err != nil {
        return nil, err
    }

    var root yaml.Node
    if err := yaml.Unmarshal(bytes, &root); err != nil {
        return nil, err
    }

    return &Source{file: file, root: &root}, nil
}

Para encontrar la ubicación en el código fuente de una `Path`, hay que recorrer un árbol de nodos YAML:

func (source *Source) Location(path Path) *Location {
    cursor := source.root
    for len(path) > 0 {
        switch cursor.Kind {
        // Ignore multiple docs in our PoC.
        case yaml.DocumentNode:
            cursor = cursor.Content[0]
        case yaml.MappingNode:
            // Objects are stored as an array.
            // Content[2 * n] holds to the key and
            // Content[2 * n + 1] to the value.
            for i := 0; i < len(cursor.Content); i += 2 {
                if cursor.Content[i].Value == path[0] {
                    cursor = cursor.Content[i+1]
                    path = path[1:]
                }
            }
        }
    }
    return &Location{
        File:   source.file,
        Line:   cursor.Line,
        Column: cursor.Column,
    }
}

Conjuntos y árboles de rutas

Con esto resuelto, redujimos el problema: en lugar de inferir automáticamente las ubicaciones en el código fuente que se usan en una política, inferiremos automáticamente las rutas de atributos.

Esto también es importante por otros motivos. Por ejemplo, Snyk puede aplicar las mismas políticas tanto a los recursos de IaC como a los recursos detectados mediante análisis de la nube. Estos últimos no tienen ubicaciones significativas en el código fuente, pero sí cuentan con rutas de atributos útiles.

Por lo tanto, queremos definir conjuntos de rutas de atributos. Como las rutas se respaldan con arreglos, lamentablemente no podemos usar algo como `map[Path]struct{}` como conjunto en Go.

En su lugar, tendremos que almacenarlas en un árbol recursivo.

type PathTree map[string]PathTree

Esta representación tiene otras ventajas. En general, solo nos interesan las rutas más largas que usa una política, ya que son más específicas. Nuestra política de ejemplo usa `Path{"Resources", "PrivateSubnet", "Properties"}` y también `Path{"Resources", "PrivateSubnet", "Properties", "CidrBlock"}`; solo nos interesa esta última.

Definiremos un método recursivo para insertar una `Path` en nuestro árbol:

func (tree PathTree) Insert(path Path) {
    if len(path) > 0 {
        if _, ok := tree[path[0]]; !ok {
            tree[path[0]] = map[string]PathTree{}
        }
        tree[path[0]].Insert(path[1:])
    }
}

…y también una manera de obtener una lista de `Paths`. Esto genera algunas asignaciones de memoria innecesarias, pero podemos aceptarlo.

func (tree PathTree) List() []Path {
    if len(tree) == 0 {
        // Return the empty path.
        return []Path{{}}
    } else {
        out := []Path{}
        for k, child := range tree {
            // Prepend `k` to every child path.
            for _, childPath := range child.List() {
                path := Path{k}
                path = append(path, childPath...)
                out = append(out, path)
            }
        }
        return out
    }
}

Ahora tenemos una forma práctica de almacenar las `Paths` que usó una política, y podemos convertirlas en ubicaciones del código fuente.

Análisis estático frente a análisis en tiempo de ejecución

La siguiente pregunta es cómo determinar qué `Paths` de una entrada determinada usa una política y luego insertarlas en el árbol.

No es una pregunta sencilla, ya que el código puede manipular la entrada de distintas maneras antes de usar las rutas. Tendremos que revisar tanto las funciones definidas por el usuario ( `has_bad_subnet`) como las funciones integradas ( `object.get`), solo para ilustrar uno de los posibles obstáculos:

has_bad_subnet(props) {
    [_, mask] = split(props.CidrBlock, "/")
    to_number(mask) < 24
}

deny[resourceId] {
    resource := input.Resources[resourceId]
    resource.Type == "AWS::EC2::Subnet"
    has_bad_subnet(object.get(resource, "Properties", {}))
}

Por suerte, no somos los primeros en preguntarnos qué hacen los programas: la gente siente curiosidad por esto desde que se escribió el primero. Por lo general, hay dos maneras de responder una pregunta como esta sobre un fragmento de código:

  • Análisis estático: Intentar responder examinando el árbol de sintaxis, los tipos y otra información estática que podamos obtener del intérprete de OPA (o agregarle). La ventaja es que no necesitamos ejecutar esta política, lo cual es ideal si no confiamos en quienes la escribieron. La desventaja es que las técnicas de análisis estático suelen generar falsos negativos y falsos positivos.

  • Análisis en tiempo de ejecución: Rastrear la ejecución de políticas específicas e inferir qué `Paths` se usan a partir de la información en tiempo de ejecución. La desventaja es que tenemos que ejecutar la política, y agregar este análisis puede ralentizar su evaluación.

Probamos ambos enfoques, pero decidimos optar por el segundo porque nos resultó mucho más fácil implementarlo de forma confiable y el impacto en el rendimiento fue insignificante. También vale la pena mencionar que no se trata de una elección excluyente: podrías adoptar un enfoque híbrido y combinar ambos.

OPA ofrece una interfaz Tracer que permite recibir eventos sobre lo que hace el intérprete. Un caso de uso común de los rastreadores es enviar métricas o información de depuración a un registro centralizado. Sin embargo, hoy la usaremos para otra cosa.

type locationTracer struct {
    tree PathTree
}
func newLocationTracer() *locationTracer {
    return &locationTracer{tree: PathTree{}}
}
func (tracer *locationTracer) Enabled() bool {
    return true
}

Rastrear el uso de términos

Rego es un lenguaje expresivo. Aunque se realizan algunas transformaciones para simplificarlo y reducirlo a un formato más simple para el intérprete, sigue habiendo una cantidad considerable de eventos.

Solo nos interesan dos de ellos. Consideramos que un valor se usa si:

1. Se unifica (puedes pensar en esto como una asignación; no entraremos en detalles) con otra expresión, como:

x = input.Foo

Esto también incluye `==` y `:=`. Como esta prueba puede fallar, podemos afirmar que usamos tanto el lado izquierdo como el derecho.

2. Se usa como argumento de una función integrada, como:

regex.match("/24$", input.cidr)

Aunque Rego toma algunos conceptos de los lenguajes perezosos, los argumentos de las funciones integradas siempre están completamente evaluados antes de invocar la función. Por lo tanto, podemos decir que usamos todos los argumentos que se pasan a la función integrada.

3. Se usa como expresión independiente, por ejemplo:

volume.encrypted

Esto se usa comúnmente para evaluar booleanos y comprobar que existan atributos.

Ahora es momento de implementar. Buscamos dos eventos y delegamos el trabajo a una función específica para que el código sea un poco más legible:

func (tracer *locationTracer) Trace(event *topdown.Event) {
    switch event.Op {
    case topdown.UnifyOp:
        tracer.traceUnify(event)
    case topdown.EvalOp:
        tracer.traceEval(event)
    }
}

Más adelante nos encargaremos de insertar elementos en nuestro `PathTree` mediante una función auxiliar llamada `used(*ast.Term)`. Por ahora, marquemos como usados tanto el lado izquierdo como el derecho de la unificación:

func (tracer *locationTracer) traceUnify(event *topdown.Event) {
    if expr, ok := event.Node.(*ast.Expr); ok {
        operands := expr.Operands()
        if len(operands) == 2 {
            // Unification (1)
            tracer.used(event.Plug(operands[0]))
            tracer.used(event.Plug(operands[1]))
        }
    }
}

`event.Plug` es una función auxiliar que reemplaza las variables por sus valores reales.

Un evento `EvalOp` cubre los casos (2) y (3) mencionados antes. En el caso de una función integrada, tendremos un arreglo de términos: el primer elemento es la función y los demás son los argumentos. Podemos comprobar que se trata de una función integrada consultando `ast.BuiltinMap`.

El caso de una expresión independiente es sencillo.

func (tracer *locationTracer) traceEval(event *topdown.Event) {
    if expr, ok := event.Node.(*ast.Expr); ok {
        switch terms := expr.Terms.(type) {
        case []*ast.Term:
            if len(terms) < 1 {
                // I'm not sure what this is, but it's definitely
                // not a built-in function application.
                break
            }
            operator := terms[0]
            if _, ok := ast.BuiltinMap[operator.String()]; ok {
                // Built-in function call (2)
                for _, term := range terms[1:] {
                    tracer.used(event.Plug(term))
                }
            }
        case *ast.Term:
            // Standalone expression (3)
            tracer.used(event.Plug(terms))
        }
    }
}

Anotar términos

Al intentar implementar `used(*ast.Term)`, surge la siguiente pregunta: dado un término, ¿cómo lo vinculamos con una `Path` de la entrada?

Una opción sería buscar términos coincidentes en el documento de entrada. Pero eso produciría muchos falsos positivos, ya que una cadena como `10.0.0.0/24` podría aparecer varias veces en la entrada.

En su lugar, vamos a anotar todos los términos con su ruta. Los términos en OPA pueden incluir metadatos, como la ubicación en el archivo fuente de Rego. Podemos reutilizar este campo para almacenar una `Path` de entrada. Es un poco improvisado, pero, si lo miramos con buenos ojos, no nos estamos desviando demasiado: el campo está pensado para almacenar ubicaciones.

El siguiente fragmento muestra cómo queremos anotar las primeras líneas de nuestra plantilla de CloudFormation:

Resources:                    # ["Resources"]
  Vpc:                        # ["Resources", "Vpc"]
    Type: AWS::EC2::VPC       # ["Resources", "Vpc", "Type"]
    Properties:               # ["Resources", "Vpc", "Properties"]
      CidrBlock: 10.0.0.0/16  # ["Resources", "Vpc", "Properties", "CidrBlock"]

`annotate` implementa un recorrido recursivo para determinar la `Path` de cada nodo del valor. Para mantener la concisión, solo admitimos objetos y dejamos fuera los conjuntos y los arreglos.

func annotate(path Path, term *ast.Term) {
    // Annotate current term by setting location.
    if bytes, err := json.Marshal(path); err == nil {
        term.Location = &ast.Location{}
        term.Location.File = "path:" + string(bytes)
    }
    // Recursively annotate children.
    switch value := term.Value.(type) {
    case ast.Object:
        for _, key := range value.Keys() {
            if str, ok := key.Value.(ast.String); ok {
                path = append(path, string(str))
                annotate(path, value.Get(key))
                path = path[:len(path)-1]
            }
        }
    }
}

Con esta anotación, es fácil escribir `used(*ast.Term)`. Lo único que hay que tener en cuenta es que no todos los valores están anotados. Solo lo hacemos con los que provienen del documento de entrada, no con, por ejemplo, los literales incluidos en el código fuente de Rego.

func (tracer *locationTracer) used(term *ast.Term) {
    if term.Location != nil {
        val := strings.TrimPrefix(term.Location.File, "path:")
        if len(val) != len(term.Location.File) {
            // Only when we stripped a "path" suffix.
            var path Path
            json.Unmarshal([]byte(val), &path)
            tracer.tree.Insert(path)
        }
    }
}

Conclusión

¡Eso es todo! Omitimos muchos detalles, como el uso de arreglos y cómo aplicar esto a un lenguaje de IaC más complejo, como HCL.

Además, también marcamos los atributos `Type` como usados, ya que los comprobamos en nuestra política. Esto no es lo ideal y, como alternativa, intentamos ofrecer una API de Rego orientada a los recursos. Pero eso queda fuera del alcance de este ejemplo.

Si te interesa obtener más información sobre cualquiera de estas funciones, te recomendamos consultar snyk/policy-engine para conocer la implementación principal o la versión actualizada de Snyk IaC, que incluye esta función y muchas otras, entre ellas un paquete exhaustivo de reglas.

A continuación, presentamos una función principal que integra todo y muestra información de depuración. En su mayor parte, solo reúne las primitivas que definimos hasta ahora y las ejecuta con un ejemplo. Pero la incluimos para que esta publicación sirva como ejemplo independiente y reproducible.

func infer(policy string, file string) error {
    source, err := NewSource(file)
    if err != nil {
        return err
    }

    bytes, err := ioutil.ReadFile(file)
    if err != nil {
        return err
    }

    var node yaml.Node
    if err := yaml.Unmarshal(bytes, &node); err != nil {
        return err
    }

    var doc interface{}
    if err := yaml.Unmarshal(bytes, &doc); err != nil {
        return err
    }

    input, err := ast.InterfaceToValue(doc)
    if err != nil {
        return err
    }

    annotate(Path{}, ast.NewTerm(input))
    if bytes, err = ioutil.ReadFile(policy); err != nil {
        return err
    }

    tracer := newLocationTracer()
    results, err := rego.New(
        rego.Module(policy, string(bytes)),
        rego.ParsedInput(input),
        rego.Query("data.policy.deny"),
        rego.Tracer(tracer),
    ).Eval(context.Background())
    if err != nil {
        return err
    }

    fmt.Fprintf(os.Stderr, "Results: %v\n", results)
    for _, path := range tracer.tree.List() {
        fmt.Fprintf(os.Stderr, "Location: %s\n", source.Location(path).String())
    }
    return nil
}
func main() {
    if err := infer("policy.rego", "template.yml"); err != nil {
        fmt.Fprintf(os.Stderr, "%s\n", err)
        os.Exit(1)
    }
}

Puedes encontrar el código completo de esta prueba de concepto en este gist.

Seguridad de IaC diseñada para desarrolladores

Snyk protege tu infraestructura como código desde el ciclo de vida del desarrollo de software hasta el runtime en la nube con un motor unificado de políticas como código, para que todos los equipos puedan desarrollar, implementar y operar de forma segura.