Changelog
Every release, newest first. Rendered from CHANGELOG.md, the file GitHub and the release tooling read.
2.1.1 — 2026-10-03
Changed
- K3 is the logo in the app and on the site: a favicon set (ICO with 16 and 32 px, an SVG that switches ink with the colour scheme, an apple-touch icon) renders from one geometry.
- Each theme declares its own
color-scheme, so Chrome's Auto Dark Mode no longer recolours the light theme. - Every colour comes from
DESIGN.md: the app's dark surfaces, the default brand's emerald (now the same on site and app), code text and the footer ink. - The site's stylesheets are consolidated into seven, on one token sheet, with one callout and one table component.
- The site's fonts are subset to the characters it draws; the preloads are the weights the first view uses.
DESIGN.mdis rewritten: K3, diagrams,color-scheme, and which rules hold where.- Website and maintainer docs: every diagram is a draw.io SVG in one style, light and dark, checked for labels on lines and clipped text; the German blog post shows German labels; the home page shows the reporting flow as one diagram; on a phone a diagram keeps its size and scrolls inside its figure. Mermaid is gone.
- The site's corners follow
DESIGN.md's radius scale, as the app's do; they were 2 and 4 px. - The website is built from its sources by
scripts/build_site.pyand now lives at/en/and/de/; the old addresses forward. The app's links (installation counting in the four locales, HelmNOTES.txt) point to/en/docs/.
Fixed
- No site page is wider than a 360 px phone; the German home stacks its tables like the English one.
- Dark-mode code blocks drew a box around every line.
- Contrast: footer text, code and terminal comments and labels, the Required pill and the posts' dark call-to-action button now reach AA.
- The two code samples on the docs page get the code-block frame and label.
- With forced colours (Windows High Contrast), a blog post's code block has an edge.
- A blog step's title starts level with its numeral, and the numeral reads (it was 1.3:1).
- The sidebar of the docs, roadmap and changelog marks the section whose heading tops the view, not the one before it.
- The language menu no longer wraps its names.
2.1.0 — 2026-09-27
Upgrade notes
- Migration 009 adds
must_change_passwordto every account,falsefor existing ones: nothing records who set their password. - Migration 010 recomputes every feedback deadline by §17 Abs. 2 HinSchG: three calendar months from the acknowledgement or, without one, three months and seven days from receipt. Unacknowledged reports get a deadline for the first time, and their reminders start.
- Migration 011 gives every account an organisation, the default one where it had none.
- Migration 012 records who made each account (
created_by_id), from the audit log. - An unedited
docker-compose.ymlor.env.exampleinstall no longer starts. Compose now reads.env, and no example key passes the 32-character check. SetSECRET_KEYandENCRYPTION_KEY. - Images are published only after CI, E2E and the security scans pass on the tagged commit, and only for a tag on
main. A release tag withoutDOCKERHUB_TOKENnow fails. latest,XandX.Ymove only to the highest stable release in their line: never to a pre-release, never back to an old tag being re-published.
Added
- Every admin changes their own password, and must when someone else set it. My account in the sidebar opens
/admin/accountfor every role: username, role, organisation, login methods. A password change (POST /admin/account/password) needs the current password, the new one twice and a current TOTP code, counts wrong guesses towards the sign-in lockout, ends every other session of the account and is audited (auth.password_changed). An account whose password an admin chose on/admin/users, a superadmin reset or the host's reset script set must change it before any other admin page opens; a new or reset account enrols its authenticator first. LDAP and SSO accounts see where to change theirs. InDEMO_MODEthe demo accounts keep theirs. - Admins link their own single sign-on identity. Signed in with password and TOTP, choose Link single sign-on on
/admin/account(POST /admin/oidc/link); Unlink single sign-on removes it. The link request is bound to that session and to the purpose "link" in Redis, so a login state never links and a link state never signs in. An identity linked to another account is refused. Both are audited (auth.sso_linked,auth.sso_unlinked). - A lost authenticator can be reset. A superadmin clicks Reset authenticator on
/admin/users(POST /admin/users/{id}/reset-totp): the account becomes new again. The old app and the old password stop working, the user's sessions end, and the superadmin sees a random temporary password once, to hand over; with it, the next login enrols a new app at/admin/mfa/setup. The password is in no log and no audit entry. LDAP and SSO accounts keep their first factor. Not for one's own account, and not for the demo accounts inDEMO_MODE. On the host,scripts/reset_admin_password.py --reset-totp <username>prints a new secret andotpauth://URI once, for any account including the last superadmin. Both are audited (admin.totp_reset).
Documentation
- The blog is in English and German. Every article has its twin in the other language, linked both ways by
hreflangand a language switch; the English index is/blog/en.html, and the English pages link their Blog item there. The German URLs are unchanged.test_every_blog_page_exists_in_english_and_germanholds it for new articles. - New article: removing metadata without altering the evidence photo, in English and German: what the bug bounty measured, and why no test noticed.
- A changelog page on the website, rendered from this file (
docs/changelog.html, checked bytests/test_changelog_page.py). - Diagrams and screenshots in the documentation, in the reader's theme: architecture, submission flow, case lifecycle and login as Mermaid sources rendered to SVG, and the main pages as screenshots re-taken by
scripts/take_screenshots.py. - The user documentation is rewritten to measured prose limits (no sentence over 30 words, average under 18) and corrected where it contradicted the code: OIDC logins also need TOTP, a correct PIN always opens its report, the app sets the security headers, case numbers are random.
CONTRIBUTING.mdcarries the documentation rules; guard tests hold them.- The installation example now puts the Redis password into
REDIS_URL. The production Redis runs with--requirepass "${REDIS_PASSWORD}", so the documentedredis://redis:6379/0could not connect..env.examplenow pinsOPENWHISTLE_VERSION2.0.1;docs.htmlno longer names the default, which had gone stale at 2.0.0. The retention section says what HinSchG §11 Abs. 5 says: deletion three years after the procedure ends, not a three-year minimum. - Both landing pages are rewritten for "open source whistleblower software" and "kostenloses Hinweisgebersystem". Claims the code or the statute contradict are gone: "100 % HinSchG-konform", rate limits "never IP-based", "OIDC or TOTP", and competitor prices without a source.
- A comparison page,
open-source-whistleblowing-software.html: OpenWhistle, GlobaLeaks, SecureDrop and Hush Line on licence, hosting, deadlines, Tor, languages and audits, each sourced. - A new German article,
blog/interne-meldestelle-kostenlos.html: what a free system still costs. The German comparison article keeps only vendor-published facts, each sourced and dated; the three older articles carried 2025 as publication date, they were published 2026-04-27. - The German pages are re-targeted to the queries Search Console shows. "Hinweisgebersystem Software", "Hinweisgebersystem Anbieter (Vergleich)" and "HinSchG Software" had about 1,300 impressions and no click in three months, at positions 15 to 29.
de/index.htmlnow leads with "Hinweisgebersystem Software", the comparison article with "Hinweisgebersystem Anbieter Vergleich" and seven selection criteria. A new article,blog/hinweisgebersystem-dsgvo-eu-hosting.html, answers the EU-hosting, encryption and GDPR questions (positions 9 to 10) in its first sentence. Both landing FAQs add the question-style queries. - Every page has a search head: title of at most 60 characters, unique description, canonical, hreflang, Open Graph, JSON-LD.
docs/sitemap.xmlis rendered from the heads byscripts/render_sitemap.py; missing paths get404.html.tests/test_seo.pyholds all of it. - The share image is real.
og-image.pngwas a blank navy rectangle, so every shared link showed an empty card. The touch icon is now 180×180. - HinSchG citations are corrected and checked by
tests/test_hinschg_citations.py: the pages cited a third Absatz of § 17 and a seventh of § 16, which do not exist; deletion is § 11 Abs. 5, not § 26; a missing reporting office costs up to 20,000 € (§ 40 Abs. 2 Nr. 2, Abs. 6), not 50,000 €.
Security
- Three code-scanning alerts closed before this release. Draft and status-session ids were checked with
^…$andre.match, and$also matches before a trailing newline; every such check isfullmatchnow (#107). The language switch and draft-cookie alerts were shown not exploitable by tests and dismissed with that evidence. - Every request path and query string reached the container's stdout, the setup token and OIDC codes included.
--no-access-logempties uvicorn's access handlers; importing the app gave them back. Measured with the Dockerfile's own command:"GET /setup?token=… HTTP/1.1" 200. - One TOTP code opened two sessions. pyotp compares after Unicode normalisation, so
575203(fullwidth) matched a used575203while the single-use key held the raw string. Codes are six ASCII digits; one code authenticates one action, sign-in after enrolment included. - The setup wizard stored any TOTP secret the hidden field sent:
AAAAbecame the first superadmin's second factor,!!!!was a 500. - A plain admin could re-enable a superadmin another superadmin had disabled; in
DEMO_MODEany visitor could deactivate or demote a demo account and lock out everyone after them. - Four eyes were two accounts. An admin could make a second admin, sign in with the password they had just chosen, and confirm their own deletion request. An account and the accounts it made, directly or through others, no longer confirm each other.
- A confirmed deletion left no trace.
report.delete_confirmedhad labels in every locale and was never written, and the report's own entries go with it. It is written now, with case number, requester and confirmer. - Office files kept the Windows account name in the saved-folder path (
absPath), template and link paths (C:\Users\<name>\…),fileSharing, SharePoint columns, revision-session ids and document variables. PDF XMP on pages, images and fonts (dc:creator) and editorPieceInfosurvived; a PDF with embedded files is now refused. A JPEG's motion-photo video or JFIF thumbnail rode along after the image. - A session refreshed during a password change survived it: the refresh stored its new token after the sweep had passed.
- The TLS private key was 0644 until its chmod; it is 0600 from the first byte.
- Quay lost releases 1.3.1, 1.4.0 and 1.5.0. The weekly cleanup deleted old
sha-tags by digest, which deletes every tag on that digest; 2.0.0 was three pushes from the same fate. It now deletes only digests no kept tag uses.
Fixed
- Switching LDAP on locked out every local account, the setup wizard's superadmin included. A directory user named like a local account was a 500; an entry without
LDAP_ATTR_USERNAMEprovisionedALICEnext toalice. The lockout countedalice,AliceandALICEseparately and expired from the first wrong password: ten spread over 29 minutes locked for one. - The §17 HinSchG deadlines were computed five ways.
+90 daysis a day late for an acknowledgement on 31 January or 1 February; a case moved to "in review" without acknowledging had no feedback deadline and no reminder; 12 hours before the deadline the dashboard said overdue and the case page 0 days left; the PDF called 7 days 12 hours compliant; the 7-day rate counted a report received today as missed. One module computes them now. - Photos and screenshots are stored as taken. A palette PNG came out black, an animated PNG or WebP kept one frame, a 5.5 MB JPEG grew to 14.4 MB, past the limit. JPEG, PNG and WebP are cleaned by dropping metadata segments, pixels untouched; iPhone MPO photos are accepted.
- One upload could stall the server: a 450 KB PNG cost 1.1 GB of RAM, a 518 KB GIF 26 s of CPU. PNG, JPEG and WebP are no longer decoded, GIF and TIFF are capped at 50 megapixels, and cleaning runs off the event loop. A file over 10 MB after cleaning is refused.
- Multi-tenancy could not give an organisation its own admin: a new account took its creator's organisation. Accounts made before multi-tenancy was switched on, or by LDAP, had none and then saw no case. A superadmin chooses the organisation on
/admin/users. - Text at its
maxlengthwas refused or cut for its line breaks. Browsers send CRLF;maxlengthcounts one (Chromium: 20 characters sent as 24). An admin reply took any length; a long category slug, location code or organisation name, or a large sort order, was a 500. - A mistyped notification address was stored: the form is
novalidate, sotype=emailchecked nothing, and the mail never came. A reply to a closed case was stored from a page left open; it is refused and the whistleblower told. A lower-case case number was refused. - The audit export ignored the page's filters and stopped at 10 000 rows. Taking it, downloading an attachment and unassigning are now recorded; a superadmin's search is recorded in every organisation whose reports it read.
TZmoved the "03:00 UTC" jobs: withTZ=Europe/Berlinretention ran at 01:00 UTC. The retention page named tomorrow for tonight's run between 00:00 and 03:00.- The digest said "1 new message" for three replies on one case; it counts cases and now says so.
- A non-ASCII CSRF token was a 500, and two
ow_csrfcookies refused every form. - The username fields refused
.,@and spaces the server accepts. - Retention left the status sessions of the cases it deleted in Redis.
- Unlinking SSO was allowed when LDAP had been switched off and the link was the only way in.
- The Quick Start ignored
.env:docker-compose.ymlhard-codedSECRET_KEYand set noENCRYPTION_KEY. - The publish job could call a tag it could not read "verified": a failure inside
for x in $(…)does not tripset -e. - The Ansible role could not finish a TLS install or renew its certificate (nginx held port 80 before the certificate existed), and its
.envchanged values containing$. helm upgradewith changed values restarted no pods, reset the HPA's replicas, and the ingress refused uploads over 1 MB./static/was not rate-limited: 80 parallel requests, 80 × 200; now 49 get 429, as on/.- Helm, Ansible and
.env.exampleshipped the old brand colour#0f4c81. - CI's nginx pin could never be updated: Renovate matched none of the three
docker runpins. - A test patched
asyncio.create_taskand left aMagicMockin the notification queue, failing later tests depending on order.
- Admin lists hid their actions behind a sideways scrollbar. Users, categories and locations sat in two thirds of the page, as organisations did in 2.0.1: the role select read "Falll" and the categories' actions were out of view at 1920 px. Every list now runs under its form, across the full width, and table cells are 0.75 rem a side, so the German dashboard's eight columns fit 1,064 px.
- OIDC login could never succeed. Nothing ever wrote an account's OIDC
suband issuer, so every SSO login ended in "no account is linked". Linking now exists (see Added). - A lost authenticator locked its admin out for good. The reset script kept the TOTP secret, and the docs said to edit the database. See Added.
- No session is accepted for an account whose authenticator awaits enrolment. A reset takes effect in the same commit, before its sessions are swept from Redis.
- Whoever set a password for someone else knew it for good. No page let an admin change their own password, so the admin who created an account, or the superadmin who reset it, kept knowing it. See Added.
- The host's password reset left the account's sessions running and the audit log empty.
reset_admin_password.py --usernamenow ends every session of the account and recordsadmin.password_reset, never the password. - Four form fields skipped their format check in Chrome. Browsers compile
patternwith thevflag, where a bare-closing a character class is a syntax error; the new-user, location, organisation and setup forms logged it and checked nothing. Now escaped, held bytests/test_pattern_attributes.py. - The sidebar's Log out sat 12 px left of the links above it, and the second panel of a two-column admin page (users, categories, locations) started 20 px below the first.
Removed
/admin/demo/reset. It reset nothing (the seed only adds what is missing) and no page linked to it.app/schemas. No route used it; it looked like the validation the wizard was missing.
2.0.1 — 2026-09-27
Upgrade notes
- An installation without a superadmin gets one. Migration 008 promotes the earliest active admin, normally the account the setup wizard created, when no superadmin exists yet. An installation that already has a superadmin is left alone. Check
/admin/usersafter upgrading.
Fixed
- No installation had a superadmin, so organisations could not be managed. The setup wizard created the first account as a plain admin, and only a superadmin may grant superadmin:
/admin/organisationsand multi-tenancy were unreachable on every installation. The wizard now creates a superadmin, and migration 008 promotes the first account of an existing installation. - The organisations list cut off its own actions. Six columns, one a full reporting URL, in two thirds of the page: the Deactivate button sat past a sideways scrollbar even at 1920 px. The list now runs under the form, across the full width. Nobody could reach the page before.
- Pages that fit the window scrolled anyway, with the footer below the fold. The submission wizard and the login and MFA screens sized themselves as the viewport minus a guessed nav and footer height (144 or 112 px, against a real 151 px, plus the 36 px demo banner). They now fill the space between nav and footer by flexbox, whatever those measure.
- The wizard's sidebar made the page as tall as its own text. In German it runs to about 1,000 px, so the wizard scrolled on a Full HD window with the footer out of sight. In the two-column layout it now takes the form's height and scrolls inside itself.
- The demo login did not fit a Full HD window. From 1024 px on, the demo credentials sit beside the login form instead of above it.
- The wizard's first step still scrolled on a Full HD window. Its form column was capped at 680 px on a 1920 px screen, so every text wrapped to more lines than needed. It now takes up to 860 px with tighter spacing, and fits 1920 × 890 with the demo banner, in English and German.
- Disabled pagination buttons failed text contrast. At 38 % opacity they fell below 4.5:1; they now use the muted text colour and carry
aria-disabled.
2.0.0 — 2026-09-26
A major version: the webhook payload, the Compose TLS/port layout and the first-run setup flow all break compatibility with 1.5.x — see Changed (breaking) below and the upgrade notes before deploying.
Upgrade notes
- LDAP with a private CA reads
LDAPTLS_CACERT(orLDAPTLS_CACERTDIR).SSL_CERT_FILEis ignored for LDAPS and StartTLS: pointLDAPTLS_CACERTat the CA's PEM file. - LDAP uses python-ldap (the OpenLDAP client) instead of ldap3, which has had no release since 2021.
- LDAP and S3 are optional extras (
ldap,s3). The container image includes both; installing from source needspip install '.[ldap,s3]'andlibldap2-dev libsasl2-dev. - A fresh install needs the setup token.
/setupasks for a one-time token: setSETUP_TOKEN(32+ characters; the app refuses to start with a shorter one), or read the random one the app logs once at WARNING on its first start. AfterMAX_LOGIN_ATTEMPTSwrong tokens,/setuprefuses every token forLOGIN_LOCKOUT_MINUTES. An existing installation is not affected. - Setting
ENCRYPTION_KEYon an existing install needsENCRYPTION_KEY_PREVIOUS. Existing data is underSECRET_KEY: setENCRYPTION_KEY=<new>together withENCRYPTION_KEY_PREVIOUS=<your SECRET_KEY>, recreate the container, then runscripts/rotate_encryption_key.py. The app now refuses to start, before migrating, when neither key opens a stored report key or TOTP secret (also whenENCRYPTION_KEYis removed again). - New-report and reply notices arrive once a day (00:00 UTC) unless
NOTIFICATION_BATCH_MINUTESis set; the default was 60. - Admin sessions end 12 hours after login (
SESSION_MAX_HOURS), however often "Stay signed in" is used; then password and TOTP again. - Upgrading the Compose stack needs
git pull, not onlydocker compose pull: it needs the newnginx/snippets/and thetls-initservice. A customisednginx/nginx.confmakes the pull conflict; replace it with the shipped one. - The Compose stack listens on 443 and only redirects on 80 (see Added). Copy your certificate into
nginx/certs/(fullchain.pem,privkey.pem; no symlinks, key root-owned 0600 or 0644): the old./nginx/certs:/etc/nginx/certsmount is gone. Without one, a self-signed certificate is served. - Behind an external TLS terminator (Cloudflare, Traefik, a host nginx), point it at
https://…:443, or adddocker-compose.behind-proxy.yml, which proxies plain HTTP on 80 and publishes no 443. Aimed at the new port 80, the terminator loops on the redirect. - nginx publishes
127.0.0.1:8080(the onion listener). If the host already uses 8080,docker compose upfails: free the port first. - Back up before upgrading; the rollback changed. The 1.5.0 image refuses the migrated schema. Restore the backup, or run
alembic downgrade 7d4e2b9c1a05with the 2.0.0 image (docker compose run --rm app alembic downgrade 7d4e2b9c1a05) before pinning 1.5.0. The downgrade keeps the day-rounded times of migration 006: the exact times are gone by design. BRAND_SECONDARY_COLORis ignored with a warning at startup; delete it from.env.- Webhooks carry counts only, never case numbers or deadlines; update receivers that parsed the old fields. The SLA reminder is one webhook per scheduler run, not one per case.
- Generic reminder:
case_number,deadline,days_leftandtimestampare gone;ack_due,feedback_dueandmessageare new. - Generic digest:
new_reports/new_messagesare counts, not arrays of case numbers, plusmessage. - Slack: the
fieldsblock is onetextblock. Teams: the reminder'sFactSetis aTextBlock, and the digest has one "Activity" fact instead of case numbers. - The reminder email is unchanged: it still carries the case number, since only your own admins receive it.
- Generic reminder:
docker-compose.prod.ymlpins the image to${OPENWHISTLE_VERSION:-2.0.0}instead of:latest; setOPENWHISTLE_VERSIONto upgrade.- Migrations 004–006 run at start: TOTP secrets are encrypted, audit rows get their organisation, whistleblower times are rounded to the day (not reversible).
Security
- Switching the language needs the CSRF token (
POST /set-language), like every other form: another site can no longer change a visitor's language cookie. - Categories and locations stay inside their organisation. With multi-tenancy on, an org admin saw every organisation's categories and locations and could deactivate or reactivate them by id; now the lists are scoped and another organisation's id answers 404. A slug or code is unique per organisation (a shared slug no longer caused a 500), new ones are created in the admin's own organisation, and deactivating or reactivating a location is audited. Creating a category or location commits in one transaction with its audit row.
- The first-run setup page belonged to whoever opened it first.
/setupnow needs a one-time setup token (SETUP_TOKEN, or a random token logged once at start), deleted when the first admin exists. Wrong tokens are rate-limited (MAX_LOGIN_ATTEMPTSperLOGIN_LOCKOUT_MINUTES, counted per instance), and an operator-set token needs 32 characters. - TOTP secrets are encrypted at rest (migration 004): a database dump no longer yields the second factor of every account.
- Admin sessions have an absolute lifetime (
SESSION_MAX_HOURS, default 12). "Stay signed in" renews the token up to that limit, never past it, and the renewal needs the CSRF header. - A deactivated account gets neither the TOTP step nor a session, on every login path (password, LDAP, OIDC, first TOTP setup).
- An unknown role is refused with 422 when creating a user or changing a role (it used to create an admin); the role picker defaults to case manager.
- Only admins may dismiss the IP-header warning.
- Audit entries carry their organisation (migration 005 backfills existing rows), so an organisation's audit log shows its own entries and no one else's; an admin without an organisation sees only entries they wrote. A superadmin's action on a user, organisation, category or location is filed under the target's organisation, so its admins see it.
- Containers are hardened: read-only root file system, all capabilities dropped,
no-new-privileges, base images pinned by digest, nocurlin the image, uid 1000 to match the Helm chart'ssecurityContext(readOnlyRootFilesystem, no privilege escalation). - LDAP refuses an empty password (a simple bind with an empty password is an anonymous bind and succeeds), escapes the username in the search filter, demands a valid certificate with TLS 1.2 or later, and always closes its connections.
- CI runs
pip-auditand Trivy on every pull request and weekly.
Added
- Per-organisation reporting link (multi-tenancy). Each organisation's wizard is at
/submit/<org-slug>: it offers only that organisation's categories and locations and files the report under it./submitis the default organisation's; an unknown or deactivated slug is a 404, and there is no public list of organisations. With multi-tenancy on andDEFAULT_ORG_SLUGnaming no active organisation,/submitanswers 503 and a set-up instance refuses to start. The link, with a copy button, is on/admin/organisationsand on an organisation admin's dashboard. With multi-tenancy off, nothing changes:/submit/<default slug>redirects to/submit, any other slug is a 404. - Tor onion address (
ONION_LOCATION, optional). Sends anOnion-Locationheader, so Tor Browser offers the switch, and shows the address on the submit page for reporters on a monitored network. nginx setsX-OW-Oniononly on the onion listener and strips any client-sent copy; withoutONION_LOCATIONthe app ignores the header. - Virus scan of uploads with ClamAV (
CLAMAV_HOST,CLAMAV_PORT,CLAMAV_TIMEOUT_SECONDS; optionalclamavcompose profile). Fail-closed: ifclamdcannot be reached, the upload is refused, never stored unscanned. - Remove an attached file before submitting. Attachments now stay attached when going back (see Fixed), so the attachments step has a Remove button for each file.
- TLS by default in the shipped Compose stack. The bundled nginx now serves HTTPS on 443 and redirects plain HTTP on 80 — nothing is proxied without TLS. A one-shot
tls-initservice generates a self-signed certificate forTLS_HOSTNAME(.env, defaultlocalhost) before nginx starts, so the stack comes up without any manual certificate step; drop your ownfullchain.pem/privkey.pemintonginx/certs/and restart to use a real one instead. A certificate there that cannot be read, or is a symlink, stopstls-initwith the path and the reason instead of falling back to self-signed. docker-compose.behind-proxy.ymlfor an install behind an external TLS terminator: nginx proxies plain HTTP on 80 and publishes no 443.- Voluntary installation count (
TELEMETRY_ENABLED, off by default). Only if an admin agrees — in the setup wizard (unchecked by default) or on/admin/system, no restart — one request a day:GET https://telemetry.wdkro.de/v1/openwhistle/count?id=<32 hex>&v=<version>, nothing else. The identifier is 16 random bytes made on the server and kept in the newtelemetry_statetable (migration 007); the System page shows the exact request and the identifier, the last success, a switch (one audit entry per change) and Reset identifier. An installation upgraded to 2.0.0 stays off until an admin switches it on. The first attempt waits a random part of an hour, a Redis lock lets one replica send, a failure is a debug line retried at the next hourly check, redirects are refused, the timeout is 10 s.TELEMETRY_ENABLED=falselocks it off,trueon;DEMO_MODEis never counted. The far end keeps timestamp, identifier and version, not the address, for 35 days. Every request the application can make is now listed in the docs under "Every request that leaves the host". - Helm
extraEnvsets any non-secret setting thatvalues.yamlhas no key for. - Separate, rotatable encryption key (
ENCRYPTION_KEY, optional). At-rest encryption is now rooted inENCRYPTION_KEYinstead ofSECRET_KEY; unset falls back toSECRET_KEY(pre-v2.0.0 behaviour, warned at startup).ENCRYPTION_KEY_PREVIOUSkeeps old keys readable during rotation, andscripts/rotate_encryption_key.pyre-encrypts every DEK, confidential identity and contact, secure e-mail, TOTP secret and identity-reveal reason under the new key. - Maintainer tooling: local review login (
LOCAL_REVIEW_LOGIN, requiresDEMO_MODE=true,SECURE_COOKIES=falseand a loopbackAPP_PUBLIC_URL; set only by thedocker-compose.review.ymloverride, never in a deployment). A one-click button on/admin/loginsigns a browser agent in as the seeded demo admin for the release's Chrome check. The route answers 404 for every method unless the flag is on, the request carries no proxy header and theHostis loopback. Seedocs-tech/local-review.md.
Changed (breaking)
BRAND_SECONDARY_COLORis gone (see Removed); a.envthat still sets it gets a warning.- Existing whistleblower times are rounded to the day by migration 006, irreversibly (see Changed).
.docand.xlsuploads are refused; their author field cannot be removed. The message tells the reporter to save as.docx/.xlsx.
Privacy
- The whistleblower's status session is no longer extended on every view, so its remaining lifetime in Redis does not reveal the last visit; it ends 2 hours after login. Failed-attempt counters are keyed by an HMAC of the case number, not the case number itself.
- Photos inside Word and Excel files lose their EXIF (GPS, camera) on upload, like a photo uploaded on its own. PDFs lose their comment authors and times, the EXIF of embedded JPEG photos, and the file identifier that linked the upload to the original file.
- Database errors no longer log their bound values. Every engine (app, migrations, scripts) sets
hide_parameters, so a failed query on/statusor a reply no longer writes the case number or report id into the error log next to an exact time. - Notification digests are daily by default (
NOTIFICATION_BATCH_MINUTES=1440, was 60). Report times are stored as the day, and an hourly digest said which hour a report or reply arrived. Set a smaller value to hear sooner, at that precision. - The confidential identity is shown only to the handler, with an audited reason. The case page no longer prints the reporter's name and contact: the assigned handler (for an unassigned case, an admin of the case's organisation) enters a 10–500 character reason, sees the identity once, and the reason is stored encrypted in the audit log. Every case view is audited too; the audit log hides views unless Show case views is ticked.
- Search inside reports. The dashboard search also finds words in descriptions and messages (three characters or more), decrypted in memory for that request only — no index is stored, the confidential name never matches, and at most the 5,000 newest cases in the current view are searched. The search is a POST form, so the term stays out of URLs and the browser history, and each word search is audited (
report.content_searched, term encrypted, hit count). - The audit CSV export neutralises spreadsheet formulas and writes each detail as one JSON cell, so a reason cannot forge extra fields.
- Excel's own "Name:" label is removed from comment text in
.xlsxuploads, not only the comment's author field. - S3 objects stored before v1.5.0 are moved to keys without the filename at start (once across replicas), and no log line or error message names a storage key.
- PDF export leaves the confidential identity out unless the handler gives an audited reason. The default export prints "Identity: [on file — not included]"; a new "Export PDF with identity" button asks for the same 10–500 character reason as the on-screen reveal and writes the same
IDENTITY_REVEALEDaudit entry. - PDF export prints Latin, Greek and Cyrillic text intact. DejaVu LGC Sans (bundled under
app/fonts/) replaces fpdf2's core Helvetica, which silently turned anything outside latin-1 into "?" — a report or message written in Polish, Greek or Cyrillic used to lose its own text in the printed record. CJK and right-to-left scripts are still unsupported (missing-glyph boxes). - A case manager's counts cover only their own cases. The dashboard's status counts and the
/admin/statsfigures counted every case in the organisation, though the list showed only the cases assigned to them.
Changed
- Dependencies refreshed (SQLAlchemy 2.1, uvicorn 0.54, boto3 1.43.103, ruff 0.16.9, uv 0.12.19); the SQLAlchemy mypy plugin, removed in 2.1, is no longer configured.
- Helm: the Ingress rate-limits every route like the bundled nginx (
limit-rps: "10", burst 30). Before, a Kubernetes deployment had no limit onPOST /submitor any other public route. ingress-nginx answers a rejected request with 503 and keys on the peer address; the chart and its install notes now state thaterror-log-level: critis required, since below it the controller logs each rejected reporter's IP address. - Times the whistleblower causes are stored and shown as the day only (UTC): submission, the receipt message, whistleblower messages and attachment uploads. Migration 006 rounds existing rows; the exact times are gone for good (downgrade leaves the rounded values). Thread order is kept. Admin replies, notes, acknowledgement and closure keep their time, on screen and in the PDF. The 7-day acknowledgement deadline and the stats page's on-time rate now count from 00:00 UTC of the submission day. A same-day message keeps its place in the thread, one microsecond after the message before it. Demo data follows the same rule.
- The dashboard orders reports with equal submission days by id, so a page boundary is stable.
- The dashboard has no KPI tiles; each status filter pill carries its count, within the chosen location. The pills keep the location and search when switching status.
- The case page has five panels: Report (with its attachments), Communication thread, Notes, History (linked cases and the collapsed activity log) and Actions (acknowledge, assign, status, confidential identity, PDF export, and deletion collapsed under Delete report). The facts sit above Actions without panel chrome.
- Website: the German landing page and the blog are on the current design; every page shares one nav (collapsing below 1080 px) and one footer. The landing pages link each other with
hreflang, and the German FAQ's structured data matches its visible questions. The unused Spectral and Source Serif fonts are removed.
Fixed
- A category deleted outright read differently on the PDF (its raw slug) than on the pages (title-cased). One fallback now serves the templates, the stats page and the PDF.
- Paging and sorting the dashboard dropped the location filter. Every dashboard link now keeps the whole current view.
- A case-number collision expired every object the caller held (a full rollback), so the next attribute access failed with
MissingGreenlet; each attempt now uses its own SAVEPOINT. - Report creation retried on any database integrity error; it now retries only a case-number collision and raises any other error at once.
- Two messages posted to one case at the same moment could get the same time and an arbitrary order; the case row is now locked while a message's time is chosen.
- The case page's confirmation prompts, "(current)" status label and "no further transitions" note were English in every language; a French prompt showed
'for each apostrophe. - Organisations and admin-users pages were hardcoded English, with button/badge classes (
btn--danger,badge--green, ...) that don't exist insite.css. A sweep of every template found the same two problems on the retention, system, telephone-channel and category pages too (BEM-style classes that were never styled, a couple of untranslated titles and hints, a brokenfield-errorCSS selector missing its leading dot). All now use real locale keys and the actual button/badge/grid class names. - Creating and deactivating organisations failed with a CSRF error. The page posted
{{ csrf_token }}, a context variable the route never sets, instead of{{ request.state.csrf_token }}— every real submission sent an empty token and got a 403. - The dashboard's active filter pills all carried
aria-current="page", but two independent pill groups (status/my-cases and location) can each have an active pill at once — "page" implies a single current page. Filter pills now usearia-current="true"; pagination and navigation keep"page". - The browser's own Back button broke the submission wizard halfway through (a "Confirm Form Resubmission" dialog, or a stale step that rewound progress when resent). Every wizard step now answers its POST with a redirect to
GET /submit, and a step that does not match the session's progress is ignored instead of processed. Found and fixed by Zachary Bridges; his #94 fix is ported into this release with him as co-author. - The wizard's Back button silently dropped uploaded attachments: a file input is always empty on revisit, so Next from there cleared them. Files now stay attached unless new ones are chosen, and the attachments step lists what is already attached. Found and fixed by Zachary Bridges; his #94 fix is ported into this release with him as co-author.
- A description that failed validation (too short or too long) was discarded, and the step showed an empty field or the previous text. It now keeps what was typed (a too-long one cut at 10,000 characters). Found and fixed by Zachary Bridges; his #94 fix is ported into this release with him as co-author.
- A double final submit created two reports: two concurrent POSTs of the review form (a double click with JavaScript off, e.g. Tor Browser "Safest") both read the draft before either deleted it, and the PIN of one report was never shown. Now the draft is claimed atomically, exactly one report is created, and both responses show its case number and PIN. A click that finds the first one still running says so and offers "Check again", never a receipt without a case number. The report and its attachments are committed together: a failure before the commit, or a worker that dies before it, gives the draft back; a commit whose reply was lost is looked up, never repeated. Each draft carries its report's id, so the database refuses a second report of the same draft whatever the timing or fault; a draft whose report exists is never given back, and the page then shows its case number. The race predates the #94 port: v1.5.0 has the same load → create → delete sequence.
- The final submit re-checks each step the way the step itself does: a location or category switched off, or confidential mode disabled, after the reporter chose it returns them to that step with a message instead of failing.
- The audit log sorted only by time, so entries with the same time could repeat or go missing between pages; ties are now broken by id, as the report list already does.
- The wizard processed any posted step when
actionwas neithernextnorback, so a crafted request could skip ahead (store a file on a fresh draft) or submit a rejected description. Unknown actions are now ignored, and the final submit re-checks every field before the report is created. The skip-ahead was already possible in v1.5.0. - Categories showed in English on the dashboard, case page, stats and PDF export (the PDF printed the raw slug). They now use the admin's language, falling back to English, and a label comes only from the admin's own organisation.
- A new admin user defaulted to
superadmin; the role picker now defaults tocase_manager.role.label.superadminhad no translation and showed as the raw key. - An icon next to text (SLA value, assignee, filename) rendered on its own line.
- Stylesheet and script URLs carried no version, and
/static/had noCache-Control, so a browser kept stale files after an upgrade. Links now carry?v={app_version}, and every/static/response isno-cache(the nginx snippet's conflictingimmutableis gone). - A long German status badge pushed the dashboard's "Ansehen" button out of view; the badge wraps and the action column stays pinned. The pinned header is chosen by class, so the audit log's last column no longer detaches from its data.
docs.htmland two blog articles overflowed horizontally on a phone (up to 519 px).- The local-review login button sat flush against the demo-credentials box.
- The PIN on the success screen was cut off. A 36-character PIN or case number scrolled behind the copy button instead of fitting its box; it now wraps only after a hyphen (never inside a group) and is always fully visible, at every width, without scrolling.
- The review step's German label "EINREICHUNGSMODUS" overlapped its value "Anonym" (a fixed 7rem label column, too narrow for the longest label in some locales); the review list now stacks each label over its value instead of sizing one column for every language.
- The description step's character counter always showed "10,000" regardless of language; a shared
format_counthelper (Python and JavaScript) now groups digits per locale ("10.000" in German and Portuguese, "10 000" in French). - The wizard eyebrow said "CONFIDENTIAL REPORT" even in anonymous mode, clashing with "Anonymous" right below it; it is now a neutral "Secure report" in all four languages.
/admin/stats's two-column grid pushed "nach Kategorie" 23px below "nach Status": a stacking margin meant for panels in normal vertical flow was also firing inside the grid, on top of its owngap.- A logged-in admin opening a stale
/admin/reports/<id>got a raw JSON{"detail":"Not Found"}page. A browser request (Accept: text/html) to an HTML route now gets the styled, localised error page; an API/JSON client is unaffected. - The "Was ist neu in 2.0" blog article was dated 25 September; the release is the 26th (visible date, meta tags, JSON-LD
datePublished/dateModified, sitemaplastmod). - With multi-tenancy on, the wizard offered every organisation's categories and locations, and every report was filed under the default organisation (since v1.4). A category or location of another organisation is now refused at its step and again at the final submit.
- The demo accounts belonged to no organisation, so with multi-tenancy on they saw none of the default organisation's demo reports; the seed now gives them the default organisation, also on an existing database.
DEFAULT_ORG_SLUGwas ignored by setup and by the organisations page: setup always createddefault, and onlydefaultwas protected from deactivation. Both now use the configured slug.
Design
- Signal design across the app: one admin shell with a role-aware sidebar (a case manager sees only the pages they may open) and a phone menu; SVG icons instead of emoji; an icon theme toggle; footer as a list; eyebrows only where they carry information; panel headers are real headings.
- Phones: the report form comes first, a case number or PIN never breaks across lines, and tables stack into labelled rows that keep their table semantics.
- The website describes 2.0 (English and German landing pages, the "Was ist neu in 2.0" article), serves every font it uses itself, and the roadmap moved from
ROADMAP.mdto openwhistle.net/roadmap.html.
Process
- CI runs codespell, markdownlint and ruff over the whole repository, a runtime check of the nginx onion-listener trust boundary, and every GitHub Action is pinned by commit SHA.
- A test fails when a setting added since the previous release is missing from this changelog, or any setting from the documentation's environment table or
docker-compose.prod.yml(seven were:ACCESS_TOKEN_EXPIRE_MINUTES,ALGORITHM, the four lockout settings andAPP_VERSION/APP_NAME). - Every new guard of this release is pinned by a mutation that turns its test red (
scripts/mutation_audit.py). - The docs pages' horizontal-overflow check ran in the light theme only; it now runs in dark too.
Removed
- The PayPal link in
.github/FUNDING.yml: the sponsor options are GitHub Sponsors and Ko-fi (jp1337), like the other projects; a test keeps personal payment handles out of the repository. BRAND_SECONDARY_COLOR— it styled nothing; Signal has one accent.
1.5.0 — 2026-09-24
Every finding carried forward from the 2026-09-23 audit is closed. The guiding rule: a whistleblower can never be locked out, deanonymised by a file, a timestamp or a Redis dump, or promised more than the software does. 124 mutations over this release's and v1.4.0's guards, all caught.
Upgrade notes
- Retention is on by default (
RETENTION_ENABLED=true): closed reports are deleted 1095 days after closing (HinSchG §11 Abs. 5). OpenWhistle dates from 2026, so nothing is old enough to be deleted yet. Setfalseto opt out. - Notifications are batched (
NOTIFICATION_BATCH_MINUTES=60) and the generic webhook payload changed to{"event": "new_activity", "new_reports": [...], "new_messages": [...]}with no timestamp. Update receivers that parse the oldnew_reportevent.0restores immediate delivery. - Logout is a POST with a CSRF token, for admins and whistleblowers; a GET no longer logs anyone out.
- OIDC now requires the provider to return an
id_token(all compliant providers do); it is verified against the provider's JWKS. - Drafts in progress restart after the upgrade (new encrypted draft format).
- Redis in
docker-compose.prod.ymland the Ansible role now runs with--maxmemory 1gb --maxmemory-policy noeviction. - Migration
003encrypts existing attachment filenames and widens the column. - New settings:
NOTIFICATION_BATCH_MINUTES,DRAFT_REDIS_MEMORY_PERCENT,ADMIN_FAILED_LOGIN_ALERT_THRESHOLD,ADMIN_FAILED_LOGIN_ALERT_WINDOW_MINUTES,LDAP_START_TLS.
Security
- A whistleblower can no longer be locked out of their own case. The status lockout was keyed by the 5-digit case number, so anyone could block a case by typing wrong PINs. Now a correct case number and PIN always open the case; wrong attempts are still counted and answered with a wait notice. Unknown case numbers take the same time as known ones (no timing oracle). The
/replyfallback shares the same check instead of trusting a client-supplied token. - Password spraying is detected. Failed admin passwords are counted instance-wide (no IP, no usernames); crossing the threshold raises one alert per window by email/webhook and in the audit log. MFA remains the barrier.
- OIDC: PKCE (S256), a verified
nonce, and a verifiedid_token(signature,iss,aud,exp,azp); identity is taken from the token. - CSRF cookie gets
Secure; logouts are CSRF-protected POSTs. - Setup wizard is atomic (advisory lock) — two first-run requests cannot create two admins; migrations are serialised across replicas.
- One password policy for the wizard, admin-created users and the reset script; passwords or PINs over 72 bytes no longer cause a 500 (bcrypt 5).
- LDAP StartTLS (
LDAP_START_TLS) with certificate verification. t()marks text as HTML only for.htmllocale keys; SSO error pages no longer echo the provider'serrorparameter.
Privacy
- Attachment filenames are encrypted with the report key (they can carry a name), and new S3 object keys no longer contain the filename.
- Office files lose comment and tracked-change authors (DOCX/XLSX), their thumbnails, and zip timestamps and uid/gid.
- Submission drafts are encrypted with a key that exists only in the whistleblower's cookie; a Redis dump alone reveals nothing. Draft attachments are capped, and refused when Redis is nearly full.
- Notifications no longer reveal submission times (batched digest).
- The app never sees client addresses: IP headers and the peer address are removed from every request after the proxy check.
- Pages are not cached (
Cache-Control: no-store) — a shared office PC keeps no PIN or report page. - Helm ingress turns the nginx access log off by default.
Changed
- Renovate replaces Dependabot: patch updates merge themselves behind the required checks; minor/major, the Python runtime and security-relevant libraries wait for review. GitHub Actions are pinned to digests. Tests fail if a Renovate manager reaches nothing or a tool carries two versions. Renovate merges its own patch PRs (
platformAutomerge: false), because the repository's "Allow auto-merge" setting is off. - Dashboard search by case number. Report content is encrypted and is deliberately not searchable.
- Readable audit log: every action has a translated label; details are shown as text. The CSV keeps machine codes and adds a label column.
- Copy: sentence case; no absolute promises ("completely protected"); plain language instead of "Two-Factor Access / UUID4"; every error says what happened and what to do. All user-facing strings, including upload errors, are translated.
Fixed
- Errors are tied to their fields (
aria-invalid,aria-describedby) on every form; blank admin login fields answered with a JSON 422. - The authenticator setup page showed raw locale keys in en, de and fr.
- Creating a location was logged as
category.created. - Wrong copy: "no cookies" (functional cookies exist) and confidential data "only visible to the assigned admin" (every admin with access sees it).
1.4.0 — 2026-09-24
Attachments no longer identify the whistleblower, multi-tenant installs keep organisations apart, and every page passes an automated contrast and layout check on a phone and in both themes. Found and verified with a mutation audit of every guard this release adds (28 of 28 caught).
Upgrade notes: migration 002 runs on start. The default BRAND_PRIMARY_COLOR is now #0c7253 (was #0e7c5a, 4.4:1 on tinted backgrounds); installs that set their own colour are unaffected. E2E and demo logins for the demo accounts use the static code 000000; real TOTP codes are single-use for every account.
Fixed
- Cleaned PDFs still contained their XMP metadata. Unlinking it from the catalog left the stream in the file as an orphaned object; orphans are now removed. PDFs restricted by an owner password only are accepted and cleaned instead of refused.
- Every page passes axe at impact serious and critical, in both themes. Fixed: accent colour on tinted backgrounds (4.4 → 5.0:1), white on amber in the dark demo badges (2.3:1), dark-theme secondary text (3.9 → 4.8:1), role badges (2.1 and 3.9:1), footer links distinguishable only by colour, inactive categories and locations faded below AA, an unlabelled role selector and link field, scrollable tables unreachable by keyboard.
- Six more admin pages scrolled sideways on a phone (users, categories, locations, statistics, retention, system — up to 569 px), and long audit entries on the report view.
- No page scrolls sideways on a phone any more. At 390 px the navigation (12 items in the admin) now wraps instead of running off-screen, the dashboard toolbar and the report view (two columns, no breakpoint) fit the width. An E2E test checks
/submit,/statusand/admin/loginat 390 px. - Confidential mode works without JavaScript (Tor Browser "Safest"): the name/contact fields are shown by CSS
:has()instead of a script. - The language picker works without JavaScript and no longer misuses
listbox/optionroles: it is a native<details>list of forms. - Contrast: input borders 1.25:1 → ≥ 3:1; a visible focus outline on inputs and on the submission-mode cards (focused and selected looked identical); secondary text 4.37:1 → 4.98:1 on cards; alert titles no longer dimmed.
Changed
- Stricter tests. The axe checks fail on serious violations, not only critical ones (contrast failures used to ship green). A new UI check visits every public and admin page in both themes at 390 and 1440 px and fails on axe violations, console errors or sideways scrolling. Every guard of the release is mutation-tested (
scripts/mutation_audit.py). - Maintainer documentation moved to
docs-tech/(release procedure, invariants, performance baseline); a test keeps it out of the published site. - Images are built once and published identically to all three registries. Each platform builds on a native runner (arm64 no longer under QEMU) and is pushed to GHCR by digest; one multi-arch index is then written under every tag to GHCR, Docker Hub and Quay.io. Docker Hub and Quay now get the same provenance, SBOM and cosign signature as GHCR (before: separate unsigned builds). The job fails unless every tag and every platform manifest behind it is pullable. Quay.io stays best-effort with a warning.
- Dependencies are locked in
uv.lock. The image, CI and the E2E/perf workflows install exactly the locked versions; CI fails if the lock is out of date. Dependabot now uses theuvecosystem — the oldpipentry only saw>=floors and had never opened a pull request. - The production image carries runtime dependencies only (no pytest, mypy or ruff), uv is taken from its official image (0.6.0 → 0.12.18), and the fonts are copied from
docs/fontsinstead of downloaded unverified at build time. python-josereplaced by PyJWT, which dropsecdsa(PYSEC-2026-1325, no fix planned). Unusedauthlibandaiofilesremoved;cryptographyis now a declared dependency instead of an accidental transitive one.
Security
- Multi-tenancy: an org admin now sees and manages only their own organisation. The users page, role changes, (de)activation, the assignment picker and target, the audit log and its CSV export, and the dashboard and statistics counts were unscoped; new users now join the creator's organisation. Single-organisation installs are unaffected.
DEMO_MODEno longer weakens a real installation. The static TOTP000000is accepted only for the seeded demo accounts, and demo data is not seeded into a database that completed the setup wizard and has no demo account.- Internal admin notes are encrypted at rest with the report's data key, like descriptions and messages. Existing notes are shown as stored.
- Attachments no longer carry identifying metadata. EXIF/GPS and camera data (JPEG, PNG, WebP, GIF), PDF document info and XMP, and DOCX/XLSX author and company properties are removed on upload — before the file reaches the draft store. A file that cannot be parsed for cleaning is refused instead of stored as-is. The upload step tells the whistleblower what is and is not cleaned.
- Attachments are encrypted at rest with the report's own data key, in PostgreSQL and in S3. Rows from before this release are served as stored (migration
002addsattachments.encrypted).
1.3.1 — 2026-09-23
Security
- OIDC logins now require TOTP. The OIDC callback issued a session directly, so SSO accounts skipped the mandatory second factor. All three login paths (local, LDAP, OIDC) now go through the same MFA step; SSO users enrol TOTP on their next login. Deactivated accounts are also rejected on the OIDC path.
- LDAP first login provisions a Case Manager, not an Admin. Any directory entry matching
LDAP_USER_FILTERpreviously got full admin access. - LDAP username is escaped before it is placed into the search filter (filter injection), and LDAPS now verifies the server certificate (
CERT_NONEbefore). Private CAs: setSSL_CERT_FILE. - Deleting a report removes its S3 objects. Manual deletion, 4-eyes deletion and the retention job only removed database rows; attachment files in the bucket were kept forever.
- nginx no longer logs client IPs.
error_logran atwarn, and nginx prefixes rate-limit (429) and body-size (413) errors with the client address. It now logs atcritonly. - "Start over" in the submission wizard is now CSRF-protected.
Fixed
- Uploads over 1 MB failed behind the bundled nginx (default
client_max_body_size). Set to 55 MB (5 × 10 MB attachments). - Fresh production installs could not start PostgreSQL 18. The 18 image refuses a volume at
/var/lib/postgresql/dataunlessPGDATApoints there;docker-compose.prod.ymland the Ansible template now set it. Existing data is unaffected. - GHCR images were unpullable (
manifest unknown): the weekly cleanup job deleted the untagged per-platform manifests that every multi-arch tag points to. GHCR cleanup is removed; Docker Hub and Quay.io were not affected. - Helm chart deployed v0.5.0 by default —
appVersionwas never bumped. It now tracks the app version, enforced by a test. - "Start over" on the review step returned 405 (GET link to a POST-only route).
- Footer text had 1.8:1 contrast on the dark footer; now 7.7:1.
- Animations now respect
prefers-reduced-motion. - HinSchG citations: the 3-year deletion rule is §11 Abs. 5, not §12 Abs. 3, and it is a deletion deadline, not a minimum retention period.
- "Back" in the submission wizard no longer triggers validation. The double-submit guard disabled the form's first submit button — which on every wizard step is "Back" — while the browser was still building the submitted entry list. Disabled controls are excluded from that list, so
action=backnever reached the server and the step was processed as a "Next", rejecting an empty description instead of navigating back. The guard now targets the button the user actually clicked and applies after the form data is collected.
1.3.0 — 2026-07-15
The "Signal" design system — a ground-up visual redesign that unifies the app and the public site under one identity, plus a documented design specification.
Added
DESIGN.md— a canonical design system ("Signal") in the google/design.md format: front-matter design tokens (colour, typography, spacing, radii, elevation) and prose covering every component, with first-class light and dark themes.- Design token foundation in the stylesheet: a
--space-spacing scale, a--text-type scale, and shared.anim-in/.delay-*animation utilities.
Changed
- Full "Signal" restyle of the application — a monochrome warm-neutral ground with a single emerald accent, Sora for display and body text, and JetBrains Mono for case numbers, PINs, timestamps and deadlines. Dark mode is now a warm near-black rather than a cold navy.
- Self-hosted fonts reduced to two (Sora + JetBrains Mono); the Docker image downloads exactly those, replacing the previous three-font set. The default
BRAND_PRIMARY_COLORis now emerald (#0e7c5a). - Public marketing and documentation pages (openwhistle.net) converted from their separate serif/gold look onto the same Signal tokens.
- Stylesheet and template cleanup: consolidated duplicated components (
.info-banner→.alert,.env-table→table, three admin grids →.admin-split-grid, inline-form wrappers →.inline-form), rebuilt the admin report page's 56 numbered one-off classes into semantic classes on the type scale, and unified the scattered animation-delay helpers.
Fixed
- The intended body typeface never loaded — its
@font-facepointed at font files that did not exist, so the app silently fell back tosystem-ui. A real self-hosted font now ships. - Dark mode ignored the configured brand colour (the accent was hardcoded); it now derives from
BRAND_PRIMARY_COLORin both themes. - The public report-status page's status pills were unstyled (they referenced CSS classes that were never defined); they now use the themed badge styles.
- Removed references to several undefined CSS custom properties.
1.2.1 — 2026-07-14
Follow-up hardening release resolving the remaining bug-bounty findings (#42–#46).
Security
- CSRF protection extended to the two remaining state-changing admin POST endpoints (
/admin/ip-warning/dismiss,/admin/demo/reset). AJAX requests authenticate via anX-CSRF-Tokenheader (read from a<meta>tag, since the double-submit cookie is HttpOnly) (#44). - Case numbers now use a random 5-digit suffix instead of a global sequence, so a new report no longer reveals aggregate cross-tenant report volume. Format is unchanged (
OW-YYYY-NNNNN) and existing numbers stay valid (#42). - Attachment uploads are now verified by magic number: the file's leading bytes must match its extension (PDF, JPEG, PNG, GIF, WebP, DOCX/XLSX, DOC/XLS), so a file cannot lie about its type (e.g. HTML bytes disguised as a
.png). Text formats have no signature and are unaffected (#43). - Reverse-proxy flood protection for the submission channel: the bundled nginx configs now apply a per-IP
limit_reqto dynamic endpoints. The IP is used only for in-memory throttling — never logged or forwarded upstream — so whistleblower anonymity is preserved (#46). - Removed a dead, unreachable "this account uses Single Sign-On" login branch; SSO-only accounts already receive the generic "invalid credentials" error, which avoids leaking account existence / auth method (#46).
Fixed
- Downloading an attachment whose S3 object is missing now returns 404 instead of an unhandled 500; genuine backend errors still surface as 5xx (#45).
1.2.0 — 2026-07-13
Added
- Admin System page + opt-in update check: a new Admin → System page shows the installed version and, when
UPDATE_CHECK_ENABLED=true, whether a newer release is available on GitHub. The check is off by default, runs as a daily background job (result cached in Redis, ETag-conditional), and sends no instance data to GitHub — only a standard request. The installed version is also shown in the footer. - File integrity check on the Admin → System page: verifies the shipped application files against a SHA-256 manifest generated at Docker build time and reports any missing, modified, or unexpected files. Purely local (no external calls); detects accidental modification, incomplete deployments and corruption (not tamper-proof against an attacker who can also rewrite the manifest — the manifest's own hash is shown for optional out-of-band verification).
1.1.1 — 2026-07-13
Security release: four privately-reported advisories plus an internal adversarial "bug-bounty" audit that fixed ~25 further edge-case defects. All users of 1.1.0 should upgrade.
Breaking
SECRET_KEYmust now be at least 32 characters. The application refuses to start with a shorter key.SECRET_KEYis the root secret for admin authentication and for encrypting confidential whistleblower identities, so a weak key undermines the platform's core protection. Generate a strong one withpython -c 'import secrets; print(secrets.token_urlsafe(48))'. Note: rotatingSECRET_KEYmakes previously-encrypted confidential fields unreadable — set a strong key from the start.
Security
- Report deanonymization / IDOR (GHSA-q3v3-5xf4-xjqr, High): every
/admin/reports/{id}*endpoint now enforces object-level authorization. Case managers can only access reports assigned to them; admins are scoped to their own organisation (superadmins span all) when multi-tenancy is enabled. The dashboard list and the confidential-identity block are scoped the same way, so an unassigned case manager can no longer read a confidential whistleblower's identity. - Privilege escalation (GHSA-g3xj-3929-r45h, High): the role-assignment endpoints now enforce privilege tiers. Only a superadmin may grant or modify the superadmin role, an account can no longer change its own role, and the last active administrator can no longer be demoted away.
- Stored XSS (GHSA-24hg-pf84-jj7x, High): admin usernames and organisation names are no longer interpolated into inline
onclickhandlers; confirmation prompts moved to a safedata-confirmattribute. Locally-created usernames are validated against a strict allowlist. - Weak / duplicated HTTP security headers (GHSA-gh23-4h5j-cqj8, Medium): security headers are now emitted by a single authoritative layer (the application middleware); the bundled nginx template no longer re-emits them, removing the duplicated/conflicting
Strict-Transport-Security,X-Content-Type-OptionsandX-Frame-Optionsheaders. The Content-Security- Policy no longer uses'unsafe-inline': it is now a strict, per-response nonce-based policy for both scripts and styles.
Reported by @openblow.
Fixed (internal bug-bounty audit)
Real defects found by an adversarial audit, each covered by a regression test in tests/test_bug_bounty_v111.py:
- Retention could delete reopened cases early:
closed_atwas never refreshed when a case was reopened and re-closed, so the auto-deletion job could remove reports far before the statutory retention period had elapsed since their actual closure. It is now cleared on reopen and re-stamped on re-close. - Superadmin lockout: a plain admin could deactivate a superadmin, and the last active privileged account could be deactivated/demoted, leaving no one able to administer the instance. Both are now blocked.
- Attachment downloads with non-Latin-1 filenames (CJK, Cyrillic, emoji) raised
UnicodeEncodeErrorand 500'd — the evidence became permanently undownloadable.Content-Dispositionnow uses RFC 5987 encoding. PDF export no longer crashes on non-Latin-1 note authors either. - MFA brute-force: TOTP guessing is now rate-limited, and a valid code is one-time-use within its window (blocks AiTM replay into a second session).
acknowledgeis now idempotent so the statutory feedback deadline cannot be pushed out by re-invoking it.- Concurrent submissions no longer 500 on a case-number collision (retry).
- Reports can no longer be assigned to a deactivated user (orphaned cases).
- Linked-report metadata is filtered through the object-level authz check.
- SLA reminder de-duplication now covers the full warn window (was re-firing every ~hour for days); per-report failures are isolated.
SUBMISSION_MODE_ENABLED=falsenow actually forces anonymous submissions, and confidential PII is purged from the session when switching to anonymous.- The whistleblower PIN lockout is keyed on the case number, so it can no longer be bypassed by fetching a fresh anonymous session token before each guess.
- Login now runs a constant dummy password hash for unknown users (removes a username-enumeration timing side-channel).
- Background scheduler jobs take a Redis lock so a scaled/stateless deployment does not run them once per replica (duplicate audit entries / notifications).
- 4-eyes delete confirmation re-checks the request under a row lock, so a concurrent cancel cannot be raced into deleting a withdrawn report.
- The submission wizard rejects out-of-order steps (blocks jumping straight to the attachment step to stash blobs in Redis) and no longer adopts a client-supplied session id with no server-side state (session fixation).
- Case-insensitive duplicate-username check; empty decrypted bodies no longer fall back to raw ciphertext; oversized uploads are rejected without buffering the whole body; relinking already-linked cases returns 409 instead of 500; decryption failures are logged rather than silently shown as blank.
Remaining lower-severity findings are tracked in GitHub issues #42–#46.
Changed
- All Python dependency floors raised to their current major versions (notably redis 8, bcrypt 5, SQLAlchemy 2.0.51, uvicorn 0.51, FastAPI 0.139, mypy 2, pytest 9, pytest-asyncio 1.x). GitHub Actions
actions/checkoutandcodecov/codecov-actionbumped to v7.
1.1.0 — 2026-04-28
Added
- Playwright E2E test suite (
tests/e2e/): 13 test modules covering every critical user journey — admin login (incl. MFA), setup wizard redirect behaviour, whistleblower anonymous/confidential/file-attachment submissions, status page with deadline display, admin workflow (acknowledge → reply → status transitions), 4-eyes deletion flow, language switcher persistence, PDF export download, session expiry, user management RBAC, category and location management lifecycle - Automated accessibility tests (
tests/e2e/test_accessibility.py): axe-core injected into 8 pages;run_axehelper filters to critical/serious violations and fails on any finding; CDN-unavailable skips gracefully; keyboard navigation smoke-test (skip link, tab order, form labels) - Locust performance test suite (
tests/perf/locustfile.py): three user classes (WhistleblowerUser,AdminUserwith TOTP login inon_start,StatusChecker); configurable concurrency;tests/perf/README.mdwith thresholds and run instructions - OpenAPI contract tests (
tests/test_openapi_contract.py): validates OpenAPI 3.x structure, required paths (/health,/status,/submit), admin route auth enforcement (7 routes assert 3xx for unauthenticated requests), and snapshot regression detection viatests/fixtures/openapi_snapshot.json - E2E CI workflow (
.github/workflows/e2e.yml): buildsopenwhistle:e2eimage, starts full Docker Compose stack withDEMO_MODE=true, waits for/health, runs Playwright tests with Chromium headless, uploads trace on failure - Performance CI workflow (
.github/workflows/perf.yml): manualworkflow_dispatchwith configurable users/run-time/host; uploads HTML + CSV Locust artifacts - Performance baseline (
docs/performance-baseline.md): SLO thresholds (/healthp95 < 50 ms,/statusp95 < 200 ms,/admin/dashboardp95 < 400 ms) and user mix ratios for reproducible benchmarks
Changed
pyproject.toml: new[e2e]and[perf]optional dependency groups;e2eandperfpytest markers registered; mypy overrides forplaywright.andlocust.; ruffper-file-ignoresextended to covertests/e2e/andtests/perf/
1.0.0 — 2026-04-27
Added
- Envelope encryption at rest: every new report is encrypted on write with a per-report Data Encryption Key (DEK) wrapped via AES-256 (Fernet); the DEK is encrypted with a Master Encryption Key (MEK) derived from
SECRET_KEYusing HKDF-SHA256; MEK is never stored; report description and all message bodies are encrypted; pre-encryption rows are readable without decryption (backward compat) - Data retention (GDPR / HinSchG):
RETENTION_ENABLED=trueactivates a daily job (03:00 UTC) that permanently deletes closed reports older thanRETENTION_DAYS(default 1095 = 3 years — HinSchG §12 Abs. 3 minimum); each deletion writes an immutable audit-log entry (report.auto_deleted) recording the case number, closure date, and legal basis - Multi-tenancy:
MULTI_TENANCY_ENABLED=trueactivates multi-organisation support;Organisationmodel withname,slug,is_active, andbrandingJSON; all reports, users, categories, locations, and audit entries carry anorg_idforeign key; per-org unique constraints on category slugs and location codes; superadmin role manages organisations via/admin/organisations - Superadmin role: new
superadminrole aboveadmin;require_superadmindependency guards the organisation management endpoints; existingadminrole retains all previous permissions; role added toAdminRoleenum viaALTER TYPE adminrole ADD VALUE IF NOT EXISTS 'superadmin' - Telephone reporting channel guide (
/admin/telephone-channel): compliance page covering HinSchG §16 requirements, implementation options (internal hotline vs. external ombudsman), §10 recording prohibition, and a compliance checklist - Data retention admin page (
/admin/retention): shows current retention config, next scheduled run, legal basis (GDPR Art. 5/17, HinSchG §12), and configuration reference table - Organisation management page (
/admin/organisations): superadmin-only page to create and deactivate organisations (default org cannot be deactivated)
Changed
- Report description and message content are now stored encrypted; existing plaintext rows are transparently decrypted on first read (backward compat via
decrypt_field_safe) - Admin report detail page and whistleblower status page now render decrypted content instead of raw ciphertext
- Scheduler refactored: both SLA reminders and retention cleanup share a single
AsyncIOSchedulerinstance; previous per-feature scheduler creation eliminated ReportCategory.slugandLocation.codeunique constraints changed from global to per-organisation composite (slug + org_id,code + org_id)- Nav bar in all admin templates updated with links to Telephone Channel, Retention, and Organisations pages
Migrations
- 012 — Creates
organisationstable; addsorg_idFK andencrypted_dekcolumn to all data-bearing tables; addssuperadmintoadminroleenum - 013 — Data migration: backfills
org_idwith default org; makesorg_idNOT NULL; encrypts all existing report descriptions and message bodies; makesencrypted_dekNOT NULL - 014 — Replaces global unique constraints on
report_categories.slugandlocations.codewith per-org composite unique constraints - 015 — Reverts
admin_users.org_idto nullable to support superadmin accounts (org_id = NULL means cross-organisation scope) and direct AdminUser creation in external tooling without a prior org lookup
0.5.0 — 2026-04-26
Added
- Health-check v2:
/healthendpoint now queries the database (SELECT 1) and Redis (PING) and reports per-component status; returns HTTP 200 with{"status":"ok"}when all healthy, HTTP 503 with{"status":"degraded"}on any failure; suitable for Kubernetes liveness and readiness probes - Structured JSON logging:
LOG_LEVEL(defaultINFO) andLOG_FORMAT(jsonortext, defaultjson) environment variables; JSON output viapython-json-logger; all uvicorn loggers reconfigured uniformly at startup - Slack / Teams webhook formatter:
NOTIFY_WEBHOOK_TYPE(generic,slack,teams) selects the payload format; Slack uses Block Kit (header + fields + action button); Teams uses Adaptive Cards (v1.4, FactSet + OpenUrl action); both new-report and SLA-reminder notifications respect the setting - SLA reminder system: background scheduler (
APScheduler, interval 30 min) firessend_sla_reminders(); checks all non-closed reports for approaching 7-day acknowledgement deadline (REMINDER_ACK_WARN_DAYS, default 2 days before expiry) and 3-month feedback deadline (REMINDER_FEEDBACK_WARN_DAYS, default 30 days before expiry); Redis dedup keys (reminder:ack:{case},reminder:feedback:{case}) with 1-hour TTL prevent duplicate notifications; enabled withREMINDER_ENABLED=true - S3-compatible attachment storage:
STORAGE_BACKEND=s3routes new attachments to an S3-compatible bucket (AWS S3, MinIO, Hetzner Object Storage) via boto3 (sync calls wrapped inasyncio.to_thread);S3_ENDPOINT_URL,S3_BUCKET_NAME,S3_ACCESS_KEY_ID,S3_SECRET_ACCESS_KEY,S3_REGION,S3_PREFIXconfigure the target; existing DB-backed attachments are unaffected (backward-compatible migration makesdatanullable, addsstorage_key VARCHAR(512)) - LDAP / Active Directory login:
LDAP_ENABLED=trueenables corporate directory authentication for admin accounts; two-phase bind (service account → user DN re-bind to verify password);ldap3runs synchronously in a thread pool; first LDAP login auto-provisions anAdminUserrecord; subsequent logins reuse the existing record;TOTPenrollment still required after first login;LDAP_SERVER,LDAP_PORT,LDAP_USE_SSL,LDAP_BIND_DN,LDAP_BIND_PASSWORD,LDAP_BASE_DN,LDAP_USER_FILTER,LDAP_ATTR_USERNAME,LDAP_ATTR_EMAILconfigure the connection - Helm chart:
charts/openwhistle/— production-grade Helm chart for Kubernetes deployments;Chart.yaml,values.yaml, 8 templates (deployment.yaml,service.yaml,ingress.yaml,hpa.yaml,configmap.yaml,secret.yaml,_helpers.tpl,NOTES.txt); all v0.5.0 env vars exposed as chart values; supports existing-secret pattern for credentials; liveness/readiness probes wire to/health - Ansible role:
ansible/roles/openwhistle/— official Ansible role for bare-metal / VM deployments (Debian/Ubuntu); installs Docker CE + Compose plugin; creates system useropenwhistle; renders.env,nginx.conf, anddocker-compose.ymlfrom Jinja2 templates; installs systemd service unit; optionally obtains TLS certificate via Certbot with auto-renewal hook;ansible/deploy.ymlexample playbook;vault.yml.examplesecrets template
Changed
app_versionbumped to0.5.0- Admin login page shows a badge when
LDAP_ENABLED=true admin_users.password_hashis now nullable (migration 011) — LDAP-only accounts have no local passwordattachments.datais now nullable (migration 010) — S3-backed attachments store onlystorage_key
Database migrations
010_s3_attachment_storage.py: makesattachments.datanullable; addsstorage_key VARCHAR(512)column toattachments011_ldap_auth.py: makesadmin_users.password_hashnullable; addsldap_username VARCHAR(255) UNIQUEcolumn toadmin_users
Dependencies added
python-json-logger>=3.2.0— structured JSON log formatterapscheduler>=3.11.0— background job scheduler for SLA remindersboto3>=1.38.0— AWS S3-compatible object storage clientldap3>=2.9.0— LDAP / Active Directory authentication
Tests
- Added
tests/test_v050.py— 68 tests covering all new v0.5.0 features - Fixed
conftest.py: addedadminroleto the enum drop list so re-runs don't fail with "type already exists" on migration 003 - Coverage maintained at ≥90% (90.36% with full test suite)
0.4.0 — 2026-04-26
Added
- Multi-step submission form: single-page
/submitreplaced with a guided 5–6 step wizard (mode → location → category → description → attachments → review); Redis session stores partial state undersubmission-session:{uuid}with a 2-hour TTL; back/next navigation throughout; progress indicator shows current step and total;ow-submission-sessioncookie - Anonymous vs. confidential mode (Step 1): whistleblowers choose anonymous (no personal data) or confidential (optional name, contact info, secure email); confidential data encrypted with Fernet symmetric encryption derived from
SECRET_KEY; decrypted only on the assigned admin's report detail view; newSUBMISSION_MODE_ENABLEDconfig toggle - Multi-location / branch selection (Step 2, conditional):
Locationmodel withid,name,code(unique),description,is_active,sort_order,created_at; location selector shown only when active locations exist; admin management at/admin/locations - Confidential fields on reports:
submission_mode(enum),location_id(FK),confidential_name(encrypted text),confidential_contact(encrypted text),secure_email(encrypted text) added toreportstable via migration 009; all nullable for zero-downtime deploy - Optional secure contact email: whistleblower can provide an anonymous email address in confidential mode; when admin posts a reply, a brief notification (no report content) is sent;
secure_emailnever appears in logs - HinSchG deadline display for whistleblowers: status page shows 7-day acknowledgement deadline with days remaining (or confirmed date) and 3-month feedback deadline with days left / pending acknowledgement indicator
- French language (fr):
app/locales/fr.jsonwith full French translations for all keys;fradded to supported languages inapp/i18n.py; language picker in nav bar shows English / Deutsch / Français dropdown - Location filter on admin dashboard: filter reports by location; location shown in report detail sidebar
- WCAG 2.1 AA accessibility improvements: skip-to-content link in
base.html;aria-labelon all nav elements;aria-current="page"on active nav links;aria-liveregions;role="alert"on errors;aria-requiredon required fields;aria-describedbyon hints;sr-onlyutility; visible focus indicators; language picker keyboard-accessible - New CSS components: submit progress indicator, mode-selection cards with
:has()focus handling, step-action row, review table, skip link, lang picker dropdown
Changed
app_versionbumped to0.4.0- Admin nav in all templates updated to include "Locations" link
- Demo seed creates two demo locations (HQ, Remote) and one confidential demo report
- PDF export includes submission mode, location, and confidential fields (secure email noted as "on file — not printed" for privacy)
add_admin_messageacceptsnotify_whistleblower=Trueto trigger async secure-email notification when a secure email is on fileget_reports_paginatedaccepts optionallocation_idfilter- Health endpoint now returns current
app_version
Fixed
- Language switcher now correctly handles French (
fr) in redirect allowlist
Migration
- Migration
009_locations_confidential.py: createslocationstable,submissionmodeenum, addslocation_id,submission_mode,confidential_name,confidential_contact,secure_emailtoreports
0.3.0 — 2026-04-26
Added
- RBAC — Role-Based Access Control:
AdminRoleenum withadminandcase_managerroles;require_role()FastAPI dependency factory; role shown in dashboard nav and report detail - Case assignment: admins can assign reports to any active staff member; "My Cases" filter tab on dashboard; assignee column in reports table
- Status workflow overhaul:
received → in_review → pending_feedback → closedreplaces the oldreceived → acknowledged → in_progress → closedflow;STATUS_TRANSITIONSdict enforces valid transitions server-side; only valid next-states shown in UI - 4-eyes deletion principle: report deletion now requires two different admins — one requests, a different one confirms; same-admin confirm returns HTTP 409
- Immutable audit log:
AuditLogmodel with 18AuditActionconstants; every admin action is recorded; exportable as CSV from/admin/audit-log; last 20 entries shown per report - Custom DB-driven categories:
ReportCategorymodel replaces hard-coded Python enum; category management page at/admin/categories; existing reports preserve category as string - Case linking:
CaseLinkmodel with normalization constraint (smaller UUID always inreport_id_a); link/unlink cases from report detail page - Internal notes:
AdminNotemodel — admin-only notes never shown to whistleblower; add notes from report detail page - PDF export: full case export via
/admin/reports/{id}/export.pdfusingfpdf2(pure Python, no system packages); includes SLA compliance section per HinSchG §17 - Admin user management: create, deactivate, reactivate, and change roles of admin users at
/admin/users; last-active-admin protection prevents lockout - Dashboard statistics:
/admin/statspage with status distribution bar charts, category breakdown, total count, and 7-day SLA compliance rate - Demo seed improvements: case manager demo user (
case_manager/demo); 4 demo reports covering all statuses; demo internal notes, case links, and audit entries - New admin navigation: persistent links to Stats, Categories, Users, Audit Log from all admin pages
Changed
- Report
categoryfield migrated from PostgreSQL enum toVARCHAR(64)— stored as plain string at submit time for history immutability (migration 006) acknowledged_report()now transitions toin_reviewinstead ofacknowledged- Status labels updated throughout UI and i18n files
Database migrations
003_roles_status_assignment.py— addsadminroleenum,role/is_activeto admin_users, addsin_review/pending_feedbackto reportstatus enum, migrates old values, addsassigned_to_idFK to reports004_audit_log.py— createsaudit_logtable005_admin_notes.py— createsadmin_notestable006_custom_categories.py— createsreport_categoriestable, seeds 7 defaults, migratesreports.categoryfrom enum to VARCHAR007_deletion_requests.py— createsdeletion_requeststable with UNIQUE(report_id)008_case_links.py— createscase_linkstable with normalization CHECK constraint
Tests
- Added
test_v030_services.py— 35 service-layer tests for new features - Added
test_v030_api.py— 25 API-level tests for new admin endpoints - Added
test_pdf_service.py— PDF generation tests - Updated existing tests to use new
ReportStatusvalues (in_review,pending_feedback)
0.2.2 — 2026-04-26
Changed
- Logo redesigned: new "Protected Signal" concept — navy shield with gradient depth, amber glow, and three-arc signal mark; consistent across app favicon, docs favicon, apple-touch-icon, and all inline SVG nav logos
- README trimmed to overview + quick start; full documentation lives exclusively at openwhistle.net/docs.html (single source of truth, no duplication)
- docs.html nav CSS aligned with index.html: SVG circle selector, border-color transition on theme-toggle hover, and light-mode stroke overrides for the logo
Fixed
- Quay.io image reference corrected to
quay.io/jp1337/openwhistleeverywhere
Tests
- Added 128 new test cases across auth, admin, reports, misc, and demo seed modules
- Coverage increased from ~75 % to 91 %
- Resolved all CI test failures caused by DEMO\_MODE=true and function-scoped event loop conflicts
- Extracted
_seed(db)helper fromdemo_seed.pyto enable direct session injection in tests
CI / CD
- Codecov integration: added
CODECOV_TOKENsecret and pinnedcodecov-action@v5 - GitHub org avatar (500×500) and repository social preview banner (1280×640) added under
docs/
0.2.1 — 2026-04-26
Fixed
- Case number generation now uses
MAX(case_number)instead ofCOUNT(*), preventing a previously-issued case number from being reused after a report is hard-deleted - Test isolation: orphaned report in
test_delete_report_only_removes_matching_sessionscaused aUniqueViolationErroron CI; the test now cleans up all created reports
Security
- Resolved 4 additional CodeQL code scanning alerts:
py/url-redirection(set-language endpoint): redirect target resolved via a static_NEXT_ALLOWLISTdict, severing any taint flow from user-supplied inputpy/cookie-injection(reply endpoint): session cookie always rotated to a freshsecrets.token_urlsafe()value on every reply, never derived from the inbound cookiepy/clear-text-logging×2 (reset_admin_password.py): replaced variable-based error messages with explicit if-chains where everyprint()argument is a string literal, eliminating any data-flow path from the password variable to a logging sink
0.2.0 — 2026-04-26
Added
- Admin session expiry warning: a non-intrusive banner appears 5 minutes before the session expires with a live countdown and a one-click "Extend Session" button that silently refreshes the JWT and Redis TTL without losing work (
GET /admin/session/ttl,POST /admin/session/refresh) - Admin dashboard pagination with configurable page size (10 / 25 / 50 / 100), server-side
- Admin dashboard column sorting (submitted date, case number, category, status)
- Admin dashboard status filtering with clickable stat cards
- File attachment support: whistleblowers can upload evidence files (PDF, images, Word, Excel, CSV, TXT — up to 10 MB each, 5 files per report); admins can download attachments from the report detail page
- Email and webhook notifications when a new report is submitted (
NOTIFY_EMAIL_andNOTIFY_WEBHOOK_environment variables) - CSRF Double-Submit Cookie protection extended to all whistleblower POST endpoints (
/submit,/status,/reply) scripts/reset_admin_password.py: interactive CLI to reset any admin user's password without direct database access; supports--list,--username,--password; enforces password strength requirements; does not touch the TOTP secret- HTML error page for form validation errors (422) instead of raw JSON API response
- Company branding:
BRAND_PRIMARY_COLOR,BRAND_SECONDARY_COLOR,BRAND_LOGO_URLenv vars allow organisations to customise the UI with their own colours and logo - OIDC Authorization Code Flow: admins can log in via any OpenID Connect provider when
OIDC_ENABLED=true(authlib 1.7+, state stored in Redis with 5-minute TTL) - Docker image cleanup workflow (GHCR, Docker Hub, Quay.io — runs weekly, retains 10 most recent
sha-tagged images per registry) edgeDocker tag published on every push tomainfor tracking the latest unreleased state- Complete UI redesign: "Trusted Institution" aesthetic (Sora + Nunito Sans typography, white navigation bar, institutional blue + teal accent palette, elevation shadows, rounded corners)
- Professional dark mode with warm blue-gray palette (
#111827) - Submit-page sidebar redesigned with brand-colour background and subtle radial gradient
- SSO button on admin login page (shown only when
OIDC_ENABLED=true) - GitHub Pages website deployed from
docs/directory
Fixed
- Whistleblower status-session Redis keys are now cleaned up immediately when a report is hard-deleted (previously persisted for up to 2 hours as orphaned entries)
- SLA "days remaining" dashboard column no longer renders a double unit (e.g. "89d Tage verbleibend")
- Session cookie deletion now passes matching security attributes (httponly, samesite, secure) so browsers reliably remove the cookie on logout
- Theme toggle button now inherits the correct body font instead of falling back to the system font
- Public forms no longer bypass browser
required-attribute validation (removednovalidatefrom/submitand/statusforms) - Empty reply content and oversized descriptions now return 422 with server-side length enforcement (previously validated by HTML attribute only, bypassable via direct HTTP requests)
Security
- All whistleblower-facing cookies now use
secure=not settings.demo_mode(was hardcodedFalse, meaning cookies were sent over HTTP even in production) - Server-side max-length validation added for report description (≤ 10 000 chars) and reply content (≤ 5 000 chars) — previously enforced by HTML
maxlengthattribute only - CSRF Double-Submit Cookie pattern extended to
/statusand/replywhistleblower endpoints
0.1.0 — 2026-04-21
Added
- Complete rewrite from C# ASP.NET Core to Python 3.14 / FastAPI
- Whistleblower report submission with category and description
- Two-factor whistleblower access: case number (OW-YYYY-NNNNN) + UUID4 secret PIN
- Bidirectional communication thread between whistleblower and reporting office (HinSchG §17)
- First-run setup wizard for admin account creation with TOTP enrollment
- Mandatory TOTP (RFC 6238) MFA for all administrator accounts
- Optional OIDC login for administrators (authlib 1.7+)
- HinSchG SLA tracking: 7-day acknowledgement deadline (§17 Abs. 1) and 3-month feedback deadline (§17 Abs. 2)
- IP anonymity: nginx configured with
access_log off, application never reads or stores IP addresses - IP leakage detection: admin dashboard warning when upstream proxies forward IP headers
- Redis-based bruteforce protection with no IP tracking (session-token-based rate limiting)
- Hard deletion of reports (DSGVO Art. 17 right to erasure)
- Demo mode with seed data (
DEMO_MODE=true) - Automatic database migration check on every startup (alembic upgrade head)
- DSGVO-compliant: all fonts and static assets self-hosted (Spectral, Source Serif 4, JetBrains Mono)
- Light / dark mode with localStorage persistence and CSS media query fallback
- Security headers: CSP, HSTS, X-Frame-Options, Referrer-Policy, Permissions-Policy
- Multi-registry Docker publishing: ghcr.io, Docker Hub, quay.io
- Image signing with Cosign
- GitHub Actions CI: mypy --strict, ruff, pytest with coverage, docker build
- HinSchG reference document (
docs/hinschg_reference.md) - PostgreSQL 18 + Redis 8 support
Technical Decisions
- Python 3.14 over Go/Rust: team familiarity with Python; mypy --strict provides compile-like type safety guarantees in CI
- FastAPI for async performance and Pydantic validation
- SQLAlchemy 2.0 async for type-safe database access
- Authlib 1.7.0+ required due to CVE-2026-28498 in earlier versions
- SSR with Jinja2 over SPA: simpler security model, no client-side secrets, works without JavaScript
- Session tokens in Redis for instant revocation without database lookups
- Rate limiting by session token (not IP) to maintain full anonymity
- alembic upgrade head on every startup to guarantee migration consistency