- Python 100%
De bron-URL is voor iBabs/ORI vaak een ondoorzichtige GUID; daarmee vindt informatie- beheer het lekkende stuk niet terug. We halen nu bij elke bron al een titel op (ORI file_name, Notubiz-documenttitel, iBabs-vergaderingtitel) maar gooiden die weg (_titel). Die bewaren we nu als 'brontitel' op de files-tabel. Daarnaast het 'title'-veld uit de PDF/Office-metadata zelf als aparte kolom 'pdf_titel' -- bewust NIET als OR/fallback maar los, want juist het verschil (sjabloon "BESTUURS- DOCUMENT" vs. echte bestandsnaam) is voer voor informatiebeheer om te verbeteren. Beide kolommen in rapport.html en Excel. Migratie voegt de kolommen toe aan bestaande databases; ze vullen zich bij een volgende ingest (brontitel) / --redetect (pdf_titel). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|---|---|---|
| avgscan | ||
| tests | ||
| .gitignore | ||
| avg_scan.py | ||
| config.example.yaml | ||
| CONTRIBUTING.md | ||
| LICENSE | ||
| NOTICE.md | ||
| README.md | ||
| requirements.txt | ||
| run_historisch.py | ||
publicatiescan
Vind persoonsgegevens die per ongeluk in je eigen publicaties staan — BSN, IBAN, NAW, e-mail — in PDF, DOCX, XLSX en PPTX.
Gemeenten publiceren duizenden documenten: bekendmakingen, raadsstukken, ingekomen brieven, Woo-besluiten, vergunningen. Daar zitten met enige regelmaat persoonsgegevens in die er niet in horen. Handmatig controleren is geen optie — het zijn er te veel. Deze tool doet wat een journalist ook zou doen: de publicatiekanalen uitlezen, de tekstlaag uit de documenten halen, en zoeken naar patronen die alleen een persoonsgegeven kúnnen zijn (een 9-cijferige reeks die de elfproef doorstaat, een IBAN dat de mod-97-controle doorstaat).
Het verschil is dat jij het als eerste weet.
ⓘ Dit is een aangepaste kloon (fork)
Deze repository is een fork van het origineel: security-commons-nl/publicatiescan — oorspronkelijke auteurs lugdunium / B. Stevens (gemeente Leiden) e.a. De originele tool, opzet en licentie (EUPL v1.2) blijven onverkort van kracht; deze fork bouwt daarop voort. De belangrijkste toevoegingen zijn een Notubiz-ingest (raads- en commissiedocumenten via de Notubiz-API) en een optie om het rapport ongemaskeerd te tonen. Het volledige overzicht staat in Wijzigingen in deze fork.
Scope
Richt deze tool uitsluitend op de publicatiekanalen van je eigen organisatie. Het scannen van portalen van een andere organisatie is scannen zonder grondslag, hoe goed je bedoelingen ook zijn.
De crawler respecteert
robots.txt, houdt een pauze tussen requests aan, en identificeert zich met een user-agent waarin jouw organisatie en een contactadres staan. Laat dat zo.
Wijzigingen in deze fork
Ten opzichte van het origineel:
- Notubiz-ingest (
--notubiz) — een nieuwe bron naast crawlen en de KOOP SRU-API. Haalt raads- en commissiedocumenten op via de publieke Notubiz-API, uit twee bronnen: de vergaderdocumenten én de documentmodules (Ingekomen stukken, Moties, Toezeggingen, Schriftelijke vragen, Besluitenlijsten enz.). Instelbaar metnotubiz_organisatiesin de config en de vlaggen--notubiz-org,--notubiz-since/--notubiz-until,--notubiz-modulesen--notubiz-no-meetings. Zie de Notubiz-route in de handleiding. - Ongemaskeerd rapport (
--geen-maskering/masker_waarden) — het rapport kan de gevonden waarden nu óók ruw (ongemaskeerd) tonen. De database bewaart voortaan de ruwe waarden; maskeren is een presentatiekeuze op rapportageniveau. Standaard blijft gemaskeerd (veilig); ongemaskeerd is een bewuste opt-in. Zie Het rapport lezen. - Aparte outputset per run (
--output-dir) — overschrijftoutput_diruit de config, zodat je bijvoorbeeld per bron (crawl / SRU / Notubiz) een eigen set downloads, status en rapport houdt. - Voortgangsweergave bij de Notubiz-ingest (aantal vergaderingen, voortgangsbalk en een telling per module) en tussentijdse commits, zodat de statusdatabase zichtbaar meegroeit en een onderbreking niet de hele oogst weggooit.
- Open Raadsinformatie-bron (
--ori) — dekt de drie niet-Notubiz-systemen in één klap: iBabs (165 gemeenten), GO. (34) en Parlaeus (3). Samen met--notubiz(127) is dat heel gemeentelijk Nederland (329 gemeenten, ruim 5 mln documenten). Inclusief--ori-list. - Meer persoonsgegevens — kentekens (de 14 RDW-sidecodes; alleen
Middelin een voertuigcontext, andersRuis) en rijbewijs-, V- en BIG-nummers (uitsluitend nabij hun eigen term — een kaal nummer is per definitie ruis). - Diepere PDF-metadata — naast de Info-velden nu ook XMP (aanwezig in ~3 van de 4 PDF's; levert e-mailadressen en telefoonnummers op die nergens anders staan), annotaties (opmerkingen incl. de naam van de beoordelaar), ingevulde formuliervelden en een melding bij in de PDF ingebedde bijlagen.
- Minder vals-positieven (ernst-niveau "Ruis") — de elfproef alleen is te grof: ~9%
van alle 9-cijfernummers (factuur-, grootboek-, project-, BTW-, telefoonnummers)
slaagt toevallig. Daarom: een BSN is alleen nog
Kritiekals er een BSN-term (burgerservicenummer/sofinummer) vlakbij staat; precies 9 aaneengesloten cijfers vereist (spaties lieten een postbus+paginanummer+postcode samensmelten tot een "BSN"); reeksen in een GUID/BTW-nummer/bedrag/adres-/financiële context → nieuweRuis-bak; een kaal maar geldig nummer →Laag. Een datum is alleenHoogbij expliciete geboortecontext, andersRuis. Op de echte corpus (114k bevindingen): Kritiek-BSN van 2838 → vrijwel 0 (geen enkele gepubliceerde BSN was gelabeld), 48k datum-ruis uit de reguliere lijst — niets weggegooid, alles filterbaar. - Robuustheid bij grote PDF's — een tijdbudget per PDF (standaard 45 s) voorkomt dat één enorme, gescande bundel de hele analyse minutenlang laat vastlopen; afgekapte documenten krijgen een zichtbare melding "Extractie afgekapt" voor handmatige controle. Niet-fatale MuPDF-waarschuwingen (kapotte numerieke tokens) worden onderdrukt.
- Overig — extra tests voor de nieuwe modules;
config.ymlen.claude/toegevoegd aan.gitignore.
De detectielogica, validators (elfproef, mod-97), de crawl- en SRU-route en de rapportopzet zijn ongewijzigd overgenomen van het origineel.
Wat de scanner zoekt
Persoonsgegevens
Elke detector is los aan/uit te zetten onder detectors: in je config. De ernst hangt af
van de context: een patroon zonder bevestigende context is bijna altijd een
vals-positief en zakt naar Ruis (zie Het rapport lezen).
Elke regel is los te lezen. Impact en zekerheid zijn twee verschillende dingen: impact hangt aan de soort (een BSN is altijd ernstig, ook als wíj twijfelen), zekerheid aan de validatie en de context. Een soort die meerdere keren voorkomt heeft per situatie een eigen regel: dezelfde vondst krijgt een andere zekerheid zodra de context verandert.
| Soort | Patroon | Validatie / context-eis | Impact | Zekerheid |
|---|---|---|---|---|
| BSN | 9 aaneengesloten cijfers | elfproef geldig én een BSN-term (burgerservicenummer, sofinummer) vlakbij |
Kritiek | Hoog |
| BSN | 9 aaneengesloten cijfers | elfproef geldig, maar géén BSN-term en geen verdachte context — onbevestigd | Kritiek | Middel |
| BSN | 9 aaneengesloten cijfers | elfproef geldig, maar in een GUID, BTW-nummer, bedrag, adres- of financiële context | Kritiek | Laag |
| Creditcard | 13–19 cijfers | Luhn-controle geldig | Kritiek | Hoog |
| IBAN | NL-IBAN | mod-97-controle geldig | Hoog | Hoog |
| IBAN | NL-IBAN | mod-97 geldig, maar ingebed in een langere token (bv. een URL) | Hoog | Laag |
| Geboortedatum | datum dd-mm-jjjj |
expliciete geboortecontext vlakbij (geboren, geboortedatum, leeftijd) |
Hoog | Hoog |
| Geboortedatum | datum dd-mm-jjjj |
géén geboortecontext — vrijwel altijd een proces-/administratieve datum | Hoog | Laag |
| NAW | postcode + huisnummer | het adres wijkt af van het onderwerp-adres van het document | Middel | Middel |
| NAW | postcode + huisnummer | het is het onderwerp-adres van het besluit, of een postbus van een organisatie | Laag | Hoog |
| e-mailadres | het domein staat niet in eigen_domeinen (dus van buiten) |
Middel | Hoog | |
| e-mailadres | werkadres op een van je eigen domeinen | Laag | Hoog | |
| Telefoon (mobiel) | 06-nummer | vulnummers (00000000) en IBAN-cijfers vallen af |
Middel | Hoog |
| Telefoon (vast) | 0xx-nummer | idem; het patroon is ruim, dus kan ook een administratienummer zijn | Laag | Middel |
| IP-adres | IPv4 | publiek adres (privé-/loopback-ranges vallen af) | Middel | Middel |
| IP-adres | IPv4 | staat bij versie/release — dan is het een versienummer |
Middel | Laag |
| Kenteken | de 14 RDW-sidecodes (geen klinkers, geen C/Q) | voertuigcontext vlakbij (kenteken, voertuig, vrachtwagen, parkeer, rdw…) |
Middel | Hoog |
| Kenteken | de 14 RDW-sidecodes (geen klinkers, geen C/Q) | géén voertuigcontext — vaak een kenmerk of datum | Middel | Laag |
| Paspoort-/rijbewijsnr. | documentnummer-patroon | alleen vlak bij een ID-term (paspoort, rijbewijs, identiteits…, documentnummer, id-kaart) |
Kritiek | Middel |
| V-nummer | 9–10 cijfers | alleen vlak bij v-nummer of vreemdelingennummer |
Kritiek | Hoog |
| Rijbewijsnummer | 10 cijfers | alleen vlak bij de term rijbewijs |
Hoog | Hoog |
| BIG-nummer | 11 cijfers | alleen vlak bij de term BIG |
Middel | Hoog |
Eigen gegevens worden automatisch gedempt. Staat een waarde in véél documenten van je corpus (drempel schaalt mee met de impact: een vast nummer vanaf 10 documenten, een BSN pas vanaf 50), dan is het je eigen briefhoofd, rekeningnummer of centrale nummer — geen burgerlek. De impact zakt naar
Laagmét uitleg; de regel blijft zichtbaar. Dat werkt zonder config en vangt ook derden (VNG, omgevingsdienst). Alleen bij een corpus van ≥200 documenten; daaronder zegt frequentie niets.
| Naam | persoonsnaam in de tekst | alleen na een aanhef (de heer, mevrouw, t.a.v.) of als voorletters + achternaam (J.W. Keuken, E. van der Neut) | Middel | Hoog |
| Naam in metadata | een naam in het author-/last_modified_by-veld van het document (hoort leeg te zijn) | Middel | Hoog |
| Creditcard | 13–19 cijfers | alleen vlak bij een kaartterm (creditcard, visa…), Luhn geldig | Kritiek | Hoog |
Signalen die geen persoonsgegeven zijn, maar wél op verstopte gegevens wijzen:
| Signaal | Waarom | Ernst |
|---|---|---|
| Bulklijst | meer dan bulklijst_drempel (standaard 50) treffers van één soort in één document — vermoedelijk een adres-/objectlijst; beoordeel het document zelf i.p.v. de losse regels |
wisselt |
| Verborgen tabblad | verborgen Excel-tabblad — klassieke vindplaats van brongegevens | Middel |
| PDF-bijlage | bestand ingebed in de PDF; valt buiten de tekstlaag | Middel |
| Extractie afgekapt | PDF te groot/traag; niet elke pagina is gescand — handmatig nakijken | Middel |
Waar hij kijkt
Niet alleen in de zichtbare tekst — juist de hoeken waar mensen niet kijken:
| Vindplaats | DOCX | XLSX/XLSM | PPTX | |
|---|---|---|---|---|
| Tekstlaag (per pagina/tabblad/dia) | ✅ | ✅ | ✅ | ✅ |
| Documentmetadata (auteur, "laatst gewijzigd door", titel…) | ✅ | ✅ | ✅ | ✅ |
| XMP-metadata (in ~3 van de 4 PDF's; bevat vaak sporen die in de gewone velden ontbreken) | ✅ | — | — | — |
| Annotaties (opmerkingen + naam van de beoordelaar) | ✅ | — | — | — |
| Ingevulde formuliervelden | ✅ | — | — | — |
| Tabellen | — | ✅ | ✅ | — |
| Verborgen tabbladen | — | — | ✅ | — |
| Sprekersnotities | — | — | — | ✅ |
Wat hij niet leest: tekst in afbeeldingen (gescande PDF's zonder tekstlaag). Die worden wel geteld als OCR-kandidaat — zie Wat het niet doet.
Handleiding
1. Installeren
Je hebt Python 3.10 of nieuwer nodig.
git clone https://github.com/security-commons-nl/publicatiescan.git
cd publicatiescan
python -m venv .venv
.venv\Scripts\activate # Windows
# source .venv/bin/activate # macOS / Linux
pip install -r requirements.txt
2. Config invullen
copy config.example.yaml config.yaml # Windows
# cp config.example.yaml config.yaml # macOS / Linux
Open config.yaml. Vier dingen moet je zelf invullen; de rest kun je laten staan.
eigen_domeinen — je eigen e-maildomeinen.
eigen_domeinen:
- jouwgemeente.nl
Dit is belangrijker dan het lijkt. Een e-mailadres van een medewerker op een werkadres is geen datalek; een privé-adres van een inwoner in een ingekomen brief wél. De scanner gebruikt dit veld om het verschil te maken — vul je het niet in, dan komt élk e-mailadres als bevinding terug en verdrinkt het echte signaal in je eigen organisatie-adressen.
gemeenten — je organisatienaam zoals die in de officiële bekendmakingen staat.
gemeenten:
- Jouwgemeente
Dat is het veld dt.creator in de landelijke bekendmakingen-API, en dat is voor
gemeenten vrijwel altijd gewoon de gemeentenaam, zonder "gemeente" ervoor. Werk je
voor een samenwerkingsverband, zet ze dan allemaal in de lijst — dan scan je in één
run voor alle deelnemers.
output_dir — waar de downloads en het rapport landen.
output_dir: "D:/scan-output"
Zet dit buiten je OneDrive, SharePoint of Dropbox. Het kunnen duizenden bestanden worden, en ze bevatten per definitie mogelijk persoonsgegevens. Die wil je niet laten synchroniseren naar een gedeelde map.
politeness.user_agent — je organisatie en een echt contactadres.
user_agent: "Gemeente Jouwgemeente publicatiescan (interne AVG-controle; contact: informatiebeveiliging@jouwgemeente.nl)"
Een beheerder die dit verkeer in zijn logs ziet, moet binnen een minuut kunnen zien wie je bent en je kunnen bellen. Dat is niet alleen netjes, het voorkomt ook dat je eigen SOC een incident opent op jouw scan.
3. Draaien — begin met de bekendmakingen
Er zijn twee manieren om aan documenten te komen, en je begint met de makkelijke.
De API-route (--sru). Alle officiële bekendmakingen van alle Nederlandse
overheden — Gemeenteblad, Staatscourant, Waterschapsblad — staan op
officielebekendmakingen.nl en zijn volledig op te halen via de landelijke KOOP
SRU-API. Dat is een echte API met directe PDF-links: geen crawlen, geen HTML parsen,
geen belasting van je eigen webserver. Overheid.nl ontraadt scrapen van dat kanaal
expliciet en wijst deze API aan als de norm. Voor de meeste gemeenten is dit meteen
het grootste kanaal in aantallen documenten — vergunning-kennisgevingen met adressen
in de titel zitten hier allemaal in.
Begin met een korte proefrun:
python avg_scan.py --config config.yaml --sru --sru-max 50
Dat haalt de 50 meest recente bekendmakingen op, scant ze, en schrijft een rapport. Duurt een paar minuten. Werkt dat, dan de volle run:
python avg_scan.py --config config.yaml --sru --sru-max 5000
Wil je alleen recent materiaal, gebruik dan een datumgrens:
python avg_scan.py --config config.yaml --sru --sru-since 2026-01-01
De Notubiz-route (--notubiz). Draait je raadsinformatiesysteem op Notubiz
(<organisatie>.notubiz.nl of .raadsinformatie.nl), dan is er óók een API — en die
wil je, want het portaal zelf is een JavaScript-applicatie met een robots.txt die
alles verbiedt: crawlen levert niets op. De publieke Notubiz-API geeft de directe
document-URL's. Zet het organisatie-id in de config (notubiz_organisaties; te vinden
in de portal-broncode als organisation_id, bv. 1810 voor Neder-Betuwe):
python avg_scan.py --config config.yaml --notubiz --notubiz-since 2026-01-01 # proefrun
python avg_scan.py --config config.yaml --notubiz # volle historie
De harvest haalt twee bronnen op, want ze overlappen maar deels:
- Vergaderdocumenten — alles wat aan een agendapunt hangt (incl. besluitenlijsten).
- Documentmodules — de registers Ingekomen stukken, Moties, Toezeggingen, Schriftelijke vragen enz. Een groot deel daarvan hangt níét aan een vergadering en zou anders volledig gemist worden — bij Neder-Betuwe alleen al ruim 6.000 ingekomen stukken, precies het hoogste-risicokanaal.
Reken dus op véél documenten (duizenden). Beperk desnoods het tijdvenster van de
vergaderingen met --notubiz-since/--notubiz-until (dat venster geldt niet voor de
modules), kies specifieke modules met --notubiz-modules, of scan alleen de modules
met --notubiz-no-meetings.
De Open Raadsinformatie-route (--ori). Draait je raad op iBabs, GO.
(Gemeenteoplossingen) of Parlaeus? Dan haal je de stukken via Open Raadsinformatie:
het landelijke, door VNG/GGU gefinancierde project (uitvoering: Open State Foundation) dat
álle vier de Nederlandse raadsinformatiesystemen in één API ontsluit — samen 329 gemeenten
en ruim 5 miljoen documenten:
| Systeem | Documenten | Gemeenten | Route |
|---|---|---|---|
| iBabs | 2,4 mln | 165 | --ori |
| Notubiz | 2,2 mln | 127 | --notubiz |
| GO. / Gemeenteoplossingen | 0,5 mln | 34 | --ori |
| Parlaeus | 0,1 mln | 3 | --ori |
python avg_scan.py --ori-list # welke gemeenten bestaan er?
python avg_scan.py --config config.yml --ori --ori-since 2026-01-01 # proefrun
python avg_scan.py --config config.yml --ori # volle historie
Zet je gemeenteslug in ori_gemeenten (bv. diemen). De downloads gaan rechtstreeks naar
de bron (bv. iBabs), niet door de resolver van Open Raadsinformatie — dat scheelt hún
infrastructuur duizenden downloads en levert ons het originele bestand mét zijn eigen
metadata.
Zit je gemeente op Notubiz? Gebruik dan --notubiz. Die route is vollediger: ORI haalt
de vergaderstukken op maar mist de documentmodules (ingekomen stukken e.d.) — voor
Neder-Betuwe 14.459 documenten tegenover 17.242 unieke via de directe Notubiz-API.
Een iBabs-gemeente die niet in ORI staat? Gebruik --ibabs. Sommige iBabs-gemeenten
zijn (nog) niet door Open Raadsinformatie geïndexeerd — bijvoorbeeld de DUO+ gemeenten
(Diemen, Uithoorn, Ouder-Amstel). Voor die scant --ibabs het publieksportaal rechtstreeks:
python avg_scan.py --config config.yml --ibabs --ibabs-site Diemen,Uithoorn,OuderAmstel
Zet de sitename(s) in ibabs_sites (staat in de portal-URL <site>.bestuurlijkeinformatie.nl).
Vertrouwelijke stukken geven een 403 en worden overgeslagen — dat hoort zo.
De crawl-route. Voor kanalen zonder API — een ander Woo-portaal, je eigen website.
Vul seeds in config.yaml en draai zonder een modus-vlag:
python avg_scan.py --config config.yaml --max-pages 200 # proefrun
python avg_scan.py --config config.yaml # volle run
Let op: het raadsinformatiesysteem is empirisch het grootste risico, niet de
bekendmakingen. Ingekomen brieven van inwoners en hun bijlagen zijn precies waar het
in de praktijk misgaat. Draait dat op Notubiz, gebruik dan --notubiz hierboven; elk
ander RIS-product (iBabs, Parlaeus, Qualigraf, GO.) werkt net anders, en sommige zijn
JavaScript-applicaties waar de crawler niet doorheen komt. Loop dat kanaal dus niet
alleen machinaal na.
Overige vlaggen:
| Vlag | Doet |
|---|---|
--sru |
Ingest via de bekendmakingen-API in plaats van crawlen |
--sru-creators Naam1,Naam2 |
Overschrijft gemeenten uit de config |
--sru-max N |
Maximaal N records per organisatie (standaard 150) |
--sru-since JJJJ-MM-DD |
Alleen bekendmakingen vanaf deze datum |
--notubiz |
Ingest via de Notubiz-API (raads-/commissiedocumenten) i.p.v. crawlen |
--notubiz-org 1810,... |
Overschrijft notubiz_organisaties uit de config |
--notubiz-since / --notubiz-until JJJJ-MM-DD |
Tijdvenster op vergaderdatum (niet op modules) |
--notubiz-modules 1,4,6 |
Beperk tot specifieke documentmodules (standaard: alle) |
--notubiz-no-meetings |
Sla vergaderingen over; scan alléén de documentmodules |
--ori |
Ingest via Open Raadsinformatie (iBabs/GO./Parlaeus) |
--ori-list |
Toont alle gemeenten die via Open Raadsinformatie beschikbaar zijn |
--ori-gemeente diemen,... |
Overschrijft ori_gemeenten uit de config |
--ori-since / --ori-until JJJJ-MM-DD |
Tijdvenster op besprekingsdatum |
--ibabs |
Ingest via het iBabs-publieksportaal (iBabs-gemeenten buiten ORI) |
--ibabs-site Diemen,... |
Overschrijft ibabs_sites uit de config |
--ibabs-since / --ibabs-until JJJJ-MM-DD |
Tijdvenster op vergaderdatum |
--redetect |
Draait de detectie opnieuw over reeds gedownloade bestanden |
--max-pages N |
Crawl-limiet, handig voor een proefrun |
--report-only |
Bouwt alleen het rapport opnieuw uit de bestaande status |
--geen-maskering |
Toont de ruwe waarden in het rapport i.p.v. gemaskeerd |
--output-dir MAP |
Aparte set downloads/status/rapport (overschrijft output_dir) |
--cleanup-downloads |
Verwijdert ná een geslaagde run de gedownloade documenten (status + rapport blijven) |
Onderbreken met Ctrl-C mag: de status staat in een SQLite-database in je output-map, en de volgende run pakt de wachtrij weer op waar hij gebleven was. Identieke bestanden worden op sha256 gededupliceerd, dus dezelfde bijlage op vijf plekken wordt één keer gescand.
4. Het rapport lezen
Je krijgt rapport.html (printbaar) en rapport.xlsx in je output-map, gesorteerd op
ernst. Standaard zijn alle gevonden waarden gemaskeerd — ook de andere persoonsgegevens
die toevallig in hetzelfde tekstfragment stonden. Het rapport is dan zelf geen datalek.
Ongemaskeerd rapport. Wil je de ruwe waarden zien om een hit direct te kunnen
beoordelen, draai dan met --geen-maskering (of zet masker_waarden: false in je config).
Het rapport toont dan de leesbare BSN's, IBAN's, e-mailadressen en adressen — en is daarmee
zelf een datalek-gevoelig document: bewaar het buiten je clouddrive, deel het niet, en
ruim het na afhandeling op. De database bewaart de ruwe waarden sowieso, dus je kunt met
--report-only --geen-maskering een bestaande scan alsnog ongemaskeerd uitdraaien.
| Ernst | Wat |
|---|---|
| Kritiek | Als dit klopt, is het meteen raak: BSN, creditcard, V-nummer, ID-nummer. |
| Hoog | Ernstig: IBAN, een expliciet benoemde geboortedatum, rijbewijsnummer. |
| Middel | Persoonsgegeven met beperkter risico: vreemd e-maildomein, mobiel nummer, afwijkend adres, kenteken, IP-adres — plus de verstopte-gegevens-signalen. |
| Laag | Laag risico: je eigen werkadres, een algemeen vast nummer, het onderwerp-adres van een besluit — en alles wat als gegeven van je eigen organisatie is herkend. |
En de tweede as, zekerheid — hoe zeker weten we dát het klopt:
| Zekerheid | Wat |
|---|---|
| Hoog | Harde validatie (elfproef, mod-97, Luhn) of het gegeven staat bij zijn eigen term. |
| Middel | Plausibel, maar onbevestigd: een elfproef-geldig nummer zonder BSN-context, een postcode+huisnummer. |
| Laag | Vrijwel zeker een vals-positief: een 9-cijferreeks in een GUID/BTW-nummer/bedrag, een datum zonder geboortecontext, een kenteken zonder voertuig. Niets weggegooid — deze staan in een apart rapport-ruis.html. |
Twee datumkolommen helpen bij het prioriteren — een recent lek weegt zwaarder dan een stuk uit 2013, en ze zeggen elk iets anders:
| Kolom | Waar vandaan | Wat het betekent |
|---|---|---|
| Publicatie | de bron-API (Notubiz, ORI, SRU) | wanneer het stuk openbaar werd |
| Documentdatum | het bestand zélf (PDF-/Office-metadata) | wanneer het document is gemaakt |
Ze lopen vaak uiteen (publicatie 2017-04-18 bij een documentdatum 2013-01-09) — een oud
stuk kan jaren later alsnog gepubliceerd zijn. Staat er —, dan gaf de bron of het bestand
geen datum. De datums horen bij het voorbeelddocument van die regel.
De kolom Producer toont waarmee de PDF is gemaakt. Staat daar niet een tool uit
anonimiseer_producers (standaard datamask), dan is het document níét door een
anonimiseringstool gehaald en krijgt het een ⚠-markering: een kandidaat om dat alsnog te
doen. In de praktijk staat daar meestal Word, Ghostscript of een scanner — zelden een
anonimiseerder.
Twee kolommen benoemen het brondocument, zodat een bevinding vindbaar is en informatiebeheer weet welk stuk vervangen moet worden:
| Kolom | Waar vandaan | Waarvoor |
|---|---|---|
| Brontitel | de bron-API (ORI file_name, Notubiz-documenttitel, iBabs-vergaderingtitel) |
het stuk terugvinden in het raadsinformatiesysteem — de bron-URL is vaak een ondoorzichtige GUID |
| PDF-titel | het title-veld in de PDF/Office-metadata zelf |
voer voor informatiebeheer: staat er een sjabloonwaarde ("BESTUURSDOCUMENT") of juist een bestandsnaam met persoonsgegevens |
Ze staan er los naast elkaar (geen samenvoeging): de bron-API-titel is de betrouwbaarste
naam om een stuk mee terug te vinden, terwijl de PDF-titel maar in ~40% van de PDF's is
ingevuld en vaak een sjabloon is — juist dát verschil is voor informatiebeheer een
verbeterpunt. Bestaande scans vullen beide kolommen bij een volgende scan of --redetect
(de PDF-titel) respectievelijk een nieuwe ingest-run (de brontitel).
Het rapport sorteert op impact, dan zekerheid: bovenaan staat wat ernstig én zeker is. Welk gegeven welke waarden krijgt, en waaróm, staat per detector in Wat de scanner zoekt.
Je hebt een hit. Wat nu?
Het rapport ordent en maskeert. Het oordeelt niet. Elke bevinding vraagt menselijke beoordeling, en de meeste zijn geen lek.
Begin bij Kritiek. Een BSN in een gepubliceerd document is vrijwel nooit legitiem. De elfproef geeft weinig vals-positieven, maar hij is niet waterdicht: een zaaknummer van negen cijfers kan hem toevallig doorstaan. Open het document en kijk.
Let op de adres-triage. De scanner scheidt het onderwerp-adres van een afwijkend adres. Een verleende omgevingsvergunning hóórt het bouwadres te noemen — dat is geen lek, en die hits worden naar Laag gezet. Maar staat er een postcode in het document die niet het onderwerp-adres is, dan blijft die op Middel staan met de opmerking "adres niet op pagina 1 — mogelijk niet het onderwerp-adres". Dat is je signaal. Dat is het adres van de bezwaarmaker, de buurman, de briefschrijver. Kijk daar het eerst.
Bij een echt lek: niet stil verwijderen. Dit is de fout die het vaakst gemaakt wordt. Raadsstukken, Woo-besluiten en bekendmakingen kennen een publicatieplicht — je mag ze niet zomaar offline halen. De juiste handeling is het document vervangen door een correct geanonimiseerde versie.
Leg de AVG-afweging bij je FG. Of dit een datalek is dat gemeld moet worden bij de Autoriteit Persoonsgegevens, en of betrokkenen geïnformeerd moeten worden, is niet aan de scanner en niet aan de scanner-bediener. Dat is het werk van je Functionaris Gegevensbescherming of privacy-officer. Lever de bevinding, lever het document, laat de afweging daar.
Deel je bevindingen niet in een issue. Zie CONTRIBUTING.md.
Vals-positieven onderdrukken
Sommige waarden komen bij elke scan terug en zijn géén lek: het eigen BTW-nummer, een
algemeen zaaknummer dat op een telefoonnummer lijkt, het KvK-nummer op elk briefpapier.
Die kun je onderdrukken in een false_positives.csv naast je rapport (Excel-vriendelijk,
puntkomma of komma). De scanner maakt het bestand met alleen de kopregel aan zodra je een
rapport draait:
waarde;url;reden
810162593;;eigen BTW-nummer
0344 123456;https://api1.ibabs.eu/...;algemeen nummer van de balie
- waarde — de exacte waarde die je wilt negeren (verplicht).
- url — leeg → negeer die waarde in álle documenten; ingevuld → alléén in dát document (kopieer de Voorbeeldbron-URL uit het rapport).
- reden — vrije notitie voor je collega (optioneel).
Vul het in Excel in, of laat de scanner een regel toevoegen:
python avg_scan.py --config config.yml --mark-fp 810162593 --mark-fp-reden "eigen BTW"
python avg_scan.py --config config.yml --mark-fp "0344 123456" --mark-fp-url "https://…"
Draai daarna --report-only om het rapport bij te werken. De bevindingen blijven in de
database staan — ze worden alleen niet meer getoond, en het rapport meldt hoeveel er zijn
onderdrukt. Een markering terugdraaien is simpelweg de regel weghalen; geen nieuwe scan nodig.
Wat het niet doet
Gescande PDF's zonder tekstlaag. Een ingescande brief is voor deze tool een plaatje. Ze worden geteld en gemarkeerd als OCR-kandidaat, maar niet gelezen. Juist daar zit vaak het risico — een gescande handtekening onder een formulier. OCR staat op de lijst voor fase 2.
Documenten die niet online staan. Vergunningdossiers met situatietekeningen en ondertekende formulieren staan bij veel gemeenten niet in een portaal maar worden op verzoek gemaild. Die krijg je hiermee niet te pakken; dat moet je organisatorisch borgen.
Een zwarte balk over een leesbare tekstlaag wordt wél gevonden — de tekst zit er immers nog. Dat is een van de drie klassiekers, samen met verborgen Excel-tabbladen (worden gemeld) en persoonsgegevens in documentmetadata (worden gescand).
Structuur
avg_scan.py CLI + orkestratie (ingest → analyse → rapport)
config.example.yaml voorbeeldconfig
avgscan/
config.py config laden/normaliseren
sru.py ingest via de KOOP SRU-API (bekendmakingen)
crawl.py beleefde crawler (robots.txt, rate limit, domeinfilter)
fetch.py download + sha256 (dedup, groottelimiet)
extract.py tekstlaag + metadata per bestandstype
detect.py detectors + validators (elfproef, mod-97) + maskering
report.py HTML- en Excel-rapport
state.py SQLite-status (hervatten + dedup + findings)
Licentie
EUPL-1.2 — Europese open-sourcelicentie, dezelfde als de rest van security-commons-nl.