diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 652db6263cc..459c679dc7c 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -48,6 +48,14 @@ jobs: - name: Run unit tests run: poetry run python -m pytest --import-mode=append + # The web/ Django suites (tenant-isolation, apiv2, guac, submission, ...) live + # outside testpaths=["tests","agent"], so the arg-less run above never collects + # them. They require importlib (append raises ImportPathMismatchError under + # pythonpath=web); DJANGO_SETTINGS_MODULE + pythonpath come from pyproject's + # [tool.pytest.ini_options] and still apply with an explicit path arg. + - name: Run web unit tests + run: poetry run python -m pytest web/ --import-mode=importlib + - name: Run Linux analyzer unit tests run: poetry --project . --directory "analyzer/linux" run python -m pytest -v diff --git a/conf/default/api.conf.default b/conf/default/api.conf.default index 6e2000543ac..ffd36ce5a5d 100644 --- a/conf/default/api.conf.default +++ b/conf/default/api.conf.default @@ -14,6 +14,16 @@ url = http://example.tld token_auth_enabled = no mcp = no +# Central-mode interactive live-VM (Guacamole) machine-to-machine auth. The central node +# (lib/cuckoo/common/central_guac) presents this shared secret as "Authorization: Token " +# to the worker's apiv2 tasks/machine endpoint; that endpoint authorizes its (non-tenant-scoped, +# VM-label-ONLY) read via a constant-time compare against this value, so interactive attach works +# even when token_auth_enabled=no (DRF AllowAny leaves the request anonymous). EMPTY (the default) +# = DISABLED: the endpoint then authorizes only via the viewer_for().is_local_admin gate. This is +# a bearer secret -- keep apiv2 SG-locked to the control plane and the conf file non-world-readable; +# rotate on suspicion. Leak scope is a pool VM label, never tenant/analysis data. +control_plane_token = + [list_exitnodes] enabled = no mcp = no diff --git a/conf/default/cuckoo.conf.default b/conf/default/cuckoo.conf.default index 0b74a1501ab..539dacf009c 100644 --- a/conf/default/cuckoo.conf.default +++ b/conf/default/cuckoo.conf.default @@ -251,6 +251,13 @@ unused_files_in_mongodb = no # Central control-plane mode (off = current single-node behavior; analyses stay on the local # storage/analyses tree). When on, analysis artifacts are pushed to a shared object store and # interactive Guac routing resolves job->worker via a directory backend. +# +# TENANT ISOLATION: when central mode AND multitenancy are BOTH enabled, tenant-isolated access +# requires the submit-bridge -- only a bridged task (job_id "ui-", globally unique and +# reconcile-mapped to a tenant) is a tenant-scoped own doc. A direct worker submission (keyed +# "local-", worker-local info.id, no tenancy by construction) is SINGLE-TENANT only: it is not +# addressable through the tenant-scoped own-doc read/write/delete paths. See +# lib.cuckoo.common.central_mode.central_bridge_required. enabled = no # Artifact storage backend when central mode is ON: "s3" (any S3-compatible store — AWS S3, MinIO, # Ceph RGW — via boto3, which is an optional/lazy import: pip install boto3 to use it) or "local" @@ -276,10 +283,56 @@ broker_api_token = broker_table = # Interactive-Guac worker access: how the central node reaches the worker that hosts a live task's # VM. Defaults match the standard CAPE deb layout; override for other topologies. -# File holding the worker apiv2 Token (blank/absent => requests go unauthenticated). +# File holding the token the central node presents to the worker's apiv2 for the interactive +# live-VM lookup (apiv2 tasks/machine). The worker authorizes that call by matching this value +# (constant-time) against its own [api] control_plane_token, so this MUST equal the worker's +# control_plane_token (in the AWS deploy: the same Secrets Manager value on both tiers). That +# shared-secret path works on the shipped token_auth_enabled=no worker, where DRF leaves the +# request anonymous. (On a token_auth_enabled=yes worker the endpoint ALSO accepts an +# is_local_admin principal's token — but do NOT flip local_admins_manage_all_tenants=yes just +# for this: that widens cross-tenant break-glass fleet-wide. Use an IdP-linked superuser +# instead if you go that route.) Blank/absent => requests go unauthenticated. worker_api_token_file = /etc/cape/api-token # The worker's apiv2/web port. worker_api_port = 8000 # The central node's libvirt-over-SSH identity onto workers (must be authorized on the workers). worker_ssh_user = cape worker_ssh_keyfile = /home/cape/.ssh/id_ed25519 +# SQLAlchemy URL of the CENTRAL control-plane RDS, used by WORKERS only to resolve a central task's +# tenancy (tenant_id/user_id/visibility) when stamping the shared DocumentDB analysis doc under +# multitenancy. A worker's own [database] is its LOCAL per-worker task DB (different id space) and +# centralstore rewrites info.id to the central task id, so the stamp must be resolved here. Blank +# (default) => central-mode analyses stamp fail-closed (private/unowned) rather than leak. +# IMPORTANT: point this at the WRITER/PRIMARY endpoint (the same Postgres the central node uses as +# its [database]), NOT a read replica/standby. Read-only credentials on the primary are fine, but a +# reader endpoint breaks the reconcile's cross-node advisory-lock serialization (pg_advisory_lock on +# a standby doesn't exclude the primary) and the under-lock re-read would be replication-lag-stale; +# the worker detects pg_is_in_recovery() and warns + runs unserialized-but-fail-closed in that case. +# Set on workers in a central + multitenancy deployment. Single-node and non-MT installs ignore this. +central_database_url = +# Management/UI node opt-in. A central UI node advertises the fleet's route options on the submission +# form but runs NO rooter (only workers route traffic). When yes, init_rooter/init_routing tolerate an +# unreachable rooter (warn + skip route verification/NAT while still populating the VPN list for the +# form) instead of raising CuckooStartupError at web import. Default no, so single-node AND workers +# keep failing fast on a missing rooter. Set yes ONLY on the central UI node, never on a worker. +tolerate_missing_rooter = no + +[multitenancy] +# Multi-tenant identity & job-visibility (off = current single-tenant behavior). +# IMPORTANT: enabling this on an EXISTING populated install stamps NEW analyses only. +# Pre-existing mongo reports have no tenant/visibility stamp and stay invisible to +# every tenant (fail-closed) on the search/statistics/compare surfaces until you run +# the one-shot backfill: `python utils/db_migration/mongo_backfill_tenant.py` +# (stamps tenant_id/user_id/visibility from each report's Postgres task). See +# docs/MULTITENANCY-SUPPORT.md. +enabled = no +# shared = tenant-less collaborative pool (submit default public); +# locked = per-tenant isolation (submit default tenant). +mode = shared +# Blank = per-mode default (shared->public, locked->tenant). Override with one +# of: public | tenant | private. +default_visibility = +# Operator break-glass: local Django superusers manage ALL tenants (cross-tenant +# read + management) when yes. Set no to force admin access through the IdP. +# Server-side only — a tenant/user cannot grant themselves this. +local_admins_manage_all_tenants = yes diff --git a/conf/default/reporting.conf.default b/conf/default/reporting.conf.default index 2633e4fd1ef..a839fdbfbf6 100644 --- a/conf/default/reporting.conf.default +++ b/conf/default/reporting.conf.default @@ -236,3 +236,12 @@ enabled = no # MUST be done in conf/gcp.conf. This section only controls the enabled state. enabled = no +# Central mode: ship the analysis tree to shared object storage (S3/compatible or a +# shared mount) and stamp the central job_id onto the report. This module is a +# PREREQUISITE for central mode -- with it disabled, [central_mode] is non-functional +# (no artifacts uploaded, info.job_id never stamped, info.id never rewritten to the +# central task id, and the visibility reconcile has no job_id to key on). Storage +# targets are configured under [central_mode] in conf/cuckoo.conf. Leave disabled on a +# standard single-node install. +[centralstore] +enabled = no diff --git a/docs/MULTITENANCY-SUPPORT.md b/docs/MULTITENANCY-SUPPORT.md new file mode 100644 index 00000000000..dcd1a566b66 --- /dev/null +++ b/docs/MULTITENANCY-SUPPORT.md @@ -0,0 +1,108 @@ +# Multitenancy — Supported Configurations & Boundaries + +Multitenancy (the `[multitenancy]` section of `cuckoo.conf`) scopes every read +surface so a user of one tenant cannot see or act on another tenant's tasks, +samples, reports, artifacts, statistics, search results, or live-VM (Guacamole) +sessions. This document states exactly which deployment modes that guarantee +covers today, and what is intentionally **fail-closed** (safe but limited) until +support is added. + +## Enabling on an existing (populated) install — run the backfill + +Turning `enabled = yes` stamps tenant/visibility onto **new** analyses only. Reports +already in MongoDB have no `info.tenant_id` / `info.user_id` / `info.visibility` +stamp, so the scoped search / statistics / compare surfaces treat them as +**fail-closed / invisible** to every tenant (no leak, but the history disappears +from those views) until they are stamped. Run the one-shot backfill once, after +flipping the flag: + +``` +python utils/db_migration/mongo_backfill_tenant.py +``` + +It reads each selected `analysis` doc's Postgres task and writes +`info.tenant_id` / `info.user_id` / `info.visibility` (orphans whose task was pruned +fail closed to `private`), and creates the `tenant_scope_idx` index. It touches only +(a) un-stamped docs (missing `info.visibility`, first-enable) and (b) crash-orphans in +the exact reporter fail-closed shape (`visibility=private` + null `tenant_id` AND +`user_id`) — never a stamped permissive doc — so it stays idempotent and safe to re-run. +In a **central** deployment run it on the CENTRAL node **while quiesced**: it only +restamps docs whose id space matches the node (broker `ui-*` ids ⇔ central RDS; +worker-local ids ⇔ single-node), and it is not lock-serialized against a live toggle. +The Alembic migration backfills the **SQL** columns only — the mongo stamp is this +separate step. A fresh install needs no backfill (every report is stamped at +creation). + +## Supported (isolation enforced end-to-end) + +- **Report store: MongoDB.** MT scoping of the aggregate/search/statistics/ + compare surfaces reads the tenant stamp (`info.tenant_id` / `info.user_id` / + `info.visibility`) written into the mongo analysis document. **MongoDB is + required for multitenancy.** +- **Single-node CAPE** (one host running web + processing + analysis). +- **Central control plane + broker workers** (the "central mode" path): the + central UI serves artifacts staged from workers, keyed by the broker `job_id`. + Tenant stamping works across this path **only when workers can resolve the + submitter's tenancy from the central control-plane DB**: a worker's own + `[database]` is its LOCAL per-worker task DB (a different id space), and + `centralstore` rewrites `info.id` to the CENTRAL task id, so the worker resolves + and stamps tenancy against the central RDS via `[central_mode] central_database_url`. + If that URL is unset, central-mode analyses stamp **fail-closed** (private / unowned + — invisible to everyone but break-glass), never leaked. Point `central_database_url` + at the **writer/primary** endpoint (the same Postgres the central node uses as its + `[database]`), NOT a read replica: the worker's post-write reconcile takes its + per-task advisory lock there to serialize with the central node's visibility toggle + (advisory locks are cluster-wide, so same-key locks on the same primary mutually + exclude). If it points at a standby, `pg_advisory_lock` wouldn't exclude the primary + and the re-read would be replication-lag stale — the worker detects + `pg_is_in_recovery()`, warns, and runs unserialized-but-fail-closed, leaving a narrow + reprocess-during-toggle re-widen window (still no persistent leak: the fail-closed + insert + backfill keep it safe). +- **Guacamole interactive sessions** for task-backed analyses: minting a live-VM + session (and the WebSocket tunnel re-check) is gated by `can_manage_task` + (owner / tenant-admin / break-glass), NOT mere read visibility — live keyboard/ + mouse/framebuffer control is a task action, so a read-only viewer of a public/ + tenant task cannot tunnel into another user's or tenant's VM. + +## Not yet supported (fail-closed — safe, but limited) + +These modes do **not** carry tenant context correctly. Rather than leak, MT +**fails closed** on them (data is stamped private / invisible, or the surface is +admin-only), so enabling MT on these modes is safe but the affected analyses +will simply not be visible. Adding real support is tracked as future work. + +- **Elasticsearch report store.** The visibility toggle syncs the tenant stamp + only to MongoDB; an ES-backed install would not update the ES stamp, and the ES + statistics aggregates cannot be per-record gated. **Run MT with MongoDB.** (An + ES bool-filter analogue of the scope predicate exists but is unexercised.) +- **Legacy distributed (`utils/dist.py`).** The main→worker submission does not + forward tenant/user/visibility, so a distributed worker cannot stamp the shared + mongo document correctly. When a worker processes a distributed task + (`options.main_task_id` set), the report is stamped **private/invisible** + (fail-closed) instead of world-visible. Use the broker/central path for + distributed multitenant analysis. (Our central path keys by `job_id` and never + sets `main_task_id`, so it is unaffected.) + +## Behavior notes + +- **Statistics API shape (shared mode).** With MT enabled in `shared` mode, the + `apiv2` statistics endpoint returns **per-scope** results + (`data['public']`, `data['tenant']`, `data['mine']`) instead of the legacy flat + `data['signatures']`. This is the correct scoped behavior; it is a breaking + change for API clients that assumed the flat shape on an MT-shared install. + Multitenancy-disabled installs and break-glass local-admins still receive the + flat dict. +- **Direct VNC / VM operator console.** The task-less direct-console endpoints + (`task_id=0`: raw host:port VNC, plus VM console/start/shutdown/route/snapshot + by name) have no tenant scoping and mint sessions the per-task tunnel gate does + not cover, so **all** of them are restricted to break-glass admins + (`viewer_for(user).is_local_admin`) in addition to the existing config gate — + never a tenant user. (On an MT-disabled / no-auth install every principal is a + local-admin, so the operator console stays usable.) +- **Threat-hunt facets.** `hunt()` scopes its aggregation by the viewer's entitled + scopes (`viewer_scope` `$match`); its facet `task_ids` rely on that stamp-based + `$match` with no per-id SQL backstop (a `$facet` count can't be post-filtered + per task). This is safe because the report tenant stamp is written fail-closed + on every path, so a doc can't carry a spoofed cross-tenant stamp. +- **Modes:** `shared` (public pool + own tenant + own tasks) and `locked` + (tenant-isolated). An unknown/typo `mode` fails closed to `locked`. diff --git a/lib/cuckoo/common/artifact_storage.py b/lib/cuckoo/common/artifact_storage.py index a5fe59e92eb..834dbff1fe4 100644 --- a/lib/cuckoo/common/artifact_storage.py +++ b/lib/cuckoo/common/artifact_storage.py @@ -8,6 +8,7 @@ """ import logging import os +import re from collections import OrderedDict from lib.cuckoo.common.constants import CUCKOO_ROOT @@ -43,35 +44,173 @@ def _safe_relpath(relpath): _JOB_ID_CACHE_MAX = 1024 +# A resolved job_id becomes the object-store container prefix ("//") on both the S3 and +# the local-mount backends, so it MUST be path-safe: an alnum-anchored charset with no ".." (a value like +# "..", ".foo" or "../../etc" could otherwise collapse the prefix to a parent ref and, on the local mount, +# escape the results tree to read arbitrary host files). This is the canonical guard shared by the read seam +# (job_id_from_custom below, applied at the single parse choke point) AND the write seam +# (centralstore.CentralStore.run imports _is_safe_job_id from here) so the two can never drift. +_SAFE_JOB_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$") + + +def _is_safe_job_id(job_id): + # isinstance guard, not bool(): a non-str info.job_id (e.g. an int written by a second/legacy writer of the + # shared collection -- the exact threat _store_and_container cites) would make re.match raise TypeError, and + # a TypeError is not Http404 so the central_views except-Http404 handlers would miss it (-> 500). Return + # False for any non-str so the caller raises a clean Http404 instead. + return isinstance(job_id, str) and _SAFE_JOB_ID_RE.match(job_id) is not None and ".." not in job_id + + +def job_id_from_custom(custom): + """Parse the broker job_id out of a task's RDS `custom` field. THE single parser shared by the WRITE + consumer (centralstore.resolve_job_id) and the READ/DELETE consumers (central_views.central_job_id_for_task + -> central_analysis_query / central_delete_analysis) so they can't drift. Pure (no DB). + + Anchored to match the submit-bridge's `custom NOT LIKE 'job_id=%'` enqueue filter so a client `custom` + that evades the (out-of-tree) filter can't steer the job_id (which keys info.job_id, the info.id rewrite, + the S3 prefix, and the pre-insert/scoped delete): honour 'job_id=' ONLY as the RAW first comma-field + prefix -- SQL LIKE tests the RAW column, so do NOT .strip() before the prefix test (else ' job_id=...' + would evade the filter yet resolve here) -- and NEVER a bare 'ui-' (the bridge's reserved central-id + form, which no direct submitter produces). A bare NON-ui token is the direct-submission fallback. + + The resolved value is ALSO required to be _is_safe_job_id: it becomes the store container prefix, so a + path-unsafe custom (e.g. '../../etc') is rejected HERE -> return None -> the caller falls back to the + scoped info.id lookup (read) / 'local-' (write), never a container-escaping prefix. (Defence in depth: + _store_and_container ALSO validates the resolved job_id, covering the mongo-fallback path too.) A rejected + PROBE-SHAPED candidate is logged so a job_id-seam probe stays greppable; a bare free-text `custom` (a + documented free-form field, so a note like 'my sample run' is not a job_id attempt) is only logged at + debug -- else every artifact read of a non-bridged task would re-warn on the operator's own note. + Returns the job_id or None.""" + if not custom: + return None + text = str(custom) + first = text.split(",", 1)[0] # RAW first field (no strip -> matches LIKE 'job_id=%' anchoring) + if first.startswith("job_id="): + v = first.split("=", 1)[1].strip() + if v and _is_safe_job_id(v): + return v + if v: + # An explicit 'job_id=' with an unsafe value is a deliberate attempt (probe/misconfig) -> warn. + log.warning("central: ignoring path-unsafe job_id=%r in submitted custom (probe or misconfig)", v) + token = text.strip() + if token and "=" not in token and "," not in token and not re.match(r"^ui-\d+$", token): + if _is_safe_job_id(token): + return token + # A bare token that LOOKS like a path/job_id attempt (contains '..' or '/') is warned so a seam probe + # stays greppable -- keyed on the traversal markers, NOT on whitespace (the token is already .strip()ed, + # so only interior whitespace remains and an attacker controls that: '../../etc x' must still warn). + # `custom` is free-text, so a note WITHOUT those markers is not a probe -> debug (no per-read spam; this + # resolver runs on every central artifact read). Either way the value is rejected (returns None). + if ".." in token or "/" in token: + log.warning("central: ignoring path-unsafe bare job_id token %r in submitted custom", token) + else: + log.debug("central: bare custom %r is not a job_id (free-text note); using scoped fallback", token) + return None + + +def _rds_job_id(task_id): + """The AUTHORIZED job_id from the RDS task row's custom field — RDS-derived (no mongo + id-lookup), so it's collision-free AND independent of the tenancy reconcile (custom is + stamped by the submit-bridge, present even when info.tenant_id/visibility aren't yet). + None for a non-bridged task (caller falls back to the scoped info.id lookup).""" + # Resolve int() BEFORE the DB try: a non-numeric id (the filereport/full_memory \w+ + # routes) is bad INPUT, not an RDS error — return None silently so it doesn't get + # mislabeled as a DB failure in the log below (the fallback's own int() 404s it). + try: + tid = int(task_id) + except (TypeError, ValueError): + return None + try: + from lib.cuckoo.core.database import Database + + t = Database().view_task(tid) + return job_id_from_custom(getattr(t, "custom", None) if t else None) + except Exception: + # A genuine non-bridged task returns None WITHOUT an exception (job_id_from_custom). + # This except is a real RDS error (pool exhaustion / timeout) — log it so a bridged + # OWNER silently degraded to the scoped info.id fallback (and possibly 404'd on their + # own artifact during a DB blip) leaves a signal, not a mystery. + log.exception("_rds_job_id: RDS lookup failed for task %s; falling back to scoped resolution", tid) + return None + + def _job_id_for_task(task_id, scope=None): """Central mode keys the store by the global job_id (the broker passes it in custom, stamped into info.job_id at reporting; centralstore re-keys info.id to the unique - central task id). Resolve task_id -> job_id via mongo. - - `scope` is the requesting viewer's tenant filter (e.g. entitled_scope_filter): - info.id is a per-worker sequence and collides across workers in a central - deployment, so the lookup is ANDed with the viewer's scope to guarantee the - resolved doc is one the viewer may actually see — not another tenant's analysis - that happens to share the numeric id (audit HIGH: cross-store id collision).""" + central task id). Resolve task_id -> job_id. + + PREFER the RDS-derived job_id (_rds_job_id) to identify WHICH doc, then AUTHORIZE it + per-call against the viewer scope: callers gate can_view_task on the RDS task, but the + job_id itself comes from the task's user-supplied `custom` — so it is NOT an + unforgeable authorization token. The doc it resolves to must be in the viewer's `scope` + OR unstamped (info.tenant_id null = authorized owner's not-yet-reconciled doc). A forged + custom job_id pointing at another tenant's STAMPED doc fails that check -> Http404. Only + the RDS task_id->job_id MAPPING is cached (scope-independent); the authorization is + re-checked every call. A NON-bridged task (no RDS job_id) falls back to the scoped + info.id lookup (never cached across scopes — cross-store id collision, audit HIGH).""" from dev_utils.mongodb import mongo_find_one from django.http import Http404 - # Only the unscoped (see-all / MT-absent / break-glass) path is cache-safe — see the note on - # _JOB_ID_CACHE. A present scope is authorization-sensitive, so never cache/serve it. - use_cache = not scope - if use_cache: - cached = _JOB_ID_CACHE.get(str(task_id)) - if cached is not None: - _JOB_ID_CACHE.move_to_end(str(task_id)) # mark most-recently-used - return cached - - # filereport/full_memory routes capture task_id/analysis_number as \w+ (not \d+), - # so a non-numeric segment must raise Http404 (the views catch it -> clean error), - # not an uncaught ValueError -> HTTP 500. + # Resolve the candidate job_id. ONLY the RDS-derived mapping is cached: it's scope- + # INDEPENDENT (from the task's own custom), so serving it to any viewer is safe as long + # as the per-call authorization below still runs. The non-bridged info.id fallback is + # scope-sensitive, so it is never cached / served cross-scope. + jid = _JOB_ID_CACHE.get(str(task_id)) + if jid is not None: + try: + _JOB_ID_CACHE.move_to_end(str(task_id)) # mark MRU; another thread may have evicted it + except KeyError: + pass + else: + jid = _rds_job_id(task_id) + if jid: + _JOB_ID_CACHE[str(task_id)] = jid + try: + _JOB_ID_CACHE.move_to_end(str(task_id)) + if len(_JOB_ID_CACHE) > _JOB_ID_CACHE_MAX: + _JOB_ID_CACHE.popitem(last=False) # evict LRU + except KeyError: + pass + + if jid: + # Per-call AUTHORIZATION (not cached — scope-sensitive). scope None = see-all / + # break-glass / MT-off -> no restriction. Else the doc for this job_id must be in scope + # OR be the authorized owner's not-yet-reconciled doc. The unstamped arm (info.tenant_id + # null) MUST be constrained to THIS task (info.id == the decorator-authorized task_id): + # every doc is inserted unstamped and stamped later (and stranded on reconcile-skip), so an + # UNCONSTRAINED null arm lets a forged custom job_id (ui-) read a DIFFERENT task's + # unstamped doc + its S3 artifacts cross-tenant (adversarial-review HIGH). A bridged owner's + # doc is re-keyed to its central id, so info.id == task_id resolves the legit owner; a forged + # job_id at another task's unstamped doc does not. A forged job_id -> a STAMPED doc fails the + # scope arm too -> Http404. + if scope is not None: + try: + _own_unstamped = {"$and": [{"info.tenant_id": None}, {"info.id": int(task_id)}]} + authq = {"$and": [{"info.job_id": jid}, {"$or": [scope, _own_unstamped]}]} + except (TypeError, ValueError): + authq = {"$and": [{"info.job_id": jid}, scope]} # non-numeric task id: no null arm + if not mongo_find_one("analysis", authq, {"_id": 1}): + raise Http404("task not visible") + return jid + + # Non-bridged fallback: info.id can collide across workers, so AND the viewer scope + # (defence-in-depth, audit HIGH). NOT cached — a scope-specific resolution must never be + # served to a different-scope caller. + # filereport/full_memory routes capture task_id as \w+ (not \d+), so a non-numeric + # segment must raise Http404 (views catch it -> clean error), not an uncaught ValueError. try: tid = int(task_id) except (TypeError, ValueError): raise Http404("invalid task id") + # BRIDGE-REQUIRED (central+MT): a non-bridged task has no RDS-derived ui- job_id and no tenancy by + # construction, so a tenant-scoped viewer has no tenant-safe non-bridged artifact — deny rather than fall + # through to the info.id lookup (whose scope arm could still surface a foreign PUBLIC non-bridged doc that + # collides on info.id). scope None (see-all / break-glass / MT-off) is unaffected. + if scope is not None: + from lib.cuckoo.common.central_mode import central_bridge_required + + if central_bridge_required(): + raise Http404("no bridged job_id for task") query = {"info.id": tid} if scope: query = {"$and": [query, scope]} @@ -80,12 +219,6 @@ def _job_id_for_task(task_id, scope=None): job_id = ((doc or {}).get("info") or {}).get("job_id") if not job_id: raise Http404("no job_id mapping for task") - - if use_cache: - _JOB_ID_CACHE[str(task_id)] = job_id - _JOB_ID_CACHE.move_to_end(str(task_id)) - if len(_JOB_ID_CACHE) > _JOB_ID_CACHE_MAX: - _JOB_ID_CACHE.popitem(last=False) # evict least-recently-used return job_id @@ -93,11 +226,20 @@ def _store_and_container(task_id, scope=None): """Return (ArtifactStore, container) for an analysis. Single-node: the local-FS store over storage/analyses, container=. Central: the configured backend (S3/local mount), container="/" (raises Http404 if the job_id can't resolve).""" + from django.http import Http404 + cfg = central_mode_config() store, is_central = get_artifact_store(cfg) if not is_central: return store, str(task_id) - return store, f"{cfg.s3_prefix}/{_job_id_for_task(task_id, scope)}" + jid = _job_id_for_task(task_id, scope) + # The job_id becomes the container prefix. job_id_from_custom already rejects a path-unsafe RDS custom, but + # the mongo-fallback branch of _job_id_for_task returns info.job_id straight from the doc -- validate HERE + # too so a hostile value (e.g. from a second/legacy writer of the shared collection) can't escape the + # results tree on the local-mount backend. Both read-seam return paths thus funnel through one guard. + if not _is_safe_job_id(jid): + raise Http404("invalid job id") + return store, f"{cfg.s3_prefix}/{jid}" def artifact_response(task_id, relpath, content_type, filename, chunk=8192, scope=None): @@ -194,17 +336,20 @@ def ensure_local_analysis(task_id, scope=None, exclude_prefixes=("memory/", "mem log.warning("central mode: failed to stage analysis %s: %s", task_id, e) -def ensure_local_memory(task_id, scope=None): - """Central mode: stage the memory dumps (the memory/ per-process subtree AND the root - memory.dmp[.zip/.strings] full-RAM image) — which ensure_local_analysis EXCLUDES from the - bulk stage because they are large — to the local analysis dir, on EXPLICIT demand (the - memory-download endpoints). Idempotent per-file; not marker-gated. Best-effort (a clean Http404 - propagates so the view 404s; other errors are swallowed).""" +def ensure_local_memory(task_id, scope=None, include_full_ram=True): + """Central mode: stage the memory dumps (the memory/ per-process subtree AND, when include_full_ram, the + root memory.dmp[.zip/.strings] full-RAM image) — which ensure_local_analysis EXCLUDES from the bulk stage + because they are large — to the local analysis dir, on EXPLICIT demand (the memory-download endpoints). + include_full_ram=False stages ONLY the per-process memory/ subtree: the procmemory endpoints serve + per-process dumps, so they must not pull the multi-GB root full-RAM image onto the web node (that image + is gated separately by [taskfullmemory] and served only by tasks_fullmemory). Idempotent per-file; not + marker-gated. Best-effort (a clean Http404 propagates so the view 404s; other errors are swallowed).""" cfg = central_mode_config() if not cfg.enabled: return try: - _stage_tree(task_id, scope, want=lambda rel: rel.startswith("memory/") or rel.startswith("memory.dmp")) + _stage_tree(task_id, scope, want=lambda rel: rel.startswith("memory/") or ( + include_full_ram and rel.startswith("memory.dmp"))) except Exception as e: from django.http import Http404 diff --git a/lib/cuckoo/common/cape_utils.py b/lib/cuckoo/common/cape_utils.py index 86da8daa75d..d55f2e2f030 100644 --- a/lib/cuckoo/common/cape_utils.py +++ b/lib/cuckoo/common/cape_utils.py @@ -320,7 +320,12 @@ def static_config_parsers(cape_name: str, file_path: str, file_data: bytes) -> d return cape_config -def static_config_lookup(file_path: str, sha256: str = False) -> dict: +# Shared, single-source-of-truth scope builders (avoid drift with web_utils). +from lib.cuckoo.common.tenancy_optional import viewer_scope_match as _config_lookup_scope # noqa: E402 +from lib.cuckoo.common.tenancy_optional import viewer_scope_es_filter as _config_lookup_es_filter # noqa: E402 + + +def static_config_lookup(file_path: str, sha256: str = False, viewer=None) -> dict: """ Look up static configuration information for a given file based on its SHA-256 hash. @@ -330,6 +335,10 @@ def static_config_lookup(file_path: str, sha256: str = False) -> dict: Args: file_path (str): The path to the file for which to look up configuration information. sha256 (str, optional): The SHA-256 hash of the file. If not provided, it will be calculated. + viewer: optional tenancy Viewer — when set, the dedup lookup is restricted + to the submitter's entitled analyses so it can't return ANOTHER + tenant's task id / "config exists" inference for a known hash. No-op + for break-glass / MT-disabled. Returns: dict or None: A dictionary containing the configuration information if found, otherwise None. @@ -338,17 +347,33 @@ def static_config_lookup(file_path: str, sha256: str = False) -> dict: with open(file_path, "rb") as f: sha256 = hashlib.sha256(f.read()).hexdigest() + _scope = _config_lookup_scope(viewer) if repconf.mongodb.enabled: + _q = {"target.file.sha256": sha256} + if _scope: + _q = {"$and": [_q, _scope]} document_dict = mongo_find_one( - "analysis", {"target.file.sha256": sha256}, {"CAPE.configs": 1, "info.id": 1, "_id": 0}, sort=[("_id", -1)] + "analysis", _q, {"CAPE.configs": 1, "info.id": 1, "_id": 0}, sort=[("_id", -1)] ) elif repconf.elasticsearchdb.enabled: - document_dict = es.search( - index=get_analysis_index(), - body={"query": {"match": {"target.file.sha256": sha256}}}, - _source=["CAPE.configs", "info.id"], - sort={"_id": {"order": "desc"}}, - )["hits"]["hits"][0]["_source"] + _esf = _config_lookup_es_filter(viewer) + if _esf: + # MT on: scope-restricted, exact-hash term query; None on no hits. + _esbody = { + "query": {"bool": {"must": [{"term": {"target.file.sha256": sha256}}], "filter": [_esf]}}, + "_source": ["CAPE.configs", "info.id"], + "sort": {"_id": {"order": "desc"}}, + } + _hits = es.search(index=get_analysis_index(), body=_esbody)["hits"]["hits"] + document_dict = _hits[0]["_source"] if _hits else None + else: + # MT off / break-glass: behave byte-for-byte like upstream base. + document_dict = es.search( + index=get_analysis_index(), + body={"query": {"match": {"target.file.sha256": sha256}}}, + _source=["CAPE.configs", "info.id"], + sort={"_id": {"order": "desc"}}, + )["hits"]["hits"][0]["_source"] else: document_dict = None diff --git a/lib/cuckoo/common/central_guac.py b/lib/cuckoo/common/central_guac.py index 9f50f841553..341088feba6 100644 --- a/lib/cuckoo/common/central_guac.py +++ b/lib/cuckoo/common/central_guac.py @@ -27,56 +27,51 @@ def _worker_api_token(token_file): return "" -def _worker_task_view_url(worker_ip, port, cape_task_id): - """The worker's apiv2 task-view URL. port comes from [central_mode] worker_api_port.""" - return "http://%s:%d/apiv2/tasks/view/%d/" % (worker_ip, int(port), int(cape_task_id)) +def _bracket(host): + """Bracket an IPv6 literal for use as a URL/DSN netloc; leave IPv4/hostnames as-is. + _valid_worker_ip accepts any ipaddress.ip_address, so an IPv6 worker IP (e.g. 'fd00::5') + reaches these builders and MUST be bracketed ('[fd00::5]') or the ':' collides with the + port separator and the URL/DSN is malformed.""" + text = str(host) + return "[%s]" % text if ":" in text else text + + +def _worker_machine_url(worker_ip, port, cape_task_id): + """The worker's apiv2 machine-label URL (tasks/machine): a control-plane infra read that + returns ONLY the task's analysis-VM label, so this lookup is not blocked by the worker's + per-tenant task scoping. port comes from [central_mode] worker_api_port. The presented + worker_api_token authorizes via the worker's [api] control_plane_token shared secret (or, + on a token_auth_enabled=yes worker, an is_local_admin principal).""" + return "http://%s:%d/apiv2/tasks/machine/%d/" % (_bracket(worker_ip), int(port), int(cape_task_id)) def _libvirt_ssh_dsn(ip, ssh_user, keyfile): """qemu+ssh libvirt DSN to a worker. ssh_user/keyfile come from [central_mode] (worker_ssh_user/worker_ssh_keyfile) so the deb defaults aren't hardcoded. keyfile is - URL-quoted (safe='/') so a configured path with a '&'/'?'/space can't corrupt the query.""" + URL-quoted (safe='/') so a configured path with a '&'/'?'/space can't corrupt the query. + An IPv6 worker IP is bracketed so its ':'s don't corrupt the netloc.""" from urllib.parse import quote - return "qemu+ssh://%s@%s/system?keyfile=%s&no_verify=1" % (ssh_user, ip, quote(keyfile, safe="/")) + return "qemu+ssh://%s@%s/system?keyfile=%s&no_verify=1" % (ssh_user, _bracket(ip), quote(keyfile, safe="/")) def _job_id_for_task(task_id): - """Resolve the broker job_id for a task. Prefer the RDS task.custom stamp - ('job_id=ui-', set by the submit-bridge at enqueue) — it exists DURING the - live run, which is exactly when interactive guac is needed. The DocumentDB - analysis doc is only written at reporting (after the VM is gone), so it can't be - relied on here; fall back to it only for non-bridged/seeded tasks.""" - try: - from lib.cuckoo.core.database import Database - - t = Database().view_task(int(task_id)) - custom = getattr(t, "custom", None) if t else None - if custom: - # comma-separated k=v pairs — take ONLY the job_id= value (not the rest of the - # string), matching centralstore.resolve_job_id; a trailing ',foo=bar' would - # otherwise corrupt the DynamoDB key / S3 prefix lookup. - text = str(custom) - for part in text.split(","): - part = part.strip() - if part.startswith("job_id="): - v = part.split("=", 1)[1].strip() - if v: - return v - # Bare-token form (custom is just the job id) — kept in sync with - # centralstore.resolve_job_id, which also accepts a bare token for non-bridged tasks. - token = text.strip() - if token and "=" not in token and "," not in token: - return token - except Exception: - pass + """Resolve the broker job_id for a live interactive task's VM, for the guac tunnel. + + DERIVE it deterministically from the caller's AUTHORIZED task_id -- 'ui-' -- NEVER from + the forgeable task.custom. The central submit-bridge assigns job_id='ui-' and stamps + it into custom (central-submit-bridge.py; its docstring: "job_id is deterministic 'ui-' + so the central read seam can resolve rds_task_id -> job_id"). custom is a user-supplied submission + field, and the bridge SKIPS a task whose custom is already 'job_id=%%' -- so a user who submits + custom='job_id=ui-' keeps that forged value, and reading it here resolved ANOTHER tenant's + worker/VM (adversarial-review HIGH: cross-tenant live-VM tunnel). Deriving binds the tunnel to the + caller's OWN task (can_manage_task already authorized it in the guac view/consumer); a forged custom + cannot redirect it, and a non-bridged / not-running task simply misses the broker directory + (-> no worker_ip -> local DSN). The DocumentDB doc can't help here anyway (written only at reporting, + after the VM is gone).""" try: - from dev_utils.mongodb import mongo_find_one - - doc = mongo_find_one("analysis", {"info.id": int(task_id)}, {"info.job_id": 1}) - # info may be missing OR explicitly None ({"info": None}); coalesce both before .get. - return ((doc or {}).get("info") or {}).get("job_id") - except Exception: + return f"ui-{int(task_id)}" + except (TypeError, ValueError): return None @@ -107,42 +102,124 @@ def worker_ip_for_task(task_id): return _worker_ip(central_mode_config(), task_id) -def worker_vm_for_task(task_id): - """For a live broker-dispatched interactive task, return (vm_label, guest_ip) of the - VM on the worker — needed to build the guac session_data on the central node, where - the local machines table is empty (the VM lives on the worker). Resolves the broker - record (job_id -> worker IP + the worker-local cape_task_id) then asks that worker's - apiv2 for the task's machine. Returns (None, None) for non-bridged/local tasks.""" +def _worker_machine_body(task_id): + """Resolve task_id -> the hosting worker's apiv2 tasks/machine response dict, or None. + Shared by worker_vm_for_task (VM label) and worker_vnc_port_for_task (VNC port): resolves + the broker record (job_id -> worker IP + worker-local cape_task_id) then asks that worker's + apiv2. Central mode only; None => non-bridged/local task, or a worker/auth error (logged).""" from lib.cuckoo.common.central_mode import central_mode_config from lib.cuckoo.common.job_directory import get_job_directory cfg = central_mode_config() directory = get_job_directory(cfg) if directory is None: - return (None, None) + return None try: job_id = _job_id_for_task(task_id) if not job_id: - return (None, None) + return None loc = directory.lookup(job_id) if not loc: - return (None, None) + return None worker_ip = loc.worker_ip cape_task_id = loc.cape_task_id if not worker_ip or cape_task_id is None: - return (None, None) + return None import requests token = _worker_api_token(cfg.worker_api_token_file) headers = {"Authorization": f"Token {token}"} if token else {} - r = requests.get(_worker_task_view_url(worker_ip, cfg.worker_api_port, cape_task_id), + r = requests.get(_worker_machine_url(worker_ip, cfg.worker_api_port, cape_task_id), headers=headers, timeout=10) - data = (r.json() or {}).get("data", {}) - return (data.get("machine"), None) # central guac uses the worker's localhost for VNC + if r.status_code != 200: + # A 401/403/404 here is almost always an auth/authorization misconfig — the + # presented worker_api_token doesn't match the worker's [api] control_plane_token + # (or, on a token_auth_enabled=yes worker, isn't an is_local_admin principal) — + # NOT a genuinely local task. Log it so a dead live-VM attach is diagnosable + # instead of silently degrading to None == "no VM" (the caller can't tell apart). + log.warning( + "central guac: worker %s tasks/machine for task %s returned HTTP %s " + "(verify worker_api_token matches the worker's [api] control_plane_token)", + worker_ip, task_id, r.status_code, + ) + return None + return r.json() or {} except Exception as e: log.warning("central guac: worker VM lookup failed for task %s: %s", task_id, e) + return None + + +def worker_vm_for_task(task_id): + """For a live broker-dispatched interactive task, return (vm_label, guest_ip) of the + VM on the worker — needed to build the guac session_data on the central node, where + the local machines table is empty (the VM lives on the worker). Returns (None, None) + for non-bridged/local tasks. guest_ip is None: central guac reaches the VM's VNC via the + worker's own localhost, so the guest IP isn't needed here.""" + body = _worker_machine_body(task_id) + if not body: return (None, None) + # tasks/machine returns {"error": False, "machine": "