| Name | Modified | Size | Downloads / Week |
|---|---|---|---|
| Parent folder | |||
| OpenWhistle 2.0.0 source code.tar.gz | 2026-09-26 | 2.0 MB | |
| OpenWhistle 2.0.0 source code.zip | 2026-09-26 | 2.2 MB | |
| README.md | 2026-09-26 | 27.4 kB | |
| Totals: 3 Items | 4.2 MB | 0 | |
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.
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.
Thank you, @zbridges-valid8, for finding and fixing the wizard's Back-button bugs (#68, [#94]).