Skip to content

Audit remediation + reliability fixes (v2.6.49-v2.6.57)#61

Merged
ttlequals0 merged 12 commits into
mainfrom
fix/audit-remediation-2.6.49
Jun 12, 2026
Merged

Audit remediation + reliability fixes (v2.6.49-v2.6.57)#61
ttlequals0 merged 12 commits into
mainfrom
fix/audit-remediation-2.6.49

Conversation

@ttlequals0

@ttlequals0 ttlequals0 commented Jun 11, 2026

Copy link
Copy Markdown
Owner

Summary

Remediation of an external code audit (21 findings: 4 High, 9 Medium, 8 Low) plus convergence of the two scan engines onto the chunk-distributed path. The branch then grew into a release train (v2.6.49 through v2.6.57) while chasing one production symptom on a 1.19M-file library: maintenance runs kept slowing down or stalling. Each release peeled back one layer of the same onion.

Scan engine convergence (WORKER-2)

  • /api/scan now runs the chunk-distributed engine: directory scans split into disjoint path-range (FCP) chunks fanned out across all Celery workers. Throughput scales with CELERY_CONCURRENCY.
  • Completion via last-chunk-finalizes under a PostgreSQL row lock (exactly-once), with the stuck-scan sweeper as backstop. Scans with failed chunks finalize as error (healthcheck failure ping) instead of claiming a clean run.
  • Discovery is parallel per directory, bulk-inserts pending rows, returns counts only (no 100MB+ file lists through Redis). The discovery-incompleteness guard is preserved; its timeout is now env-configurable (DISCOVERY_TASK_TIMEOUT_SECS, default 3600s).
  • The parallel task module was never registered with the worker (include list); /api/scan-parallel had effectively never run and is now a deprecated alias of /api/scan.
  • Thread-pool engine internals deleted (~1,600 lines); single-file and selected-file rescans unchanged.

Audit fixes (v2.6.49)

  • SCHED-1: /api/scan validated AFTER claiming the scan slot, so a 400 left scanning wedged behind a 409 until the 30-minute sweeper. Validation now precedes the claim; failed launches release it.
  • WORKER-1: deleted the UI heartbeat task that busy-polled the DB at 0.5s and pinned a Celery slot for the entire scan. Chunks update progress every 100 files or 60s.
  • SCHED-2/WORKER-3: path exclusions and LIKE prefix queries over-matched sibling directories (/media/movies excluded /media/movies2). New boundary-safe helpers replace all 14 sites.
  • WORKER-4/5: empty chunks no longer stomp live progress; per-file claim/write/error round-trips replaced by one bulk UPDATE...RETURNING per chunk and batched writes.
  • SCHED-3/4: scheduler commits once after dedupe iteration; interval next-run computed before add_job (no add-then-modify window).
  • Chunk retries reclaim their claimed range first (a retry previously found nothing and reported the chunk complete with 0 files scanned).

Security (v2.6.49)

  • SEC-1: argument-injection hardening for ffprobe/ImageMagick (./-prefix helper, raw subprocess.run routed through the safe wrapper, honest validate_command_args).
  • SEC-2: unauthenticated /healthz liveness endpoint; compose healthcheck pointed at it (the old /health probe always returned 401).
  • SEC-3/4: CORS is opt-in via CORS_ORIGINS (was origins "*"); session cookies default Secure.
  • SEC-5: filenames with ~, $, % are scannable again (containment enforced via realpath + safe_join).
  • SEC-6/OPS-2: compose no longer publishes Postgres/Redis ports; POSTGRES_PASSWORD required (changeme default removed).
  • OPS-1: rate limits moved to Redis (were per-gunicorn-worker memory, multiplying every limit by 4).
  • DB-1: connection pool sized to fit max_connections (was 240+ potential from the web app alone).

The maintenance-run reliability chain (v2.6.50 - v2.6.57)

Five distinct root causes stacked on top of each other. In order of discovery:

  • v2.6.50: startup migrations ran an unconditional ALTER that could wedge boot behind another container's idle-in-transaction locks. Migrations now run with SET LOCAL lock/statement timeouts, the ALTER is conditional, and the app container got a restart policy.
  • v2.6.51: cleanup Phase 1 loaded all 1.19M rows as full ORM objects. Now loads id and file_path only.
  • v2.6.52: consumed Celery results were never deleted, leaving 1M+ result keys in a 2GB noeviction Redis by the tail of a run; every broker operation slowed roughly 100x. Results are now forgotten after consumption. Also fixed the scan report modal rendering "[object Object]" for object file lists.
  • v2.6.53: the database log handler stored one INFO row per task from celery.app.trace (~850 tasks/s during maintenance), enough WAL to drive 5-minute PostgreSQL checkpoint IO storms that stalled the producer loop 2-7 minutes at a time. Excluded by default, with an additive backfill migration for existing installs.
  • v2.6.54: the producer loops consumed results via AsyncResult.get(), which on the Redis backend subscribes to a pub/sub channel the producer never drains. Redis force-closed the connection on output-buffer overrun every ~70s, and a badly timed close froze the loop for up to 7 minutes with all workers idle. Results are now read from the stored meta; no subscription, same semantics.
  • v2.6.55 (yanked): tried to fix the app-bound Celery instance silently pointing its result backend at localhost. The fix added an uppercase CELERY_RESULT_BACKEND attribute, which Celery rejects as an old-style settings key; gunicorn workers failed to boot. Rolled back within minutes.
  • v2.6.56: the same fix done safely (env fallback in create_celery), plus a regression test asserting no Config attribute collides with Celery's old-style key set, and a no-op for the dispatch-time result subscription.
  • v2.6.57: that no-op never took effect in production. Celery stores one backend instance per thread (threading.local), so the producer threads created fresh unpatched backends and kept subscribing - root-caused with a local reproduction and a stack trace through send_task. The no-op now applies to every backend instance any thread creates. Verified in production: 1,187,450 files checked in 22m08s with zero Redis forced disconnects (down from 49m with multi-minute stalls at the start of the day).

Versions

2.6.49 - 2.6.57 (2.6.55 yanked, never referenced by any deployment)

Test plan

  • Full suite green: 403 passed, 8 skipped (baseline 357)
  • New unit tests: path helpers, CLI-arg safety, chunk building, finalizer election/guards, safe_task_get semantics, thread-local backend regression, old-style Celery key guard
  • Integration regression tests: claim release on 400, 409 conflict, /healthz
  • Boot smoke test (import app) in venv and in the built image, including thread-spawned backend patch verification
  • Docker images built, scanned (Trivy, accepted kernel-CVE baseline only), and pushed
  • Deployed and verified end-to-end: full-library cleanup runs on each release, final run clean at 22m08s

Converge directory scanning onto the chunk-distributed engine: /api/scan
splits scans into disjoint path-range chunks fanned out across all Celery
workers, with last-chunk-finalizes completion under a row lock and the
stuck-scan sweeper as backstop. The thread-pool engine internals and the
never-registered parallel task path are removed.

Fixes from the external audit (21 findings):
- Scan claim leaks on validation failure (validate before claim, release on
  failed launch)
- UI heartbeat task occupying a worker slot for entire scans (deleted;
  chunks update progress every 100 files or 60s)
- Sibling-directory over-match in path exclusions and LIKE prefix queries
  (boundary-safe is_path_under/like_prefix helpers across 14 sites)
- Per-file DB round-trips in chunk scanning (bulk UPDATE...RETURNING claim)
- Argument-injection hardening for ffprobe/ImageMagick invocations
- Unauthenticated /healthz liveness probe (compose healthcheck was always 401)
- CORS now opt-in via CORS_ORIGINS; session cookies default Secure
- Rate limits moved to Redis (were per-worker memory)
- Connection pool sized to fit PostgreSQL max_connections
- Compose: datastore ports unpublished, POSTGRES_PASSWORD required,
  image tag parameterized
- Dead code removed: broken DatabaseConnectionManager, stub cleanup task,
  unscheduled tasks, scheduler commit-in-loop and add-then-modify fixes
Comment thread pixelprobe/api/scan_routes.py Dismissed
The user-suppliable source field flowed verbatim into scan_id and was
echoed in API responses, reports, and healthcheck routing (CodeQL
py/reflective-xss). The id is now rebuilt from a strict format match;
anything else gets a UUID. The literal scheduled_periodic label is
preserved for the default periodic scan.
Cleanup Phase 2 had no task-abandonment timeout: un-ready handles were
retried forever, pinning the loop at max concurrency with no log output
(observed live: frozen at 20,000/1,187,442 files with idle workers).
Progress also only wrote when the counter hit an exact multiple of 100,
so the UI froze even while the loop was healthy.

Mirrors the file-changes hardening: submitted_at tracking with
abandonment after CLEANUP_TASK_TIMEOUT_SECS (default 600s; abandoned
files counted unverifiable, never deleted), 10s heartbeat write+log,
and delta-based progress writes.
An app-only container update wedged startup: the v2.6.0 migration ran an
unconditional ALTER TABLE ... SET DEFAULT on every boot, which blocked on
an ACCESS EXCLUSIVE lock held open by the still-running worker container,
and every gunicorn worker waited behind the migration step forever. The
app service also had no restart policy, so a dead boot stayed dead.

- All migration connections set session lock_timeout (10s) and
  statement_timeout (300s), env-tunable; a blocked migration logs and is
  retried on the next boot instead of hanging
- v2.6.0 SET DEFAULT is conditional on the defaults actually missing
- pixelprobe service: restart unless-stopped, healthcheck start_period
  120s to cover migration time
- Accurate logging for CREATE TABLE IF NOT EXISTS paths
- Migration timeouts use SET LOCAL: a plain SET is session-scoped and
  survives the connection's return to the pool, imposing migration
  timeouts on unrelated app queries
- Advisory-lock waiter bounded by the statement timeout (last remaining
  infinite-wait path in startup)
- abandon_if_stuck() shared by cleanup and file-changes loops (the two
  inline copies had already drifted)
- env_int() utility replaces three divergent env-parsing styles
- Cleanup progress writes are heartbeat-only (per-100-file delta was
  ~12k commits per run with no UI benefit)
- tools/ migration helper imported at top level (no import cycle exists)
- Abandonment tests exercise the real helper instead of a local copy
ScanResult.query.all() materialized 1.18M full ORM objects (multiple GB)
inside the gunicorn worker hosting the cleanup thread - the most likely
driver of the silent-stall symptoms: memory pressure makes every loop
iteration glacial, the producer looks blocked, cancel windows are
missed, and nothing logs. The loop reads exactly id and file_path, so
Phase 1 now loads lean tuples via with_entities (~20-30x less memory).
The file-changes flow already did this; cleanup was the only full-ORM
holdout.
Maintenance task results were never forgotten and expire only after
24h: a full-library run left 1M+ result keys in the noeviction Redis,
slowing every broker/backend operation ~100x in the final stretch
(heartbeats minutes apart, misleading '0 active tasks' and 'ETA: 0m').
Both maintenance loops now forget each result after consuming it and on
abandonment, bounding Redis to in-flight tasks only.

Cleanup and file-changes reports store file lists (objects) in the
directories field; the report-details modal rendered them as
'[object Object]' rows. It now shows labeled, escaped file lists
(Orphaned Files / Changed Files) capped at 100 entries.

Sub-minute cleanup ETA reads '<1m' instead of '0m'.
Per-task INFO rows from celery.app.trace generated heavy WAL volume
during maintenance runs, driving PostgreSQL checkpoint IO storms that
stalled the producer loops. Added to the default log-exclusion list
with an additive startup migration backfilling existing installs.
…s (v2.6.54)

Celery's Redis result backend subscribes the producer to each task's
result channel at dispatch and again in AsyncResult.get(); the cleanup
and integrity producer loops never read that socket, so published
results accumulated until Redis force-closed the connection, freezing
the loops for minutes with idle workers. Cancel the dispatch-time
subscription right after apply_async and consume results via the
stored meta (task.state/task.result) instead of get().
…URL (v2.6.55)

apply_async subscribes the producer to each task's result channel via
backend.on_task_call; nothing in the producer reads that socket, so the
subscribe/unsubscribe replies accumulated until Redis force-closed the
connection every ~3 minutes during maintenance runs. The hook is now a
no-op for app instances (producers consume stored result meta), and the
per-dispatch unsubscribe from v2.6.54 is removed as redundant.

Also mirror CELERY_RESULT_BACKEND as an uppercase config attribute so
Flask copies it into app.config - the app-bound Celery instance was
silently defaulting its result backend to localhost in containers,
breaking AsyncResult state checks in scan routes and the scheduler.
….6.56)

v2.6.55 added an uppercase CELERY_RESULT_BACKEND attribute to Config;
Celery treats that name as an old-style setting key, so
celery.conf.update(app.config) raised ImproperlyConfigured and gunicorn
workers failed to boot. Rolled back to 2.6.54 in production.

The underlying fix is reimplemented safely: create_celery() falls back
to the CELERY_RESULT_BACKEND environment variable for the app path, so
the app-bound Celery instance no longer pins its result backend to
localhost in containers. A regression test asserts no uppercase Config
attribute is in Celery's _OLD_SETTING_KEYS and that conf finalization
succeeds against the full app config.
Celery stores the result backend in a threading.local unless
result_backend_thread_safe is set, so each thread gets its own backend
instance. The 2.6.55/2.6.56 no-op only patched the import thread's
instance; the cleanup/integrity producer threads created fresh
unpatched backends and kept subscribing to result channels (verified
live and reproduced locally with a stack trace through send_task).
disable_dispatch_result_subscription now wraps Celery._get_backend so
every backend instance any thread creates carries the no-op. Validated
end-to-end: the API-triggered cleanup repro went from 2,633 standing
result-channel subscriptions to zero.
@ttlequals0 ttlequals0 changed the title Audit remediation + chunk-distributed scan engine (v2.6.49) Audit remediation + reliability fixes (v2.6.49-v2.6.57) Jun 12, 2026
@ttlequals0 ttlequals0 merged commit 98b6548 into main Jun 12, 2026
10 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.

2 participants