Security
Kwetsbaarheden vinden met AI in CI: legacy PHP playbook
Een PHP-codebase van 200.000 regels, een CI-pipeline die nog werkt, en Anthropic's nieuwe framework voor kwetsbaarheidsdetectie. Zo knoopten we ze in één middag aan elkaar.

Woensdagmiddag, 14:00. Een logistiek bedrijf uit Rotterdam stuurt ons een Git-URL: 200.000 regels PHP, sinds 2021 niet meer aangeraakt, draagt de volledige orderpijplijn. Een junior ontwikkelaar had er vorige maand een generieke static-analysis tool op losgelaten en kwam terug met een PDF van 3.200 findings die niemand opende. Het security committee komt om 18:00 bij elkaar en wil een echt antwoord.
We hadden vier uur.
De opdracht, in één schets
De week ervoor was Anthropic's open source-framework voor AI-gedreven kwetsbaarheidsdetectie op de voorpagina van Hacker News beland, met een paar honderd punten en een lange, sceptische thread. Het interessante argument daar was, ruwweg, dat het LLM-as-reviewer patroon eindelijk de grens was overgestoken van 'demo' naar 'bruikbaar binnen een build pipeline'. Wij wilden die claim toetsen op een codebase die niemand in het team van de klant graag aanraakte.
De opdracht was klein. Aan het eind van de middag moest elke pull request op deze repository een agent-gedreven scan draaien, de build laten falen op alles met hoge ernst, en findings tonen in de code-scanning view van GitHub. Geen nieuwe dashboards. Geen nieuwe accounts. Geen Slack-bots. Het team had al last van alert fatigue.
Waarom een PHP-monoliet een dankbaar doelwit is
PHP-monolieten hebben een slechte reputatie, en niet altijd onterecht. Maar voor een LLM-reviewer zijn ze op drie manieren vriendelijk. De control flow is grotendeels lokaal: een request komt binnen via één front controller, loopt door een handvol includes, en gaat terug. Geen JIT, geen async runtime, geen metaprogrammering die dik genoeg is om een model in de war te brengen. En de historische bug-klassen zijn goed gedocumenteerd. SQL-injectie via string-concatenatie. XSS in echo-statements. unserialize() op user input. Zwakke crypto in oude sessie-handlers. Een model getraind op het publieke web heeft elke variant van de OWASP Top 10 wel gezien.
Slecht nieuws: in een monoliet van 200k regels staat elke variant waarschijnlijk meerdere keren. Je kunt één agent niet vragen de hele codebase in één pass te lezen zonder het kwartaalbudget aan tokens van de klant op te branden. Je moet chunken, en je moet eerlijk zijn over de chunkstrategie.
Het halve-dag plan
We splitsten de middag in drie passes.
Pass één was een lokale dry-run op een developer-laptop. Doel: het ruisniveau aflezen voordat CI erbij betrokken werd.
Pass twee was een CI-job die op elke pull request draait, maar alleen tegen de diff plus een smalle context window. Dit is het hek dat toekomstig werk beschermt.
Pass drie was een nachtelijke sweep over de hele codebase, in een queue zodat het mensenwerk nooit blokkeert, met de resultaten in de code-scanning view van GitHub.
Totaal aantal framework-invocations die middag: ongeveer 140. Totale kosten: onder de €60. De PDF met 3.200 findings van de junior schrompelde na anderhalf uur triage tot 38 echte findings, waarvan we er elf diezelfde avond patchten.
Pass één: baseline op een laptop
Voordat je ook maar één pipeline aanraakt: clone de repo, installeer het framework, draai het tegen één directory die je goed kent, en lees iedere finding handmatig. Je doet dit om het model te kalibreren, niet om resultaten op te leveren.
git clone git@github.com:client/orderflow.git
cd orderflow
# installeer volgens de README van het framework; wij pinden op een tagged release
export ANTHROPIC_API_KEY=sk-ant-...
./bin/vulnscan scan \
--path app/Catalog \
--severity high \
--format sarif \
--out catalog-baseline.sarif
Open catalog-baseline.sarif in een willekeurige SARIF-viewer (VS Code heeft een prima variant) en lees alles. Er verschijnen drie categorieën. Echte bugs die je meteen patcht. False positives die je noteert voor suppressie. En 'interessant maar niet exploiteerbaar' notities die in een derde bestand gaan voor de volgende pentest. Als bij jouw gekozen ernst meer dan 40% van de findings false positive is, verhoog dan de drempel voordat je verder gaat. Wij landden op deze codebase op ongeveer 18% false-positive rate bij hoge ernst, wat we acceptabel vonden.
Zet altijd een hard kostenplafond op pipeline-niveau, niet alleen in de framework-config. Een buggy retry-loop zonder bovengrens kan een maandelijks tokenbudget binnen een uur leegtrekken. Wij gebruiken beide: de eigen max-cost flag van het framework, plus een timeout-minutes in GitHub Actions die de job na vier uur runtime afkapt.
Pass twee: CI alleen op diffs
De volledige repo had 200.000 regels. Alles bij elke pull request naar het model sturen zou het budget binnen een week opvreten. Dus de PR-job scant alleen de diff plus een kleine context window van omliggende bestanden.
# .github/workflows/vulnscan-pr.yml
name: vulnscan (PR)
on:
pull_request:
branches: [main]
jobs:
scan:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
pull-requests: write
security-events: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Compute changed PHP files
id: diff
run: |
git fetch origin ${{ github.base_ref }}
CHANGED=$(git diff --name-only \
origin/${{ github.base_ref }}...HEAD -- '*.php' \
| tr '\n' ' ')
echo "files=$CHANGED" >> "$GITHUB_OUTPUT"
- name: Run vulnscan on diff
if: steps.diff.outputs.files != ''
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
./bin/vulnscan scan \
--files "${{ steps.diff.outputs.files }}" \
--context-radius 2 \
--severity high \
--format sarif \
--out vulnscan.sarif
- name: Upload SARIF to code scanning
if: steps.diff.outputs.files != ''
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: vulnscan.sarif
Twee opmerkingen bij de YAML.
Ten eerste: context-radius was de vlag die ons het meest interesseerde. Het idee: stuur het model de gewijzigde functie plus N bestanden hogerop in de call graph, zodat het kan zien hoe een parameter binnenkomt. Als jouw framework dit niet aanbiedt, fake het door de diff plus elk bestand dat matcht met de gewijzigde class name mee te sturen. Context is het verschil tussen wel of niet zien dat $_GET['id'] vier bestanden verderop in een query belandt.
Ten tweede: SARIF uploaden naar de code-scanning view van GitHub betekent dat je geen nieuw dashboard nodig hebt. Findings verschijnen naast Dependabot-meldingen en CodeQL-resultaten. Het team weet al waar het moet kijken.
Pass drie: de nachtelijke sweep
# .github/workflows/vulnscan-nightly.yml
name: vulnscan (nightly)
on:
schedule:
- cron: '0 2 * * *' # 02:00 UTC
workflow_dispatch:
jobs:
full-scan:
runs-on: ubuntu-latest
timeout-minutes: 240
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@v4
- name: Full-codebase scan
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
./bin/vulnscan scan \
--path . \
--severity medium \
--chunk-size 4000 \
--max-cost 25 \
--format sarif \
--out vulnscan-full.sarif
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: vulnscan-full.sarif
category: vulnscan-full
De --max-cost vlag is de vlag waarover je het gesprek met finance hebt. Wij kapten op €25 per nachtelijke run, genoeg om in één pass door 200.000 regels te lopen, en laag genoeg dat een doorgeslagen loop niet stiekem voor de ochtend €4.000 opmaakt. Heeft jouw framework geen kostenplafond, schrijf dan een wrapper die de tokenuitgaven volgt en het proces afsluit zodra je drempel wordt overschreden.
Triage die het team daadwerkelijk doet
De eerste nachtelijke run leverde 312 findings op met ernst medium of hoger. Zelfde probleem als met de PDF van de junior: niemand opent op een dinsdagochtend 312 findings.
We deden drie dingen.
Eén, we schreven een klein jq-filter dat findings groepeerde per bestand en bestanden rangschikte op totale ernst. Het team trieerde de topbestanden, niet de top-findings. Daardoor klapten 312 individuele entries in tot 47 groepen op bestandsniveau, waarvan er 18 hoge ernst bevatten en als eerste werden gelezen.
jq -r '
.runs[0].results
| group_by(.locations[0].physicalLocation.artifactLocation.uri)
| map({
file: .[0].locations[0].physicalLocation.artifactLocation.uri,
count: length,
severities: (map(.properties.severity) | unique)
})
| sort_by(-.count)
' vulnscan-full.sarif
Twee, we zetten een .vulnscan-suppress.yml bestand in de root van de repo, met geaccepteerde findings per rule id en bestandspad. Na triage werd alles wat nog in de SARIF zat en niet op de suppress-lijst stond een build failure bij de eerstvolgende PR op dat bestand. Wat wel op de suppress-lijst stond, bleef zichtbaar in code scanning maar blokkeerde geen werk.
# .vulnscan-suppress.yml
suppressions:
- rule: weak-rand-session
path: legacy/auth/session_legacy.php
reason: scheduled for removal in Q3 migration; ticket SEC-441
expires: 2026-09-30
- rule: php-unserialize
path: tools/import/csv_legacy_import.php
reason: internal CLI only, no network exposure
expires: 2027-01-01
Het expires veld weegt zwaarder dan de reden. Een suppressie zonder einddatum is een permanent excuus. Een suppressie met datum is een agenda-afspraak.
Drie, het hek voor hoge ernst bleef maar in één richting streng: een nieuwe high-severity finding die in een pull request wordt geïntroduceerd, laat de check falen. Een bestaande high-severity finding uit de nachtelijke sweep laat een PR die het bestand niet aanraakt niet falen. Die bug bestond gisteren al, en het PR van vandaag daarvoor straffen is precies hoe teams leren om het hek uit te zetten.
Wat menselijk blijft
We lieten het framework op drie terreinen niks goedkeuren.
Authenticatie en sessie-afhandeling. Het model is redelijk in het herkennen van zwakke sessietokens of ontbrekende CSRF-checks. De prijs van een false negative is hier een complete account takeover. Twee menselijke reviewers op elke auth-wijziging, ongeacht wat de agent zegt.
Alles wat de betaalflow raakt. Dit is net zo goed een regulatoire keuze als een technische. Het PSD2 audit trail verwacht reviewers met naam, en een LLM kan dat niet zijn.
Autorisatie op businesslogica. Het model weet dat het aanroepen van is_admin() goed is. Het weet niet dat is_admin() true retourneert voor elke gebruiker met een ID deelbaar door zeven, vanwege een marketingcampagne uit 2018 die niemand de moed heeft om te verwijderen. Domeinkronkels vragen om domeinreviewers.
Voor de rest, de klassieke webkwetsbaarheden, deserialisatie, file inclusion, command injection, ving het framework echte bugs die het team al jaren over het hoofd zag. De 38 bevestigde findings van die eerste nacht bevatten drie SQL-injecties in productzoek, een unserialize() op een cookiewaarde, en een path-traversal bug in een CSV-download endpoint die sinds 2017 live stond. Geen ervan vroeg om slim prompten. Het model las de code, schreef het op, en wees de regel aan.
Het eerlijke deel
Dit vervangt geen pentest. Wat het vervangt, is het gat tussen pentests. Een serieuze kwartaalreview heeft nog steeds mensen nodig die drie bugs kunnen aaneenrijgen tot een echte aanval, en het bedrijf goed genoeg kennen om te weten welke aanval er toe doet. Maar de ruis waar mensen anders hun eerste dag aan kwijt zijn, de voor de hand liggende string-concat SQL, de voor de hand liggende unescaped echo, de voor de hand liggende unserialize, dat werk hoort nu thuis in de pipeline. Pentesters beginnen dan op dag twee, en daar waren ze sowieso al interessanter.
Toen we dit voor het logistieke team in Rotterdam aansloten, zat de frictie niet in het framework zelf. Het zat in GitHub Actions netjes laten samenwerken met SARIF-upload op een private repo (je hebt security-events: write nodig én ofwel GitHub Advanced Security ofwel publieke zichtbaarheid om de code-scanning UI te laten renderen), en in het suppress-bestand zo schrijven dat het team het onderhoudt in plaats van haat. We doen dit soort AI-in-CI werk voor klanten met PHP-, Laravel- en Magento-monolieten; de halve-dag versie is echt en we hebben hem inmiddels op zes codebases gedraaid.
Heb je een legacy PHP-codebase en een CI die je vertrouwt, dan is het kleinste nuttige wat je vandaag kunt doen: het framework handmatig op je laptop tegen één directory draaien die je goed kent, voordat je de pipeline aanraakt. Lees iedere finding. Het ruisniveau dat je in die eerste run meet, is de enige eerlijke input voor elke beslissing die daarna volgt.
Kern
De pipeline vangt de bug-klassen waar mensen anders hun eerste dag aan verspillen. Pentesters beginnen dan op dag twee, en dat was altijd al de dag waarop ze nuttiger waren.
FAQ
Vervangt dit een pentest?
Nee. Het vangt de voor de hand liggende bug-klassen af (SQL-injectie, XSS, deserialisatie, path traversal) zodat menselijke pentesters op dag twee van een opdracht beginnen in plaats van dag één. De interessante aanvallen vragen nog steeds om een mens.
Hoe ziet de CI-rekening eruit voor een repo van 200k regels?
Diff-only PR-runs liggen tussen €0,20 en €0,40 per stuk. Een nachtelijke volledige sweep met een plafond van €25 is genoeg voor 200k regels in één pass. Totale maandkosten op een actieve repo: €60 tot €120.
Werkt dit op private GitHub-repositories?
Ja, maar om findings binnen de code-scanning UI van GitHub op een private repo te tonen heb je GitHub Advanced Security nodig, een betaalde add-on. De SARIF wordt zonder dat ook geüpload en is via de API hoe dan ook te queryen.
Hoe zit het met Laravel, Symfony of Magento, geen kale PHP?
Zelfde playbook. Framework-bewuste findings werken omdat het model Eloquent query builders, Symfony route attributes en Magento controllers herkent. Pas de directorylijst in pass één aan op de structuur van jouw framework.