SEC-003: fix polynomial ReDoS in the admin email shape check - #115
Conversation
GitHub code scanning (CodeQL default setup) reported two open high-severity alerts on main. This resolves both: one is a genuine bug, one is heuristic noise that is now documented rather than "fixed". py/polynomial-redos (genuine) — the SEC-001 address check used `^[^@\s]+@[^@\s]+\.[^@\s]+$`, which is ambiguous because `[^@\s]` matches "." as well: the domain half can split at any dot, so a rejecting address makes the engine retry every split and rescan to the end each time. Taint path is ui/roles_page.py (st.text_input) -> assign_role -> _normalize_email -> match(). Reproduced with `"a@" + "a."*n + "@"` (the trailing "@" survives the strip in _normalize_email, the same shape as the existing two@@example.com case): 16 KB took 1.97s, 32 KB took 12.3s, 40 KB took 17.8s of blocked server thread. Reachability is admin-only, but Streamlit runs the script in the server process, so one paste stalls a thread. Two independent defences: 1. Every atom now excludes "." (`^[^@\s]+@[^@\s.]+(?:\.[^@\s.]+)+$`), forcing each split at a literal dot and matching in linear time — the same inputs now take 1.9ms / 5.5ms / 8ms. 2. An RFC 5321 254-character cap is checked before the regex, so the work stays bounded however the pattern is edited later. revoke_role is deliberately left alone: it never runs the regex, and its non-empty-only check is what lets an admin clean up badly-shaped rows that predate SEC-001. Deliberate side effect, consistent with SEC-001's intent: empty DNS labels (a@a..b, a@.b.c, a@b.c.) are now rejected. All pre-existing accept/reject cases are unchanged. Note on the tests: the timing guard asserts on _EMAIL_SHAPE directly rather than through assign_role. Code review caught that routing it through assign_role made it vacuous — the length cap short-circuits the `or`, so the regex is never reached (0 invocations) and the test passed even with the ambiguous pattern restored. Testing the pattern directly keeps defence 1 guarded independently of defence 2; a second test pins the short-circuit ordering that makes the cap a real bound. Both were mutation-checked: reverting either protection fails the corresponding test. py/clear-text-storage-sensitive-data (false positive) — the SARIF flow names normalize_secret_safe_json() as the sensitive-data source. CodeQL's classifier is name-based, so the substring "secret" makes it treat the return value as a secret; that function is the redactor, so the alert flags the sanitizer's own output. What is written is the HMAC-signed envelope with already-redacted provenance, carrying the signature and never the signing key. Renaming a public boundary function with 55 references across 16 files to satisfy a substring match was considered and rejected; the analysis is recorded in the audit register and the alert is dismissed on GitHub as a false positive. Gates: pytest 1935 passed / 1 skipped, coverage 89.79% (floor 89), ruff clean, mypy clean over 257 files, bandit clean, pip-audit clean, compileall clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DoRmAmMu1997
left a comment
There was a problem hiding this comment.
Codex follow-up review (COMMENT): the production ReDoS fix is sound. I reviewed the exact a154f248...c80ff2d4 range, followed the admin-input path through role persistence, reproduced the old/new timing difference, and completed a Codex Security diff scan with no reportable vulnerabilities. GitHub's PR-head CodeQL analyses also report zero results.
Before merge I found four bounded follow-ups:
tests/test_admin_roles_service.py:145resolves_EMAIL_SHAPEthrough the module after monkeypatching it, so the recording double recursively calls itself if the length guard regresses. The double should capture and delegate to the original compiled pattern.- The exact 254/255 application boundary is not pinned; add valid boundary cases without changing the production regex.
docs/architecture/audit-2026-06.md:316names a nonexistent timing test, and the RFC wording should distinguish this code-point work bound from full RFC/octet validation.- The audit note says CodeQL alert #1 was dismissed and that no secret is persisted. The alert is currently still open, and the latter wording is broader than the documented best-effort redaction contract. I independently confirmed the CodeQL name heuristic and the HMAC/redaction path; I will narrow the prose and dismiss/read back alert #1 as an authorized false positive.
I am applying these as a surgical follow-up commit, then will rerun the repository gates and the diff security scan before updating this PR.
Capture the original email matcher before replacing it with the ordering test double so a regressed guard produces the intended assertion instead of recursive test-helper failure. Pin the inclusive 254-code-point boundary with realistic shape-valid addresses on both sides. Clarify that the bound is an application work limit rather than full RFC/octet validation, align the storage-capacity comment, correct the audit's test name, and describe the CodeQL false-positive path without overstating best-effort redaction. Co-authored-by: Codex <codex@openai.com>
|
Codex follow-up complete at Implemented:
Verification:
Local-only note: this machine has Python 3.13 rather than the supported CI 3.11/3.12 and no Docker. Its full run reached The follow-up commit includes |
Why
GitHub code scanning reported two open high-severity CodeQL alerts on
main.Both are resolved here: one is a genuine bug and is fixed, the other is a
name-heuristic false positive and is documented rather than "fixed".
py/polynomial-redospy/clear-text-storage-sensitive-dataAlert #2 — polynomial ReDoS (genuine)
The SEC-001 address check used
^[^@\s]+@[^@\s]+\.[^@\s]+$. That pattern isambiguous:
[^@\s]matches.as well, so[^@\s]+\.[^@\s]+can split atany dot in the domain. On a rejecting address the engine retries every split
and rescans to the end each time — quadratic.
SARIF taint path:
ui/roles_page.py(st.text_input) →assign_role→_normalize_email→.match().Reproduced with the witness
"a@" + "a."*n + "@"— the trailing@survives the.strip()in_normalize_email, and is the same shape as the long-standingtwo@@example.comtest case:Reachability is admin-only (
ui/roles_page.pyreturns early without the adminrole), so the realistic impact is a careless or compromised admin session rather
than anonymous DoS — but Streamlit runs the script in the server process, so one
paste blocks a server thread for ~12 s.
Two independent defences
.(
^[^@\s]+@[^@\s.]+(?:\.[^@\s.]+)+$), forcing each split at a literal dot andmatching in linear time.
stays bounded however the pattern is edited later. SMTP wire limits use
octets, so this is deliberately not presented as full RFC validation.
revoke_roleis deliberately left alone: it never runs the regex, and itsnon-empty-only check is what lets an admin clean up badly-shaped rows that
predate SEC-001.
Deliberate side effect, consistent with SEC-001's intent: empty DNS labels
(
a@a..b,a@.b.c,a@b.c.) are now rejected. Every pre-existing accept/rejectcase is unchanged, and the new pattern was proven to be a strict subset of
the old one (exhaustive enumeration to length 6 over
a.@␠-, plus 300k randomstrings, found zero addresses newly accepted) — so this cannot widen who may be
granted a role.
A note on the tests, because review changed them
The first version of the timing guard ran through
assign_roleand wasvacuous: the new length cap short-circuits the
or, so the regex was neverreached (0 invocations) and the test passed even with the ambiguous pattern
restored. It now asserts on
_EMAIL_SHAPEdirectly, which keeps defence 1guarded independently of defence 2, and a second test pins the short-circuit
ordering that makes the cap a real bound.
The matcher and guard were mutation-checked — reverting either protection fails
the corresponding test. The follow-up also pins a shape-valid 254-character
address as accepted and its 255-character counterpart as rejected:
test_email_shape_pattern_stays_linear_on_pathological_inputFAILStest_assign_rejects_over_length_email_before_running_the_patternFAILSAlert #1 — clear-text storage of a secret (false positive)
The SARIF flow names the sensitive-data source as
normalize_secret_safe_json()(
backend/scanning/result_contract.py), reached via_provenance_json. CodeQL'sSensitiveDataSourceclassifier is name-based — the substring "secret" inthe callee name makes it treat the return value as
classification: secret. Butthat function is the redactor, so the alert is flagging the sanitizer's own
output.
In the cited path, the HMAC-signed envelope
{schema_version, prompt_version, verdict, provenance}contains provenance thatpasses through the application's best-effort persistence redactor and carries
the signature (
integrity_hmac_sha256), never the signing key — seebackend/ai_cache_integrity.py. This disproves the alert's claimed sourcewithout overstating the broader redaction safety net as proof about arbitrary
values.
Considered and rejected: renaming
normalize_secret_safe_jsonto break thesubstring heuristic — an accurate, well-documented public boundary name with 55
references across 16 files (including
AGENTS.mdand six architecture docs).The analysis is recorded in
docs/architecture/audit-2026-06.mdunder Findingsverified FALSE (do not re-flag). Alert #1 was dismissed on GitHub as a
false positive on 2026-08-31 with the same rationale and verified by API
readback.
Codex follow-up review
Commit
8ec23fbimplements the bounded findings from the comment-first review:Normalized AST comparison confirms the follow-up changes no production Python
behavior relative to the original PR head
c80ff2d4. A second Codex Securityscan of the final
origin/main...8ec23fbrange found zero reportable findings.The follow-up commit is co-authored by Codex.
Gates
All green on this branch:
pytest— 1938 passed on each; coverage 89.83% (floor 89)ruffclean ·mypyclean over 257 files ·banditclean ·pip-auditclean ·compileallcleanpre-commit validate-configclean · CodeQL cleangit diff origin/main HEAD -- constraints.txt pyproject.tomlempty (no dependency drift)No schema/ORM behavior, CI-command, dependency, or deterministic-screener changes,
so no migration, supply-chain policy update, or golden regeneration is required.
The migration suite was nevertheless re-run in the focused follow-up verification.
/code-reviewand/security-reviewwere run on the finished diff. The originaland final Codex Security scans returned zero reportable findings, and the
published PR-head CodeQL analyses returned zero results.
🤖 Original implementation generated with Claude Code; follow-up review and hardening co-authored by Codex.