refactor(IPO-006): one shared SEBI URL canonicalizer + downloader doc riders#101
Conversation
… riders
The listing scraper (sources/sebi.py) and the prospectus downloader
(documents/downloader.py) each carried a private _canonical_sebi_url. The
copies had drifted: the downloader's gained a malformed-port guard, an
optional DNS answer check (anti-rebinding), and a PDF-path restriction the
scraper's copy lacked.
- backend/ipo/url_canonical.py: single canonical_sebi_url(value, *,
base_url, allowed_hosts, error, resolver=None, require_pdf_path=False).
Every rejection raises the CALLER's error factory, so each module keeps
its own error taxonomy. The raiser-helper shape preserves the original
implicit exception chaining (and B904 compliance) exactly.
- Both modules keep thin _canonical_sebi_url wrappers with their original
signatures, so all call sites and both test suites pass unmodified.
- One declared tightening rides along: a malformed port in the scraper now
raises SebiSourceError instead of leaking a bare ValueError (rejected
either way; now within the module's error taxonomy). Locked by test.
- New tests/test_ipo_url_canonical.py (16 tests): canonicalization shape,
every rejection path via the injected error type, require_pdf_path
on/off, DNS layer (public passes; private/mixed/empty/failing/malformed
answers fail closed; resolver=None skips the layer entirely).
- Doc riders from the June review, at the code they describe:
- %PDF- magic-byte check is header-only BY DESIGN (HTML error pages are
the threat; deep validation is the parse stage's job; the
content-addressed cache re-verifies the SHA-256 on every later read).
- os.replace publish sets no explicit file mode BY DESIGN (public SEBI
filings; threat model is tampering -> digest re-verification, not
disclosure; data_dir protection is the documented deployment model).
Gates: 1,406 passed, coverage 88.17% (floor 87); pre-commit validate,
compileall, ruff, mypy (120 files), bandit, pip-audit all clean; IPO
docstring/boundary policy guards green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DoRmAmMu1997
left a comment
There was a problem hiding this comment.
Self-review (posted as COMMENT — the bot runs as the PR author and cannot APPROVE). Verdict: Approve.
This diff touches the SSRF gate, so it got the closest read of the wave:
- Control-by-control comparison — every check in both pre-change copies (HTTPS-only, exact host allowlist, credential rejection, port 443/None, PDF-path restriction, DNS
is_globalfail-closed, fragment stripping) is preserved unchanged; one control is strictly tightened: a malformed port in the scraper now raisesSebiSourceErrorinstead of leaking a bareValueError(rejected either way — this is the PR's single declared behavior change, test-locked). - Check-order fidelity — the only ordering change on the scraper side is the port parse moving ahead of the compound boolean; outcome-identical for every URL class (non-HTTPS/wrong-host still reject on the compound check; the malformed-port-on-allowlisted-host case is the declared rider).
- No weakening via parameterization — the dangerous knobs (
resolver=None,require_pdf_path=False) are bound inside each module's private wrapper; the downloader's always-on DNS layer cannot be disabled from any call site.test_no_resolver_skips_the_dns_check_entirelyprovesresolver=Noneskips the layer rather than null-running it (an empty answer set WITH a resolver fails closed — asserted separately). - Error taxonomy intact — the shared module raises only the caller's factory; the downloader still emits its secret-safe
unsafe_urlcode, the scraper its fixed message. The_rejecthelper preserves implicit exception chaining exactly like both originals' raiser helpers (and satisfies B904). - Suites unmodified — SEBI ingestion, downloader, and filings-job tests pass untouched; the IPO subsystem's auto-discovering docstring guard and network-boundary guard both pass with the new module (the docstring guard caught my first draft of the test file — fixed by documenting every definition including nested fakes).
- Micro-note — the scraper's copy called
value.strip(); the shared body usesstr(value).strip()(the downloader's form). Identical for everystrinput, which is every call site; a non-str would previously have crashed withAttributeError.
Gates: 1,406 passed + 1 skipped, coverage 88.17% (floor 87); ruff/mypy(120)/bandit/pip-audit/pre-commit/compileall clean; constraints/pyproject diff vs main empty. Security review: no findings, one control tightened.
|
Codex review plan before fixes:
I’ll implement these with focused hostile-input regressions, run the complete quality/security/Docker gates, and update this branch. |
DoRmAmMu1997
left a comment
There was a problem hiding this comment.
Codex review findings for IPO-006. The shared canonicalizer is the right boundary, but it must canonicalize path semantics and propagate the PDF policy across redirects.
Co-authored-by: Hemant <hemantdhamija@gmail.com> Co-authored-by: Codex <codex@openai.com>
Completion verdictComplete — no remaining reportable findings. This is a completion comment, not an approval. Commit A formal diff-security review found no remaining reportable findings. |
Ticket scope (IPO-006)
Goal: deduplicate the two drifted
_canonical_sebi_urlcopies guarding every SEBI fetch, without changing either module's public behavior — plus two documentation riders the June review asked for.The drift being fixed (this is the SSRF gate, so divergence is a security-consistency problem): the downloader's copy (downloader.py:91) had grown a malformed-port guard, an optional DNS-answer check (anti-rebinding), and a
require_pdf_pathrestriction; the scraper's copy (sebi.py:77) had none of these and leaked a bareValueErroron a malformed port.In scope
backend/ipo/url_canonical.py: onecanonical_sebi_url(value, *, base_url, allowed_hosts, error, resolver=None, require_pdf_path=False). Every rejection raises the caller's error factory, so the scraper keepsSebiSourceError(fixed message) and the downloader keeps its secret-safeunsafe_urlcode — the shared module knows neither type. The raiser-helper shape preserves the originals' implicit exception chaining exactly (and satisfies flake8-bugbear B904)._canonical_sebi_urlwrappers with their original signatures — all internal call sites and both existing test suites pass unmodified.https://host:abc/) in the scraper now raisesSebiSourceErrorinstead of leaking a bareValueError. The URL was rejected either way; it now fails within the module's error taxonomy. Test-locked.tests/test_ipo_url_canonical.pycovering the shared knobs: canonicalization shape (fragment dropped, path defaulted, host casefolded,:443collapsed), each rejection path via the injected error type,require_pdf_pathon/off, and the DNS layer (public passes; private / mixed / empty / failing / malformed answers fail closed;resolver=Noneprovably skips the layer). Written to the IPO subsystem's docstring policy guard.downloader.py:b"%PDF-"check is header-only by design — it rejects HTML error pages served as PDFs; deep validation belongs to the parse stage, and the content-addressed cache re-verifies the SHA-256 on every later read, so corruption is still caught;os.replacepublish sets no explicit file mode by design — these are public SEBI filings, the threat is tampering (covered by digest re-verification), not disclosure; confidentiality of the cache rides on the documenteddata_dirprotection.Out of scope (deliberate)
ALLOWED_HOSTSconstants stay (they are part of each module's reviewed surface); the shared function takes the set as a parameter.This is ticket 4 of a 10-ticket second improvement wave (TEST-007 #98, REF-003 #99, AI-006 #100, IPO-006, TEST-006, PERF-002, DEPLOY-004, QUAL-006, QUAL-007, DOC-003), each shipped as its own PR off
main.Gates (all green, local, Python 3.13)
pytest -q --cov=backend --cov=screeners --cov=ui --cov-fail-under=87pre_commit validate-config/compileall/ruff/mypy(120 files) /bandit/pip_auditconstraints.txt/pyproject.tomldiff vs main: empty (as required).Review
/code-review: Approve — wrapper signatures byte-identical; the shared body reproduces the downloader's (stricter) logic with the scraper opting out of downloader-only knobs; exception chaining shape preserved via the raiser helper; dead imports removed (ruff-verified)./security-review: no findings — the SSRF gate's checks are strictly preserved or tightened (the scraper gains the malformed-port fail-closed path), the DNS anti-rebinding layer is unchanged for the downloader and provably skipped-not-nulled for the scraper, and the new tests lock the fail-closed behavior of every rejection path.🤖 Generated with Claude Code