Audit anchored to **SHA `8c4b9e8ced591052a00a59b558d451904568fc97`**. **Release recommendation: block Confirm until findings 1–4 are fixed.** ## Prioritized findings ### 1. Critical — Existing known cash projections do not block Confirm - **Reference:** `src/jarvis_finance/services/postfinance_service.py:312-365`, `preview_postfinance_import()`, especially `:332-338`. - The position collision query rejects **any** same-day position projection, but the cash query only detects rows where: ```sql source_type='postfinance_official_import' ``` - A known/manual/other-source `cash_balances` projection for the same E-Trading cash account, date, and currency is therefore accepted. Confirm then inserts additional official rows at `postfinance_service.py:849-877`. The schema does not enforce uniqueness across account/date/currency. - Existing coverage only tests a position collision: `tests/test_postfinance_sprint13.py:445-457`. **Minimal fix:** Treat any authoritative same-day cash projection for the target account as a conflict—at minimum remove the PostFinance-only source predicate. Recheck the collision inside the `BEGIN IMMEDIATE` transaction before persistence. Add a synthetic test inserting a non-PostFinance `cash_balances` row and asserting preview conflict plus zero Confirm writes. ### 2. High — Upload boundary has no extension, MIME, or pre-decode request-size enforcement - **References:** - `src/jarvis_finance/api/schemas/postfinance.py:8-13`, `PostFinanceImportRequest` - `src/jarvis_finance/services/postfinance_service.py:57-68`, `_decode()` / `_source_bytes()` - At the audited SHA, filenames are accepted but ignored, no MIME fields are present, and both base64 strings have only `min_length=4`. FastAPI can buffer an arbitrarily large JSON body, and `_decode()` allocates the complete decoded value before parser limits run. - Parser limits at `postfinance_documents.py:707-717` are therefore too late to prevent request/base64 memory exhaustion. **Minimal fix:** Prefer bounded multipart uploads with validated `.zip`/`.pdf` extensions, exact MIME allowlists, `Content-Length`/streaming limits, and content signatures. If JSON base64 remains, add encoded-length limits and an ASGI request-body cap before Pydantic buffering; verify claimed sizes against decoded lengths. Treat MIME and extension as hints—content validation remains authoritative. ### 3. High — Client-controlled account roles can redirect immutable financial writes - **Reference:** `src/jarvis_finance/services/postfinance_service.py:71-138`, `_resolve_accounts()`, especially `:73-78` and `:128-137`. - `account_roles` is exposed in `PostFinanceImportRequest` and explicit mappings are only checked for the three keys, distinct IDs, existence, and active status. - There is no validation that the accounts belong to PostFinance, share the expected platform, or have appropriate depot/cash account types. Confirm can therefore project positions, cash, transactions, and performance classifications into unrelated active accounts. **Minimal fix:** Remove `account_roles` from the public upload contract, or validate platform identity, expected account types, and existing immutable role mappings. Include the resolved account IDs in the preview fingerprint. ### 4. High — Preview does not bind the exact account/instrument mapping confirmed later - **References:** - `postfinance_service.py:161-183`, `_revision()` - `postfinance_service.py:267-309`, `_preview_payload()` - `postfinance_service.py:312-319`, `preview_postfinance_import()` - `postfinance_service.py:529-563`, `confirm_postfinance_import()` - `_revision()` fingerprints counts/max timestamps for five projection/import tables but excludes `accounts`, `instruments`, aliases/ISINs, account-role mappings, and the resolved position map. - Preview exposes only role names and `mapped_position_count` (`:304-305`), not a digest of account IDs or row-to-instrument mappings. - Account or instrument metadata can change after Preview without changing `preview_id`; Confirm remaps against current state and may write to a mapping the user never previewed. **Minimal fix:** Compute a canonical digest over resolved role→account IDs, position-row→instrument IDs, event→instrument IDs, and relevant active account/instrument attributes. Include it in the preview fingerprint and recompute it after `BEGIN IMMEDIATE`. ### 5. High — PDF resource limits do not cover decompression/page/text bombs - **Reference:** `src/jarvis_finance/imports/postfinance_documents.py:214-230`, `_pdf_text()`. - A 12 MB raw-file cap does not bound PDF object count, page count, decompressed stream size, extracted-text size, memory, or CPU. `PdfReader` extracts every page into memory before any such limits. - ZIP limits therefore do not prevent a small, highly compressed PDF from exhausting parser resources. **Minimal fix:** Parse in a resource-limited subprocess with timeout/memory caps, and enforce maximum pages, objects, and extracted characters incrementally. Reject over-limit inputs with a generic safe error. ### 6. Medium — Filename can establish document type without matching content - **References:** - `postfinance_documents.py:233-252`, `_classify()` - `postfinance_documents.py:718-772`, `parse_postfinance_bundle()` - `postfinance_documents.py:451-452`, `_event_from_document()` - `_classify()` returns immediately from filename markers. Content checks only occur if no filename marker matches. - Thus a selectable-text PDF named like `Kontoauszug` or `Portfolio Wertentwicklung` can be accepted without document-type-specific PostFinance markers; those types produce no economic event and can still be archived as verified evidence. - The overview parser likewise validates layout/totals but not a PostFinance issuer/export marker. **Minimal fix:** Treat filenames only as hints. Require PostFinance issuer/export markers and document-type-specific content markers/fields, with filename/content disagreement rejected. ### 7. Medium — Exact duplicate non-economic documents are not rejected during parsing - **References:** - `postfinance_documents.py:816-831`, duplicate handling in `parse_postfinance_bundle()` - `src/jarvis_finance/storage/postfinance_schema.py:51-55` - `postfinance_service.py:622-650` - Conflicting bytes for one semantic identity are rejected, and duplicate economic event fingerprints are rejected. But identical duplicate documents are allowed. - For non-event documents this reaches Confirm, where duplicate `(batch_id, document_hash)` rows violate the primary key and roll back as a generic immutable-data conflict rather than being rejected cleanly at Preview. **Minimal fix:** Require uniqueness of both `document_hash` and `semantic_identity` across all ZIP entries before event generation. ## Controls that are already fail-closed - ZIP raw size, entry count, declared uncompressed total, and per-PDF size limits: `postfinance_documents.py:707-729`. - Absolute paths, `..`, encrypted ZIP entries, and symlinks rejected: `:719-727`. - Encrypted PDFs and pages without selectable text rejected: `:214-230`. - Duplicate economic events and conflicting semantic document identities rejected: `:816-831`. - Preview performs no intended filesystem writes; the synthetic DB test checks unchanged `total_changes`: `tests/test_postfinance_sprint13.py:253-270`. - Confirm requires explicit confirmation, recomputes Preview, reparses source bytes, checks revision, uses a transaction, and cleans newly created archives on errors: `postfinance_service.py:529-578`, `:903-925`. - API `ValueError` responses are generic and do not include filenames/raw bytes: `src/jarvis_finance/api/routers/postfinance.py:19-23`. ## Verification and workspace state - Reviewed parser, service, schemas/router, persistence constraints, and synthetic tests only; no productive documents or databases were inspected. - Targeted tests could not run because the available Python environment has no `pytest`. - **No files created or modified by this audit.** - The worktree already contained uncommitted changes to `api/schemas/postfinance.py` and `services/postfinance_service.py`; findings above are intentionally anchored to the requested SHA, not those concurrent edits.