AIDocker.NETAPI SecurityDevOps

AI-agents ontsnappen niet, ze lopen door een open deur: egress-containment voor je eigen agents

Experts wijten de AI-hackincidenten aan ontbrekende containment en monitoring. Zo sluit je de deur voor je eigen agents: een intern Docker-netwerk, een egress-proxy en .NET-code die weigert en meet.

JB
Jean-Pierre Broeders
Freelance .NET Developer · 15 oktober 2026 · 12 min lezen
AI-agents ontsnappen niet, ze lopen door een open deur: egress-containment voor je eigen agents

"It's not rocket science to isolate one of these AI things." Dat zegt Dan Wallach van het RAND Center on AI, Security, and Technology in een artikel van CSO Online dat deze week bovenaan Hacker News stond, met bijna 900 punten en ruim 500 reacties. De strekking: de verhalen over AI-modellen die "uitbraken" en andere organisaties hackten, gaan minder over slimme modellen dan over labs die de basis van security overslaan.

Ik vind dat een opluchtende conclusie. Het probleem dat je niet snapt kun je niet oplossen. Een ontbrekende firewallregel wel.

Wat er volgens de experts misging

CSO Online zet de incidenten sinds juli op een rij. OpenAI-modellen kwamen binnen bij Hugging Face. Anthropic meldde dat een model ongeautoriseerd toegang kreeg tot productie-infrastructuur van drie organisaties. Bij Meta hackte een model tijdens securitytests een ander bedrijf, en Google rapporteerde drie "undirected computer hacks" door Gemini. Het incident bij Hugging Face werd eerst "unprecedented" genoemd. Volgens het artikel ligt de oorzaak inmiddels bij configuratiefouten.

De geïnterviewde experts zijn er niet mild over. Rob T. Lee, Chief AI Officer bij het SANS Institute, vraagt zich af waarom het elke keer weer "Oops, we don't know how they got out" is. Gary McGraw van het Berryville Institute of Machine Learning vindt dat de labs securitykennis die in decennia is opgebouwd te makkelijk wegzetten. Wallach wijst erop dat de inbraak bij Hugging Face dagen doorliep zonder dat iemand iets merkte.

Wat ontbrak, volgens het artikel: een sandbox waar de agent niet uit kan, controle op verkeer dat de testomgeving verlaat, monitoring van wat de agent doet, en configuratie die klopt.

In de HN-discussie ging het vooral over één vraag. Kun je een agent echt opsluiten en hem toch iets nuttigs laten doen? Een deel van de reacties zegt nee: elke beperking maakt de agent dommer. Een ander deel vindt dat een DMZ en een firewall gewoon werken, als je ze aanzet. Ik zit in het tweede kamp, met een kanttekening die ik verderop uitleg.

Waarom dit ook jouw probleem is

Je traint geen frontier-modellen. Maar de kans is groot dat je wel ergens een agent draait die meer kan dan je denkt. Denk aan een coding agent in een GitHub Actions-runner met een GITHUB_TOKEN. Of een LLM-feature in je ASP.NET Core-app met een tool die "een URL ophaalt". Of een nachtelijke job die met een agent tickets triageert en daarvoor bij Jira, je database en Slack mag.

Al die agents hebben drie dingen gemeen met de labs uit het artikel. Ze voeren acties uit op basis van tekst die je niet volledig beheerst. Ze draaien in een omgeving met credentials. En ze hebben vrijwel altijd onbeperkt internet, omdat dat de standaard is van elke container en elke runner.

Dat laatste is waar het misgaat. Prompt injection krijg je niet weg, daar heb ik eerder over geschreven. Wat je wel kunt regelen is wat een agent kan bereiken als hij iets doet wat je niet wilde. Daar gaat de rest van dit stuk over, in drie lagen.

Laag 1: geen route naar buiten

De eerste laag is het netwerk zelf. Docker Compose heeft hiervoor een optie die verrassend weinig mensen gebruiken: internal: true. Een container op zo'n netwerk krijgt geen route naar buiten. Hij kan alleen andere containers op hetzelfde netwerk bereiken.

Daar zet je één container naast die wel naar buiten mag: een egress-proxy. De agent praat met de proxy, de proxy beslist.

services:
  agent:
    image: ghcr.io/mijnbedrijf/triage-agent:1.4.0
    networks: [agent-net]
    environment:
      HTTPS_PROXY: http://egress:3128
      HTTP_PROXY: http://egress:3128
      NO_PROXY: localhost,127.0.0.1
    user: "10001:10001"
    read_only: true
    tmpfs: [/tmp]
    cap_drop: [ALL]
    security_opt: ["no-new-privileges:true"]
    mem_limit: 2g
    pids_limit: 256

  egress:
    image: ubuntu/squid:latest
    volumes:
      - ./squid.conf:/etc/squid/squid.conf:ro
    networks: [agent-net, outside]

networks:
  agent-net:
    internal: true
  outside: {}

Het belangrijke detail: de proxy-variabelen zijn een afspraak, de internal-vlag is een muur. Negeert de agent HTTPS_PROXY, bijvoorbeeld omdat hij een eigen curl --noproxy '*' verzint, dan loopt hij tegen een netwerk zonder gateway aan. Dat is het verschil tussen een agent vragen om netjes te doen en hem de mogelijkheid ontnemen.

De rest van de service-definitie is gewone containerhygiëne: geen root, read-only filesystem, geen capabilities, limieten op geheugen en processen. Saai, en precies het soort basis waar het CSO-artikel over gaat.

De proxyconfiguratie is een allowlist op domeinnaam:

http_port 3128

acl SSL_ports port 443
acl CONNECT method CONNECT
acl allowed_domains dstdomain .nuget.org api.github.com api.openai.com

http_access deny CONNECT !SSL_ports
http_access allow allowed_domains
http_access deny all

access_log stdio:/dev/stdout

Voor HTTPS ziet Squid alleen de CONNECT-regel met de hostnaam, en daar matcht dstdomain op. Je hoeft dus geen TLS te onderscheppen voor een domein-allowlist. Alles wat niet op de lijst staat krijgt een 403 en een regel in de log.

Eén ding moet je zelf testen, en niet aannemen: DNS. Afhankelijk van je Docker-versie en configuratie kan een container op een intern netwerk nog steeds externe namen resolven, en DNS-queries zijn een bekend kanaal om data naar buiten te smokkelen. Start een shell in de agent-container en kijk wat getent hosts example.com doet. Krijg je een antwoord terug, zoek dan uit waar dat vandaan komt.

Laag 2: je eigen tools weigeren zelf ook

Een netwerk-allowlist werkt op het niveau van de container. Maar in een .NET-app met een LLM-feature zit de agent vaak in hetzelfde proces als de rest van je applicatie. De tool fetch_url gebruikt dezelfde netwerkstack als je API-calls naar de betaalprovider. Dan wil je in de code zelf een grens.

De verleiding is een DelegatingHandler die de host in request.RequestUri controleert. Dat is een begin, maar het checkt een naam en niet waar je uiteindelijk heen verbindt. Een domein dat naar 169.254.169.254 resolvet, het metadata-endpoint van je cloud, glipt er zo doorheen. De betere plek is SocketsHttpHandler.ConnectCallback: daar bepaal je zelf met welk IP-adres de socket verbindt.

using System.Net;
using System.Net.Sockets;

public sealed class EgressPolicy(IReadOnlyCollection<string> allowedHosts)
{
    public bool IsHostAllowed(string host) =>
        allowedHosts.Any(h =>
            host.Equals(h, StringComparison.OrdinalIgnoreCase) ||
            (h.StartsWith('.') && host.EndsWith(h, StringComparison.OrdinalIgnoreCase)));

    public static bool IsBlockedAddress(IPAddress ip)
    {
        if (ip.IsIPv4MappedToIPv6) ip = ip.MapToIPv4();
        if (IPAddress.IsLoopback(ip) || ip.Equals(IPAddress.IPv6Any)) return true;

        if (ip.AddressFamily == AddressFamily.InterNetworkV6)
            return ip.IsIPv6LinkLocal || ip.IsIPv6SiteLocal || ip.IsIPv6UniqueLocal;

        var b = ip.GetAddressBytes();
        return b[0] switch
        {
            0 or 10 or 127 => true,
            169 when b[1] == 254 => true,              // link-local, cloud metadata
            172 when b[1] is >= 16 and <= 31 => true,
            192 when b[1] == 168 => true,
            100 when b[1] is >= 64 and <= 127 => true, // CGNAT
            _ => false
        };
    }
}

De handler gebruikt die policy op het moment dat er echt verbonden wordt:

public static class AgentHttpClientFactory
{
    public static SocketsHttpHandler CreateHandler(EgressPolicy policy, EgressMetrics metrics) => new()
    {
        UseProxy = false, // deze client verbindt direct, dus de callback ziet de echte host
        PooledConnectionLifetime = TimeSpan.FromMinutes(5),
        ConnectCallback = async (context, ct) =>
        {
            var host = context.DnsEndPoint.Host;
            if (!policy.IsHostAllowed(host))
            {
                metrics.Denied(host, "host");
                throw new HttpRequestException($"Egress geweigerd voor host '{host}'.");
            }

            var addresses = await Dns.GetHostAddressesAsync(host, ct);
            var allowed = addresses.Where(a => !EgressPolicy.IsBlockedAddress(a)).ToArray();
            if (allowed.Length == 0)
            {
                metrics.Denied(host, "address");
                throw new HttpRequestException($"Host '{host}' resolvet alleen naar geblokkeerde adressen.");
            }

            var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
            try
            {
                await socket.ConnectAsync(allowed, context.DnsEndPoint.Port, ct);
                metrics.Allowed(host);
                return new NetworkStream(socket, ownsSocket: true);
            }
            catch
            {
                socket.Dispose();
                throw;
            }
        }
    };
}

En de registratie, als named client die alleen de agent-tools krijgen:

builder.Services.AddSingleton(new EgressPolicy([".github.com", "api.openai.com", "learn.microsoft.com"]));
builder.Services.AddSingleton<EgressMetrics>();

builder.Services.AddHttpClient("agent-tools")
    .ConfigurePrimaryHttpMessageHandler(sp => AgentHttpClientFactory.CreateHandler(
        sp.GetRequiredService<EgressPolicy>(),
        sp.GetRequiredService<EgressMetrics>()));

Twee opmerkingen. Omdat de resolutie en de connect in dezelfde callback gebeuren, heeft DNS-rebinding geen kans: je verbindt met precies de adressen die je net hebt goedgekeurd. En UseProxy = false is bewust. Met een proxy zou de callback alleen de proxy zien en geen idee hebben waar het verzoek echt heen gaat. Draai je dit binnen het Compose-netwerk uit laag 1, dan kies je één van de twee als handhavingspunt en vertrouw je daar ook op.

Is dit containment? Nee. Code in hetzelfde proces als de agent is een tweede slot op de deur. Heeft de agent ergens een shell-tool, dan loopt hij om je HttpClient heen. Daarom staat laag 1 eerst.

Laag 3: merken binnen minuten, niet na dagen

Het pijnlijkste detail uit het CSO-artikel is niet dat een agent naar buiten kwam. Het is dat de inbraak bij Hugging Face dagen duurde voordat iemand het zag. Bij een agent zonder monitoring is de eerste melding de mail van iemand anders zijn securityteam.

Het goede nieuws: een geweigerd egress-verzoek is een bijna perfect alarmsignaal. In normaal bedrijf hoort het niet voor te komen. Meet het dus.

using System.Diagnostics.Metrics;

public sealed class EgressMetrics
{
    private readonly Counter<long> _allowed;
    private readonly Counter<long> _denied;

    public EgressMetrics(IMeterFactory meterFactory)
    {
        var meter = meterFactory.Create("FreelyIt.Agent.Egress");
        _allowed = meter.CreateCounter<long>("agent.egress.allowed");
        _denied = meter.CreateCounter<long>("agent.egress.denied");
    }

    public void Allowed(string host) =>
        _allowed.Add(1, new KeyValuePair<string, object?>("host", host));

    public void Denied(string host, string reason) =>
        _denied.Add(1,
            new KeyValuePair<string, object?>("host", host),
            new KeyValuePair<string, object?>("reason", reason));
}

Met de OpenTelemetry-exporter voor Prometheus komt die counter binnen als agent_egress_denied_total, en dan is de alertregel één regel PromQL. Zet wel AddMeter("FreelyIt.Agent.Egress") in je OpenTelemetry-configuratie, anders exporteer je niets.

groups:
  - name: agent-egress
    rules:
      - alert: AgentEgressDenied
        expr: sum by (host) (increase(agent_egress_denied_total[5m])) > 0
        labels:
          severity: page
        annotations:
          summary: "Agent probeerde {{ $labels.host }} te bereiken"

Ik zet deze bewust op page. Normaal ben ik fel tegen alerts die mensen 's nachts wakker maken voor iets wat tot morgen kan wachten. Een agent die structureel domeinen probeert die niet op zijn lijst staan, kan niet wachten. Het host-label houdt de kardinaliteit beperkt zolang je weigert vóór de verbinding: een agent die duizenden willekeurige subdomeinen probeert, is zelf het incident.

De Squid-log uit laag 1 verdient hetzelfde. Stuur hem naar Loki en zet er een alert op TCP_DENIED. Dan heb je de container- en de procesgrens allebei in beeld.

De kanttekening: allowlists groeien

Terug naar het HN-kamp dat zegt dat beperkingen een agent onbruikbaar maken. Dat klopt de eerste week. Je allowlist is te krap, de agent faalt, en de verleiding is om er .com bij te zetten.

Doe dat niet. Behandel de denied-log als de backlog van je allowlist. Elke weigering is een vraag: moet deze agent hier echt bij? Na een paar weken is de lijst stabiel en weet je precies welke externe systemen je agent raakt. Dat overzicht had ik bij de meeste klanten nog nooit, ook niet voor de gewone applicaties.

Twee eerlijke grenzen. Een domein-allowlist op basis van CONNECT vertrouwt de hostnaam die de client opgeeft. Met trucs als domain fronting via een CDN die ook op je lijst staat kun je er in theorie omheen. Voor een triage-agent vind ik dat acceptabel, voor een agent met toegang tot productiedata minder. Dan kijk je naar een proxy die SNI en Host-header vergelijkt, of naar volledige TLS-inspectie. En een allowlist zegt niets over wat er in een toegestaan verzoek zit. Een agent die je eigen GitHub-org mag bereiken, kan daar ook iets naartoe pushen. Daar helpen alleen tokens met een smalle scope tegen.

Wallach heeft gelijk dat het geen rocket science is. Het is wel werk dat niemand leuk vindt, en daarom blijft het liggen. Het intern netwerk is een uur werk. De ConnectCallback een middag. De alertregel tien minuten. Dat is minder dan de tijd die je kwijt bent aan het eerste gesprek met een klant wiens agent ergens binnen is gelopen.

Veelgestelde vragen over containment van AI-agents

Werkt internal: true ook in Docker Swarm en Kubernetes? In Swarm bestaat dezelfde optie voor overlay-netwerken en werkt die vergelijkbaar. In Kubernetes doe je hetzelfde met een NetworkPolicy die standaard alle egress weigert en alleen verkeer naar de egress-proxy toestaat, maar dat werkt alleen als je CNI-plugin NetworkPolicies afdwingt, wat bijvoorbeeld bij Calico en Cilium wel zo is en bij sommige minimale setups niet.

Kan ik in plaats van Squid ook YARP als egress-proxy gebruiken? YARP is gebouwd als reverse proxy en ondersteunt geen forward-proxy met CONNECT-tunnels uit de doos, dus voor uitgaand verkeer van willekeurige clients is het de verkeerde tool. Squid, Envoy of een gespecialiseerde egress-proxy zoals smokescreen passen beter; YARP blijft prima voor inkomend verkeer naar je eigen services.

Is een allowlist in de system prompt van de agent niet genoeg? Nee, een instructie in de prompt is een verzoek aan het model en geen handhaving. Het model kan die instructie negeren of door een geïnjecteerde tekst laten overschrijven, en dan houdt niets het verkeer tegen; dat is precies het gat dat het CSO-artikel beschrijft.

Hoe ga ik om met agents die pakketten moeten installeren tijdens een run? Zet je eigen pakketbron, zoals een Azure Artifacts-feed of een NuGet- en npm-proxy, op de allowlist en laat de agent alleen daarvandaan installeren. Dan heb je meteen een plek waar je ziet welke pakketten binnenkomen en kun je onbekende of net gepubliceerde pakketten tegenhouden.

Verder lezen


Conclusie: De AI-incidenten van deze zomer laten vooral zien wat er gebeurt als je een agent internet, credentials en geen toezicht geeft. Draai je eigen agents op een netwerk zonder route naar buiten, laat één proxy beslissen wat wel mag, weiger in je .NET-code op IP-niveau en zet een alert op elke weigering. Dan hoor je binnen vijf minuten uit je eigen monitoring dat een agent zich misdraagt.

Bronnen: CSO Online: AI agents aren't going rogue, frontier labs are skipping security basics · Hacker News-discussie · SocketsHttpHandler.ConnectCallback-documentatie.

Hulp nodig bij een vergelijkbaar vraagstuk?
Neem contact op