Repository navigation
15.10 security: migrate session JWTs off python-jose to PyJWT, fix Bandit B324 - #39
Conversation
pip-audit and bandit have been red on main since at least 2026-10-12. Neither is a required check, so merges kept sailing past them. pip-audit: python-jose 3.5.0 (PYSEC-2026-4298) and its ecdsa 0.19.2 dep (PYSEC-2026-1325), both with no fix version, so pinning forward was not an option. Neither is reachable in our usage — see the PR for the analysis — but python-jose signs and verifies our session JWTs, so an unpatchable advisory sitting on the authentication path is not something to carry. core/auth.py now uses PyJWT. The token stays byte-identical for our payload (verified against jose 3.5.0: same header, same claims, same signature), so sessions issued before the deploy stay valid. decode_token still raises ValueError, so api/deps.py is unchanged. Decode now also requires exp+sub, so a correctly signed token that omits exp is refused instead of never expiring. cryptography moves from transitive (via python-jose[cryptography]) to a declared dependency — services/c2pa.py, services/device_signature.py and core/crypto.py import it directly, and dropping python-jose would otherwise have undeclared it. Removing python-jose also removes ecdsa: nothing else in the tree pulls it, and no code imports it (all P-256 work goes through cryptography's OpenSSL backend, not the pure-Python ecdsa package). deploy.yml installs an explicit package list rather than `pip install .`, so PyJWT is named there too — without it the service would fail to import on restart — and the replaced python-jose/ecdsa are uninstalled from the prod venv. bandit B324: hashlib.md5 for the badge ETag is a cache key, not a security primitive, so usedforsecurity=False is the fix; changing the digest would invalidate every cached badge ETag for nothing. bandit and pip-audit also gain a pull_request trigger: they ran on main only, so a red result was never visible on the PR that caused it and could not be made a required check at all. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Prod verification after deployDeploy run green on Checked live rather than trusted from CI:
The prod Two paths left to the owner, deliberately
# 1. password sign-in -> JWT, then that JWT against /auth/me
TOKEN=$(curl -s -X POST https://api.tetapi.dev/api/v1/auth/token \
-H 'Content-Type: application/json' \
-d '{"email":"YOUR_EMAIL","password":"YOUR_PASSWORD"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')
curl -s -o /dev/null -w 'auth/me -> HTTP %{http_code}\n' \
-H "Authorization: Bearer $TOKEN" https://api.tetapi.dev/api/v1/auth/me# 2. magic link / email code -> the emailed 6-digit code -> JWT
curl -s -X POST https://api.tetapi.dev/api/v1/auth/email-code \
-H 'Content-Type: application/json' -d '{"email":"YOUR_EMAIL"}'
curl -s -X POST https://api.tetapi.dev/api/v1/auth/verify-code \
-H 'Content-Type: application/json' \
-d '{"email":"YOUR_EMAIL","code":"THE_6_DIGITS"}'Both should return a token and a Still open — one owner settingMake Docs: infra #152 (merged) — changelog, |
Why
pip-auditandbandithave been red onmainsince at least 2026-10-12 — beforethe 1.32 quota work. Neither is a required check, so every merge since has
sailed past them. One of the two was flagging an unpatched dependency on the
authentication path.
1. Are the advisories actually reachable in our usage?
No — neither is. Both still had to go, because neither has a fix version and
python-joseis what signs and verifies our session JWTs.PYSEC-2026-4298 (= CVE-2026-85394, incomplete fix of CVE-2024-33663),
python-jose ≤ 3.5.0, CVSS 7.5 — "algorithm confusion guard bypassed by
DER-encoded public keys". DER-encoded public keys without PEM/SSH framing are
accepted as HMAC secrets, so an attacker who holds the service's public key
can forge HS256 tokens — and, per the advisory, only "when algorithm
restrictions are not explicitly configured".
Our usage fails both preconditions:
app/core/auth.pybefore this PRsettings.secret_key— we have no keypair, so there is no public key to repurpose as an HMAC secretdecodecalled without restricting algorithmsalgorithms=[ALGORITHM],ALGORITHM = "HS256", hardcoded — never read from the token headerPYSEC-2026-1325 (= CVE-2024-23342, GHSA-wj6h-64fc-37mp),
ecdsa≤ 0.19.2,CVSS High — the Minerva timing attack on P-256. It affects ECDSA signing,
keygen and ECDH via
ecdsa.SigningKey.sign_digest(); verification isunaffected, and upstream considers side channels out of scope (no fix planned).
Not reachable either:
ecdsawas only ever present as a transitive dep ofpython-jose, and nothing in the tree imports it —grep -rn "import ecdsa\|from ecdsa\|SigningKey\|sign_digest" --include=*.pyreturns nothing. All our real P-256 work (
services/c2pa.py,services/device_signature.py,generate_certs.py) goes throughcryptography's OpenSSL backend, which is not the vulnerable implementation.Because neither is reachable, no new
S-row indocs/security.md— per thetask's own condition. This is a dependency-hygiene fix on the auth path, not a
live exposure.
2. The migration — sessions survive, byte-for-byte
python-jose→PyJWTinapp/core/auth.py. Token format is not merelycompatible, it is identical: encoding the same payload with jose 3.5.0 (the
version on prod) and PyJWT 2.15.1 produces the same string, byte for byte —
same
{"alg":"HS256","typ":"JWT"}header, same claim serialisation, samesignature.
Cross-verified both directions (PyJWT decodes jose-issued tokens and vice
versa), plus expired →
ExpiredSignatureError, wrong secret →InvalidSignatureError,alg=none→InvalidAlgorithmError. So no sessionis logged out by this deploy — there is no observable difference between a
token minted before it and one minted after.
Other deliberate choices:
decode_tokenstill raisesValueError("Invalid token"), soapp/api/deps.pyneeds no change (it catches(ValueError, KeyError)).expandsub. Every token we mint has both, sothis costs nothing in compatibility — but a correctly signed token that omits
expis now refused rather than valid forever.cryptographybecomes a declared dependency. It was only reaching ustransitively via
python-jose[cryptography], while three modules import itdirectly (
services/c2pa.py,services/device_signature.py,core/crypto.py). Dropping python-jose without this would have left adirectly-imported package undeclared.
ecdsatoo — confirmed by installing the wholedependency set from scratch into a clean venv:
ecdsadoes not come back, sopip-audit's two packages both disappear.
tests/test_auth_token.py(9 tests) locks the shape in: round-trip keepssub/ver, the header really is HS256, and expired / wrong-secret /alg=none/ missing-exp/ malformed tokens all raiseValueError.deploy.ymlhad to change, and this is the part worth a second pair of eyesThe deploy does not run
pip install .— it installs an explicit list:I checked the prod venv before writing this:
python-jose 3.5.0,ecdsa 0.19.2,cryptography 49.0.0, and no PyJWT. So merging the code change alone wouldhave made
tetapi-apifail to import on restart.PyJWTis now named in thatline, and the replaced
python-jose/ecdsaare uninstalled from the prod venvso it matches the manifest. Flagging explicitly per
CLAUDE.md, since thistouches how production gets its packages. (
cryptography 49.0.0alreadysatisfies the new
>=44.0.0pin, so nothing moves there.)3. Bandit B324 — one line
hashlib.md5atapp/api/routes/badge.py:79is the badge ETag cache key, nota security primitive, so the fix is
usedforsecurity=False. Changing the digestwould invalidate every cached badge ETag for no benefit.
4. Why nothing caught this — one owner setting
banditandpip-audittriggered onpush: [main]only. A red result wastherefore never visible on the PR that caused it, and they could not be made
required checks at all, since a required check has to run on the pull request.
This PR adds a
pull_request:trigger to both.That leaves the actual gate as an owner action: make
pytest,banditandpip-auditrequired status checks onmain(Settings → Branches → branchprotection). They caught a real unpatched dependency on the authentication path
and nothing enforced them — that is the whole reason this sat red for days.
Verification
pytest -q→ 120 passed (111 existing + 9 new)bandit -r app -ll→ 0 high, 0 medium, exit 0 (was 1 high)pip-audit→ nopython-jose, noecdsain the resolved setruff --select E,F,I,UP→ identical findings tomain, i.e. no new lint debt🤖 Generated with Claude Code