Werkplaats / De werkplaats
In jouw eigen tempo
← AI-security & red teaming
MODULE 01 · LES 01

Breng datastromen en vertrouwensgrenzen in kaart

Onderzoek bereikbaarheid, zoneovergangen en kortste routes vóór en na een expliciete blokkade in een eigen datastroommodel.

90 min3 checkpoints
DIT GA JE ONTDEKKEN
  • Je beschrijft waardevolle onderdelen, beïnvloedbare invoer en mogelijke gevolgen.
  • Je vergelijkt bereikbaarheid en grensovergangen vóór en na een ingreep in het gekozen model.

Een assistent leest een document en mag een interne tool aanroepen. Wat gebeurt er als dat document de assistent probeert te sturen? Begin met een kaart van de onderdelen en verbindingen, voordat je op zoek gaat naar een slimme filterregel.

Zo’n kaart helpt bij dreigingsmodellering (threat modeling): je beschrijft wat waardevol is, wie invoer kan beïnvloeden en welke ongewenste gevolgen mogelijk zijn. Onze oefening gebruikt een fictief archief met alleen node-ids. Je valt geen echte assistent, bestanden of dienst aan.

1. Volg een mogelijke route

Stel je vier kamers voor: document, model, tool en archief. Een pijl betekent dat een onderdeel in dit gekozen model iets naar het volgende onderdeel kan doorgeven. De pijl bewijst niet dat een aanval werkt; hij maakt een mogelijke route zichtbaar die je moet onderzoeken.

Het document is het beginpunt. Eerst kun je alleen dat begin aanwijzen, daarna voeg je de buren toe die vanuit al gevonden kamers bereikbaar zijn. Herhaal dit totdat er niets nieuws bijkomt.

In de formule is R de verzameling gevonden nodes, s het begin en t de zoekronde. N met een plus betekent: de buren aan het einde van uitgaande pijlen. Het unieteken ∪ voegt verzamelingen samen zonder dubbeltellingen.

R0={s}Rt+1=Rt∪N+(Rt)

Met de hand krijg je achtereenvolgens document, model, tool en archief. Alle vier zijn bereikbaar; het begin telt zelf mee. Een terugpijl naar het model voegt geen nieuwe kamer toe.

nodes = [
    dict(id="document", zone="public"),
    dict(id="model", zone="app"),
    dict(id="tool", zone="app"),
    dict(id="archief", zone="private"),
]
pairs = [
    ("document", "model"),
    ("model", "tool"),
    ("tool", "archief"),
]
edges = [dict(source=a, target=b)
         for a, b in pairs]
found = reachable_nodes(
    nodes, edges, "document")
print(len(found), "archief" in found)
# 4 True

Bewaar bezochte ids zodra je ze ontdekt. Zo blijft het zoeken eindig bij cycli of een pijl terug naar dezelfde node. De opdracht vraagt een alfabetische resultaatlijst; dat is iets anders dan de volgorde waarin je de kamers ontdekt.

01
CHECKPOINTVind bereikbare nodes in een gerichte graaf

2. Een grens is een vraag, geen automatisch alarm

De kamers hebben labels: public, app of private. Document naar model passeert een labelgrens, model naar tool niet. Toch kan ook die laatste verbinding gevaarlijk zijn wanneer de tool te veel mag.

We noemen de verzameling overgangen B. E is de verzameling pijlen, u en v zijn hun begin en einde, en z geeft het gekozen zonelabel. Het teken ≠ betekent dat de labels verschillen.

B={(u,v)∈E|z(u)≠z(v)}

Onze twee overgangen zijn document naar model en tool naar archief. Gebruik de labels om concrete vragen te stellen: wie controleert het document, welke aanvraag bereikt de tool, en wie mag bij het archief? Een zonekleur verleent zelf geen bevoegdheid.

crossings = boundary_edges(nodes, edges)
for row in crossings:
    print(row["from_zone"],
          row["to_zone"])
# public app
# app private
02
CHECKPOINTMaak de gekozen zoneovergangen zichtbaar
VERKEN HET ZELFWelke route blijft over?

Kies een beginpunt en blokkeer pijlen. Een omweg kan het archief bereikbaar houden, ook als je de eerste route onderbreekt.

Blokkeer deze pijlen
Een omweg en een cyclus: routes na de gekozen blokkadesStart document. Doorgetrokken pijlen zijn beschikbaar; gestreepte pijlen zijn geblokkeerd. Bereikbaar na de ingreep: archief, document, model, tool. Alle paden en zoneovergangen staan ook als tekst onder de graaf.document → model: geblokkeerdmodel → tool: beschikbaartool → archief: beschikbaardocument → tool: beschikbaartool → model: beschikbaardocument, zone publiek, start, bereikbaardocumentpubliekmodel, zone app, bereikbaarmodelapptool, zone app, bereikbaartoolapparchief, zone privé, bereikbaararchiefprivé

Een dikke rand markeert het beginpunt; de labels geven de zones. Een streepjespijl is geblokkeerd. De beschikbare pijlen vormen de graaf na de ingreep.

Bereikbaar vóór

archief, document, model, tool

Bereikbaar na

archief, document, model, tool

Naar archief

Vóór document → tool → archief

Na document → tool → archief

Naar document

Vóór document

Na document

Naar model

Vóór document → model

Na document → tool → model

Naar tool

Vóór document → tool

Na document → tool

Zoneovergangen vóór

  • document → model publiek → app
  • document → tool publiek → app
  • tool → archief app → privé

Zoneovergangen na

  • document → tool publiek → app
  • tool → archief app → privé

Dit is een gekozen datastroommodel. Bereikbaarheid bewijst geen echte bevoegdheid of kwetsbaarheid. Bij even korte routes kiest de oefencode het alfabetisch eerste volledige pad.

Eigen, vooraf berekende oefengegevens. Je bedient een begrensd model; er worden geen externe systemen, documenten of tools aangesproken.

3. Vergelijk vóór en na je ingreep

Blokkeer in gedachten de pijl model naar tool. In deze kaart verdwijnt daarmee de route van document naar archief. Dat is een verandering van het model; er wordt op deze pagina geen firewall, account of echte tool aangepast.

D bevat de geblokkeerde pijlen. De nieuwe pijlverzameling E met een accent ontstaat door D uit E weg te laten. Het verschilteken ∖ betekent hier verzamelingsverschil.

E′=E∖D
report = compare_routes(
    nodes, edges, "document",
    ["archief"], [edges[1]])
print(len(report[0]["before"]))
print(report[0]["after"])
# 4
# None

Een kortste route zoek je in lagen: eerst afstand nul, dan één pijl, dan twee. Voor een vaste keuze bij gelijke lengtes sorteer je de buren en verwerk je de lagen in volgorde. Vergelijk de volledige padreeks alfabetisch, niet alleen het laatste node-id.

03
CHECKPOINTVergelijk aanvalsroutes vóór en na een modelingreep

Een veelgemaakte fout is tevreden zijn zodra één getekend pad verdwijnt. Voeg een tweede verbinding toe: document mag nu ook rechtstreeks de tool bereiken. Dezelfde blokkade is dan onvoldoende.

extra = dict(source="document",
             target="tool")
report = compare_routes(
    nodes, edges + [extra], "document",
    ["archief"], [edges[1]])
print(report[0]["after"])
# ['document', 'tool', 'archief']

Geen route is None, terwijl het pad van een node naar zichzelf die ene node bevat. Houd dat onderscheid zichtbaar. Ook een ontbrekende route bewijst alleen iets over de opgenomen verbindingen, niet over vergeten tools, andere kanalen of verkeerde aannames.

Wat je nu kunt

  • Je begint bij waardevolle onderdelen, beïnvloedbare invoer en mogelijke gevolgen.
  • Je volgt gerichte routes en onderzoekt waar gekozen zones elkaar raken.
  • Je vergelijkt een ingreep met de oorspronkelijke kaart en controleert omwegen.

Hierna maak je de beïnvloeding concreet: een onschuldige gebruikersinstructie verandert de kleur van een vaste nep-LLM.

Bronnen

Gecontroleerd op 30 oktober 2026: OWASP over dreigingsmodellering en datastroomgrenzen en de actuele OWASP LLM01-uitleg over promptinjectie. De kamers, algoritmeopdracht en voorbeelden zijn eigen oefenmateriaal.

DE VOLGENDE STAPToon en begrens directe instructieoverschrijving
JOUW EXPERIMENT

Probeer het zelf

Python
main.py

Python start zodra jij code uitvoert.

UITVOER

Je uitvoer verschijnt hier.
Alles rekent in je eigen browser.

Jouw checkpoints0 / 3
01
Vind bereikbare nodes in een gerichte graaf

Schrijf reachable_nodes(nodes,edges,start). Nodes is exact list/tuple met 1–16 exacte dicts met uitsluitend id/zone. Ids zijn unieke exacte strings volgens [a-z][a-z0-9-]{0,31}; zone exact public/app/private. Edges is exact list/tuple met 0–64 exacte dicts source/target: bekende ids, unieke gerichte pijlen. Zelfpijlen en cycli mogen. Ids niet strippen of normaliseren. Start is een bekende id. Geef een nieuwe alfabetisch gesorteerde lijst van alle ids bereikbaar vanaf start, inclusief start zelf. Een cyclus blijft eindig. Dit is een vooraf gekozen datastroommodel, geen exploit- of autorisatiebewijs. Behoud alle invoer, vormen, volgorde, readonlyflags en Python-/NumPy-RNG, ook bij fouten. Mutabele resultaten zijn nieuw en delen geen containers met invoer, elkaar of resultaten van herhaalde aanroepen. Ongeldige invoer geeft ValueError; de validatiehelpers zijn gegeven. Elke functie werkt zelfstandig als de andere twee nog TODO zijn.

Een hint

Bewaar al bezochte ids voordat je hun uitgaande pijlen verder onderzoekt.

02
Maak de gekozen zoneovergangen zichtbaar

Schrijf boundary_edges(nodes,edges). Nodes is exact list/tuple met 1–16 exacte dicts met uitsluitend id/zone. Ids zijn unieke exacte strings volgens [a-z][a-z0-9-]{0,31}; zone exact public/app/private. Edges is exact list/tuple met 0–64 exacte dicts source/target: bekende ids, unieke gerichte pijlen. Zelfpijlen en cycli mogen. Ids niet strippen of normaliseren. Geef een nieuwe lijst met nieuwe dicts source/target/from_zone/to_zone voor uitsluitend pijlen waarvan de zones verschillen, gesorteerd op source en dan target. Een grensovergang is een onderzoeksvraag, niet automatisch een kwetsbaarheid. Behoud alle invoer, vormen, volgorde, readonlyflags en Python-/NumPy-RNG, ook bij fouten. Mutabele resultaten zijn nieuw en delen geen containers met invoer, elkaar of resultaten van herhaalde aanroepen. Ongeldige invoer geeft ValueError; de validatiehelpers zijn gegeven. Elke functie werkt zelfstandig als de andere twee nog TODO zijn.

Een hint

Vergelijk zones aan beide uiteinden van iedere pijl.

03
Vergelijk aanvalsroutes vóór en na een modelingreep

Schrijf compare_routes(nodes,edges,start,targets,blocked). Nodes is exact list/tuple met 1–16 exacte dicts met uitsluitend id/zone. Ids zijn unieke exacte strings volgens [a-z][a-z0-9-]{0,31}; zone exact public/app/private. Edges is exact list/tuple met 0–64 exacte dicts source/target: bekende ids, unieke gerichte pijlen. Zelfpijlen en cycli mogen. Ids niet strippen of normaliseren. Start is een bekende id. Targets is exact list/tuple met 0–16 unieke bekende ids. Blocked heeft het edges-schema en bevat uitsluitend bestaande pijlen. Geef voor elk doel een nieuw dict target/before/after in alfabetische doelvolgorde. Before/after zijn nieuwe kortste node-id-paden, of None bij geen route. Gelijke lengtes kiezen lexicografisch op de VOLLEDIGE padreeks. Begin naar zichzelf is [start]. Before gebruikt alle pijlen; after laat blocked weg. Valideer blocked ook bij lege targets. Een modelblokkade wijzigt geen echte infrastructuur. Behoud alle invoer, vormen, volgorde, readonlyflags en Python-/NumPy-RNG, ook bij fouten. Mutabele resultaten zijn nieuw en delen geen containers met invoer, elkaar of resultaten van herhaalde aanroepen. Ongeldige invoer geeft ValueError; de validatiehelpers zijn gegeven. Elke functie werkt zelfstandig als de andere twee nog TODO zijn.

Een hint

Zoek beide padverzamelingen apart; bewaar None als een route ontbreekt.

Vastgelopen? Bekijk de voorbeeldoplossing
import re
from collections import deque

# Jouw functies

def reachable_nodes(nodes, edges, start):
    zones, pairs = _g_graph(nodes, edges)
    start = _g_id(start)
    if start not in zones:
        raise ValueError('Onbekend begin')
    visited = {start}
    queue = deque([start])
    while queue:
        node = queue.popleft()
        for source, target in pairs:
            if source == node and target not in visited:
                visited.add(target)
                queue.append(target)
    return sorted(visited)

def boundary_edges(nodes, edges):
    zones, pairs = _g_graph(nodes, edges)
    return [{'source': source, 'target': target, 'from_zone': zones[source], 'to_zone': zones[target]} for source, target in sorted(pairs) if zones[source] != zones[target]]

def compare_routes(nodes, edges, start, targets, blocked):
    zones, pairs = _g_graph(nodes, edges)
    start = _g_id(start)
    if start not in zones:
        raise ValueError('Onbekend begin')
    wanted = _g_targets(targets, zones)
    denied = _g_pairs(blocked, zones)
    if not set(denied) <= set(pairs):
        raise ValueError('Alleen bestaande pijlen blokkeren')

    def paths(excluded):
        adjacency = {name: [] for name in zones}
        for source, target in pairs:
            if (source, target) not in excluded:
                adjacency[source].append(target)
        found = {start: [start]}
        queue = deque([start])
        while queue:
            node = queue.popleft()
            for target in sorted(adjacency[node]):
                if target not in found:
                    found[target] = found[node] + [target]
                    queue.append(target)
        return found
    before = paths(set())
    after = paths(set(denied))
    return [{'target': name, 'before': list(before[name]) if name in before else None, 'after': list(after[name]) if name in after else None} for name in sorted(wanted)]

# Gegeven helpers

def _g_id(value):
    if type(value) is not str or re.fullmatch('[a-z][a-z0-9-]{0,31}', value) is None:
        raise ValueError('Ongeldige node-id')
    return value

def _g_pairs(rows, ids):
    if type(rows) not in (list, tuple) or len(rows) > 64:
        raise ValueError('Maximaal 64 pijlen')
    result = []
    for row in rows:
        if type(row) is not dict or set(row) != {'source', 'target'}:
            raise ValueError('Exact source en target')
        pair = (_g_id(row['source']), _g_id(row['target']))
        if any((node not in ids for node in pair)) or pair in result:
            raise ValueError('Onbekende of dubbele pijl')
        result.append(pair)
    return result

def _g_graph(nodes, edges):
    if type(nodes) not in (list, tuple) or not 1 <= len(nodes) <= 16:
        raise ValueError('1..16 nodes')
    zones = {}
    for row in nodes:
        if type(row) is not dict or set(row) != {'id', 'zone'}:
            raise ValueError('Exact id en zone')
        name = _g_id(row['id'])
        zone = row['zone']
        if name in zones or type(zone) is not str or zone not in ('public', 'app', 'private'):
            raise ValueError('Dubbele id of ongeldige zone')
        zones[name] = zone
    return (zones, _g_pairs(edges, zones))

def _g_targets(targets, ids):
    if type(targets) not in (list, tuple) or len(targets) > 16:
        raise ValueError('Maximaal 16 doelen')
    result = []
    for value in targets:
        name = _g_id(value)
        if name not in ids or name in result:
            raise ValueError('Onbekend of dubbel doel')
        result.append(name)
    return result

Voer de code uit en verklaar voor jezelf waarom elke stap werkt.