Security
AI-vulnerability-scan op legacy PHP voor de refactor
Voor één enkele regel refactor richtten we een open-source AI-vulnerability scanner op 60.000 regels legacy PHP. Dit is de toolchain en de punch list die eruit kwam.

De codebase landde op donderdagavond op onze shared drive. Een zip van 60.412 regels PHP, geschreven in fragmenten tussen 2011 en 2024, vier verschillende coding conventions, twee ORM's (één zelfgebouwd, geen documentatie), en een directory genaamd /admin/ met een bestand genaamd do_things.php. De klant wilde voor de herfst migreren naar Laravel. We zeiden dat ze moesten wachten tot we een vulnerability scan over het geheel hadden gedraaid.
Voor welke refactor dan ook wilden we een baseline. Geen nette lijst met typescript-achtige warnings. Een echte inventaris van wat de applicatie verkeerd doet zodra ze user input, file paths of de database aanraakt. Het soort kaart dat je vertelt welke delen van de codebase je niet mag aanraken voordat je back-ups hebt, en welke delen op dit moment stilletjes bloeden.
Eerst scannen, dan refactoren
De meeste legacy-migratieprojecten doen dit in de verkeerde volgorde. Het team kiest een doelframework, plant een rewrite, en vindt de security holes halverwege de vertaling. De helft overleeft in het nieuwe systeem omdat de developer een query letterlijk heeft overgenomen "om het oude gedrag te matchen". Op dat moment is de rewrite de enige plek waar de bug nog bestaat, en niemand weet wanneer die geland is.
De scan eerst draaien lost drie dingen tegelijk op. Je krijgt een punch list van issues die een hotfix nodig hebben voordat iemand het live systeem aanraakt. Je krijgt een vocabulaire voor de rewrite: elke PR kan zeggen "fixes vulnerability VL-014, mapped from legacy/users.php:142". En je vindt de delen van de oude code die nooit zo slecht waren als het team dacht, wat rewrite-tijd scheelt die je anders had besteed aan het opnieuw implementeren van de verkeerde abstractie.
Basislijn met vier tools
We draaien niet één enkele scanner. Elke tool vangt een ander type bug, en in de overlap zit je vertrouwen. Onze default stack op een PHP-project ziet er zo uit:
- Semgrep met de
p/phpenp/security-auditrulesets, plus een handvol project-specifieke regels. - Psalm met
--taint-analysisaan, die user input traceert van source naar sink door de type graph. - PHPStan op level 6, vooral voor de type-safety findings die dode branches en ontbrekende null checks aan het licht brengen.
- Een open-source AI-vulnerability scanner. De categorie is jong, maar de volwassen exemplaren redeneren over functiegrenzen heen in plaats van te matchen op lokale patronen, en daar zitten de interessante bugs.
De eerste drie zijn pattern matchers en type checkers. Ze zijn snel, voorspelbaar, en ze missen alles wat redeneren over meer dan één of twee bestanden vereist. De vierde, de AI-scanner, pikt multi-step vulnerabilities op waarbij een attacker één variabele in handen heeft, die door drie functiegrenzen stroomt en eindigt in een sink die niemand heeft geflagd omdat niemand de regel heeft geschreven.
Alle vier in één pass draaien
We wrappen de scanners in een klein Bash-script dat structured output naar disk schrijft. Elke tool produceert JSON of SARIF, wat de triage daarna simpel houdt.
#!/usr/bin/env bash
set -euo pipefail
REPO="${1:-./legacy-app}"
OUT="./scan-output/$(date +%Y%m%d-%H%M)"
mkdir -p "$OUT"
# 1. Semgrep
semgrep --config p/php --config p/security-audit \
--json --output "$OUT/semgrep.json" "$REPO"
# 2. Psalm with taint analysis
( cd "$REPO" && psalm --taint-analysis --report=psalm.sarif ) \
&& mv "$REPO/psalm.sarif" "$OUT/psalm.sarif"
# 3. PHPStan
( cd "$REPO" && phpstan analyse --error-format=json --level=6 ) \
> "$OUT/phpstan.json" || true
# 4. AI vulnerability scanner
ai-vuln-scan --target "$REPO" --json > "$OUT/ai-scan.json"
echo "Done. Findings written to $OUT"
Op de codebase van 60k regels duurde de hele sweep 38 minuten op een M2 Pro. De AI-scanner domineerde de runtime met 31 van die minuten, wat te verwachten was: hij leest bestanden van begin tot eind in plaats van te matchen op patronen.
De ruis dedupliceren
De ruwe output was luidruchtig. Semgrep flagde 198 findings, Psalm 89, PHPStan 1.407 (de meeste type-safety issues los van security), en de AI-scanner 312. Veel daarvan wezen naar dezelfde code met andere terminologie. Ons triage-script normaliseert alles naar (file, line, category) tuples en groepeert ze.
import json, collections
def load(path, mapper):
with open(path) as f:
return [mapper(x) for x in json.load(f)]
# Each mapper returns (file, line, category, severity, source)
findings = load("semgrep.json", semgrep_map)
findings += load("psalm.sarif", psalm_map)
findings += load("ai-scan.json", ai_map)
groups = collections.defaultdict(list)
for f, l, cat, sev, src in findings:
groups[(f, l, cat)].append((sev, src))
# A finding confirmed by two or more tools jumps to the top of triage.
confirmed = {k: v for k, v in groups.items() if len(v) >= 2}
solo_ai = {k: v for k, v in groups.items()
if len(v) == 1 and v[0][1] == "ai-scan"}
"Bevestigd door twee of meer tools" is geen garantie voor een echte bug, maar wel een sterke prior. Op dit project landden 71 findings in confirmed. Nog eens 184 waren AI-only, waarvan ongeveer de helft standhield na handmatige review, voornamelijk multi-file flows die de klassieke tools niet konden zien.
AI-scanners hallucineren code die er niet staat. We behandelen elke AI-only finding als een hypothese, niet als een vonnis. Twee minuten grep tegen het geciteerde bestand bespaart uren spookjagen.
Wat de triage overleefde
Na twee dagen review zag de punch list er zo uit. De categorieën zijn de saaie uit de OWASP Top 10, precies wat je verwacht van een codebase die dertien jaar is doorgegroeid zonder security review.
- SQL-injection op 7 plekken. Allemaal string-geconcatenateerde
WHEREclauses in admin tools. Eén ervan was bereikbaar vanaf een publieke route via een vergeten?action=exporthandler. - Stored XSS in 23 templates. Directe
echo $row['title']zonder escaping. De meeste zaten in admin-pagina's met weinig verkeer, maar de review-pagina richting de klant was er ook bij. - RCE via
include $_GET['page']. Niet-geauthenticeerd pad. Het bestand was in 2014 toegevoegd "als tijdelijke debug helper". Het overleefde de developer die het schreef. - Een hardcoded Mailgun API-token in
config.inc.php, gecommit in 2017, nog altijd geldig, nog altijd in de live config. De token had send-rechten op het domein. - Session fixation in de login flow. Geen
session_regenerate_id()na authenticatie. Triviaal om te wapenen zodra je een XSS hebt op hetzelfde domein, en die hadden we. - Mass-assignment in de zelfgebouwde ORM, waar één helper elke
$_POST-key op het model-object kopieerde. Hetusers-model had eenis_adminkolom.
De hardcoded token was het soort finding dat de AI-scanner ving en waar de klassieke tools hun schouders bij ophaalden. Semgrep heeft er regels voor, maar alleen als de token matcht met een bekend regex-formaat. De Mailgun-key was jaren geleden gerotateerd naar een custom prefix en glipte langs elke pattern matcher die we probeerden.
Patchen vóór de rewrite
We hotfixten de kritieke zes in de legacy codebase voordat we ook maar één Laravel-bestand openden. De migratie zou acht weken gaan duren. De RCE en de SQLi konden geen acht weken wachten. De fixes waren lelijk (parameterised queries gepropt in een code-stijl die ze niet verwachtte), maar ze waren correct, en ze gaven ons een veilige baseline om vanaf te rewriten.
Elke hotfix ging naar een tracked branch met een one-liner: VL-002: SQLi in legacy/admin/export.php. Fixed via prepared statement. Re-implement properly in UserRepository. Toen de Laravel-kant weken later in dat gebied aankwam, pikte de PR-beschrijving die note weer op en sloot de loop.
Hoe de AI-scanner onze triage veranderde
De klassieke tools doen nog steeds het meeste werk. Semgrep vindt de patronen, Psalm traceert de types, PHPStan houdt de codebase eerlijk. Wat de AI-scanner toevoegde was iets dat geen van de anderen ons kon geven: een beschrijving van waarom een finding gevaarlijk was, in proza, met de data flow uitgeschreven. Dat versnelt de triage enorm. In plaats van vier bestanden te openen om te begrijpen of een finding echt is, lees je twee alinea's en bevestig je tegen één bestand.
De trade-off is eerlijkheid. Ongeveer één op de vijf AI-only findings was een zelfverzekerde hallucinatie. De scanner beschreef een functie die niet bestond, of een flow die eindigde bij de verkeerde sink. Behandel de output zoals je een enthousiaste junior zonder commit-rechten zou behandelen: nuttig, gemotiveerd, af en toe verkeerd over de codebase die hij net heeft gelezen.
De vijf-minuten-versie
Kun je vandaag niet de hele stack draaien? Pak dan het ene bestand in je codebase waarvan je niet zou willen dat een vreemde het zorgvuldig leest. Draai semgrep --config p/security-audit path/to/file.php en lees de top drie findings voor de lunch. Verrast er eentje? Dan verrast de rest van de codebase je nog meer. Dat is het moment om de echte audit in te plannen.
Toen we de pre-migration audit bouwden voor een Nederlandse SaaS-klant op PHP 5.6, liepen we ertegenaan dat de statische scanners en de AI-scanner op bijna 40% van de findings van mening verschilden. Uiteindelijk schreven we een klein reachability-filter dat issues rangschikt op het aantal hops dat ze van een publieke route zitten, wat de ruis halveert. Dat soort pre-flight zit nu bij elke legacy migratie die we oppakken.
Kern
Draai de security scan vóór de refactor, niet erna. Elke kwetsbaarheid die je eerst vindt wordt een checklist-item; elke die je tijdens de rewrite vindt wordt een defect dat live gaat.
FAQ
Met welke scanner kun je het beste beginnen op een legacy PHP-codebase?
Semgrep met de p/security-audit ruleset, omdat hij in seconden draait en de voor de hand liggende wins zichtbaar maakt. Heb je een baseline, voeg dan Psalm met taint analysis en een AI-scanner toe.
Kan een AI-vulnerability scanner traditionele statische analyse vervangen?
Nog niet. AI-tools vangen multi-file flows die pattern matchers missen, maar ze hallucineren. Draai beide, en vertrouw findings waar twee onafhankelijke tools het over eens zijn.
Wanneer fix je kwetsbaarheden in legacy code in plaats van te wachten op de rewrite?
Alles wat bereikbaar is vanaf een publieke route krijgt eerst een hotfix in de legacy codebase. Een rewrite duurt weken. SQL-injection en RCE kunnen niet zo lang wachten.
Hoe lang duurt een volledige security scan op een PHP-codebase van 60k regels?
Rond de 40 minuten end-to-end op een moderne laptop. Semgrep en PHPStan zijn in seconden klaar. Psalm met taint analysis duurt een paar minuten. De AI-scanner domineert de runtime.