The independent reviewer returned "SIGNIFICANT CHANGES NEEDED" on the security
review, with the code review itself approved. This addresses the three
blocking items plus the legitimate medium-severity ones.
Blocking items
--------------
1. Weak password hashing (SHA-256 -> scrypt)
The custom salted SHA-256 helper is replaced with
`werkzeug.security.generate_password_hash` / `check_password_hash`, which
use scrypt by default. The hash carries its own salt and is deliberately
slow to compute, so it is not attackable with a GPU.
The legacy plaintext accounts still work, but a successful legacy login now
**rewrites the row in place** with a proper hash. The plaintext credential
disappears from the database the first time the account is used.
Verified against the live database: the `beer` account's plaintext
`beer_pass` was replaced with a scrypt hash on first login, and login with
the hash still succeeds.
2. Plaintext password persistence
Fixed by the migrate-on-login above. There is no longer a code path that
leaves a password in plaintext.
3. Hardcoded secret defaults
`SECRET_KEY` no longer has a default. The app raises `ImproperlyConfigured`
and refuses to start when it is missing or still set to a placeholder
value. A known session-signing key would let anyone forge a login cookie,
so a misconfigured deployment must fail loudly.
The Dockerfile no longer ships a placeholder either — the variable is simply
not set, so a deploy that forgets it fails immediately and visibly.
Note: Werkzeug 3 removed `ImproperlyConfigured` and Flask has never
exported one, so the app defines its own as a `RuntimeError` subclass.
Medium items
------------
4. NoSQL injection risk
Every value that reaches a Mongo query is now explicitly coerced first:
run ids and dates go through `_as_id` / `_as_date`, which turn anything
that is not a plain string or number into a string (or an empty string).
Without this, a caller passing `{"$ne": ""}` would have been interpreted
by Mongo as a query operator rather than as a value.
Two tests cover this: one drives the login path with an operator payload,
one checks the coercion helper directly.
5. Dependency pinning
`requirements.txt` now pins exact versions of the runtime dependencies, and
`requirements-dev.txt` pins the test dependencies. The image is rebuilt on
every merge, so an unpinned dependency could change behaviour underneath a
deploy with no commit to show for it.
6. Template logic
The `sg / 1000` arithmetic has been moved out of the Jinja templates and
into the domain layer — `run_summary()` now provides `og_decimal_str`,
`fg_decimal_str` and `min_sg_decimal`, pre-formatted. No template does
arithmetic on the data any more.
7. Health check depth
`/healthz` stays a shallow **liveness** probe that never touches Mongo — a
database outage must not make the kubelet restart a healthy process. A new
`/readyz` **readiness** probe pings Mongo and returns 503 when it is
unreachable, so a pod with no database is pulled out of the Service
endpoints instead of serving errors. The RUNBOOK documents wiring both.
Low items
---------
8. Mongo aggregation pipeline for `runs()`
Not changed. The dataset is 47 documents across 18 runs and will stay in
that range for years — the app exists to record a handful of readings per
brew. Moving the grouping into an aggregation pipeline would add real
complexity for no measurable gain, and the grouping in Python is what makes
the run aggregation straightforward to test. Noted for whenever the data
volume makes it matter.
9. Kubernetes Secret for the credentials
Not changed here, and deliberately so. The live Deployment carries
`MONGO_PASS` as a plain environment value. Replacing it means replacing
hand-provisioned live values, which is an operator decision. The RUNBOOK
now documents the Secret manifest to use when that is done, and includes a
ready-to-paste probe configuration.
10. Rate limiting on `/login`, Flask-Talisman, password complexity
Not added. This is a single-user home-brew tracker on a LAN, reachable
through a NodePort. Rate limiting and security-header middleware are
production-grade hardening that adds dependencies and configuration
surface without addressing a realistic threat here — the credentials are
`beer`/`beer_pass` on a service only the operator and their own scanning
can reach. The one change that materially matters — the session-signing
key — is now enforced.
11. Least-privilege Mongo user
Not changed. The app connects as `root` today, matching the existing
deployment. Creating a scoped user is a cluster-side operation and
belongs with the credential work in item 9.
Tests
-----
73 tests (up from 64). New coverage: legacy password migration, hash
verification, empty-credential rejection, the NoSQL operator payload, the
secret-key guards, and the readiness probe in both states.
Brew Log
Stores specific-gravity data for home-brewed beer runs in Mongo and turns it into useful figures — alcohol by volume, days in the fermenter, gravity trends.
Flask app, Mongo backend, no build step for the front end.
Running it locally
python -m venv .venv
. .venv/bin/activate
pip install -r requirements-dev.txt
export MONGO_HOST=192.168.178.229
export MONGO_USER=root
export MONGO_PASS=...
export SECRET_KEY=anything-for-local-use
cd src/flask
python -m beer_data # dev server on :5000
# or, production-style:
python -m waitress --listen=0.0.0.0:5000 --call beer_data:create_app
Then open http://localhost:5000/ and sign in.
Tests
. .venv/bin/activate
python -m pytest tests/ -q
The tests use mongomock, so they need no database and no network.
Layout
src/flask/
beer_data.py entry point; exposes `app` and `create_app`
app_factory.py application factory, request-scoped Mongo repository
brewing.py pure domain logic — gravity parsing, ABV, run aggregation
views/ blueprints: auth, brews, (later) analysis
mongo/repository.py Mongo access for the beer_data and beer_runs collections
templates/ Jinja templates
static/ CSS, JS, fonts, logo (all served locally — no CDN)
tests/ pytest suite
brewing.py holds every piece of arithmetic and has no Flask or Mongo
dependency, so the maths is unit-testable on its own.
Data model
beer_data — one document per gravity reading
| Field | Type | Notes |
|---|---|---|
_id |
string | 24 random characters |
beer_run_id |
string | The run this reading belongs to, e.g. "20" |
beer_type |
string | Beer/cider name. Free text, per reading — it drifts across a run |
sg |
string | Specific gravity in points: 1039 means 1.039 |
date |
string | YYYY-MM-DD |
final_reading |
string | "True" / "False" — marks the last reading of a run |
comment |
string | Free text |
The unique key is (date, beer_run_id): re-submitting a reading for the same
run and day updates it in place rather than creating a duplicate.
A run is active while it has no reading with final_reading == "True".
beer_runs — one document per run (metadata)
Added alongside the readings to hold what cannot be derived from them: the canonical beer name, the tap number, the recorded original gravity, and brew notes. This collection is only ever inserted into, never written over by the backfill.
Alcohol by volume
Standard homebrew formula:
ABV% = (OG - FG) × 131.25
With gravity stored in points, the drop is already in points, so
ABV% = points_drop × 0.13125.
The previous version of this app used
(max - min) / 7.36 + 0.5, which is not a recognised brewing formula and overstated every result. The old figure is still computed bybrewing.legacy_abv_percent()so the history page can show the difference between the two.
For a run still in the fermenter the figure is an estimate: it uses the run's first reading as the original gravity. A run with a single reading and no recorded OG has no reliable estimate, and the UI shows a dash rather than a misleading number.
Deployment
The image is built and pushed by .gitea/workflows/build_push.yml on push to
master. See RUNBOOK.md for the cluster side.