From e9192a8aba0dd13adcd368d3da45706763bd8a96 Mon Sep 17 00:00:00 2001 From: Jordy Hoogeveen Date: Fri, 18 Sep 2026 13:38:22 +0200 Subject: [PATCH] cve fixes --- BEHEER.md | 26 +- Changelog.md | 77 ++++ Dockerfile | 23 ++ README.md | 57 ++- frontend/package.json | 2 +- frontend/src/components/InstellingenModal.jsx | 19 +- frontend/src/context/AppContext.jsx | 55 ++- frontend/src/utils/htmlExport.js | 354 ++++++++++++++++++ frontend/src/utils/index.js | 1 + package.json | 2 +- test.ps1 | 59 ++- 11 files changed, 645 insertions(+), 30 deletions(-) create mode 100644 frontend/src/utils/htmlExport.js 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 ![Node.js](https://img.shields.io/badge/Node.js_22-339933?style=flat&logo=node.js&logoColor=white) ![PostgreSQL](https://img.shields.io/badge/PostgreSQL_18-4169E1?style=flat&logo=postgresql&logoColor=white) ![Docker](https://img.shields.io/badge/Docker_Compose-2496ED?style=flat&logo=docker&logoColor=white) -![Version](https://img.shields.io/badge/versie-1.0.9-8b5cf6?style=flat) +![Version](https://img.shields.io/badge/versie-1.0.10-8b5cf6?style=flat) ![Built with Claude](https://img.shields.io/badge/Gebouwd_met-Claude.ai-8b5cf6?style=flat&logo=anthropic&logoColor=white) VibeFinance dashboard @@ -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 ` + + + + +VibeFinance — overzicht ${esc(exportDatum)} + + + +
+
+

VibeFinance

+

Volledig overzicht · geëxporteerd op ${esc(exportDatum)}${gebruiker ? ` · ${esc(gebruiker)}` : ""}

+
+ +

Samenvatting

+
+
${kpis.map(([l, v]) => `
${esc(l)}${esc(v)}
`).join("")}
+
+ +${sectie("Eigen vermogen", evRijen.length ? maakTabel(["Categorie", "Post", "Waarde"], evRijen, [2]) : "", "Geen vermogensposten ingevoerd.")} + +${sectie("Schulden", schuldRijen.length ? maakTabel(["Blok", "Onderdeel", "Waarde"], schuldRijen, [2]) : "", "Geen schulden ingevoerd.")} + +${sectie("Voortgang eigen vermogen", voortgangRijen.length + ? maakTabel( + [ + "Datum", + ...cats.map((c) => c.naam), + // Een verwijderde categorie heeft geen naam meer; de sleutel is dan + // het enige wat er nog van over is. + ...wezen.map((k) => `Verwijderd (${k})`), + "Totaal storting", "Netto waarde", "Verschil", "Opmerkingen", + ], + voortgangRijen, + // Alle bedragkolommen: vanaf kolom 1 tot en met "Verschil". + Array.from({ length: kolomIds.length + 3 }, (_, i) => i + 1), + ) + : "", "Geen voortgangsmomenten vastgelegd.")} + +${sectie("Historie eigen vermogen en schuld", historieRijen.length + ? maakTabel(["Datum", "Eigen vermogen", "Schuld", "Netto waarde", "Opmerkingen"], historieRijen, [1, 2, 3]) + : "", "Geen historie vastgelegd.")} + + +
${jsonBlok} +`; +} + +/** + * Haalt de ingebakken backup uit een geëxporteerde pagina terug. + * + * Bewust met DOMParser en niet met een reguliere expressie: de gegevens kunnen + * zelf van alles bevatten, en een parser die HTML écht begrijpt trapt daar niet + * in. De pagina wordt niet uitgevoerd — DOMParser draait geen scripts, en het + * blok is `type="application/json"`, dus ook in een browser geen code. + * + * @returns {object|null} de data, of null als het geen export-pagina is + */ +export function leesHtmlBackup(htmlTekst) { + const doc = new DOMParser().parseFromString(htmlTekst, "text/html"); + const blok = doc.getElementById("vibefinance-backup"); + if (!blok) return null; + return JSON.parse(blok.textContent); +} diff --git a/frontend/src/utils/index.js b/frontend/src/utils/index.js index 17efd31..8ed4185 100644 --- a/frontend/src/utils/index.js +++ b/frontend/src/utils/index.js @@ -1,3 +1,4 @@ export * from "./format.js"; export * from "./storage.js"; export * from "./grafiek.js"; +export * from "./htmlExport.js"; diff --git a/package.json b/package.json index c285d24..63e1177 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vibefinance", - "version": "1.0.10", + "version": "1.0.11", "description": "VibeFinance — persoonlijk vermogensbeheer", "private": true, "repository": "https://vibehoogie.duckdns.org/vibe/VibeFinance", diff --git a/test.ps1 b/test.ps1 index bd7fbca..39776ac 100644 --- a/test.ps1 +++ b/test.ps1 @@ -5,7 +5,8 @@ # 1. Dependency-check (staan package.json's op de laatste versies?) # 2. Backend-tests (Dockerfile target 'test' → node --test, zonder database) # 3. Productiebuild (Vite-build + app-image; faalt de frontend, faalt dit) -# 4. Smoketest (productie-image + postgres, /api/health en / opvragen) +# 4. Imagescan (Trivy op het gebouwde image; OS- én npm-pakketten) +# 5. Smoketest (productie-image + postgres, /api/health en / opvragen) # # De smoketest publiceert geen hostpoorten en gebruikt een eigen netwerk, dus # hij botst niet met een draaiende dev-stack. @@ -14,14 +15,17 @@ # .\test.ps1 # alle stappen # .\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 -Strict # faal als een dependency achterloopt; scan ook op +# # MEDIUM en LOW in plaats van alleen CRITICAL/HIGH # .\test.ps1 -SkipDeps # dependency-check overslaan (bijv. offline) +# .\test.ps1 -SkipScan # imagescan overslaan (bijv. offline) param( [switch]$Unit, [switch]$NoSmoke, [switch]$Strict, - [switch]$SkipDeps + [switch]$SkipDeps, + [switch]$SkipScan ) $ErrorActionPreference = "Stop" @@ -43,7 +47,7 @@ function Write-Step { # Vergelijkt de versies in frontend/ en backend/package.json met het # npm-register. Bewuste uitzonderingen staan in docker/update-exceptions.json. if (-not $SkipDeps) { - Write-Step "[1/4] Dependency-check" + Write-Step "[1/5] Dependency-check" $checkArgs = @("node", "docker/check-updates.mjs") if ($Strict) { $checkArgs += "--strict" } @@ -59,11 +63,11 @@ if (-not $SkipDeps) { Write-Host "Let op: er zijn nieuwere versies beschikbaar (zie hierboven)." -ForegroundColor Yellow } } else { - Write-Step "[1/4] Dependency-check overgeslagen (-SkipDeps)" + Write-Step "[1/5] Dependency-check overgeslagen (-SkipDeps)" } # ── 2. Backend-tests ────────────────────────────────────────────────────── -Write-Step "[2/4] Backend-tests" +Write-Step "[2/5] Backend-tests" docker build --target test -t $testImage $PSScriptRoot if ($LASTEXITCODE -ne 0) { Write-Error "Testimage bouwen mislukt"; exit 1 } @@ -78,7 +82,7 @@ if ($Unit) { } # ── 3. Productiebuild (inclusief Vite-build) ────────────────────────────── -Write-Step "[3/4] Productie-image bouwen" +Write-Step "[3/5] Productie-image bouwen" docker build --target production ` --build-arg APP_VERSION=$appVersion ` --build-arg VITE_APP_VERSION=$appVersion ` @@ -87,13 +91,50 @@ docker build --target production ` if ($LASTEXITCODE -ne 0) { Write-Error "Productiebuild mislukt"; exit 1 } Write-Host "Productie-image gebouwd: $prodImage" -ForegroundColor Green +# ── 4. Imagescan ────────────────────────────────────────────────────────── +# Stap 1 kijkt naar de package.json's; deze kijkt 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 (OpenSSL in Alpine, en npm's eigen dependencies) en +# geen enkele uit deze applicatie. Een dependency-check had er nul van gezien. +# +# --ignore-unfixed: alleen falen op iets waar een fix voor bestaat. Anders +# blokkeert een CVE zonder patch je release zonder dat je er iets aan kunt doen. +if (-not $SkipScan) { + Write-Step "[4/5] Imagescan (Trivy)" + + $trivyCache = Join-Path $env:USERPROFILE ".cache\trivy" + New-Item -ItemType Directory -Force -Path $trivyCache | Out-Null + + # Normaal blokkeren alleen CRITICAL en HIGH; -Strict trekt de lat naar alles. + $ernst = if ($Strict) { "CRITICAL,HIGH,MEDIUM,LOW" } else { "CRITICAL,HIGH" } + + # Uitvoer opvangen en alleen tonen als er iets is: Trivy print bij succes + # een tabelregel voor élk schoon pakket, en dat is honderden regels ruis + # waarin een echte melding juist wegvalt. + $scanUit = docker run --rm ` + -v /var/run/docker.sock:/var/run/docker.sock ` + -v "${trivyCache}:/root/.cache" ` + aquasec/trivy:latest image ` + --scanners vuln --severity $ernst --ignore-unfixed --exit-code 1 --quiet ` + $prodImage 2>&1 + if ($LASTEXITCODE -ne 0) { + $scanUit | ForEach-Object { Write-Host $_ } + Write-Error "Imagescan vond op te lossen kwetsbaarheden ($ernst)" + exit 1 + } + Write-Host "Imagescan schoon ($ernst)" -ForegroundColor Green +} else { + Write-Step "[4/5] Imagescan overgeslagen (-SkipScan)" +} + if ($NoSmoke) { Write-Host "" Write-Host "Smoketest overgeslagen (-NoSmoke) — klaar." -ForegroundColor Green exit 0 } -# ── 4. Smoketest op het productie-image + postgres ──────────────────────── -Write-Step "[4/4] Smoketest (app + postgres)" +# ── 5. Smoketest op het productie-image + postgres ──────────────────────── +Write-Step "[5/5] Smoketest (app + postgres)" function Remove-SmokeStack { docker rm -f $smokeApp $smokeDb 2>$null | Out-Null -- 2.47.3