Skip to content

[pull] master from kevoreilly:master#503

Merged
pull[bot] merged 57 commits into
threatcode:masterfrom
kevoreilly:master
Jul 13, 2026
Merged

[pull] master from kevoreilly:master#503
pull[bot] merged 57 commits into
threatcode:masterfrom
kevoreilly:master

Conversation

@pull

@pull pull Bot commented Jul 13, 2026

Copy link
Copy Markdown

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

node5-sublime and others added 30 commits July 9, 2026 09:38
…or-neutral)

Adds an opt-in [nexthop]/[gwX] routing mode that binds each analysis task's
guest source IP to a chosen egress gateway via a dedicated policy-routing
table, MASQUERADing onto that gateway's interface. Disabled by default: with
[nexthop] enabled=off the existing routing paths are byte-for-byte unchanged.

- conf/default/routing.conf.default: [nexthop] + [gwX] profile sections; the
  "nexthop" sentinel route is the pool default (default_policy).
- lib/cuckoo/core/rooter.py: gateways registry + _select_gateway
  (explicit id / roundrobin / random; liveness-gated; fail-closed on None).
- lib/cuckoo/core/startup.py: load_nexthop_profiles + validate_default_route
  (section-header id; required-option validation; each [gwX] rt_table must be a
  dedicated unique table -- rejects reserved/fail/duplicate/VPN/dirty-line;
  accepts gateway id / roundrobin / random / nexthop as a VPN-less default route;
  intra-subnet exception installed whenever nexthop enabled, blackhole only when
  fail_closed).
- lib/cuckoo/core/analysis_manager.py: route_network resolves tunX before nexthop
  and lets nexthop consume the route (bind or fail-closed drop); reserved routes
  never resolve to a gateway; unroute mirrors the persisted tuple + guest ingress.
- utils/rooter.py: nexthop_init/enable/disable/intra_exception/fail_closed/teardown
  (reserved-table guard; per-task forward ACCEPT via CAPE_ACCEPTED_SEGMENTS,
  constrained to the guest ingress iface (anti-spoof) and idempotent
  delete-until-gone; intra-subnet exception just below the per-task band; teardown
  sweeps only vm_net-sourced band rules by full selector; SIGTERM no-op when disabled).
- web/submission: the route selector lists the "nexthop" pool + each configured
  [gwX] gateway when nexthop is enabled (defensive; no-op when disabled).
- tests: selection, route/unroute, rooter primitives, netns integration.

Fail-closed throughout: any unresolved/typo'd/reserved route drops rather than
leaking onto the host default route.
…ngines)

Design for replacing the fork+long-lived-pool+nested-pool model that
deadlocked cape-processor. Pluggable engine seam: current pebble path
preserved as A/B control; single-threaded prefork supervisor (ephemeral
per-task children, process-group timeout kill, os._exit) as the target.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…design spec

Audit verdict: all heavy load-once structures (YARA, geoip, capa, pyattck,
DECRYPTORS) are read-only after load; _CLAMAV_CACHE is per-worker and
self-clearing. Nothing needs live mutable cross-worker sharing, so Approach A's
warm-base COW fork preserves the "load once, share" model identically and
reclaims C-lib leaks per task.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…d lazy-load cost

Lifecycle decided: fork-per-task, no supervisor->child dispatch channel
(child runs one task then os._exit). Avoids rebuilding the inter-process
channel whose deadlock motivated the redesign; max leak containment.
recycle-after-N left as a documented future knob.

Adds the load taxonomy (Category 1 init-time/COW-shared incl. yara-forge;
Category 2 lazy first-use) and the measured Category-2 cost: loldrivers
~0.35s/+41MB, security_tools ~0, mmbot disabled, sigma via subprocess.
~1.6% per-task => no pre-warm registry needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
11 TDD tasks across 3 phases: engine seam + PebbleEngine control (behavior-
preserving), PreforkEngine supervisor (fork-per-task, os._exit, killpg timeout,
single-threaded invariant), extractor sub-pool spike+fix, A/B runbook.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-only

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ault pebble)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…store per-task proctitle/formatter cleanup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tion

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rdering

Add test_worker_init_called_in_child to tests/test_prefork_engine.py using
the db_file fixture (file-backed SQLite visible across fork boundary). The
test asserts that a filesystem flag written by worker_init is present when
task_fn runs in the child, locking the _child_main contract from Task 6.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Resolves design-spec §4.2(d): measured pebble.ProcessPool latency and
killpg orphan safety for both 'fork' and 'forkserver' contexts. Decision:
use fork (115.7 ms mean vs 125.5 ms / 219 ms p95 for forkserver; both
pass orphan check). Task 10 will implement _EXTRACTOR_POOL accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ss-group kill

Replace the process-wide _EXTRACTOR_POOL cache and _get_extractor_pool() lazy
init with a per-call pebble.ProcessPool using multiprocessing.get_context("fork")
(Task 9 SPIKE decision). The pool is created inside generic_file_extractors,
used for all extractor scheduling and result collection, then closed and joined
in a try/finally block. This ensures worker processes are deterministically torn
down before the ephemeral task child exits, and that killpg(child_pgid) from
the prefork supervisor sweeps any survivors if per-call teardown fails.

Deleted: _EXTRACTOR_POOL, _EXTRACTOR_POOL_LOCK, _get_extractor_pool(), threading import.
Added: test_extractor_pool_is_swept_by_killpg confirming fork+setsid+killpg leaves zero survivors.
…in Task 10

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…mpletion log; runbook paths; decoded waitpid status; wire max_analysis_count to both engines
…es native C-extension threads like STPyV8 V8 workers)
…hreads at module load, breaking prefork single-threaded invariant)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…nel-single-threaded

imagehash (via PIL/Pillow C extensions) spawns +15 native kernel threads at
import time, breaking PreforkEngine._assert_single_threaded which now checks
both Python threads and /proc/self/task. Move the import inside _load_imagehash()
with the same deferred-binding pattern used for peepdf/STPyV8. Also includes
mtime-ordered reindex_screenshots and zxing-cpp QR support already in working tree.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The prefork child forks once per task, runs every extracted file, then
os._exit()s. generic_file_extractors runs once per file, so a per-call pool
re-forks worker processes N times per task (~1.2s/file on heavy tasks) — the
exact cost upstream's shared _EXTRACTOR_POOL eliminated.

Reclaim it safely: the prefork child opts into a single shared pool
(enable_shared_extractor_pool) reused across all files and torn down once
(shutdown_shared_extractor_pool) before exit. Safe here — unlike pebble worker
recycling, which deadlocks joining a persistent nested pool in _exit_function —
because the child never cleanly joins mid-life; it exits and any straggler is
swept by the supervisor's process-group kill. Pebble keeps the per-call pool.
nic_available only proves the link exists (true for administratively DOWN
interfaces), so _gw_live treated a down gateway NIC as live and round-robin/random
selection could bind a task to a dead exit. Add a nic_up rooter verb that checks
the IFF_UP flag and use it for gateway liveness.
…an't wedge the worker

pool.join() was unbounded: a wedged extractor grandchild (D-state IO, a hung
Java decompiler) could block teardown forever, hanging the worker with no
external timeout to break it (the pebble task timeout only covers execution,
not teardown). Both teardown paths (per-call pebble, shared prefork) now go
through _teardown_extractor_pool: graceful join up to EXTRACTOR_POOL_JOIN_TIMEOUT,
then stop()+join() to force-kill stragglers. Addresses the last unbounded-hang
in the pebble A/B path without touching the -mc 7 recycling default.
wmetcalf and others added 27 commits July 9, 2026 21:30
…it it (COW)

autoprocess now compiles the YARA ruleset once before the engine forks any
workers. Every forked child inherits the compiled rules via copy-on-write fork
(0s + shared read-only pages) and its init_worker File.init_yara() short-circuits
on the idempotency guard. Prefork forks one child per task, so without this each
task paid the full compile (~0.3-3s depending on ruleset); measured ~321ms ->
~0.05ms per child. Safe: compilation is single-threaded, preserving the prefork
single-threaded-before-fork invariant.
…+ nexthop_init blackhole seed)

- _resolve_nexthop clears self.nexthop_* on entry so a failed resolve (route->drop) cannot leave a
  stale binding that _dispatch_nexthop still installs on route_network re-entry/retry.
- nexthop_init seeds a blackhole default before the real replace, so a failed route setup leaves the
  gateway table fail-closed instead of empty (falling through to main), even with fail_closed=no.
…ed + default_policy)

- reserve the [routing] dirty-line rt_table only when the dirty line is enabled (internet != none),
  so a nexthop-only node reusing that id is not falsely rejected at startup.
- validate [nexthop] default_policy is roundrobin/random or a configured gateway id (else every pool
  task silently drops).
…collision at startup

- nexthop_enable deletes the VM's prior rule by (from,priority) regardless of table, so a rebind to a
  different gateway cannot leave a stale old-table rule that wins by priority.
- load_nexthop_profiles rejects fail_closed when NEXTHOP_FAIL_TABLE collides with a VPN/dirty-line table.
…; killpg fallback; idle backoff

- _reap now iterates per-pid and holds a timed-out child until its process group
  has been escalated to SIGKILL (kill_deadline cleared). Reaping it earlier popped
  it from _inflight so _escalate_kills never SIGKILLed the group, orphaning any
  surviving grandchildren.
- _enforce_timeouts falls back to os.kill(pid) when killpg raises ProcessLookupError
  (group not yet created / already gone), and only skips setting kill_deadline when
  the process is truly gone, so escalation still runs.
- run() backs off to idle_poll_interval (5s) when fully idle instead of a 0.2s hot
  loop hammering the DB ~5x/sec.
…orker starvation

Querying only `limit` rows then filtering out in-flight ids could return fewer
(or zero) tasks when the oldest tasks are all in-flight, idling free workers.
Fetch `limit + len(exclude_ids)` and slice the filtered result back to limit.
- _teardown_extractor_pool now bounds the join AFTER stop() too: a grandchild
  wedged in uninterruptible (D-state) IO survives SIGKILL, so an unbounded reap
  join would still hang. Abandon after EXTRACTOR_POOL_STOP_JOIN_TIMEOUT.
- _qr_decode_zxing opens the image via `with` so its fd is released promptly.
…in test

The pool loop moved into the engine modules, leaving time/TimeoutError/
free_space_monitor/TASK_COMPLETED/TASK_FAILED_PROCESSING/Task unused in
process.py (ruff F401). Keep the pebble dep guard with a noqa. Split a
two-on-one-line import in test_prefork_engine.py (E401).
…sweep

init_routing() is called by cuckoo.py (scheduler), web settings (Django startup) and vpncheck. The
nexthop stale-sweep flushed the per-task rule band on EVERY call, so a web restart mid-analysis tore
down running tasks' egress. Gate all nexthop rooter mutations behind an opt-in apply flag; only the
scheduler passes init_routing(apply_nexthop_state=True).
…state)

init_rooter() is also called by web/web/settings.py before init_routing; its cleanup_rooter/
forward_drop/state_* reset CAPE-rooter iptables incl. live per-task nexthop rules. A web/gunicorn
restart on a nexthop node therefore tore down running analyses. Gate the reset behind apply_state
(default False); only the scheduler passes init_rooter(apply_state=True). Reachability still checked.
… longer spawns 50 threads

Module-level ThreadPool(50) spawned 53 threads in every process that imported
cleaners_utils, including the processor. PreforkEngine.run() imports it (for
free_space_monitor) before the first fork, so those threads tripped the
single-threaded-before-fork invariant — prefork couldn't launch a single task on
any upstream-based checkout. Defer pool creation to first .map() use; only the
cleaner/deletion paths need it. Prefork prerequisite.
reindex_screenshots' chronological mtime sort is unrelated to the processing
engine and broke the upstream test (which mocks listdir/rename, not getmtime).
Restore upstream's sorted(os.listdir(...)) behavior.
…bprocess

The pytest process is multi-threaded (other suites leak clients/pools), so it
can't satisfy the prefork single-threaded-before-fork invariant nor safely fork.
Run PreforkEngine in a fresh interpreter via a helper; expire the parent session
before reads (subprocess writes over a separate SQLite connection). Also stub
free_space_monitor in the pebble test (run() sys.exit()s on missing storage/analyses).
…pg misses

Mirror the _enforce_timeouts fallback in _escalate_kills: if a child hung before
os.setsid() its process group won't exist, so killpg(SIGKILL) raises
ProcessLookupError and the child would leak. Signal the pid directly instead.
…tory (default OFF)

Adds an opt-in "central mode" that lets a CAPE web/API node serve a fleet of workers
from a shared data plane, WITHOUT changing single-node behavior. Everything is gated by
a new [central_mode] stanza that defaults to enabled = no, so a stock install is
byte-for-byte unchanged (verified by an OFF-path + MT-absent import smoke test).

What it adds (all lazy / import-optional):

- [central_mode] config (conf/default/cuckoo.conf.default + central_mode.py): toggle +
  vendor-neutral backend settings. boto3 stays an optional, lazily-imported dependency (never
  core) — the same way the repo's existing modules/machinery/aws.py already treats it.

- FS -> object-store artifact seam (storage_backend.py + artifact_storage.py, read side;
  modules/reporting/centralstore.py, write side). Artifacts key by <prefix>/<job_id>/<rel>
  on any S3-compatible store (AWS S3 / MinIO / Ceph) OR a shared local/NFS mount. The read
  seam lazily stages the tree back to the local storage/analyses/<task_id>/ dir so the many
  report features that read the local filesystem work unchanged. A completion marker
  (.centralstore.done) gates the read-side staging cache and the worker's local cleanup.

- Pluggable job -> worker directory (job_directory.py + central_guac.py) so the central node
  can route interactive Guacamole to the worker hosting a live task's VM. Default backend is
  the vendor-neutral broker HTTP status API; an optional lazy-boto3 DynamoDB backend ships too.
  Worker access paths (api-token file, port, ssh user/keyfile) are config-driven.

- tenancy-optional facades (tenancy_optional.py x2, analysis/central_scope.py) + a
  settings.py _users_app_present() shim. Central mode is ORTHOGONAL to multi-tenancy: the
  facades delegate to the multi-tenant `users` app when it is deployed and degrade to
  single-tenant see-all (fail-CLOSED on runtime errors) via ImportError when it is not — so
  this lands independently of any multi-tenant layer.

- DocumentDB compatibility knobs in dev_utils/mongodb.py (explicit tls= / retryWrites=,
  both defaulting to today's behavior, documented in conf/default/reporting.conf.default) and a
  $facet -> per-category $group hunt aggregation (hunt_query.py) that Amazon DocumentDB accepts;
  the single-node $facet path is unchanged.

The web view/endpoint changes (analysis/views.py, apiv2/views.py, guac/*, submission/views.py)
are all `if central_mode_config().enabled:` seam sites with function-local imports; the OFF
path is the original code (submission status/remote_session gate the worker-VM fallback on
central mode, so single-node is byte-for-byte upstream — no degenerate empty-guest_ip session).
guac/views.py additionally derives the interactive VM label + guest IP authoritatively from the
task rather than trusting the session_data path segment (prevents a takeover/SSRF via a forged
label/IP).

Tests: tests/test_central_mode.py (config + store + directory + marker + hunt), tenancy_optional
+ central_scope facade tests (MT-absent behavior runs for real; MT-present delegation/fail-closed
cases importorskip when the users app is absent), test_mt_absent_smoke.py (proves every touched
view imports and the facades degrade with the MT layer absent) and test_settings_mt_optional.py.
- ruff E701/E702: expand the one-line helper classes in test_tenancy_optional (was failing CI).
- artifact_storage._job_id_for_task: coalesce a null info doc ({"info": None}) before .get (was
  AttributeError), and cache task_id->job_id (immutable) so a view's repeated artifact_exists/stream
  calls don't each re-query mongo. Bounded, scope-keyed.
- ensure_local_analysis / ensure_local_memory: let a clean Http404 propagate (so a direct view
  caller returns 404 instead of a broken page) while still swallowing transient errors best-effort.
- central_guac._job_id_for_task + central_views.central_job_id_for_task: also accept the bare-token
  custom form, matching centralstore.resolve_job_id; coalesce null info doc in the mongo fallback.
- S3Store.materialize: move tempfile.mkstemp inside the try so an OSError returns (None, False)
  instead of escaping and breaking the caller contract.
_get_vnc_port gained an optional dsn param (local hypervisor, or a worker's libvirt-over-SSH in
central mode) and the consumer now passes it positionally; the app-factory stub was 1-arg and
raised 'takes 1 positional argument but 2 were given', failing the 5 connect() tests.
…ing)

- _JOB_ID_CACHE: only cache the UNSCOPED (see-all / MT-absent / break-glass) resolution, keyed by
  task_id alone. task_id->job_id is immutable so that is safe forever, but a tenant-SCOPED lookup is
  authorization-sensitive (a task's tenant/visibility can be reassigned) — a process-lifetime cache
  could serve a job_id the caller may no longer see. Scoped lookups now always re-query; the
  repeated-lookup win is kept for the common single-tenant central deployment.
- _stage_tree docstring: drop the stale 'never raises' claim — it intentionally lets Http404 from
  _store_and_container propagate so callers can return a clean 404 (ensure_local_* catch the rest).
…ravity review)

- ensure_local_analysis / ensure_local_memory: after re-raising a clean Http404, log.warning the
  swallowed exception instead of dropping it silently — S3 creds/permission/network staging failures
  are now diagnosable (added a module logger).
- _JOB_ID_CACHE: OrderedDict LRU (move_to_end on hit, popitem(last=False) at threshold) instead of
  clear-all, so hot recently-viewed tasks stay cached and eviction is one-at-a-time (no miss-storm).
…a shared global

suricata.py appended the passlist file into the imported module-global
domain_passlist_re on every run(). In a long-lived worker process (e.g. the
pebble engine with maxtasksperchild=0, which never recycles a worker) the
shared list grew by the whole passlist every task, so the per-event re.search
loop became O(tasks_processed x events) and eventually stalled Suricata
processing for minutes -- presenting as a hung/"deadlocked" task. Engines that
use a fresh process per task reset the global and hid it.

Fix: build the passlist per run via a new _compile_passlist() helper (local
task_passlist_re) starting from a base safelist compiled once at module import,
matching with compiled.search(). network.py had the same append into the same
shared global at import time, which also polluted Suricata's copy with
duplicates -- switched to a private, pre-compiled dns_passlist_re. Invalid
passlist entries are skipped instead of raising.

Adds regression tests asserting the shared global is never mutated and that
filtering behaviour is unchanged; the network test forces a clean reload so the
module's top-level build is actually re-run and measured.
…match, sys.path.insert in tests

- network.py: log a warning when a base/file passlist regex fails to compile
  (was silently suppressed), matching suricata.py.
- network.py: break out of the Pcap2 hostname passlist loop on first match.
- tests: sys.path.insert(0, ...) so the local checkout takes import priority.
central-mode: optional object-store artifact seam + job->worker directory (default OFF)
…lation

fix: build Suricata/network DNS passlist per-run instead of mutating a shared global
Pluggable processing engine: prefork option to fix nested-pool deadlocks (pebble default unchanged)
rooter: generic next-hop egress routing primitive (opt-in, fail-closed)
@pull pull Bot locked and limited conversation to collaborators Jul 13, 2026
@pull pull Bot added the ⤵️ pull label Jul 13, 2026
@pull
pull Bot merged commit fa044fb into threatcode:master Jul 13, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants