Deze repository is een fork van het origineel https://github.com/security-commons-nl/publicatiescan
Find a file
Martijn de Vrieze 1f2f8bbaf9 Rapport: twee losse titelkolommen (bron-API-titel + PDF-metadata-titel)
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>
2026-07-23 09:39:11 +02:00
avgscan Rapport: twee losse titelkolommen (bron-API-titel + PDF-metadata-titel) 2026-07-23 09:39:11 +02:00
tests Rapport: twee losse titelkolommen (bron-API-titel + PDF-metadata-titel) 2026-07-23 09:39:11 +02:00
.gitignore False-positives: onderdruk waarden via false_positives.csv (Excel-vriendelijk) 2026-07-23 09:24:51 +02:00
avg_scan.py Rapport: twee losse titelkolommen (bron-API-titel + PDF-metadata-titel) 2026-07-23 09:39:11 +02:00
config.example.yaml iBabs-ingest (--ibabs): publieksportaal scrapen voor gemeenten buiten ORI 2026-07-19 21:41:47 +02:00
CONTRIBUTING.md Eerste publicatie: publicatiescan 2026-07-13 14:03:51 +02:00
LICENSE Eerste publicatie: publicatiescan 2026-07-13 14:03:51 +02:00
NOTICE.md Attributie: NOTICE-bestand met herkomst en licentie 2026-07-16 09:18:53 +02:00
README.md Rapport: twee losse titelkolommen (bron-API-titel + PDF-metadata-titel) 2026-07-23 09:39:11 +02:00
requirements.txt Eerste publicatie: publicatiescan 2026-07-13 14:03:51 +02:00
run_historisch.py Historische scan: tijdvenster-slicing, retry op 429, kenmerk-filter, Excel-fix 2026-07-14 08:45:59 +02:00

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 met notubiz_organisaties in de config en de vlaggen --notubiz-org, --notubiz-since / --notubiz-until, --notubiz-modules en --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) — overschrijft output_dir uit 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 Middel in een voertuigcontext, anders Ruis) 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 Kritiek als 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 → nieuwe Ruis-bak; een kaal maar geldig nummer → Laag. Een datum is alleen Hoog bij expliciete geboortecontext, anders Ruis. 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.yml en .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-mail e-mailadres het domein staat niet in eigen_domeinen (dus van buiten) Middel Hoog
E-mail 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 Laag mé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 PDF 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:

  1. Vergaderdocumenten — alles wat aan een agendapunt hangt (incl. besluitenlijsten).
  2. 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.