Restricciones de Red y Sistema de Archivos para Entornos de Build CI/CD

Los pipelines CI/CD se encuentran entre las cargas de trabajo más privilegiadas de cualquier organización. Descargan código fuente, obtienen dependencias, acceden a secretos y hacen push de artefactos a registros de producción. Sin embargo, en muchos entornos, los procesos de build que hay detrás de estos pipelines se ejecutan con acceso de red sin restricciones y permisos totales sobre el sistema de archivos, una combinación que representa una de las brechas más explotables de la entrega de software moderna.

Cuando un entorno de build puede alcanzar cualquier dirección IP y escribir en cualquier ruta del disco, una única dependencia comprometida o un pull request malicioso pueden exfiltrar secretos, manipular artefactos o establecer puertas traseras persistentes. Esta guía cubre técnicas prácticas para restringir el acceso de red y al sistema de archivos en los entornos de build CI/CD, desde las NetworkPolicies de Kubernetes hasta los sistemas de build herméticos.

Por qué los entornos de build sin restricciones son peligrosos

Antes de adentrarnos en las soluciones, conviene entender las amenazas concretas que crean los entornos de build sin restricciones. Estos riesgos no son teóricos: se han explotado en ataques a la cadena de suministro del mundo real.

Exfiltración de datos

Los entornos de build suelen tener acceso a secretos: claves de API, credenciales de registro, claves de firma y tokens de despliegue. Si un proceso de build tiene acceso de red saliente sin restricciones, una dependencia comprometida puede enviar esos secretos a un servidor controlado por el atacante. Esto puede ocurrir mediante un script postinstall malicioso en un paquete de npm, una dependencia de PyPI comprometida o incluso un target elaborado en un Makefile. Sin restricciones de red, no hay ninguna barrera entre el secreto y el endpoint del atacante.

Ataques a la cadena de suministro

Un atacante que puede ejecutar código arbitrario durante un build puede modificar los artefactos de salida. Si el sistema de archivos es escribible sin restricciones, los binarios compilados, las imágenes de contenedor o los manifiestos de despliegue pueden manipularse después del paso de build legítimo pero antes de que el artefacto se haga push. Esta es la esencia de muchos ataques a la cadena de suministro: el código fuente parece limpio, pero el artefacto entregado está envenenado.

Movimiento lateral

Los entornos de build que comparten red con otra infraestructura (bases de datos, APIs internas, servicios de metadatos de la nube) proporcionan al atacante un punto de pivote. Un job de build comprometido puede escanear las redes internas, acceder a los endpoints de metadatos de las instancias de la nube (como 169.254.169.254) y escalar desde un contexto CI/CD hasta un acceso más amplio a la infraestructura.

Restricciones de red

El control de mayor impacto que puedes implementar es restringir el acceso de red saliente desde los entornos de build. Los builds necesitan descargar dependencias y hacer push de artefactos, pero rara vez necesitan acceso a internet sin restricciones.

NetworkPolicy de Kubernetes para los pods de runner

Si ejecutas runners CI/CD en Kubernetes (por ejemplo, usando Actions Runner Controller o el ejecutor de Kubernetes de GitLab), los recursos NetworkPolicy te dan un control de grano fino sobre el acceso de red a nivel de pod. Una política bien diseñada deniega todo el egress por defecto y luego permite únicamente los endpoints específicos que el build necesita.

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: ci-runner-netpol
  namespace: ci-runners
spec:
  podSelector:
    matchLabels:
      app: ci-runner
  policyTypes:
    - Egress
  egress:
    # Allow DNS resolution
    - to:
        - namespaceSelector: {}
      ports:
        - protocol: UDP
          port: 53
        - protocol: TCP
          port: 53
    # Allow access to container registry
    - to:
        - ipBlock:
            cidr: 10.0.50.0/24
      ports:
        - protocol: TCP
          port: 443
    # Allow access to artifact storage
    - to:
        - ipBlock:
            cidr: 10.0.60.0/24
      ports:
        - protocol: TCP
          port: 443
    # Deny everything else by omission

Esta política permite que los pods de runner resuelvan DNS, alcancen el registro de contenedores y accedan al almacenamiento de artefactos; nada más. Cualquier otra conexión saliente se descarta. Si usas un plugin CNI que admite NetworkPolicy (Calico, Cilium o Weave Net), esto surte efecto de inmediato al aplicarse.

Para un control más granular, la CiliumNetworkPolicy de Cilium admite reglas basadas en DNS, lo que te permite especificar nombres de dominio en lugar de bloques de IP:

apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
  name: ci-runner-cilium-policy
  namespace: ci-runners
spec:
  endpointSelector:
    matchLabels:
      app: ci-runner
  egress:
    - toEndpoints:
        - matchLabels:
            io.kubernetes.pod.namespace: kube-system
            k8s-app: kube-dns
      toPorts:
        - ports:
            - port: "53"
              protocol: ANY
    - toFQDNs:
        - matchName: "ghcr.io"
        - matchName: "registry.npmjs.org"
        - matchName: "pypi.org"
      toPorts:
        - ports:
            - port: "443"
              protocol: TCP

Docker –network=none

Para los pasos de build basados en Docker que no deberían necesitar ningún acceso de red (compilación, análisis estático, pruebas unitarias), puedes eliminar por completo el acceso de red ejecutando el contenedor con --network=none:

docker run --network=none \
  --rm \
  -v "$(pwd)/src:/workspace:ro" \
  -v "$(pwd)/output:/output" \
  my-build-image:latest \
  make build

Con --network=none, el contenedor no tiene ninguna interfaz de red, ni siquiera loopback en algunas configuraciones. Este es el aislamiento de red más fuerte que puedes lograr para un paso de build. La clave es estructurar tu pipeline de modo que la descarga de dependencias ocurra en una etapa (con acceso de red limitado) y el build propiamente dicho ocurra en una etapa separada y sin red.

Reglas de firewall para runners autoalojados

Si usas runners autoalojados en VMs en lugar de contenedores, las reglas de firewall a nivel de host proporcionan una protección equivalente. En Linux, las reglas de iptables o nftables pueden restringir el tráfico saliente de la cuenta de usuario que ejecuta los jobs de CI:

# Allow DNS
iptables -A OUTPUT -m owner --uid-owner ci-runner -p udp --dport 53 -j ACCEPT
iptables -A OUTPUT -m owner --uid-owner ci-runner -p tcp --dport 53 -j ACCEPT

# Allow HTTPS to specific registries
iptables -A OUTPUT -m owner --uid-owner ci-runner -p tcp --dport 443 \
  -d registry.example.com -j ACCEPT
iptables -A OUTPUT -m owner --uid-owner ci-runner -p tcp --dport 443 \
  -d ghcr.io -j ACCEPT

# Deny all other outbound traffic from the CI runner
iptables -A OUTPUT -m owner --uid-owner ci-runner -j DROP

Este enfoque funciona bien cuando ejecutas el agente de CI bajo una cuenta de usuario dedicada y necesitas permitir que el propio sistema host mantenga una conectividad más amplia para la gestión y las actualizaciones.

Listas de permitidos para registros y APIs

Sea cual sea el mecanismo de aplicación, el principio es el mismo: denegar el egress por defecto y luego permitir en la lista únicamente lo que el build realmente necesita. Una lista de permitidos típica incluye el registro de paquetes (npm, PyPI, Maven Central), el registro de contenedores (Docker Hub, GHCR, ECR), la API de la plataforma CI/CD (para las actualizaciones de estado y las subidas de artefactos) y, posiblemente, un proxy o mirror que tú controles. Todo lo demás debería bloquearse. Usa un proxy o mirror interno para las dependencias siempre que sea posible: reduce la lista de permitidos a un único endpoint y te da caché y registro de auditoría de forma gratuita.

Restricciones del sistema de archivos

Las restricciones de red impiden que los datos salgan del entorno de build. Las restricciones del sistema de archivos impiden las modificaciones no autorizadas dentro de él. Juntas, forman una sólida postura de defensa en profundidad.

Sistema de archivos raíz de solo lectura

Ejecutar los contenedores de build con un sistema de archivos raíz de solo lectura impide que cualquier proceso modifique la imagen base. Esto bloquea una clase de ataques en la que el código malicioso modifica los binarios del sistema, instala puertas traseras o altera las configuraciones de las herramientas de build a nivel del sistema.

En Docker, usa el flag --read-only:

docker run --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=512m \
  --tmpfs /workspace/build:rw,size=2g \
  -v "$(pwd)/src:/workspace/src:ro" \
  my-build-image:latest \
  make build

En Kubernetes, establece el security context en la especificación del pod:

apiVersion: v1
kind: Pod
metadata:
  name: ci-build-pod
spec:
  containers:
    - name: build
      image: my-build-image:latest
      securityContext:
        readOnlyRootFilesystem: true
        runAsNonRoot: true
        allowPrivilegeEscalation: false
      volumeMounts:
        - name: build-tmp
          mountPath: /tmp
        - name: build-output
          mountPath: /workspace/build
        - name: source
          mountPath: /workspace/src
          readOnly: true
  volumes:
    - name: build-tmp
      emptyDir:
        medium: Memory
        sizeLimit: 512Mi
    - name: build-output
      emptyDir:
        sizeLimit: 2Gi
    - name: source
      configMap:
        name: source-code

tmpfs para los artefactos de build

Cuando el sistema de archivos raíz es de solo lectura, los builds necesitan espacio escribible para los archivos temporales, las cachés y los artefactos de salida. Usa montajes tmpfs (respaldados por RAM) o volúmenes emptyDir (en Kubernetes) para estas rutas. Esto tiene la ventaja añadida de que todos los artefactos de build se limpian automáticamente cuando el contenedor termina: no persisten datos obsoletos entre builds.

Monta tmpfs con opciones restrictivas siempre que sea posible: noexec impide la ejecución de binarios escritos en los directorios temporales (bloqueando un vector de ataque habitual), nosuid previene los ataques de bit SUID y los límites de size impiden que un build descontrolado agote la memoria del host.

Impedir escrituras en rutas sensibles

Más allá del sistema de archivos raíz, hay rutas específicas que merecen protección adicional. Monta el código fuente como solo lectura para impedir que el build modifique sus propias entradas. Asegúrate de que /etc, /usr y /var no sean escribibles. Si el build necesita escribir en un directorio home (para la configuración de herramientas), proporciona un montaje escribible dedicado en lugar de hacer escribible todo el directorio home. Bloquea el acceso a los sockets de Docker, los tokens de cuentas de servicio de Kubernetes y los archivos de credenciales de la nube no montándolos en absoluto en los contenedores de build.

Builds herméticos

El estándar de referencia para la seguridad del entorno de build es el build hermético: un build que no tiene ningún acceso de red y usa únicamente entradas declaradas explícitamente y descargadas previamente. Los builds herméticos eliminan clases enteras de ataques a la cadena de suministro porque el proceso de build no puede descargar código que no se haya especificado y verificado explícitamente.

El patrón de build hermético

Un pipeline de build hermético suele tener dos fases. En la primera fase (la fase de resolución/descarga), las dependencias se descargan de fuentes aprobadas, sus checksums se verifican contra un lockfile y se almacenan en una caché local o en un directorio vendored. Esta fase requiere un acceso de red limitado. En la segunda fase (la fase de build), la compilación o el ensamblaje propiamente dichos ocurren con cero acceso de red. Todas las entradas provienen del sistema de archivos local: el código fuente y las dependencias descargadas previamente.

# Phase 1: Fetch dependencies (limited network)
docker run --network=ci-restricted \
  -v "$(pwd):/workspace" \
  my-build-image:latest \
  sh -c "cd /workspace && npm ci --ignore-scripts"

# Phase 2: Build (no network)
docker run --network=none \
  --read-only \
  --tmpfs /tmp:rw,noexec,size=512m \
  -v "$(pwd):/workspace:ro" \
  -v "$(pwd)/dist:/dist" \
  my-build-image:latest \
  sh -c "cd /workspace && npm run build && cp -r build/* /dist/"

Bazel y los builds herméticos

Bazel está diseñado en torno a la hermeticidad. Con --sandbox_default_allow_network=false, Bazel bloquea el acceso de red durante las acciones de build por defecto. Las dependencias se declaran en los archivos WORKSPACE o MODULE.bazel con hashes SHA-256 explícitos, y Bazel las descarga en una fase separada antes de que comience el build. Si una dependencia no coincide con su hash declarado, el build falla.

# In .bazelrc
build --sandbox_default_allow_network=false
build --incompatible_strict_action_env
fetch --repository_cache=/shared/bazel-cache/repos

Esto hace que los builds de Bazel sean reproducibles y resistentes a los ataques de dependency confusion. Cada entrada está direccionada por contenido y verificada.

Nix y los builds reproducibles

Nix adopta un enfoque similar. Cada derivación de build especifica sus entradas mediante un hash de contenido, y el sandbox de build de Nix bloquea el acceso de red por defecto. El comando nix-build descarga todas las fuentes en el Nix store (verificando los hashes) y luego ejecuta el build en un entorno aislado sin red y con un sistema de archivos mínimo. Esto garantiza que los builds sean reproducibles: las mismas entradas siempre producen la misma salida.

Implementación práctica

Veamos cómo implementar estas restricciones en plataformas CI/CD concretas.

GitHub Actions con Actions Runner Controller (ARC) + NetworkPolicy

Si usas Actions Runner Controller para ejecutar GitHub Actions en Kubernetes, puedes aplicar NetworkPolicies directamente a los pods de runner. ARC crea pods con etiquetas predecibles, lo que facilita apuntarlos con políticas.

apiVersion: actions.summerwind.dev/v1alpha1
kind: RunnerDeployment
metadata:
  name: secure-runner
  namespace: ci-runners
spec:
  replicas: 3
  template:
    metadata:
      labels:
        app: ci-runner
        security-tier: restricted
    spec:
      containers:
        - name: runner
          securityContext:
            readOnlyRootFilesystem: true
            runAsNonRoot: true
            allowPrivilegeEscalation: false
            capabilities:
              drop:
                - ALL
          volumeMounts:
            - name: work
              mountPath: /runner/_work
            - name: tmp
              mountPath: /tmp
      volumes:
        - name: work
          emptyDir:
            sizeLimit: 10Gi
        - name: tmp
          emptyDir:
            medium: Memory
            sizeLimit: 1Gi
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: secure-runner-netpol
  namespace: ci-runners
spec:
  podSelector:
    matchLabels:
      app: ci-runner
  policyTypes:
    - Egress
    - Ingress
  ingress: []
  egress:
    - to:
        - namespaceSelector: {}
      ports:
        - protocol: UDP
          port: 53
    - to:
        - ipBlock:
            cidr: 0.0.0.0/0
      ports:
        - protocol: TCP
          port: 443

Esta configuración deniega todo el tráfico de ingress (los runners no deberían aceptar conexiones entrantes) y limita el egress a DNS y HTTPS. Para uso en producción, sustituye el CIDR 0.0.0.0/0 por rangos de IP específicos para la API de GitHub, tu registro de contenedores y tu almacén de artefactos.

GitLab CI con la configuración del runner

El ejecutor de Kubernetes de GitLab admite la configuración del security context en el config.toml del runner. Puedes establecer directamente el sistema de archivos de solo lectura y otras restricciones:

# config.toml for GitLab Runner (Kubernetes executor)
[[runners]]
  name = "secure-k8s-runner"
  executor = "kubernetes"
  [runners.kubernetes]
    namespace = "ci-runners"
    image = "alpine:latest"
    privileged = false
    allow_privilege_escalation = false
    [runners.kubernetes.pod_security_context]
      run_as_non_root = true
      run_as_user = 1000
    [runners.kubernetes.build_container_security_context]
      read_only_root_filesystem = true
      allow_privilege_escalation = false
      [runners.kubernetes.build_container_security_context.capabilities]
        drop = ["ALL"]
    [runners.kubernetes.volumes]
      [[runners.kubernetes.volumes.empty_dir]]
        name = "build-tmp"
        mount_path = "/tmp"
        medium = "Memory"
        size_limit = "512Mi"
      [[runners.kubernetes.volumes.empty_dir]]
        name = "build-workspace"
        mount_path = "/builds"
        size_limit = "5Gi"

Combina esto con una NetworkPolicy aplicada al namespace ci-runners y tendrás en marcha tanto las restricciones del sistema de archivos como las de red.

Restricciones de Docker-in-Docker

Docker-in-Docker (DinD) se usa habitualmente para construir imágenes de contenedor en CI. También es uno de los patrones más arriesgados porque normalmente requiere el modo privilegiado. Si debes usar DinD, aplica estas restricciones:

# Use rootless DinD instead of privileged mode
services:
  dind:
    image: docker:24-dind-rootless
    environment:
      - DOCKER_TLS_CERTDIR=/certs
    volumes:
      - dind-certs:/certs/client
      - dind-data:/var/lib/docker

# When running builds inside DinD, pass network and filesystem restrictions
docker --host tcp://dind:2376 --tlsverify \
  run --network=none --read-only \
  --tmpfs /tmp:rw,noexec,size=256m \
  --security-opt=no-new-privileges \
  my-build-image:latest make build

Mejor aún, sustituye DinD por herramientas que no necesiten ningún demonio de Docker. kaniko, buildah y ko pueden construir imágenes de contenedor sin acceso privilegiado, y funcionan bien con sistemas de archivos de solo lectura y redes restringidas.

Monitorización y auditoría

Las restricciones solo son útiles si sabes cuándo se están probando o eludiendo. La monitorización completa el panorama de seguridad.

Detectar conexiones de red inesperadas

Usa Hubble de Cilium, los flow logs de Calico o Falco para detectar conexiones de red que tu política debería haber bloqueado (o conexiones a destinos inusuales en puertos permitidos). Configura alertas para cualquier consulta DNS a dominios que no estén en tu lista de permitidos, conexiones salientes a puertos no estándar, conexiones a rangos de IP conocidos como maliciosos y cualquier tráfico de egress desde pods que deberían tener --network=none.

# Falco rule: detect unexpected outbound connections from CI runners
- rule: CI Runner Unexpected Outbound Connection
  desc: Detect network connections from CI runner pods to non-approved destinations
  condition: >
    evt.type in (connect, sendto) and
    container and
    k8s.ns.name = "ci-runners" and
    not (fd.sip in (approved_registry_ips) or fd.sport = 53)
  output: >
    Unexpected outbound connection from CI runner
    (command=%proc.cmdline connection=%fd.name container=%container.name
    pod=%k8s.pod.name namespace=%k8s.ns.name)
  priority: WARNING
  tags: [network, ci-cd, supply-chain]

Auditar el acceso al sistema de archivos

Monitoriza las escrituras en el sistema de archivos de los contenedores de build para detectar modificaciones inesperadas. El auditd de Linux puede vigilar rutas específicas, y Falco puede detectar escrituras en ubicaciones sensibles. Las rutas clave que hay que monitorizar incluyen /etc y /usr (nunca deberían escribirse en un build), la ruta del socket de Docker, las rutas de los tokens de cuentas de servicio de Kubernetes y cualquier ruta que contenga credenciales o claves de firma.

Si usas sistemas de archivos raíz de solo lectura, cualquier intento de escritura en una ruta protegida genera un error: registra estos errores y alerta sobre ellos. Indican o bien un build mal configurado, o bien un posible ataque.

Compromisos y experiencia de desarrollo

Las restricciones estrictas de red y sistema de archivos crean inevitablemente fricción. Comprender y gestionar los compromisos es fundamental para una adopción exitosa.

Velocidad del build

Los builds herméticos requieren que todas las dependencias se descarguen previamente, lo que añade una etapa al pipeline. Sin embargo, esto también significa que las dependencias pueden almacenarse en caché de forma agresiva. En la práctica, muchos equipos descubren que los builds herméticos son en realidad más rápidos porque la tasa de aciertos de caché es mucho mayor cuando la resolución de dependencias es determinista. Usa una caché compartida (una caché remota de Bazel, una caché binaria de Nix o una simple caché HTTP para las dependencias vendored) para amortizar el coste entre builds.

Experiencia de desarrollo

Los desarrolladores se encontrarán con fallos cuando los builds intenten acceder a endpoints de red bloqueados o escribir en rutas de solo lectura. Los buenos mensajes de error son esenciales. Envuelve tus pasos de build en scripts que capturen los errores de permisos y los fallos de red, y luego emitan mensajes accionables que expliquen por qué se bloqueó el acceso y cómo solucionar el problema (normalmente añadiendo una dependencia al lockfile o cambiando la ruta de salida).

Considera implementar un despliegue gradual: empieza con un modo de monitorización (registra las violaciones pero no bloquea) y luego pasa a la aplicación. Esto da a los equipos tiempo para actualizar sus configuraciones de build sin romper todos los pipelines a la vez.

Depuración

Depurar los fallos de build en un entorno restringido es más difícil cuando no puedes instalar herramientas adicionales ni alcanzar servicios externos. Proporciona un «modo de depuración» que relaje las restricciones para una ejecución de pipeline específica y disparada manualmente (nunca para las ejecuciones automatizadas en la rama principal). Registra que se usó el modo de depuración y quién lo disparó. Nunca permitas que el modo de depuración eluda las restricciones en los builds de artefactos de producción.

Poniéndolo todo junto

Este es un resumen del enfoque por capas para asegurar los entornos de build CI/CD:

Capa 1 — Restricciones de red: denegar el egress por defecto con listas de permitidos para registros y APIs. Usa NetworkPolicy de Kubernetes, --network=none de Docker o reglas de firewall a nivel de host según tu infraestructura de runners.

Capa 2 — Restricciones del sistema de archivos: sistema de archivos raíz de solo lectura, tmpfs para las rutas escribibles con límites de tamaño y noexec, código fuente montado como solo lectura.

Capa 3 — Builds herméticos: separa la resolución de dependencias del build. Ejecuta la fase de build con cero acceso de red y solo con entradas descargadas previamente y verificadas por hash.

Capa 4 — Monitorización: detecta y alerta sobre las violaciones de política, las conexiones inesperadas y los intentos de modificación del sistema de archivos.

Ninguna capa por sí sola es suficiente. Las restricciones de red sin controles del sistema de archivos siguen permitiendo la manipulación de artefactos. Las restricciones del sistema de archivos sin controles de red siguen permitiendo la exfiltración. Los builds herméticos sin monitorización te dejan ciego ante los intentos de ataque. Las capas se refuerzan mutuamente.

Guías relacionadas

Para saber más sobre cómo asegurar tu pipeline CI/CD, consulta estas guías relacionadas:

Empieza por las restricciones de red: ofrecen el mayor impacto con el menor esfuerzo de implementación. Después, añade las restricciones del sistema de archivos y avanza hacia los builds herméticos a medida que aumenta la madurez de tu pipeline. Cada capa que añades hace que los ataques a la cadena de suministro sean significativamente más difíciles de ejecutar.