diff --git a/BEHEER.md b/BEHEER.md
index 15b33ca..e1516c3 100644
--- a/BEHEER.md
+++ b/BEHEER.md
@@ -337,7 +337,31 @@ Pullen zelf werkt anoniem zolang de organisatie public is — zie
### PostgreSQL versie-upgrade
-Bij een upgrade van PostgreSQL (bijv. 16 → 18) is de bestaande data niet compatibel. Stappen:
+Twee soorten, met een belangrijk verschil. Kijk eerst welke je voor je hebt:
+
+```bash
+docker exec vibefinance-postgres postgres --version
+```
+
+**Patch binnen dezelfde major** (18.4 → 18.6) — de data-directory blijft
+geldig, dus je hoeft niets te dumpen. Image ophalen en de container opnieuw
+laten maken; het volume blijft staan:
+
+```bash
+docker compose pull postgres
+docker compose up -d postgres # let op: géén -v
+```
+
+> Gebruik hier nooit `docker compose down -v`. Dat wist het volume en dan is je
+> database echt weg, terwijl deze upgrade dat helemaal niet vraagt.
+
+De tag `postgres:18-alpine` beweegt mee met de patches, dus `pull` is genoeg —
+je hoeft niets in `docker-compose.yml` aan te passen. Loont de moeite: tussen de
+image van 18.4 en die van 18.6 zat een verschil van 143 naar 45 bevindingen in
+de kwetsbaarhedenscan, vooral in de Alpine-pakketten eronder.
+
+**Major-upgrade** (bijv. 16 → 18) — dán is de bestaande data niet compatibel en
+moet je er wel doorheen met een dump:
```bash
# 1. Backup maken
diff --git a/Changelog.md b/Changelog.md
index ba241f9..caa0dcc 100644
--- a/Changelog.md
+++ b/Changelog.md
@@ -2,6 +2,83 @@
## [Niet vrijgegeven]
+### Beveiliging
+- **PostgreSQL 18.4 → 18.6.** Het `postgres:18-alpine` image dat in gebruik was
+ dateerde van vier maanden geleden. Dat is een patch binnen dezelfde major, dus
+ de data-directory blijft geldig en er komt geen dump/restore aan te pas — het
+ volume blijft gewoon staan. De kwetsbaarhedenscan op het image gaat van 143
+ naar 45 bevindingen, het grootste deel in de Alpine-pakketten eronder. Wat
+ overblijft (1 kritiek, 21 hoog) zit allemaal in Go's `stdlib` v1.24.6 in een
+ meegecompileerde binary; dat kan alleen upstream oplossen
+- `BEHEER.md` beschreef alleen de major-upgrade, met een `docker compose down
+ -v` erin. Wie dat bij een patch-upgrade zou volgen, wist zijn database zonder
+ dat het nodig is. De twee gevallen staan nu apart beschreven
+- **De imagescan staat op nul.** De scan van 1.0.10 gaf 39 bevindingen
+ (1 kritiek, 12 hoog, 15 middel, 11 laag). Geen enkele kwam uit deze
+ applicatie: twintig zaten in `libssl3` en `libcrypto3` (Alpine in het
+ node-image liep achter op 3.5.8-r0) en negentien in de dependencies die npm
+ zelf meelevert — `tar`, `pacote`, `sigstore`, `brace-expansion`, `picomatch`,
+ `ip-address`. De eigen `ip-address` van de app stond al op 10.7.0 en was dus
+ nooit het probleem
+- Het productie-image doet nu een `apk upgrade` aan het eind van de build. Het
+ node-image wordt minder vaak herbouwd dan Alpine patches uitbrengt, dus
+ OpenSSL loopt er structureel op achter
+- npm, npx, corepack en yarn gaan uit het productie-image. De container start
+ met `node src/server.js` en gebruikt nooit een pakketbeheerder; ze zaten er
+ alleen in omdat het base-image ze meelevert. Weg met de tool is weg met die
+ hele categorie, ook bij de volgende ronde — npm's dependencies zijn een
+ terugkerende bron van meldingen
+- Nieuwe teststap **[4/5] Imagescan** met Trivy, die het gebouwde image scant en
+ faalt op CRITICAL of HIGH waarvoor een fix bestaat (`--ignore-unfixed`, want
+ een CVE zonder patch hoort je release niet te blokkeren). `-Strict` trekt de
+ lat naar MEDIUM en LOW, `-SkipScan` slaat hem over. Stap 1 kijkt naar de
+ `package.json`'s en had geen van deze 39 gezien; deze kijkt naar wat er
+ werkelijk in het image zit. `release.ps1` draait dezelfde scan vóór het
+ pushen, dus een image met openstaande CVE's komt de registry niet meer in
+
+### Toegevoegd
+- **Export naar HTML.** Onder Instellingen staat naast backup en restore nu
+ "Exporteren naar HTML": één zelfdragend bestand met al je gegevens, bedoeld
+ als laatste vangnet. Draait de app niet meer, is de database weg en ligt er
+ geen backup, dan is dit bestand nog steeds te openen — in elke browser, op een
+ computer zonder internet. Er zit niets van buiten in: opmaak staat inline,
+ geen lettertypen, geen afbeeldingen, geen scripts
+- De pagina toont een samenvatting met de KPI's, en daaronder eigen vermogen en
+ schulden per categorie met subtotalen en een eindtotaal, de voortgangstabel
+ met verschil per moment, en de historie van EV en schuld. Bedragen staan met
+ twee decimalen — anders dan het scherm, want dit is een archiefstuk waar de
+ exacte waarde in hoort — rechts uitgelijnd met cijfers van gelijke breedte
+- **Het bestand is tegelijk een backup.** Onderin staat de complete ruwe data in
+ een `` in de gegevens, sloopt de pagina
+ niet en breekt de ingebakken backup niet open
+- Nieuwe `utils/htmlExport.js`, met `leesHtmlBackup` voor de weg terug. Die
+ gebruikt `DOMParser` en geen reguliere expressie: de gegevens kunnen van alles
+ bevatten, en een parser die HTML echt begrijpt trapt daar niet in. Geen nieuwe
+ dependency
+- De voortgangstabel is Datum plus één kolom per categorie plus vier vaste
+ kolommen — bij zes categorieën twaalf stuks, en die passen op geen enkel
+ scherm. De tabellen scrollen daarom horizontaal in plaats van kolommen buiten
+ beeld te laten vallen. De datumkolom blijft daarbij staan, want zonder die
+ kolom weet je niet meer naar welk moment je kijkt, en een schaduwrandje
+ verschijnt aan de kant waar nog inhoud zit. Dat laatste is een CSS-truc met
+ meeschuivende achtergronden: de pagina mag geen scripts bevatten, maar zonder
+ hint ziet niemand dat er rechts meer staat. Op print vervalt het allemaal en
+ wordt de tabel kleiner gezet, zodat alle kolommen op papier alsnog meekomen
+- De **initiële schuld** stond wel in de gegevens maar niet op de pagina. Hij
+ hoort niet bij een schuldblok, dus zonder eigen regel viel hij buiten de
+ export. Staat er nu onder de schuldentabel, met "afgelost sinds start" erbij
+- Stortingen op een **verwijderde categorie** blijven in oude voortgangsrijen
+ staan, maar de app toont alleen kolommen van bestaande categorieën — die
+ bedragen waren dus nergens meer te zien. De export geeft ze alsnog een kolom,
+ gemerkt als `Verwijderd (c9)`. Voor een bestand dat alles moet bewaren wat je
+ ooit hebt ingevoerd is stil weglaten het verkeerde gedrag
+
+## [1.0.10] - 2026-09-17
+
### Opgelost
- Het laatste label op de x-as viel over zijn buurman heen (`mei '26` en `26`
over elkaar). De stap werd vanaf het begin van de reeks geteld en het laatste
diff --git a/Dockerfile b/Dockerfile
index 028bfac..7011025 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -60,6 +60,29 @@ ARG APP_VERSION=dev
ENV NODE_ENV=production
ENV APP_VERSION=$APP_VERSION
+# ── Aanvalsoppervlak beperken ─────────────────────────────────────────────────
+# Twee stappen die samen elke CVE uit de imagescan wegnemen. Ze staan hier en
+# niet in een andere stage: alleen dit image gaat naar de registry.
+#
+# 1. OS-pakketten bijwerken. Het node-image wordt minder vaak herbouwd dan
+# Alpine patches uitbrengt, dus libssl3 en libcrypto3 lopen er structureel
+# op achter — goed voor twintig CVE's in de laatste scan. Dit haalt ze op
+# het moment van bouwen alsnog bij.
+#
+# 2. De pakketbeheerders eruit. De container start met `node src/server.js` en
+# gebruikt npm, npx, corepack of yarn nooit; ze zitten er alleen in omdat
+# het base-image ze meelevert. Alle negentien Node-CVE's uit de scan zaten
+# in npm's eigen meegeleverde dependencies (tar, pacote, sigstore,
+# brace-expansion, picomatch, ip-address) en hadden niets met deze
+# applicatie te maken. Weg met de tool is weg met die hele categorie —
+# inclusief de volgende ronde, want ze komen steeds terug.
+RUN apk upgrade --no-cache \
+ && rm -rf /usr/local/lib/node_modules/npm \
+ /usr/local/lib/node_modules/corepack \
+ /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack \
+ /usr/local/bin/yarn /usr/local/bin/yarnpkg \
+ /opt/yarn-v*
+
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
diff --git a/README.md b/README.md
index 3c62ffe..4d4445e 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@ Eigen vermogen · Schulden · Voortgang · Gebruikersbeheer



-
+

@@ -117,11 +117,12 @@ uitzonderingen nodig. Handige acties:
### Testen
```powershell
-.\test.ps1 # dependency-check + backend-tests + build + smoketest
+.\test.ps1 # deps + backend-tests + build + imagescan + smoketest
.\test.ps1 -Unit # dependency-check + backend-tests
-.\test.ps1 -NoSmoke # zonder de stack te starten
-.\test.ps1 -Strict # faal als een dependency achterloopt
+.\test.ps1 -NoSmoke # zonder de stack te starten (imagescan draait wél)
+.\test.ps1 -Strict # faal op achterlopende deps; scan ook op MEDIUM/LOW
.\test.ps1 -SkipDeps # dependency-check overslaan (bijv. offline)
+.\test.ps1 -SkipScan # imagescan overslaan (bijv. offline)
```
Alles loopt in Docker — Node op de host is niet nodig. De backend-tests draaien
@@ -141,6 +142,26 @@ backend/package.json (15 dependencies)
alles actueel
```
+Stap 4 scant het gebouwde image met [Trivy](https://trivy.dev) en blokkeert bij
+CRITICAL of HIGH waarvoor een fix bestaat. Dat is iets anders dan stap 1: die
+kijkt naar de `package.json`'s, deze naar wat er daadwerkelijk in het image zit
+— de Alpine-pakketten en alles wat het base-image meelevert. Dat onderscheid is
+niet theoretisch. Bij de scan van 1.0.10 kwamen alle 39 bevindingen daarvandaan
+en geen enkele uit de applicatie zelf:
+
+| Bron | Aantal | Waar het vandaan kwam |
+|---|---|---|
+| `libssl3` + `libcrypto3` | 20 | Alpine in het node-image liep achter op 3.5.8-r0 |
+| npm's eigen dependencies | 19 | `tar`, `pacote`, `sigstore`, `brace-expansion`, `picomatch`, `ip-address` |
+
+Een dependency-check had er nul van gezien. Het productie-image doet daarom aan
+het eind van de build een `apk upgrade` en gooit npm, npx, corepack en yarn
+eruit: de container start met `node src/server.js` en gebruikt nooit een
+pakketbeheerder. Dat bracht de scan op nul, en houdt hem daar — npm's
+dependencies zijn een terugkerende bron die nu simpelweg niet meer in het image
+zit. `release.ps1` draait dezelfde scan vóór het pushen, dus een image met
+openstaande CVE's komt de registry niet in.
+
### Productie release
```powershell
@@ -171,7 +192,7 @@ build — anders zou de release met een placeholder als notes online komen.
| 📉 **Schuld** | Schulden bijhouden per blok met afbetalingsoverzicht |
| 📈 **Voortgang Schuld & EV** | Grafieken en trends van EV en schuld over tijd (apart bij te houden) |
| 👥 **Gebruikers** | Accounts aanmaken, bewerken en beheren (Admin-rol vereist) |
-| ⚙️ **Instellingen** | Kleurthema, licht/donker, backup downloaden en backup terugzetten |
+| ⚙️ **Instellingen** | Kleurthema, licht/donker, backup downloaden, backup terugzetten en export naar HTML |
De grafiek "Ontwikkeling van Eigen Vermogen" op het dashboard tekent twee lijnen
op één as: je netto waarde per voortgangsmoment, en daaronder de cumulatief
@@ -209,6 +230,28 @@ downloadt een ZIP (`data.json` + `meta.json`) met je eigen financiële data,
"Backup terugzetten" leest zo'n ZIP weer in en schrijft hem via `/api/data` weg.
Dat is de gebruikersvariant; een volledige database-dump staat in `BEHEER.md`.
+Daarnaast staat er **"Exporteren naar HTML"**: één zelfdragend bestand met al je
+gegevens, bedoeld als laatste vangnet. Draait de app niet meer, is de database
+weg en ligt er geen backup, dan opent dit bestand nog steeds — in elke browser,
+op een computer zonder internet. Er zit niets van buiten in: de opmaak staat
+inline, er worden geen lettertypen, afbeeldingen of scripts geladen. Je ziet een
+samenvatting met de KPI's, eigen vermogen en schulden per categorie met
+subtotalen, de initiële schuld met wat er sinds de start is afgelost, de
+voortgangstabel en de historie.
+
+Alles wat je ooit hebt ingevoerd komt mee, ook wat de app zelf niet meer toont:
+stortingen op een inmiddels verwijderde categorie krijgen een eigen kolom
+(`Verwijderd (c9)`). De voortgangstabel is bij zes categorieën twaalf kolommen
+breed en scrollt horizontaal in plaats van kolommen af te kappen; de datumkolom
+blijft daarbij staan. Op print vervalt de scrollbalk en wordt de tabel kleiner
+gezet, zodat alle kolommen op papier alsnog passen.
+
+Het bestand is tegelijk een backup: onderin staat de complete ruwe data in een
+`` in de gegevens het blok voortijdig
+ // af en is zowel de pagina als de backup stuk.
+ const jsonBlok = ruweData
+ ? `\n\n`
+ : "";
+
+ return `
+
+