publicatiescan

Vind persoonsgegevens die per ongeluk in je eigen publicaties staan.

Status: in gebruik. Draait echt en heeft groene tests.

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.

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.

Voor wie

CISO's, privacy officers en communicatieteams bij publieke organisaties.

Snel starten

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.

De scanner haalt per bekendmaking twee dingen op: de kennisgevingstekst zelf én de externe bijlagen - het onderliggende besluit, de brief aan de aanvrager. Dat onderscheid is belangrijker dan het lijkt. De bijlage is het document waar de persoonsgegevens in staan (naam en woonadres van de aanvrager), maar hij is in de API vrijwel onzichtbaar: geen eigen record, geen directe link, alleen een metadataveld aan het moederrecord. In een steekproef had ruim een derde van de gemeentelijke bekendmakingen zo'n bijlage.

Draaide je een eerdere versie van deze scanner? Versies van vóór 22-07-2026 scanden alleen de kennisgevingsteksten en sloegen de externe bijlagen stil over. Een schone uitkomst uit zo'n run zegt dus niets over de bijlagen - draai het kanaal opnieuw.

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 crawl-route. Voor kanalen zonder API - je Woo-portaal, je eigen website. Vul seeds in config.yaml en draai zonder --sru:

python avg_scan.py --config config.yaml --max-pages 200      # proefrun
python avg_scan.py --config config.yaml                      # volle run

Meerdere bronnen tegelijk (bronnen:)

Naast de losse --sru- en crawl-route kun je in config.yaml een lijst bronnen zetten. Elke bron heeft een type dat naar een connector wijst, plus zijn eigen parameters. Staat er een bronnen:-lijst, dan draait één run ze allemaal achter elkaar en schrijft één gecombineerd rapport. Zo scan je bekendmakingen, raadsinformatie en je website in één keer.

bronnen:
  - { naam: bekendmakingen, type: sru, gemeenten: [Jouwgemeente], vanaf: "2014-01-01" }
  - { naam: raad, type: openraadsinformatie, gemeenten: [Jouwgemeente] }

De beschikbare types:

type wat status
sru officiële bekendmakingen (KOOP-API) werkend
crawl HTML-crawl van je eigen website/portalen werkend
openraadsinformatie raadsinformatie via de landelijke Elasticsearch-API; de tekst is daar al geëxtraheerd, dus geen download en snel. Dezelfde bron die de VNG voor haar tweede lijst gebruikt. Dekking en actualiteit verschillen per gemeente werkend
parlaeus / qualigraf raadsinformatie Qualigraf/Parlaeus (zelfde platform, twee domeinen). Enumereert de publieke modules (ingekomen stukken, moties, verordeningen, ...) en haalt de bijlagen op. Met van/tot scan je de volledige historie. robots.txt = Disallow: /, dus draaien mag pas ná crawl-akkoord + SOC/leverancier informeren werkend
mijnpublicaties terinzageleggingen op mijnpublicaties.nl (TerInzageLeggingPortaal), 23 gemeenten aangesloten waaronder Amsterdam, Haarlem, Breda, Zwolle en Zoetermeer. Geen sleutel, geen login, geen robots.txt. Zet organisatie_naam (exacte naam op de hoofdpagina) of organisatie (GUID uit de portaal-URL) werkend
notubiz raadsinformatie Notubiz nog niet af - faalt luid (bouwplan)
ibabs raadsinformatie iBabs via de Public WCF Service (SOAP, geen api_key): vereist een sitename, IP-whitelisting bij iBabs en een burger-account met view-rechten. Gebouwd tegen het gedocumenteerde contract, verifieer op je eigen site met ibabs_diagnose.py werkend

Over mijnpublicaties twee dingen die je moet weten voordat je de uitkomst leest. Het portaal toont alleen wat op dit moment ter inzage ligt, geen archief: een lege of kleine uitkomst betekent "weinig lopende terinzageleggingen", niet "schoon verleden". En veel organisaties publiceren er enkel een samenvatting; de onderliggende dossierstukken gaan op verzoek en zijn dus niet te scannen. Voor historie is sru de aangewezen bron. De kracht van deze bron is juist preventief: draai hem periodiek en je ziet een probleem tijdens de inzagetermijn, niet jaren later via een datalekmelding.

Let op: de risico's zitten in de bijlagen, niet in de hoofddocumenten. In het raadsinformatiesysteem zijn dat ingekomen brieven van inwoners met hun bijlagen; bij de bekendmakingen zijn het de externe bijlagen met het onderliggende besluit. In beide gevallen is het hoofddocument (agenda, kennisgeving) vrijwel altijd schoon en zit het persoonsgegeven één laag dieper. Elk RIS-product werkt anders, en sommige zijn JavaScript-apps waar de crawler niet doorheen komt - daarvoor zijn deze connectors. Een connector die niet kan draaien faalt luid en wordt als 'niet uitgevoerd' gemeld; een lege uitkomst betekent hier dus nooit vanzelf 'schoon'.

Bouwen aan Notubiz

Notubiz is de enige connector die nog niet af is. Gebruikt jouw gemeente Notubiz, dan is dit je startpunt - het patroon staat in avgscan/bronnen.py: een connector is een generator die Document-objecten yieldt (elk met een url om te downloaden, óf directe text/ chunks), en die luid faalt (raise BronNietGereed(...)) zodra hij niet kan draaien. Kijk naar _parlaeus als werkend voorbeeld: eerst de lijst-endpoint enumereren, dan per item de bijlagen ophalen. Een PR is welkom.

Notubiz - het dichtst bij af. Het downloaden van één document is al geverifieerd (GET https://api.notubiz.nl/document/<id>/1 → application/pdf). Wat nog ontbreekt is de enumeratie van alle document-id's per gemeente:

  1. Zoek de organisatie-id van je gemeente op (api.notubiz.nl/organisations).
  2. Loop de events/agenda's af (/events?organisation_id=<id>, gepagineerd op datum) en verzamel per event de gekoppelde document-id's uit de agendapunten en bijlagen.
  3. yield Document(bron=naam, url="https://api.notubiz.nl/document/<id>/1", ext="pdf").
  4. Voeg een van/tot-datumvenster toe, net als bij _parlaeus, voor de volledige historie.

Sneller alternatief: Open Raadsinformatie ingest Notubiz al. Probeer eerst het type openraadsinformatie - dan hoef je deze connector misschien niet te bouwen.

iBabs is inmiddels werkend via de Public WCF Service (SOAP, wcf.ibabs.eu), dankzij een externe bijdrage - je hoeft het niet meer te bouwen. Anders dan het commerciële api.ibabs.eu heeft die service geen API-sleutel nodig, wel een sitename, IP-whitelisting bij iBabs en een burger-account met view-rechten. Gebruik het type ibabs in je config (installeer eerst pip install zeep) en draai ibabs_diagnose.py om te zien welke agendatypes je account mag zien.

Ook hier geldt: een module/endpoint die niets teruggeeft mag je stil overslaan ('niet aanwezig'), maar een connector die zijn werk niet kán doen moet luid falen - nooit stil een lege lijst, want dat leest als 'schoon'.

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
--max-pages N Crawl-limiet, handig voor een proefrun
--report-only Bouwt alleen het rapport opnieuw uit de bestaande status

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. Alle gevonden waarden zijn gemaskeerd - ook de andere persoonsgegevens die toevallig in hetzelfde tekstfragment stonden. Het rapport is dus zelf geen datalek.

Bij een bekendmakingen-scan (--sru of een sru-bron) krijgt elke bevinding automatisch drie extra kolommen: Gemeente, Onderwerp (de titel van de bekendmaking) en Vergunning (het afgeleide type, bijvoorbeeld Omgevingsvergunning). Zo kun je een bevinding meteen naar het juiste team routeren en per gemeente filteren. De HTML toont alleen Kritiek en Hoog als rijen (de vermoedelijke lekken); Middel en Laag staan samengevat, met de volledige lijst in het Excel-bestand.

Ernst Wat
Kritiek BSN (elfproef geldig) · paspoort-/rijbewijsnummer nabij een ID-term
Hoog IBAN (mod-97 geldig) · geboortedatum die expliciet zo benoemd wordt · persoonsnaam met een woonadres er direct bij
Middel E-mail op een vreemd domein · mobiel nummer · afwijkend adres · persoonsnaam in een aanhef · verborgen Excel-tabblad
Laag E-mail op je eigen domein · vast nummer · het onderwerp-adres van een besluit

Over die naam-adres-combinatie: dit is precies hoe een publicatielek er in de praktijk uitziet. Een besluitbrief hoort niet gepubliceerd te worden mét de aanhef en het woonadres van de aanvrager erin - maar het adres alléén is als onderwerp-adres van het besluit juist verwacht (ernst Laag), en een naam alléén kan ook een wethouder zijn (ernst Middel). Pas de combinatie maakt het een vrijwel zeker lek, en die krijgt dus Hoog.

Bijdragen

Zie de CONTRIBUTING van de organisatie: daar staat per project een formulier, ook zonder Git-ervaring.

Licentie

EUPL-1.2, zie LICENSE.

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.

Wat het niet doet

Gescande PDF's zonder tekstlaag. Een ingescande brief is voor deze tool standaard een plaatje. Ze worden geteld en gemarkeerd als OCR-kandidaat. Wil je ze wél lezen, zet dan OCR aan (ocr.enabled: true in de config, of de vlag --ocr). Dat gebeurt on-prem via rapidocr - geen externe aanroep, dus de persoonsgegevens blijven op je eigen machine. Installeer daarvoor de extra dependency:

pip install rapidocr-onnxruntime

Let op: OCR is traag (seconden per pagina) en niet bit-identiek reproduceerbaar zoals de gewone tekstlaag. Bevindingen uit OCR krijgen de locatie "pagina N (OCR)", zodat de triage ziet dat zo'n hit een extra kritische blik verdient. Juist hier zit vaak het risico: een gescand, ondertekend formulier met een BSN erop.

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
  bronnen.py           connector-registry (sru/crawl/openraadsinformatie/notubiz/parlaeus/
                       ibabs/mijnpublicaties)
  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)

Gedeelde PII-fixture

De detectiekern van publicatiescan (avgscan/detect.py) overlapt met die van anonimizer-local en anonimizer-browser: BSN met elfproef, IBAN, e-mail, postcode en telefoon. Om de drie implementaties aantoonbaar gelijk te houden draait in elke repo dezelfde fixture met testgevallen:

Repo Rol Bestand
anonimizer-local canoniek (bron van waarheid) tests/fixtures/pii-patronen.json
anonimizer-browser kopie + hash src/lib/fixtures/pii-patronen.json + .sha256
publicatiescan kopie + hash tests/fixtures/pii-patronen.json + .sha256

tests/test_fixture_gedeeld.py haalt elk geval door scan_text en controleert daarnaast dat de lokale kopie byte-identiek is aan de vastgelegde sha256. Categorieen die publicatiescan niet kent (KVK, FG-nummer, IPv4) staan in NIET_ONDERSTEUND en worden overgeslagen. Gevallen waarin publicatiescan bewust anders oordeelt (bijvoorbeeld: een kale postcode zonder huisnummer is hier geen NAW-gegeven) staan in BEKENDE_AFWIJKINGEN met de reden; de fixture zelf wordt niet versoepeld.

Een geval toevoegen of wijzigen:

  1. Bewerk de canonieke fixture in anonimizer-local/tests/fixtures/pii-patronen.json en draai daar pytest tests/.
  2. Kopieer het bestand ongewijzigd naar publicatiescan/tests/fixtures/ en anonimizer-browser/src/lib/fixtures/.
  3. Werk in beide kopie-repo's de hash bij (vanuit de map met de kopie): sha256sum pii-patronen.json > pii-patronen.sha256.
  4. Draai de suites in alle drie de repo's. Haalt een implementatie de norm niet, leg dat dan vast in BEKENDE_AFWIJKINGEN van die repo.

Gebruik in de fixture uitsluitend fictieve gegevens: testnummers die de elfproef of mod-97 halen maar niet zijn uitgegeven, voorbeelddomeinen en verzonnen plaatsen.