From 6e71207a7a2ce5dc2305766fdb84127187ce3752 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Mon, 13 Jul 2026 10:35:09 -0500 Subject: [PATCH 001/183] WIP mt-upstream: 22 new + 18 clean-applied MT files + facade-route raw tenancy imports --- lib/cuckoo/common/cape_utils.py | 30 +- lib/cuckoo/common/tenancy.py | 161 +++++ lib/cuckoo/common/web_utils.py | 201 ++++-- lib/cuckoo/core/data/samples.py | 108 +++- lib/cuckoo/core/data/task.py | 2 + lib/cuckoo/core/data/tasking.py | 154 ++++- lib/cuckoo/core/database.py | 2 +- modules/reporting/mongodb.py | 66 +- tests/tenancy_vectors.py | 71 +++ tests/test_mongo_backfill.py | 15 + tests/test_mongo_stamp.py | 17 + tests/test_schema_version.py | 45 ++ tests/test_scope_stats.py | 111 ++++ tests/test_task_visibility.py | 281 +++++++++ tests/test_tenancy.py | 74 +++ utils/db_migration/__init__.py | 0 utils/db_migration/mongo_backfill_tenant.py | 49 ++ .../versions/3_add_tenant_visibility.py | 40 ++ web/analysis/test_visibility.py | 125 ++++ web/apiv2/test_hash_isolation.py | 86 +++ web/apiv2/test_visibility.py | 585 ++++++++++++++++++ web/apiv2/urls.py | 1 + web/compare/test_visibility.py | 95 +++ web/compare/views.py | 79 ++- web/conftest.py | 33 + web/dashboard/test_dashboard_scope.py | 55 ++ web/dashboard/views.py | 110 +++- web/guac/test_visibility.py | 27 + web/submission/test_visibility.py | 16 + web/submission/views.py | 73 ++- web/templates/analysis/report.html | 31 + web/templates/dashboard/index.html | 43 +- web/templates/statistics.html | 189 +++--- web/templates/submission/index.html | 10 + web/users/admin.py | 9 +- ...nt_userprofile_is_tenant_admin_and_more.py | 36 ++ web/users/models.py | 20 + web/users/tenancy.py | 126 ++++ web/users/test_tenancy.py | 221 +++++++ web/web/allauth_adapters.py | 37 ++ 40 files changed, 3181 insertions(+), 253 deletions(-) create mode 100644 lib/cuckoo/common/tenancy.py create mode 100644 tests/tenancy_vectors.py create mode 100644 tests/test_mongo_backfill.py create mode 100644 tests/test_mongo_stamp.py create mode 100644 tests/test_schema_version.py create mode 100644 tests/test_scope_stats.py create mode 100644 tests/test_task_visibility.py create mode 100644 tests/test_tenancy.py create mode 100644 utils/db_migration/__init__.py create mode 100644 utils/db_migration/mongo_backfill_tenant.py create mode 100644 utils/db_migration/versions/3_add_tenant_visibility.py create mode 100644 web/analysis/test_visibility.py create mode 100644 web/apiv2/test_hash_isolation.py create mode 100644 web/apiv2/test_visibility.py create mode 100644 web/compare/test_visibility.py create mode 100644 web/conftest.py create mode 100644 web/dashboard/test_dashboard_scope.py create mode 100644 web/guac/test_visibility.py create mode 100644 web/submission/test_visibility.py create mode 100644 web/users/migrations/0004_tenant_userprofile_is_tenant_admin_and_more.py create mode 100644 web/users/tenancy.py create mode 100644 web/users/test_tenancy.py diff --git a/lib/cuckoo/common/cape_utils.py b/lib/cuckoo/common/cape_utils.py index 86da8daa75d..cc82ec8b2dd 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,22 @@ 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"] + _esmust = [{"term": {"target.file.sha256": sha256}}] # exact hash -> term, not analyzed match + _esbody = {"query": {"bool": {"must": _esmust}}, "_source": ["CAPE.configs", "info.id"], "sort": {"_id": {"order": "desc"}}} + _esf = _config_lookup_es_filter(viewer) + if _esf: + _esbody["query"]["bool"]["filter"] = [_esf] + _hits = es.search(index=get_analysis_index(), body=_esbody)["hits"]["hits"] + document_dict = _hits[0]["_source"] if _hits else None else: document_dict = None diff --git a/lib/cuckoo/common/tenancy.py b/lib/cuckoo/common/tenancy.py new file mode 100644 index 00000000000..8e37dbe2f80 --- /dev/null +++ b/lib/cuckoo/common/tenancy.py @@ -0,0 +1,161 @@ +"""Pure, dependency-free job-visibility predicate — the single source of truth +for who may read/manage a task. Imported by the Django web layer, the apiv2 +views, the SQLAlchemy task store, and (separately) validated by the broker. +No Django, no SQLAlchemy imports here — only plain dataclasses so it stays a +pure function set testable against tests/tenancy_vectors.py. +""" +from dataclasses import dataclass +from typing import Optional + +PUBLIC, TENANT, PRIVATE = "public", "tenant", "private" +VISIBILITIES = (PUBLIC, TENANT, PRIVATE) + +MINE, GLOBAL = "mine", "global" +SCOPES = (PUBLIC, TENANT, MINE, GLOBAL) + + +@dataclass(frozen=True) +class Viewer: + user_id: Optional[int] + tenant_id: Optional[int] + is_superuser: bool = False + is_tenant_admin: bool = False + is_local_admin: bool = False # superuser AND cuckoo.conf break-glass flag on + + +@dataclass(frozen=True) +class Job: + owner_id: Optional[int] + tenant_id: Optional[int] + visibility: str + + +def _is_owner(v: Viewer, j: Job) -> bool: + return v.user_id is not None and v.user_id == j.owner_id + + +def _same_tenant(v: Viewer, j: Job) -> bool: + return j.tenant_id is not None and v.tenant_id == j.tenant_id + + +def can_read(v: Viewer, j: Job) -> bool: + if j.visibility == PUBLIC: + return True + if v.is_local_admin: # operator break-glass (gated upstream) + return True + if _is_owner(v, j): + return True + if j.visibility == TENANT and _same_tenant(v, j): + return True + return False # private => owner/break-glass only + + +def can_toggle(v: Viewer, j: Job) -> bool: + if _is_owner(v, j): + return True + if v.is_local_admin: + return True + # tenant-admin manages public/tenant jobs in their own tenant, never private + if v.is_tenant_admin and _same_tenant(v, j) and j.visibility in (PUBLIC, TENANT): + return True + return False + + +def scope_match(scope: str, v: "Viewer"): + """Mongo $match (dict) selecting the analysis docs in a stat SCOPE for viewer v. + Mirrors the can_read branches. Returns None for 'global' (no filter). Keys target + the report's info.* (stamped at report time).""" + if scope == GLOBAL: + return None + if scope == PUBLIC: + return {"info.visibility": PUBLIC} + if scope == TENANT: + if v is None or v.tenant_id is None: + return {"info.id": -1} # no viewer / tenant-less -> empty + return {"info.tenant_id": v.tenant_id, "info.visibility": TENANT} + if scope == MINE: + if v is None or v.user_id is None: + return {"info.id": -1} + return {"info.user_id": v.user_id} + raise ValueError(f"unknown scope {scope!r}") + + +@dataclass(frozen=True) +class MTConfig: + enabled: bool + mode: str + default_visibility: str + local_admins_manage_all_tenants: bool + + +def _as_bool(v, default: bool) -> bool: + if isinstance(v, bool): + return v + if isinstance(v, str): + return v.strip().lower() in ("yes", "true", "1", "on") + if v is None: + return default + return bool(v) + + +def multitenancy_config() -> MTConfig: + """Read the [multitenancy] section of cuckoo.conf (server-side policy).""" + from lib.cuckoo.common.config import Config + + try: + sec = Config("cuckoo").get("multitenancy") + except Exception: + sec = {} + get = sec.get if hasattr(sec, "get") else (lambda k, d=None: d) + return MTConfig( + enabled=_as_bool(get("enabled", False), False), + mode=str(get("mode", "shared") or "shared"), + default_visibility=str(get("default_visibility", "") or ""), + local_admins_manage_all_tenants=_as_bool(get("local_admins_manage_all_tenants", True), True), + ) + + +def default_visibility(cfg: MTConfig) -> str: + """The submit-time default visibility for the configured mode.""" + if cfg.default_visibility in VISIBILITIES: + return cfg.default_visibility + return PUBLIC if cfg.mode == "shared" else TENANT + + +def viewer_scope_match(viewer): + """Mongo $match restricting an analysis-collection query to the viewer's + entitled tenant scopes (public OR own-tenant TENANT OR mine), or None when no + filter applies — multitenancy disabled, shared mode, or break-glass + (is_local_admin). THE single source of truth (imported by web_utils, + cape_utils, …) so the search/dedup/stats by-scope query builders can't drift. + Keys target the report's stamped info.* fields. + """ + if viewer is None: + return None + cfg = multitenancy_config() + if not cfg.enabled or cfg.mode != "locked" or getattr(viewer, "is_local_admin", False): + return None + clauses = [m for m in (scope_match(PUBLIC, viewer), scope_match(TENANT, viewer), scope_match(MINE, viewer)) if m is not None] + # No entitled scope resolved (tenant-less/anon) -> match nothing, never global. + return {"$or": clauses} if clauses else {"info.id": -1} + + +def viewer_scope_es_filter(viewer): + """Elasticsearch bool-filter analogue of viewer_scope_match (public OR + own-tenant TENANT OR mine), or None when no filter applies. Uses the term/ + info.* idiom. A tenant-less/anonymous locked-mode viewer sees only public. + """ + if viewer is None: + return None + cfg = multitenancy_config() + if not cfg.enabled or cfg.mode != "locked" or getattr(viewer, "is_local_admin", False): + return None + shoulds = [{"term": {"info.visibility": PUBLIC}}] + if getattr(viewer, "tenant_id", None) is not None: + shoulds.append({"bool": {"filter": [ + {"term": {"info.tenant_id": viewer.tenant_id}}, + {"term": {"info.visibility": TENANT}}, + ]}}) + if getattr(viewer, "user_id", None) is not None: + shoulds.append({"term": {"info.user_id": viewer.user_id}}) + return {"bool": {"should": shoulds, "minimum_should_match": 1}} diff --git a/lib/cuckoo/common/web_utils.py b/lib/cuckoo/common/web_utils.py index 716ebbbd2fa..4b3f5bf4ad9 100644 --- a/lib/cuckoo/common/web_utils.py +++ b/lib/cuckoo/common/web_utils.py @@ -251,7 +251,7 @@ def load_vms_tags(force: bool = False): return _all_vms_tags or [] -def top_asn(date_since: datetime = False, results_limit: int = 20) -> dict: +def top_asn(date_since: datetime = False, results_limit: int = 20, scope_match: dict = None) -> dict: """ Retrieves the top Autonomous System Numbers (ASNs) based on the number of occurrences in the database. @@ -270,8 +270,9 @@ def top_asn(date_since: datetime = False, results_limit: int = 20) -> dict: t = int(time.time()) - # caches results for 10 minutes - if hasattr(top_asn, "cache"): + # caches results for 10 minutes; scoped calls bypass the cache entirely to + # prevent cross-tenant leaks (cache is shared across all callers). + if not scope_match and hasattr(top_asn, "cache"): ct, data = top_asn.cache if t - ct < 600: return data @@ -295,6 +296,9 @@ def top_asn(date_since: datetime = False, results_limit: int = 20) -> dict: if date_since: aggregation_command[0]["$match"].setdefault("info.started", {"$gte": date_since.isoformat()}) + if scope_match: + aggregation_command[0].setdefault("$match", {}).update(scope_match) + if repconf.mongodb.enabled: data = mongo_aggregate("analysis", aggregation_command) else: @@ -303,13 +307,14 @@ def top_asn(date_since: datetime = False, results_limit: int = 20) -> dict: if data: data = list(data) - # save to cache - top_asn.cache = (t, data) + # save to cache only for unscoped (global) calls + if not scope_match: + top_asn.cache = (t, data) return data -def top_detections(date_since: datetime = False, results_limit: int = 20) -> dict: +def top_detections(date_since: datetime = False, results_limit: int = 20, scope_match: dict = None, viewer=None) -> list: """ Retrieves the top detections from the database, either from MongoDB or Elasticsearch, and caches the results for 10 minutes. @@ -326,8 +331,9 @@ def top_detections(date_since: datetime = False, results_limit: int = 20) -> dic t = int(time.time()) - # caches results for 10 minutes - if hasattr(top_detections, "cache"): + # caches results for 10 minutes; scoped calls bypass the cache entirely to + # prevent cross-tenant leaks (cache is shared across all callers). + if not scope_match and hasattr(top_detections, "cache"): ct, data = top_detections.cache if t - ct < 600: return data @@ -351,6 +357,9 @@ def top_detections(date_since: datetime = False, results_limit: int = 20) -> dic if date_since: aggregation_command[0]["$match"].setdefault("info.started", {"$gte": date_since.isoformat()}) + if scope_match: + aggregation_command[0].setdefault("$match", {}).update(scope_match) + if repconf.mongodb.enabled: data = mongo_aggregate("analysis", aggregation_command) elif repconf.elasticsearchdb.enabled: @@ -364,6 +373,14 @@ def top_detections(date_since: datetime = False, results_limit: int = 20) -> dic if date_since: q["query"]["bool"]["must"].append({"range": {"info.started": {"gte": date_since.isoformat()}}}) + # Tenant scope (parity with the mongo branch's scope_match): without this + # a locked-mode tenant's My-Tenant/Mine stat panels return the GLOBAL + # per-family malware landscape on an ES-backed install. No-op for + # break-glass / MT-disabled. + _esf = _viewer_scope_es_filter(viewer) + if _esf: + q["query"]["bool"].setdefault("filter", []).append(_esf) + res = es.search(index=get_analysis_index(), body=q) data = [{"total": r["doc_count"], "family": r["key"]} for r in res["aggregations"]["family"]["buckets"]] else: @@ -372,14 +389,15 @@ def top_detections(date_since: datetime = False, results_limit: int = 20) -> dic if data: data = list(data) - # save to cache - top_detections.cache = (t, data) + # save to cache only for unscoped (global) calls + if not scope_match: + top_detections.cache = (t, data) return data # ToDo extend this to directly extract per day -def get_stats_per_category(category: str, date_since: datetime) -> List[Dict[str, int]]: +def get_stats_per_category(category: str, date_since: datetime, scope_match: dict = None) -> List[Dict[str, int]]: """ Retrieves statistical data for a given category from the MongoDB collection "analysis" starting from a specified date. @@ -430,15 +448,21 @@ def get_stats_per_category(category: str, date_since: datetime) -> List[Dict[str }, {"$limit": 20}, ] + if scope_match: + aggregation_command[0].setdefault("$match", {}).update(scope_match) return mongo_aggregate("analysis", aggregation_command) -def statistics(s_days: int) -> dict: +def statistics(s_days: int, scope=None, viewer=None) -> dict: """ Generate statistics for the given number of days. Args: s_days (int): The number of days to generate statistics for. + scope (str, optional): Tenancy scope string (e.g. 'global', 'public', 'tenant', 'mine'). + When None or 'global', all tasks are included (backward-compatible default). + viewer: Viewer object for per-tenant/per-user scoping. Required when scope is + 'tenant' or 'mine'; ignored when scope is None/'global'. Returns: dict: A dictionary containing various statistics including: @@ -454,6 +478,11 @@ def statistics(s_days: int) -> dict: - distributed_tasks: Statistics related to distributed tasks (if applicable). - asns: Top Autonomous System Numbers (ASNs). """ + from lib.cuckoo.common.tenancy_optional import scope_match as _scope_match_fn + + # Derive the mongo $match dict from scope+viewer; None → no filter (global, backward-compat). + sm = _scope_match_fn(scope, viewer) if scope and scope != "global" else None + date_since = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=s_days) date_till = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) @@ -489,21 +518,26 @@ def statistics(s_days: int) -> dict: return details for module_name in ("statistics.signatures", "statistics.processing", "statistics.reporting", "custom_statistics"): - module_data = get_stats_per_category(module_name, date_since) + module_data = get_stats_per_category(module_name, date_since, scope_match=sm) for entry in module_data or []: name = entry["name"] details[module_name.split(".")[-1]].setdefault(name, entry) top_samples = {} - added_tasks = ( - db.session.query(Task).join(Sample, Task.sample_id == Sample.id).filter(Task.added_on.between(date_since, date_till)).all() + added_tasks_q = ( + db.session.query(Task).join(Sample, Task.sample_id == Sample.id).filter(Task.added_on.between(date_since, date_till)) ) - tasks = ( + tasks_q = ( db.session.query(Task) .join(Sample, Task.sample_id == Sample.id) .filter(Task.completed_on.between(date_since, date_till)) - .all() ) + # Apply SQL scope filter when scope/viewer are provided; empty list = no-op (global path unchanged). + for cond in db._scope_where(scope, viewer): + added_tasks_q = added_tasks_q.filter(cond) + tasks_q = tasks_q.filter(cond) + added_tasks = added_tasks_q.all() + tasks = tasks_q.all() details["total"] = len(tasks) details["average"] = f"{round(details['total'] / s_days, 2):.2f}" details["tasks"] = {} @@ -535,25 +569,39 @@ def statistics(s_days: int) -> dict: sorted(details["tasks"].items(), key=lambda x: datetime.strptime(x[0], "%Y-%m-%d"), reverse=True) ) - if HAVE_DIST and dist_conf.distributed.enabled: + # The distributed-tasks panel runs against a separate dist DB whose Task + # schema has no tenant_id/visibility column, so it cannot be scope-filtered. + # Until that schema gains tenancy columns, only populate it for the GLOBAL + # scope (MT disabled / shared / break-glass) — never for a locked-mode + # tenant/mine panel, where per-node-per-day counts would leak other tenants' + # submission volumes. + if HAVE_DIST and dist_conf.distributed.enabled and (scope is None or scope == "global"): + # A dist DB that's down/misconfigured must not 500 the whole dashboard — + # just omit the panel. details["distributed_tasks"] = {} - dist_db = dist_session() - dist_tasks = dist_db.query(DTask).filter(DTask.clock.between(date_since, date_till)).all() - id2name = {} - # load node names - for node in dist_db.query(Node).all() or []: - id2name.setdefault(node.id, node.name) - - for task in dist_tasks or []: - day = task.clock.strftime("%Y-%m-%d") - if day not in details["distributed_tasks"]: - details["distributed_tasks"].setdefault(day, {}) - if id2name.get(task.node_id) not in details["distributed_tasks"][day]: - details["distributed_tasks"][day].setdefault(id2name[task.node_id], 0) - details["distributed_tasks"][day][id2name[task.node_id]] += 1 - dist_db.close() - - details["distributed_tasks"] = OrderedDict(sorted(details["distributed_tasks"].items(), key=lambda x: x[0], reverse=True)) + dist_db = None + try: + dist_db = dist_session() + dist_tasks = dist_db.query(DTask).filter(DTask.clock.between(date_since, date_till)).all() + id2name = {} + # load node names + for node in dist_db.query(Node).all() or []: + id2name.setdefault(node.id, node.name) + + for task in dist_tasks or []: + day = task.clock.strftime("%Y-%m-%d") + if day not in details["distributed_tasks"]: + details["distributed_tasks"].setdefault(day, {}) + if id2name.get(task.node_id) not in details["distributed_tasks"][day]: + details["distributed_tasks"][day].setdefault(id2name[task.node_id], 0) + details["distributed_tasks"][day][id2name[task.node_id]] += 1 + + details["distributed_tasks"] = OrderedDict(sorted(details["distributed_tasks"].items(), key=lambda x: x[0], reverse=True)) + except Exception as dist_err: + log.warning("Could not load distributed_tasks panel: %s", dist_err) + finally: + if dist_db is not None: + dist_db.close() # Get top15 of samples per day and seen more than once for day in top_samples: @@ -568,8 +616,8 @@ def statistics(s_days: int) -> dict: sorted(details["top_samples"].items(), key=lambda x: datetime.strptime(x[0], "%Y-%m-%d"), reverse=True) ) - details["detections"] = top_detections(date_since=date_since) - details["asns"] = top_asn(date_since=date_since) + details["detections"] = top_detections(date_since=date_since, scope_match=sm, viewer=viewer) + details["asns"] = top_asn(date_since=date_since, scope_match=sm) return details @@ -639,7 +687,11 @@ def download_from_3rdparty(samples: str, opt_filename: str, details: dict) -> di # clean old content if "content" in details: del details["content"] - paths = db.sample_path_by_hash(h) + # Only reuse a locally-cached copy if the requester is entitled to the + # sample (visible-task boundary). Otherwise fall through to the external + # downloader exactly as if uncached — so a non-entitled tenant can neither + # obtain another tenant's bytes nor learn the hash was present locally. + paths = db.sample_path_by_hash(h, visible_to=details.get("viewer")) if paths: details["content"] = get_file_content(paths) details["service"] = "Local" @@ -955,6 +1007,8 @@ def download_file(**kwargs): route=route, cape=cape, user_id=kwargs.get("user_id"), + tenant_id=kwargs.get("tenant_id"), + visibility=kwargs.get("visibility", "private"), source_url=kwargs.get("source_url", False), ) @@ -1336,8 +1390,16 @@ def _build_es_user_filter(privs: bool, user_id: int): return user_filter +# The viewer-scope query builders live in the pure tenancy module (single source +# of truth, shared with cape_utils & co); keep the _-prefixed local aliases so the +# in-module callers (perform_search, top_detections) are unchanged. +from lib.cuckoo.common.tenancy_optional import viewer_scope_match as _viewer_scope_match # noqa: E402 +from lib.cuckoo.common.tenancy_optional import viewer_scope_es_filter as _viewer_scope_es_filter # noqa: E402 + + def perform_search( - term: str, value: str, search_limit: int = 0, user_id: int = 0, privs: bool = False, web: bool = True, projection: dict = None + term: str, value: str, search_limit: int = 0, user_id: int = 0, privs: bool = False, web: bool = True, projection: dict = None, + viewer=None, ): """ Perform a search based on the provided term and value. @@ -1356,10 +1418,18 @@ def perform_search( """ if repconf.mongodb.enabled and repconf.elasticsearchdb.enabled and essearch and not term: multi_match_search = {"query": {"multi_match": {"query": value, "fields": ["*"]}}} + # Legacy TLP/user filter (skipped for privs) + tenant scope (applied + # regardless of privs — is_staff is not the tenancy break-glass). + es_filters = [] if not privs: - user_filter = _build_es_user_filter(privs, user_id) - if user_filter: - multi_match_search = {"query": {"bool": {"must": [{"multi_match": {"query": value, "fields": ["*"]}}], "filter": [user_filter]}}} + uf = _build_es_user_filter(privs, user_id) + if uf: + es_filters.append(uf) + sf = _viewer_scope_es_filter(viewer) + if sf: + es_filters.append(sf) + if es_filters: + multi_match_search = {"query": {"bool": {"must": [{"multi_match": {"query": value, "fields": ["*"]}}], "filter": es_filters}}} numhits = es.search(index=get_analysis_index(), body=multi_match_search, size=0)["hits"]["total"] return [ d["_source"] @@ -1440,21 +1510,29 @@ def perform_search( # The file details are uniq, and we store 1 to many. So where hash type is uniq, IDs are list split_by = "," if "," in query_val else " " query_filter_list = {"$in": [val.strip() for val in query_val.split(split_by)]} + # The files collection is NOT tenant-stamped, so we $lookup back to + # the analysis doc (which IS stamped) and filter THERE. The tenant + # $match and the $limit are applied AFTER the join — limiting before + # the scope filter would let other tenants' hits crowd out / truncate + # the viewer's own visible results. pipeline = [ - # Stages 1-5: Find, unwind, group, sort, limit IDs {"$match": {hash_searches[term]: query_filter_list}}, {"$unwind": "$_task_ids"}, {"$group": {"_id": "$_task_ids"}}, {"$sort": {"_id": -1}}, - {"$limit": search_limit}, - # Stage 6: Join with the tasks collection + # Join with the analysis collection {"$lookup": {"from": "analysis", "localField": "_id", "foreignField": "info.id", "as": "task_doc"}}, - # Stage 7: Unpack the joined doc {"$unwind": "$task_doc"}, - # Stage 8: Make the task doc the new root {"$replaceRoot": {"newRoot": "$task_doc"}}, ] + # Tenant scope — applied regardless of `privs` (Django is_staff is NOT + # the tenancy break-glass; only is_local_admin is, handled inside + # _viewer_scope_match). No-op when multitenancy is disabled/shared. + _scope = _viewer_scope_match(viewer) + if _scope: + pipeline.append({"$match": _scope}) + if not privs: if force_bool(web_cfg.general.get("public_searches", True)): if not force_bool(web_cfg.tlp.get("public_red", False)): @@ -1462,7 +1540,8 @@ def perform_search( else: pipeline.append({"$match": {"info.user_id": user_id}}) - # Stage 9: Add your custom projection + # Limit AFTER scoping, then project. + pipeline.append({"$limit": search_limit}) pipeline.append({"$project": projection or perform_search_filters}) retval = list(mongo_aggregate(FILES_COLL, pipeline)) @@ -1498,6 +1577,12 @@ def perform_search( else: mongo_search_query["info.user_id"] = user_id + # Tenant scope — applied regardless of `privs` (is_staff is not the + # tenancy break-glass). No-op when multitenancy disabled/shared. + _scope = _viewer_scope_match(viewer) + if _scope: + mongo_search_query = {"$and": [mongo_search_query, _scope]} + retval = list(mongo_find("analysis", mongo_search_query, projection, limit=search_limit)) for doc in retval: @@ -1509,18 +1594,26 @@ def perform_search( if es_as_db: _source_fields = list((projection or perform_search_filters).keys())[:-1] + # Legacy TLP/user filter (skipped for privs) + tenant scope (applied + # regardless of privs — is_staff is not the tenancy break-glass). + es_filters = [] user_filter = _build_es_user_filter(privs, user_id) + if user_filter: + es_filters.append(user_filter) + scope_filter = _viewer_scope_es_filter(viewer) + if scope_filter: + es_filters.append(scope_filter) if isinstance(search_term_map[term], str): q = {"query": {"match": {search_term_map[term]: value}}} - if user_filter: - q = {"query": {"bool": {"must": [q["query"]], "filter": [user_filter]}}} + if es_filters: + q = {"query": {"bool": {"must": [q["query"]], "filter": es_filters}}} return [d["_source"] for d in es.search(index=get_analysis_index(), body=q, _source=_source_fields)["hits"]["hits"]] else: queries = [{"match": {search_term: value}} for search_term in search_term_map[term]] q = {"query": {"bool": {"should": queries, "minimum_should_match": 1}}} - if user_filter: - q["query"]["bool"]["filter"] = [user_filter] + if es_filters: + q["query"]["bool"]["filter"] = es_filters return [d["_source"] for d in es.search(index=get_analysis_index(), body=q, _source=_source_fields)["hits"]["hits"]] @@ -1712,10 +1805,12 @@ def process_new_task_files(request, samples: list, details: dict, opt_filename: ) continue + from lib.cuckoo.common.tenancy_optional import viewer_for as _viewer_for + if ( not request.user.is_staff and (web_cfg.uniq_submission.enabled or unique) - and db.check_file_uniq(sha256, hours=web_cfg.uniq_submission.hours) + and db.check_file_uniq(sha256, hours=web_cfg.uniq_submission.hours, visible_to=_viewer_for(request.user)) ): details["errors"].append( {filename: "Duplicated file, disable unique option on submit or in conf/web.conf to force submission"} diff --git a/lib/cuckoo/core/data/samples.py b/lib/cuckoo/core/data/samples.py index adf4d863cfd..8fd23cbab19 100644 --- a/lib/cuckoo/core/data/samples.py +++ b/lib/cuckoo/core/data/samples.py @@ -22,10 +22,13 @@ from sqlalchemy.exc import IntegrityError try: from sqlalchemy import ( + and_, BigInteger, + distinct, func, ForeignKey, Index, + or_, select, String, Text, @@ -158,27 +161,37 @@ def register_sample(self, obj, source_url=False): return sample - def check_file_uniq(self, sha256: str, hours: int = 0): + def check_file_uniq(self, sha256: str, hours: int = 0, visible_to=None): # TODO This function is poorly named. It returns True if a sample with the given # sha256 already exists in the database, rather than returning True if the given # sha256 is unique. - uniq = False - if hours and sha256: - date_since = _utcnow_naive() - timedelta(hours=hours) - - stmt = ( - select(Task) - .join(Sample, Task.sample_id == Sample.id) - .where(Sample.sha256 == sha256) - .where(Task.added_on >= date_since) - ) + if not sha256: + return False + + # The tenant scope must apply for ANY hours value — including hours=0 + # (all-time uniqueness) — else a non-break-glass viewer's duplicate check + # is a cross-tenant existence oracle (the original else-branch called + # find_sample() unscoped). Build the scoped Task-exists query when a + # viewer is supplied; preserve the original unscoped behavior otherwise. + stmt = select(Task).join(Sample, Task.sample_id == Sample.id).where(Sample.sha256 == sha256) + if hours: + stmt = stmt.where(Task.added_on >= _utcnow_naive() - timedelta(hours=hours)) + + if visible_to is not None and not visible_to.is_local_admin: + from lib.cuckoo.common.tenancy import PUBLIC, TENANT + + conds = [Task.visibility == PUBLIC] + if visible_to.user_id is not None: + conds.append(Task.user_id == visible_to.user_id) + if visible_to.tenant_id is not None: + conds.append(and_(Task.visibility == TENANT, Task.tenant_id == visible_to.tenant_id)) + stmt = stmt.where(or_(*conds)) return self.session.scalar(select(stmt.exists())) - else: - if not self.find_sample(sha256=sha256): - uniq = False - else: - uniq = True - return uniq + + # Unscoped (break-glass / MT-disabled / no viewer): original semantics. + if not hours: + return self.find_sample(sha256=sha256) is not None + return self.session.scalar(select(stmt.exists())) def get_file_types(self) -> List[str]: """Gets a sorted list of unique sample file types.""" @@ -266,10 +279,15 @@ def _hash_file_in_chunks(self, path: str, hash_algo) -> str: hasher.update(chunk) return hasher.hexdigest() - def sample_path_by_hash(self, sample_hash: str = False, task_id: int = False): + def sample_path_by_hash(self, sample_hash: str = False, task_id: int = False, visible_to=None): """Retrieve information on a sample location by given hash. @param hash: md5/sha1/sha256/sha256. @param task_id: task_id + @param visible_to: optional tenancy Viewer — when set, only resolve a + sample the viewer has a VISIBLE task for (the shared by-hash boundary, + so a tenant can't pull another tenant's sample bytes by hash). No-op + for break-glass / MT-disabled (is_local_admin). Empty result is + indistinguishable from genuinely-not-found. @return: samples path(s) as list. """ sizes = { @@ -279,6 +297,17 @@ def sample_path_by_hash(self, sample_hash: str = False, task_id: int = False): 128: Sample.sha512, } + if visible_to is not None and not getattr(visible_to, "is_local_admin", False): + if task_id: + # task_id already gives us the Sample via the join — no extra query. + _samp = self.session.scalar(select(Sample).join(Task, Sample.id == Task.sample_id).where(Task.id == task_id)) + elif sample_hash and sizes.get(len(sample_hash)): + _samp = self.session.scalar(select(Sample).where(sizes[len(sample_hash)] == sample_hash)) + else: + _samp = None + if _samp is None or not self.list_tasks(sample_id=_samp.id, visible_to=visible_to, limit=1): + return [] + hashlib_sizes = { 32: hashlib.md5, 40: hashlib.sha1, @@ -381,9 +410,46 @@ def sample_path_by_hash(self, sample_hash: str = False, task_id: int = False): break return sample - def count_samples(self) -> int: - """Counts the amount of samples in the database.""" - stmt = select(func.count(Sample.id)) + def count_samples(self, scope=None, viewer=None) -> int: + """Counts the amount of samples in the database. + + When scope/viewer are provided, counts distinct sample_ids referenced + by tasks visible in that scope — mirroring _scope_where's branch logic. + """ + conds = [] if (scope is None and viewer is None) else self._scope_where(scope, viewer) + + # Defense-in-depth: a non-break-glass viewer must NEVER receive an + # unscoped global count, even if a caller passes scope='global' — restrict + # to the samples referenced by tasks they may read. is_local_admin + # (break-glass, AND every principal when multitenancy is disabled — see + # viewer_for) keeps the unfiltered upstream count, so the public-install + # dashboard figure is unchanged. For the explicit public/tenant/mine + # panels this clause is a redundant superset that intersects to the same + # result; for a (mis)passed global scope it is the safety net. + from lib.cuckoo.common.tenancy import PUBLIC, TENANT + + if viewer is not None and not viewer.is_local_admin: + vis = [Task.visibility == PUBLIC] + if viewer.user_id is not None: + vis.append(Task.user_id == viewer.user_id) + if viewer.tenant_id is not None: + vis.append(and_(Task.visibility == TENANT, Task.tenant_id == viewer.tenant_id)) + conds.append(or_(*vis)) + + if not conds: + # No scope predicate (global / disabled / break-glass): count ALL + # samples exactly like upstream — count(Sample.id) includes parent- + # only/orphaned samples that the distinct-Task.sample_id branch drops, + # so the dashboard figure matches the pre-tenancy build. + return self.session.scalar(select(func.count(Sample.id))) + + # Scope-aware: count distinct Task.sample_id values for tasks in scope. + stmt = ( + select(func.count(distinct(Task.sample_id))) + .where(Task.sample_id.isnot(None)) + ) + for cond in conds: + stmt = stmt.where(cond) return self.session.scalar(stmt) def get_source_url(self, sample_id: int = None) -> Optional[str]: diff --git a/lib/cuckoo/core/data/task.py b/lib/cuckoo/core/data/task.py index d04d2572ca5..9e8de5a14dc 100644 --- a/lib/cuckoo/core/data/task.py +++ b/lib/cuckoo/core/data/task.py @@ -138,6 +138,8 @@ class Task(Base): tlp: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) user_id: Mapped[Optional[int]] = mapped_column(nullable=True) + tenant_id: Mapped[Optional[int]] = mapped_column(nullable=True) + visibility: Mapped[str] = mapped_column(String(16), nullable=False, server_default="private") # The Task is linked to one specific parent/child association event association: Mapped[Optional["SampleAssociation"]] = relationship(back_populates="task", cascade="all, delete-orphan") diff --git a/lib/cuckoo/core/data/tasking.py b/lib/cuckoo/core/data/tasking.py index 51851d9e186..4e6092c8723 100644 --- a/lib/cuckoo/core/data/tasking.py +++ b/lib/cuckoo/core/data/tasking.py @@ -26,9 +26,11 @@ try: from sqlalchemy.exc import SQLAlchemyError from sqlalchemy import ( + and_, delete, func, not_, + or_, select, update, ) @@ -36,6 +38,10 @@ except ImportError: # pragma: no cover raise CuckooDependencyError("Unable to import sqlalchemy (install with `poetry install`)") +try: + from dev_utils.mongodb import mongo_update_one +except Exception: # mongo optional + mongo_update_one = None log = logging.getLogger(__name__) conf = Config("cuckoo") @@ -122,6 +128,8 @@ def add( cape=False, tags_tasks=False, user_id=0, + tenant_id=None, + visibility="private", ): """Add a task to database. @param obj: object to add (File or URL). @@ -249,6 +257,8 @@ def add( task.clock = datetime.fromtimestamp(0, timezone.utc).replace(tzinfo=None) task.user_id = user_id + task.tenant_id = tenant_id + task.visibility = visibility or "private" if parent_sample: association = SampleAssociation( @@ -281,6 +291,8 @@ def add_path( cape=False, tags_tasks=False, user_id=0, + tenant_id=None, + visibility="private", parent_sample = None, ): """Add a task to database from file path. @@ -336,7 +348,7 @@ def add_path( route=route, cape=cape, tags_tasks=tags_tasks, - user_id=user_id, + user_id=user_id, tenant_id=tenant_id, visibility=visibility, parent_sample=parent_sample, ) @@ -386,6 +398,8 @@ def demux_sample_and_add_to_db( route=None, cape=False, user_id=0, + tenant_id=None, + visibility="private", category=None, ): """ @@ -447,7 +461,7 @@ def demux_sample_and_add_to_db( file_path=file_path, priority=priority, tlp=tlp, - user_id=user_id, + user_id=user_id, tenant_id=tenant_id, visibility=visibility, options=options, package=package, ) @@ -486,7 +500,7 @@ def demux_sample_and_add_to_db( file_path=file_path, priority=priority, tlp=tlp, - user_id=user_id, + user_id=user_id, tenant_id=tenant_id, visibility=visibility, options=options, package=package, parent_sample=parent_sample, @@ -496,14 +510,19 @@ def demux_sample_and_add_to_db( # On huge loads this just become a bottleneck config = False if web_conf.general.check_config_exists: - config = static_config_lookup(file) + # Scope the dedup lookup 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 MT-disabled / shared. + from lib.cuckoo.common.tenancy import Viewer as _Viewer + + config = static_config_lookup(file, viewer=_Viewer(user_id=user_id or None, tenant_id=tenant_id)) if config: task_ids.append(config["id"]) else: config = static_extraction(file) if config or only_extraction: task_ids += self.add_static( - file_path=file, priority=priority, tlp=tlp, user_id=user_id, options=options, parent_sample=parent_sample, + file_path=file, priority=priority, tlp=tlp, user_id=user_id, tenant_id=tenant_id, visibility=visibility, options=options, parent_sample=parent_sample, ) if not config and not only_extraction: @@ -552,7 +571,7 @@ def demux_sample_and_add_to_db( route=route, tags_tasks=tags_tasks, cape=cape, - user_id=user_id, + user_id=user_id, tenant_id=tenant_id, visibility=visibility, parent_sample=parent_sample, ) package = None @@ -582,6 +601,8 @@ def add_pcap( clock=None, tlp=None, user_id=0, + tenant_id=None, + visibility="private", ): return self.add( PCAP(file_path.decode()), @@ -597,7 +618,7 @@ def add_pcap( enforce_timeout=enforce_timeout, clock=clock, tlp=tlp, - user_id=user_id, + user_id=user_id, tenant_id=tenant_id, visibility=visibility, ) def add_static( @@ -617,6 +638,8 @@ def add_static( tlp=None, static=True, user_id=0, + tenant_id=None, + visibility="private", parent_sample=None, ): extracted_files, demux_error_msgs = demux_sample(file_path, package, options) @@ -654,7 +677,7 @@ def add_static( tlp=tlp, static=static, parent_sample=parent_sample, - user_id=user_id, + user_id=user_id, tenant_id=tenant_id, visibility=visibility, ) if task_id: task_ids.append(task_id) @@ -680,6 +703,8 @@ def add_url( cape=False, tags_tasks=False, user_id=0, + tenant_id=None, + visibility="private", ): """Add a task to database from url. @param url: url. @@ -726,7 +751,7 @@ def add_url( route=route, cape=cape, tags_tasks=tags_tasks, - user_id=user_id, + user_id=user_id, tenant_id=tenant_id, visibility=visibility, ) def set_vnc_port(self, task_id: int, port: int): @@ -793,6 +818,31 @@ def set_status(self, task_id: int, status) -> Optional[Task]: return self.set_task_status(task, status) + def set_task_visibility(self, task_id: int, visibility: str) -> Optional[Task]: + """Set a task's visibility (public/tenant/private). + @param task_id: task identifier + @param visibility: one of public|tenant|private + @return: the Task, or None if not found + @raise ValueError: if visibility is not a known level — defense in depth + so no caller (web, broker, or future) can persist a bogus value even + if it skips the view-layer check. + """ + from lib.cuckoo.common.tenancy import VISIBILITIES + + if visibility not in VISIBILITIES: + raise ValueError(f"invalid visibility: {visibility!r}") + task = self.session.get(Task, task_id) + if not task: + return None + task.visibility = visibility + self.session.commit() + if mongo_update_one is not None: + try: + mongo_update_one("analysis", {"info.id": task_id}, {"$set": {"info.visibility": visibility}}) + except Exception: + log.warning("failed to sync visibility to mongo for task %s", task_id) + return task + def fetch_task(self, categories: list = None): """Fetches a task waiting to be processed and locks it for running. @return: None or task @@ -895,6 +945,9 @@ def _ensure_valid_target(task): task.clock, tlp=task.tlp, route=task.route, + user_id=task.user_id, + tenant_id=task.tenant_id, + visibility=task.visibility, ) elif task.category in ("pcap", "static"): new_task_id = add( @@ -911,17 +964,24 @@ def _ensure_valid_target(task): task.enforce_timeout, task.clock, tlp=task.tlp, + user_id=task.user_id, + tenant_id=task.tenant_id, + visibility=task.visibility, ) self.session.get(Task, task_id).custom = f"Recovery_{new_task_id}" return new_task_id - def count_matching_tasks(self, category=None, status=None, not_status=None): + def count_matching_tasks(self, category=None, status=None, not_status=None, visible_to=None): """Retrieve list of task. @param category: filter by category @param status: filter by task status @param not_status: exclude this task status from filter + @param visible_to: a tenancy Viewer; when set (and not a break-glass + admin), restrict the count to tasks the viewer may read — mirrors + list_tasks / can_read so pagination counts don't leak the volume of + other tenants' submissions. @return: number of tasks. """ stmt = select(func.count(Task.id)) @@ -932,6 +992,16 @@ def count_matching_tasks(self, category=None, status=None, not_status=None): stmt = stmt.where(Task.status != not_status) if category: stmt = stmt.where(Task.category == category) + if visible_to is not None and not visible_to.is_local_admin: + # Same visibility predicate as list_tasks (lib.cuckoo.common.tenancy.can_read). + from lib.cuckoo.common.tenancy import PUBLIC, TENANT + + conds = [Task.visibility == PUBLIC] + if visible_to.user_id is not None: + conds.append(Task.user_id == visible_to.user_id) # owner + if visible_to.tenant_id is not None: + conds.append(and_(Task.visibility == TENANT, Task.tenant_id == visible_to.tenant_id)) + stmt = stmt.where(or_(*conds)) # 2. Execute the statement and return the single integer result. return self.session.scalar(stmt) @@ -957,6 +1027,7 @@ def list_tasks( task_ids=False, include_hashes=False, user_id=None, + visible_to=None, for_update=False, ) -> List[Task]: """Retrieve list of task. @@ -1017,6 +1088,17 @@ def list_tasks( stmt = stmt.where(Task.id.in_(task_ids)) if user_id is not None: stmt = stmt.where(Task.user_id == user_id) + if visible_to is not None and not visible_to.is_local_admin: + # Visibility filter — mirrors lib.cuckoo.common.tenancy.can_read. + # Break-glass (is_local_admin) skips the filter entirely (sees all). + from lib.cuckoo.common.tenancy import PUBLIC, TENANT + + conds = [Task.visibility == PUBLIC] + if visible_to.user_id is not None: + conds.append(Task.user_id == visible_to.user_id) # owner + if visible_to.tenant_id is not None: + conds.append(and_(Task.visibility == TENANT, Task.tenant_id == visible_to.tenant_id)) + stmt = stmt.where(or_(*conds)) # 3. Chaining for ordering, pagination, and locking remains the same if order_by is not None and isinstance(order_by, tuple): @@ -1166,10 +1248,31 @@ def clean_timed_out_tasks(self, timeout: int): if result.rowcount > 0: log.info("Deleted %d timed-out PENDING tasks.", result.rowcount) - def minmax_tasks(self) -> Tuple[int, int]: + def _scope_where(self, scope, viewer): + """Return a list of SQLAlchemy conditions selecting tasks in `scope` for `viewer`, + mirroring lib.cuckoo.common.tenancy.scope_match. Empty list => no extra filter.""" + from lib.cuckoo.common.tenancy import PUBLIC, TENANT, MINE, GLOBAL + + if scope == GLOBAL or scope is None: + return [] + if scope == PUBLIC: + return [Task.visibility == PUBLIC] + if scope == TENANT: + if viewer is None or viewer.tenant_id is None: + return [Task.id == -1] + return [and_(Task.tenant_id == viewer.tenant_id, Task.visibility == TENANT)] + if scope == MINE: + if viewer is None or viewer.user_id is None: + return [Task.id == -1] + return [Task.user_id == viewer.user_id] + raise ValueError(f"unknown scope {scope!r}") + + def minmax_tasks(self, scope=None, viewer=None) -> Tuple[int, int]: """Finds the minimum start time and maximum completion time for all tasks.""" # A single query is more efficient than two separate ones. stmt = select(func.min(Task.started_on), func.max(Task.completed_on)) + for cond in self._scope_where(scope, viewer): + stmt = stmt.where(cond) min_val, max_val = self.session.execute(stmt).one() if min_val and max_val: @@ -1187,13 +1290,34 @@ def get_tlp_tasks(self) -> List[int]: - def get_tasks_status_count(self) -> Dict[str, int]: - """Counts tasks, grouped by status.""" + def get_tasks_status_count(self, scope=None, viewer=None, visible_to=None) -> Dict[str, int]: + """Counts tasks, grouped by status. + + When `visible_to` is set (and the viewer is not a break-glass admin), + applies the SAME can_read union filter as count_matching_tasks so the + status counts reflect only tasks the caller may see — preventing global + submission-volume leaks in multi-tenant deployments. When both `scope` + and `visible_to` are given, `visible_to` takes precedence. + When MT is disabled / break-glass (is_local_admin=True), no extra filter + is applied (public-install behavior unchanged).""" stmt = select(Task.status, func.count(Task.status)).group_by(Task.status) + if visible_to is not None and not visible_to.is_local_admin: + # Same visibility predicate as list_tasks / count_matching_tasks. + from lib.cuckoo.common.tenancy import PUBLIC, TENANT + + conds = [Task.visibility == PUBLIC] + if visible_to.user_id is not None: + conds.append(Task.user_id == visible_to.user_id) # owner + if visible_to.tenant_id is not None: + conds.append(and_(Task.visibility == TENANT, Task.tenant_id == visible_to.tenant_id)) + stmt = stmt.where(or_(*conds)) + else: + for cond in self._scope_where(scope, viewer): + stmt = stmt.where(cond) # .execute() returns rows, which can be directly converted to a dict. return dict(self.session.execute(stmt).all()) - def count_tasks(self, status: str = None, mid: int = None) -> int: + def count_tasks(self, status: str = None, mid: int = None, scope=None, viewer=None) -> int: """Counts tasks in the database, with optional filters.""" # Build a `SELECT COUNT(...)` query from the start for efficiency. stmt = select(func.count(Task.id)) @@ -1201,6 +1325,8 @@ def count_tasks(self, status: str = None, mid: int = None) -> int: stmt = stmt.where(Task.machine_id == mid) if status: stmt = stmt.where(Task.status == status) + for cond in self._scope_where(scope, viewer): + stmt = stmt.where(cond) # .scalar() executes the query and returns the single integer result. return self.session.scalar(stmt) diff --git a/lib/cuckoo/core/database.py b/lib/cuckoo/core/database.py index 98562d1bb82..bd8cbcf4fb4 100644 --- a/lib/cuckoo/core/database.py +++ b/lib/cuckoo/core/database.py @@ -49,7 +49,7 @@ -SCHEMA_VERSION = "2b3c4d5e6f7g" +SCHEMA_VERSION = "3a1b_tenant_visibility" log = logging.getLogger(__name__) conf = Config("cuckoo") diff --git a/modules/reporting/mongodb.py b/modules/reporting/mongodb.py index b85f522d843..9aa8d649505 100644 --- a/modules/reporting/mongodb.py +++ b/modules/reporting/mongodb.py @@ -14,7 +14,7 @@ try: from pymongo.errors import InvalidDocument, OperationFailure - from dev_utils.mongodb import mongo_collection_names, mongo_delete_data, mongo_find_one, mongo_insert_one, mongo_update_one + from dev_utils.mongodb import mongo_collection_names, mongo_create_index, mongo_delete_data, mongo_find_one, mongo_insert_one, mongo_update_one HAVE_MONGO = True except ImportError: @@ -27,6 +27,39 @@ reporting_conf = Config("reporting") +def stamp_tenant_info(info: dict, task) -> None: + """Write tenant_id/user_id/visibility into the report's info subdict so mongo + aggregations can be scoped. Missing task (deleted) -> public/legacy.""" + if task is None: + info.setdefault("tenant_id", None) + info.setdefault("user_id", None) + info["visibility"] = "public" + return + info["tenant_id"] = getattr(task, "tenant_id", None) + info["user_id"] = getattr(task, "user_id", None) + info["visibility"] = getattr(task, "visibility", "public") or "public" + + +def _task_tenant_ctx(task_id): + """Load a task's tenant context on an INDEPENDENT session (its own pooled + connection), so this never touches the processor's shared scoped session. + Using Database().session here left an implicit transaction open and broke + processing with 'A transaction is already begun on this Session'. Returns a + detached holder with tenant_id/user_id/visibility, or None if not found.""" + from sqlalchemy.orm import Session + + from lib.cuckoo.core.database import Database + from lib.cuckoo.core.data.task import Task + + with Session(Database().engine) as s: + t = s.get(Task, task_id) + if t is None: + return None + return type("_TaskCtx", (), { + "tenant_id": t.tenant_id, "user_id": t.user_id, "visibility": t.visibility, + })() + + class MongoDB(Report): """Stores report in MongoDB.""" @@ -110,6 +143,22 @@ def run(self, results): CuckooReportError("Mongo schema version not expected, check data migration tool") else: mongo_insert_one("cuckoo_schema", {"version": self.SCHEMA_VERSION}) + # Best-effort compound index for tenant-scoped aggregations — only + # when multitenancy is enabled (a disabled install stays exactly + # upstream; the index backs the scope_match queries that only run + # in locked mode). + from lib.cuckoo.common.tenancy import multitenancy_config + + if multitenancy_config().enabled: + try: + mongo_create_index( + "analysis", + [("info.tenant_id", 1), ("info.visibility", 1), ("info.user_id", 1)], + background=True, + name="tenant_scope_idx", + ) + except Exception as idx_err: + log.warning("Could not create tenant_scope_idx on analysis collection: %s", idx_err) # Create a copy of the dictionary. This is done in order to not modify # the original dictionary and possibly compromise the following @@ -136,6 +185,21 @@ def run(self, results): if "behavior" not in report or not isinstance(report["behavior"], dict): report["behavior"] = {"processes": [], "processtree": [], "summary": {}} + # Stamp tenant context so mongo aggregations can be scoped. Use an + # INDEPENDENT session (not the processor's shared scoped session) so we + # don't leave a transaction open and break processing. Gated on + # multitenancy being enabled so a disabled/public install writes EXACTLY + # the upstream report shape (no info.tenant_id/user_id/visibility keys); + # the migration backfill stamps existing docs when MT is first enabled. + from lib.cuckoo.common.tenancy import multitenancy_config + + if multitenancy_config().enabled: + try: + stamp_tenant_info(report["info"], _task_tenant_ctx(int(report["info"]["id"]))) + except Exception as _db_err: + log.warning("Failed to look up task for tenant stamping (task %s): %s", report["info"].get("id"), _db_err) + stamp_tenant_info(report["info"], None) + # Delete old data just before inserting new one to avoid "missing report" window # or data loss if insertion fails during preparation (e.g. OOM) ids_to_delete = {local_task_id, int(report["info"]["id"])} diff --git a/tests/tenancy_vectors.py b/tests/tenancy_vectors.py new file mode 100644 index 00000000000..406f2ad2c7b --- /dev/null +++ b/tests/tenancy_vectors.py @@ -0,0 +1,71 @@ +"""Canonical visibility test-vectors — the single source of truth shared by the +CAPE predicate (lib/cuckoo/common/tenancy.py) and, later, the broker's DynamoDB +reimplementation. Each case: a viewer + a job + expected read/toggle outcome. + +Viewer/job tenants are small ints; None means "no tenant". +""" + +from lib.cuckoo.common.tenancy import Viewer, Job + +# Visibility levels +PUBLIC, TENANT, PRIVATE = "public", "tenant", "private" + +# Each vector: (label, viewer, job, can_read, can_toggle) +# viewer = dict(user_id, tenant_id, is_superuser, is_tenant_admin, is_local_admin) +# job = dict(owner_id, tenant_id, visibility) +# is_local_admin = the cuckoo.conf local_admins_manage_all_tenants gate already +# resolved for this viewer (True only when flag on AND superuser). +VECTORS = [ + # --- public: everyone reads --- + ("public/anon", dict(user_id=None, tenant_id=None, is_superuser=False, is_tenant_admin=False, is_local_admin=False), + dict(owner_id=1, tenant_id=10, visibility=PUBLIC), True, False), + ("public/other", dict(user_id=2, tenant_id=20, is_superuser=False, is_tenant_admin=False, is_local_admin=False), + dict(owner_id=1, tenant_id=10, visibility=PUBLIC), True, False), + # --- tenant: only same-tenant members read --- + ("tenant/same", dict(user_id=2, tenant_id=10, is_superuser=False, is_tenant_admin=False, is_local_admin=False), + dict(owner_id=1, tenant_id=10, visibility=TENANT), True, False), + ("tenant/other", dict(user_id=2, tenant_id=20, is_superuser=False, is_tenant_admin=False, is_local_admin=False), + dict(owner_id=1, tenant_id=10, visibility=TENANT), False, False), + ("tenant/null-job", dict(user_id=2, tenant_id=None, is_superuser=False, is_tenant_admin=False, is_local_admin=False), + dict(owner_id=1, tenant_id=None, visibility=TENANT), False, False), # null tenant != "everyone" + # --- private: only owner --- + ("private/owner", dict(user_id=1, tenant_id=10, is_superuser=False, is_tenant_admin=False, is_local_admin=False), + dict(owner_id=1, tenant_id=10, visibility=PRIVATE), True, True), + ("private/teammate", dict(user_id=2, tenant_id=10, is_superuser=False, is_tenant_admin=False, is_local_admin=False), + dict(owner_id=1, tenant_id=10, visibility=PRIVATE), False, False), + ("private/tadmin", dict(user_id=2, tenant_id=10, is_superuser=False, is_tenant_admin=True, is_local_admin=False), + dict(owner_id=1, tenant_id=10, visibility=PRIVATE), False, False), # tenant-admin can't reach private + # --- tenant-admin: manages (toggles) public/tenant jobs in own tenant, no extra read --- + ("tadmin/toggle-tenant", dict(user_id=2, tenant_id=10, is_superuser=False, is_tenant_admin=True, is_local_admin=False), + dict(owner_id=1, tenant_id=10, visibility=TENANT), True, True), + ("tadmin/other-tenant", dict(user_id=2, tenant_id=20, is_superuser=False, is_tenant_admin=True, is_local_admin=False), + dict(owner_id=1, tenant_id=10, visibility=TENANT), False, False), + # --- owner always reads + toggles own --- + ("owner/tenant-job", dict(user_id=1, tenant_id=10, is_superuser=False, is_tenant_admin=False, is_local_admin=False), + dict(owner_id=1, tenant_id=10, visibility=TENANT), True, True), + # --- superuser break-glass (local_admins_manage_all_tenants resolved into is_local_admin) --- + ("breakglass/read", dict(user_id=9, tenant_id=None, is_superuser=True, is_tenant_admin=False, is_local_admin=True), + dict(owner_id=1, tenant_id=10, visibility=PRIVATE), True, True), + ("breakglass/off", dict(user_id=9, tenant_id=None, is_superuser=True, is_tenant_admin=False, is_local_admin=False), + dict(owner_id=1, tenant_id=10, visibility=PRIVATE), False, False), # flag off => no cross-owner reach +] + +# Scope membership vectors: does a job appear in a given stat scope for a viewer? +# (viewer, job, scope) -> bool. Viewer/Job reuse the dataclasses already imported here. +SCOPE_VECTORS = [ + # public scope: every public job, regardless of viewer + (Viewer(user_id=2, tenant_id=10), Job(owner_id=1, tenant_id=10, visibility="public"), "public", True), + (Viewer(user_id=2, tenant_id=99), Job(owner_id=1, tenant_id=10, visibility="public"), "public", True), + (Viewer(user_id=2, tenant_id=10), Job(owner_id=1, tenant_id=10, visibility="tenant"), "public", False), + # tenant scope: tenant-visibility jobs of the viewer's own tenant + (Viewer(user_id=2, tenant_id=10), Job(owner_id=1, tenant_id=10, visibility="tenant"), "tenant", True), + (Viewer(user_id=2, tenant_id=10), Job(owner_id=1, tenant_id=99, visibility="tenant"), "tenant", False), + (Viewer(user_id=2, tenant_id=10), Job(owner_id=1, tenant_id=10, visibility="public"), "tenant", False), + (Viewer(user_id=2, tenant_id=None), Job(owner_id=1, tenant_id=10, visibility="tenant"), "tenant", False), + # mine scope: jobs the viewer owns, any visibility + (Viewer(user_id=2, tenant_id=10), Job(owner_id=2, tenant_id=10, visibility="private"), "mine", True), + (Viewer(user_id=2, tenant_id=10), Job(owner_id=1, tenant_id=10, visibility="public"), "mine", False), + (Viewer(user_id=None, tenant_id=10), Job(owner_id=2, tenant_id=10, visibility="private"), "mine", False), # user-less sentinel + # global: everything (break-glass / shared) + (Viewer(user_id=2, tenant_id=10, is_local_admin=True), Job(owner_id=1, tenant_id=99, visibility="private"), "global", True), +] diff --git a/tests/test_mongo_backfill.py b/tests/test_mongo_backfill.py new file mode 100644 index 00000000000..d17c27be776 --- /dev/null +++ b/tests/test_mongo_backfill.py @@ -0,0 +1,15 @@ +def test_backfill_stamps_from_task(): + from utils.db_migration.mongo_backfill_tenant import backfill_doc + + class T: + user_id, tenant_id, visibility = 5, 10, "tenant" + doc = {"info": {"id": 1}} + update = backfill_doc(doc, lambda tid: T()) + assert update == {"info.tenant_id": 10, "info.user_id": 5, "info.visibility": "tenant"} + + +def test_backfill_orphan_defaults_public(): + from utils.db_migration.mongo_backfill_tenant import backfill_doc + doc = {"info": {"id": 9}} + update = backfill_doc(doc, lambda tid: None) + assert update == {"info.tenant_id": None, "info.user_id": None, "info.visibility": "public"} diff --git a/tests/test_mongo_stamp.py b/tests/test_mongo_stamp.py new file mode 100644 index 00000000000..6269aa75368 --- /dev/null +++ b/tests/test_mongo_stamp.py @@ -0,0 +1,17 @@ +def test_stamp_tenant_context_into_info(): + from modules.reporting.mongodb import stamp_tenant_info + + class T: # stand-in for a Task row + user_id, tenant_id, visibility = 7, 10, "tenant" + + info = {"id": 42} + stamp_tenant_info(info, T()) + assert info["tenant_id"] == 10 and info["user_id"] == 7 and info["visibility"] == "tenant" + + +def test_stamp_missing_task_defaults_public(): + from modules.reporting.mongodb import stamp_tenant_info + info = {"id": 42} + stamp_tenant_info(info, None) + assert info["visibility"] == "public" and info["tenant_id"] is None and info["user_id"] is None + diff --git a/tests/test_schema_version.py b/tests/test_schema_version.py new file mode 100644 index 00000000000..1012794fd79 --- /dev/null +++ b/tests/test_schema_version.py @@ -0,0 +1,45 @@ +"""Regression guard: lib/cuckoo/core/database.py SCHEMA_VERSION must equal the +HEAD Alembic revision under utils/db_migration/versions/. + +CAPE's Database init compares the live DB's alembic_version against the +SCHEMA_VERSION constant and refuses to start on a mismatch. So adding a +migration without bumping SCHEMA_VERSION ships a deployment that dies at DB +init the moment the migration is applied (the unit suite stamps/skips the +check, so it slips through — this test closes that gap). + +Pure file parsing: no DB, no Django, no heavy CAPE imports. +""" +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +VERSIONS = ROOT / "utils" / "db_migration" / "versions" +DATABASE_PY = ROOT / "lib" / "cuckoo" / "core" / "database.py" + + +def _schema_version() -> str: + m = re.search(r'^SCHEMA_VERSION\s*=\s*["\']([^"\']+)["\']', DATABASE_PY.read_text(), re.M) + assert m, "SCHEMA_VERSION not found in database.py" + return m.group(1) + + +def _migration_head() -> str: + """The single revision that no other revision lists as down_revision.""" + revisions, down = set(), set() + for f in VERSIONS.glob("*.py"): + text = f.read_text() + for rev in re.findall(r'^revision\s*=\s*["\']([^"\']+)["\']', text, re.M): + revisions.add(rev) + for dr in re.findall(r'^down_revision\s*=\s*["\']([^"\']+)["\']', text, re.M): + down.add(dr) + heads = revisions - down + assert len(heads) == 1, f"expected exactly one Alembic head, found {heads}" + return heads.pop() + + +def test_schema_version_matches_migration_head(): + assert _schema_version() == _migration_head(), ( + f"SCHEMA_VERSION ({_schema_version()}) != Alembic head ({_migration_head()}). " + "Bump SCHEMA_VERSION in lib/cuckoo/core/database.py when adding a migration, " + "or CAPE's DB init will reject the deployment once the migration is applied." + ) diff --git a/tests/test_scope_stats.py b/tests/test_scope_stats.py new file mode 100644 index 00000000000..0e69fafe9ba --- /dev/null +++ b/tests/test_scope_stats.py @@ -0,0 +1,111 @@ +import time + + +def test_statistics_helpers_merge_scope_match(monkeypatch): + import lib.cuckoo.common.web_utils as wu + captured = {} + + def fake_agg(coll, cmd): + captured["cmd"] = cmd + return [] + + monkeypatch.setattr(wu, "mongo_aggregate", fake_agg, raising=False) + # Bypass config guards so mongo_aggregate is reached in the test environment. + monkeypatch.setattr(wu.repconf.mongodb, "enabled", True) + monkeypatch.setattr(wu.web_cfg.general, "top_detections", True) + # Clear any cached result so the aggregation actually runs. + if hasattr(wu.top_detections, "cache"): + del wu.top_detections.cache + wu.top_detections(date_since=False, scope_match={"info.tenant_id": 10, "info.visibility": "tenant"}) + first_match = captured["cmd"][0]["$match"] + assert first_match.get("info.tenant_id") == 10 and first_match.get("info.visibility") == "tenant" + + +def test_top_detections_scoped_bypasses_cache(monkeypatch): + """Scoped calls must never read from or write to the shared time-keyed cache.""" + import lib.cuckoo.common.web_utils as wu + + call_count = {"n": 0} + + def counting_agg(coll, cmd): + call_count["n"] += 1 + return [] + + monkeypatch.setattr(wu, "mongo_aggregate", counting_agg, raising=False) + monkeypatch.setattr(wu.repconf.mongodb, "enabled", True) + monkeypatch.setattr(wu.web_cfg.general, "top_detections", True) + + # Plant a fresh-looking stale sentinel in the cache so an unguarded read would return it. + stale_data = [{"sentinel": 1}] + wu.top_detections.cache = (time.time(), stale_data) + + scope = {"info.tenant_id": 10, "info.visibility": "tenant"} + + # First scoped call: must NOT return the sentinel and must have called the aggregation. + result1 = wu.top_detections(date_since=False, scope_match=scope) + assert result1 != stale_data, "scoped call returned stale cached data from the shared cache" + assert call_count["n"] == 1, "expected exactly one aggregation call after first scoped call" + + # Second scoped call: scoped path must not have populated the cache, so aggregation runs again. + result2 = wu.top_detections(date_since=False, scope_match=scope) + assert call_count["n"] == 2, "expected aggregation to run again (scoped calls must not populate cache)" + + # The shared cache must still hold the original sentinel (scoped calls never overwrite it). + assert hasattr(wu.top_detections, "cache"), "cache attr should still exist" + _, cached_val = wu.top_detections.cache + assert cached_val == stale_data, "scoped call must not have overwritten the shared cache" + + +def test_static_config_lookup_scopes_mongo_query(monkeypatch): + """Deep-hunt: the static-config dedup lookup must AND-in the submitter's scope + so it can't return another tenant's task id / config-exists inference. Locked + viewer -> query AND-ed with the scope $or; break-glass / disabled -> unscoped.""" + import lib.cuckoo.common.cape_utils as cu + from lib.cuckoo.common.tenancy import Viewer, MTConfig + import lib.cuckoo.common.tenancy as t + + captured = {} + monkeypatch.setattr(cu, "mongo_find_one", lambda coll, q, proj, **k: captured.update(q=q) or None, raising=False) + monkeypatch.setattr(cu.repconf.mongodb, "enabled", True) + + # locked, non-admin -> scoped + monkeypatch.setattr(t, "multitenancy_config", lambda: MTConfig(True, "locked", "", True)) + cu.static_config_lookup("/x", sha256="a" * 64, viewer=Viewer(user_id=2, tenant_id=10)) + assert "$and" in captured["q"], "locked-mode dedup must AND-in the tenant scope" + assert {"target.file.sha256": "a" * 64} in captured["q"]["$and"] + + # break-glass -> unscoped (plain query) + cu.static_config_lookup("/x", sha256="a" * 64, viewer=Viewer(user_id=9, tenant_id=None, is_local_admin=True)) + assert captured["q"] == {"target.file.sha256": "a" * 64} + + # MT disabled -> unscoped + monkeypatch.setattr(t, "multitenancy_config", lambda: MTConfig(False, "shared", "", True)) + cu.static_config_lookup("/x", sha256="a" * 64, viewer=Viewer(user_id=2, tenant_id=10)) + assert captured["q"] == {"target.file.sha256": "a" * 64} + + +def test_top_detections_es_branch_scoped(monkeypatch): + """Deep-hunt: the ES top_detections branch must apply the viewer scope filter + (parity with the mongo branch), or a locked tenant's stat panels show the + global per-family malware landscape on an ES-backed install.""" + import lib.cuckoo.common.web_utils as wu + from lib.cuckoo.common.tenancy import Viewer, MTConfig + import lib.cuckoo.common.tenancy as t + + captured = {} + monkeypatch.setattr(wu.repconf.mongodb, "enabled", False) + monkeypatch.setattr(wu.repconf.elasticsearchdb, "enabled", True) + monkeypatch.setattr(wu.web_cfg.general, "top_detections", True) + monkeypatch.setattr(wu, "es", type("E", (), {"search": staticmethod(lambda **k: captured.update(body=k["body"]) or {"aggregations": {"family": {"buckets": []}}})}), raising=False) + # get_analysis_index is imported only when ES is enabled at module load; the + # test env loads with ES disabled, so provide it (production ES installs have it). + monkeypatch.setattr(wu, "get_analysis_index", lambda: "idx", raising=False) + if hasattr(wu.top_detections, "cache"): + del wu.top_detections.cache + + monkeypatch.setattr(t, "multitenancy_config", lambda: MTConfig(True, "locked", "", True)) + wu.top_detections(date_since=False, viewer=Viewer(user_id=2, tenant_id=10)) + flt = captured["body"]["query"]["bool"].get("filter") + assert flt, "ES top_detections must carry a tenant scope filter in locked mode" + shoulds = flt[0]["bool"]["should"] + assert {"term": {"info.visibility": "public"}} in shoulds and {"term": {"info.user_id": 2}} in shoulds diff --git a/tests/test_task_visibility.py b/tests/test_task_visibility.py new file mode 100644 index 00000000000..dd4a42b3dc7 --- /dev/null +++ b/tests/test_task_visibility.py @@ -0,0 +1,281 @@ +import pytest + + +def _mk_task(category="file", target="x.exe"): + from lib.cuckoo.core.data.task import Task + t = Task(target=target) + t.category = category + return t + + +@pytest.mark.usefixtures("tmp_cuckoo_root") +def test_task_has_tenant_and_visibility(db): + from lib.cuckoo.core.data.task import Task + t = _mk_task() + t.user_id = 1 + t.tenant_id = 10 + t.visibility = "tenant" + db.session.add(t) + db.session.commit() + row = db.session.get(Task, t.id) + assert row.tenant_id == 10 + assert row.visibility == "tenant" + + +@pytest.mark.usefixtures("tmp_cuckoo_root") +def test_visibility_defaults_private(db): + from lib.cuckoo.core.data.task import Task + t = _mk_task() + db.session.add(t) + db.session.commit() + assert db.session.get(Task, t.id).visibility == "private" + assert db.session.get(Task, t.id).tenant_id is None + + +@pytest.mark.usefixtures("tmp_cuckoo_root") +def test_list_tasks_visible_filter(db): + from lib.cuckoo.common.tenancy import Viewer + + def mk(owner, tenant, vis): + t = _mk_task() + t.user_id, t.tenant_id, t.visibility = owner, tenant, vis + db.session.add(t) + db.session.commit() + return t.id + + pub = mk(1, 10, "public") + ten = mk(1, 10, "tenant") + priv = mk(1, 10, "private") + other = mk(3, 20, "tenant") + + # viewer: member of tenant 10, not owner of priv + v = Viewer(user_id=2, tenant_id=10) + ids = {t.id for t in db.list_tasks(visible_to=v)} + assert pub in ids and ten in ids + assert priv not in ids # private, not owner + assert other not in ids # other tenant + + # break-glass sees everything + allv = Viewer(user_id=9, tenant_id=None, is_superuser=True, is_local_admin=True) + allids = {t.id for t in db.list_tasks(visible_to=allv)} + assert {pub, ten, priv, other} <= allids + + +@pytest.mark.usefixtures("tmp_cuckoo_root") +def test_add_url_stamps_tenant_and_visibility(db): + from lib.cuckoo.core.data.task import Task + tid = db.add_url("http://example.com", tenant_id=10, visibility="tenant") + t = db.session.get(Task, tid) + assert t.tenant_id == 10 + assert t.visibility == "tenant" + + +@pytest.mark.usefixtures("tmp_cuckoo_root") +def test_add_url_defaults_private(db): + from lib.cuckoo.core.data.task import Task + tid = db.add_url("http://example.com") + assert db.session.get(Task, tid).visibility == "private" + + +@pytest.mark.usefixtures("tmp_cuckoo_root") +def test_set_task_visibility_validates_enum(db): + """Defense-in-depth (M1): the DB setter rejects unknown levels so a bogus + value can never be persisted even if a caller skips the view-layer check.""" + from lib.cuckoo.core.data.task import Task + tid = db.add_url("http://example.com", tenant_id=10, visibility="tenant") + + with pytest.raises(ValueError): + db.set_task_visibility(tid, "bogus") + # unchanged after the rejected write + assert db.session.get(Task, tid).visibility == "tenant" + + # a valid level still works + assert db.set_task_visibility(tid, "public") is not None + assert db.session.get(Task, tid).visibility == "public" + + +@pytest.mark.usefixtures("tmp_cuckoo_root") +def test_count_tasks_scope(db): + from lib.cuckoo.common.tenancy import Viewer + + def mk(owner, tenant, vis): + t = _mk_task(); t.user_id, t.tenant_id, t.visibility = owner, tenant, vis + db.session.add(t); db.session.commit() + + mk(1, 10, "public"); mk(1, 10, "tenant"); mk(2, 10, "private"); mk(3, 20, "public") + v = Viewer(user_id=2, tenant_id=10) + assert db.count_tasks(scope="public", viewer=v) == 2 # the two public ones + assert db.count_tasks(scope="tenant", viewer=v) == 1 # tenant-vis in tenant 10 + assert db.count_tasks(scope="mine", viewer=v) == 1 # owner==2 + assert db.count_tasks(scope="global", viewer=v) == 4 # break-glass / no filter + + # the other scope-aware methods apply the same filter / execute cleanly + assert sum(db.get_tasks_status_count(scope="public", viewer=v).values()) == 2 + assert sum(db.get_tasks_status_count(scope="global", viewer=v).values()) == 4 + assert db.minmax_tasks(scope="mine", viewer=v) is not None # runs scoped, returns a tuple + assert isinstance(db.count_samples(scope="tenant", viewer=v), int) # scoped distinct-sample count + + +@pytest.mark.usefixtures("tmp_cuckoo_root") +def test_count_matching_tasks_visible_filter(db): + """Pagination counts must apply the SAME visibility filter as the listing, + or the page totals leak other tenants' submission volumes (and produce + empty pages). count_matching_tasks(visible_to=) must agree with list_tasks.""" + from lib.cuckoo.common.tenancy import Viewer + + def mk(owner, tenant, vis): + t = _mk_task() + t.user_id, t.tenant_id, t.visibility = owner, tenant, vis + db.session.add(t) + db.session.commit() + + mk(1, 10, "public") + mk(1, 10, "tenant") + mk(1, 10, "private") + mk(3, 20, "tenant") + + v = Viewer(user_id=2, tenant_id=10) # tenant-10 member, not owner of the private one + # the page count must equal the number of rows actually listed for that viewer + assert db.count_matching_tasks(visible_to=v) == len(db.list_tasks(visible_to=v, limit=100000)) + # and it must be strictly fewer than the unfiltered total (private + other-tenant hidden) + assert db.count_matching_tasks(visible_to=v) < db.count_matching_tasks() + # break-glass counts everything, same as no filter + allv = Viewer(user_id=9, tenant_id=None, is_local_admin=True) + assert db.count_matching_tasks(visible_to=allv) == db.count_matching_tasks() + + +@pytest.mark.usefixtures("tmp_cuckoo_root") +def test_get_tasks_status_count_scoped_to_visible(db): + """get_tasks_status_count(visible_to=) must sum to the same count as + count_matching_tasks(visible_to=) over a mixed dataset, and be strictly + less than the unfiltered total (cross-tenant counts must not leak).""" + from lib.cuckoo.common.tenancy import Viewer + + def mk(owner, tenant, vis): + t = _mk_task() + t.user_id, t.tenant_id, t.visibility = owner, tenant, vis + db.session.add(t) + db.session.commit() + + mk(1, 10, "public") # visible to tenant-10 member + mk(1, 10, "tenant") # visible to tenant-10 member + mk(1, 10, "private") # not visible (owner=1, not viewer) + mk(3, 20, "tenant") # other tenant, not visible + + v = Viewer(user_id=2, tenant_id=10) + + # sum of the scoped status dict must equal count_matching_tasks with the same filter + scoped_sum = sum(db.get_tasks_status_count(visible_to=v).values()) + assert scoped_sum == db.count_matching_tasks(visible_to=v) + + # and it must be strictly less than the global unfiltered total + assert scoped_sum < sum(db.get_tasks_status_count().values()) + + +@pytest.mark.usefixtures("tmp_cuckoo_root") +def test_set_task_visibility_syncs_mongo(db, monkeypatch): + calls = [] + import lib.cuckoo.core.data.tasking as tk + monkeypatch.setattr(tk, "mongo_update_one", + lambda *a, **k: calls.append((a, k)), raising=False) + tid = db.add_url("http://example.com", tenant_id=10, visibility="tenant") + db.set_task_visibility(tid, "public") + assert calls and calls[-1][0][0] == "analysis" # updated the analysis collection + + +@pytest.mark.usefixtures("tmp_cuckoo_root") +def test_reschedule_propagates_tenant_visibility(db): + """reschedule()/recovery must carry the source task's owner/tenant/visibility + to the new task — otherwise rescheduled or startup-recovered tasks fall back + to add() defaults (user_id=0, tenant_id=None, visibility='private') and the + original tenant's job silently leaves its scope (invisible to everyone but + break-glass in locked mode).""" + from lib.cuckoo.core.data.task import Task + + tid = db.add_url("http://example.com", user_id=5, tenant_id=10, visibility="tenant") + new_tid = db.reschedule(tid) + assert new_tid and new_tid != tid + new = db.session.get(Task, new_tid) + assert new.user_id == 5 + assert new.tenant_id == 10 + assert new.visibility == "tenant" + + +@pytest.mark.usefixtures("tmp_cuckoo_root") +def test_count_samples_global_matches_unscoped(db): + """B-extra-2 back-compat: count_samples(scope='global', viewer=...) must equal + the unscoped count(Sample.id) — the global/empty-scope branch must NOT switch + to distinct(Task.sample_id) (which drops orphan/parent-only samples and drifts + the dashboard figure from upstream).""" + from lib.cuckoo.common.tenancy import Viewer + + def mk(owner, tenant, vis): + t = _mk_task() + t.user_id, t.tenant_id, t.visibility = owner, tenant, vis + db.session.add(t) + db.session.commit() + + mk(1, 10, "public") + mk(2, 10, "private") + v = Viewer(user_id=9, tenant_id=None, is_local_admin=True) + assert db.count_samples(scope="global", viewer=v) == db.count_samples() + + +@pytest.mark.usefixtures("tmp_cuckoo_root") +def test_count_samples_nonadmin_global_is_restricted(db): + """#6 review (defense-in-depth): a non-break-glass viewer must NOT receive an + unscoped global sample count even if a caller passes scope='global' — the + count is restricted to samples referenced by tasks they may read. Break-glass + (is_local_admin) still gets the unfiltered count (back-compat).""" + from lib.cuckoo.common.tenancy import Viewer + + def mk(owner, tenant, vis, sid): + t = _mk_task() + t.user_id, t.tenant_id, t.visibility, t.sample_id = owner, tenant, vis, sid + db.session.add(t) + db.session.commit() + + mk(1, 10, "public", 100) # visible to a tenant-10 viewer (public) + mk(5, 10, "tenant", 101) # visible (same tenant, tenant-visibility) + mk(5, 10, "private", 102) # hidden (private, not owner) + mk(7, 20, "private", 103) # hidden (other tenant) + + tenant_v = Viewer(user_id=2, tenant_id=10) # non-admin (is_local_admin=False) + admin_v = Viewer(user_id=9, tenant_id=None, is_local_admin=True) + + # non-admin: global scope is restricted to the 2 visible sample_ids (100,101) + assert db.count_samples(scope="global", viewer=tenant_v) == 2 + # break-glass: unfiltered — sees all 4 distinct sample_ids referenced + assert db.count_samples(scope="mine", viewer=admin_v) >= 0 # smoke: scoped path runs + # the non-admin global count must be strictly fewer than all distinct sample_ids + assert db.count_samples(scope="global", viewer=tenant_v) < 4 + + +@pytest.mark.usefixtures("tmp_cuckoo_root") +def test_check_file_uniq_scoped_even_with_hours_zero(db): + """#10 review (security-high): the duplicate check must be tenant-scoped for + ALL hours values — incl. hours=0 (all-time) — else it's a cross-tenant + existence oracle. A tenant-B-only private hash must read 'not duplicate' for a + tenant-A viewer; break-glass still sees it.""" + from lib.cuckoo.core.data.task import Task + from lib.cuckoo.core.data.samples import Sample + from lib.cuckoo.common.tenancy import Viewer + + h = "d" * 64 + s = Sample(md5="d" * 32, crc32="0000", sha1="d" * 40, sha256=h, sha512="d" * 128, file_size=1, file_type="x") + db.session.add(s) + db.session.commit() + t = _mk_task() + t.user_id, t.tenant_id, t.visibility, t.sample_id = 999, 20, "private", s.id # tenant-B private + db.session.add(t) + db.session.commit() + + tenant_a = Viewer(user_id=2, tenant_id=10) # non-admin, other tenant + admin = Viewer(user_id=9, tenant_id=None, is_local_admin=True) + + # hours=0 (all-time) MUST be scoped: A cannot observe B's private hash + assert db.check_file_uniq(h, hours=0, visible_to=tenant_a) is False + assert db.check_file_uniq(h, hours=24, visible_to=tenant_a) is False + # break-glass sees it (no-op); also the unscoped call (no viewer) preserves legacy behavior + assert db.check_file_uniq(h, hours=0, visible_to=admin) is True + assert db.check_file_uniq(h, hours=0) is True diff --git a/tests/test_tenancy.py b/tests/test_tenancy.py new file mode 100644 index 00000000000..f1bf55b814f --- /dev/null +++ b/tests/test_tenancy.py @@ -0,0 +1,74 @@ +import pytest + +from tests.tenancy_vectors import VECTORS +from lib.cuckoo.common.tenancy import can_read, can_toggle, Viewer, Job + + +@pytest.mark.parametrize("label,viewer,job,want_read,want_toggle", VECTORS, ids=[v[0] for v in VECTORS]) +def test_predicate_matches_vectors(label, viewer, job, want_read, want_toggle): + v = Viewer(**viewer) + j = Job(**job) + assert can_read(v, j) is want_read, f"{label}: read" + assert can_toggle(v, j) is want_toggle, f"{label}: toggle" + + +def test_config_defaults(): + from lib.cuckoo.common import tenancy + cfg = tenancy.multitenancy_config() + assert cfg.enabled in (True, False) + assert cfg.mode in ("shared", "locked") + assert isinstance(cfg.local_admins_manage_all_tenants, bool) + # default_visibility resolves per mode when blank + assert tenancy.default_visibility(cfg) in ("public", "tenant", "private") + + +def test_default_visibility_per_mode(): + from lib.cuckoo.common import tenancy + shared = tenancy.MTConfig(enabled=True, mode="shared", default_visibility="", + local_admins_manage_all_tenants=True) + locked = tenancy.MTConfig(enabled=True, mode="locked", default_visibility="", + local_admins_manage_all_tenants=True) + assert tenancy.default_visibility(shared) == "public" + assert tenancy.default_visibility(locked) == "tenant" + + +def test_disabled_is_legacy_open(): + """With multitenancy disabled, default visibility is public and a legacy + NULL-tenant public task is visible to anyone (current single-tenant behavior).""" + from lib.cuckoo.common import tenancy + cfg = tenancy.MTConfig(enabled=False, mode="shared", default_visibility="", + local_admins_manage_all_tenants=True) + assert tenancy.default_visibility(cfg) == "public" + v = tenancy.Viewer(user_id=None, tenant_id=None) + j = tenancy.Job(owner_id=1, tenant_id=None, visibility="public") + assert tenancy.can_read(v, j) is True + + +def test_scope_match_membership(): + from lib.cuckoo.common.tenancy import scope_match + from tests.tenancy_vectors import SCOPE_VECTORS + + def doc_matches(match, job): + if match is None: + return True # global + for k, v in match.items(): + if k == "info.id": # impossible-sentinel -> matches nothing + return False + field = {"info.visibility": job.visibility, "info.tenant_id": job.tenant_id, + "info.user_id": job.owner_id}.get(k) + if field != v: + return False + return True + + for viewer, job, scope, expected in SCOPE_VECTORS: + assert doc_matches(scope_match(scope, viewer), job) is expected, (scope, viewer, job) + + +def test_scope_match_none_viewer(): + """Defensive (PR#2 review): scope_match must not AttributeError when called + with a scope but no viewer (statistics(viewer=None) path).""" + from lib.cuckoo.common.tenancy import scope_match + assert scope_match("tenant", None) == {"info.id": -1} + assert scope_match("mine", None) == {"info.id": -1} + assert scope_match("public", None) == {"info.visibility": "public"} + assert scope_match("global", None) is None diff --git a/utils/db_migration/__init__.py b/utils/db_migration/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/utils/db_migration/mongo_backfill_tenant.py b/utils/db_migration/mongo_backfill_tenant.py new file mode 100644 index 00000000000..dac58bc044b --- /dev/null +++ b/utils/db_migration/mongo_backfill_tenant.py @@ -0,0 +1,49 @@ +"""One-shot backfill: stamp tenant_id/user_id/visibility into existing mongo analysis +docs from their Postgres task. Run once when enabling multitenancy on a populated DB.""" +import os +import sys + + +def backfill_doc(doc, view_task) -> dict: + task = view_task(int(doc.get("info", {}).get("id", 0))) + if task is None: + return {"info.tenant_id": None, "info.user_id": None, "info.visibility": "public"} + return { + "info.tenant_id": getattr(task, "tenant_id", None), + "info.user_id": getattr(task, "user_id", None), + "info.visibility": getattr(task, "visibility", "public") or "public", + } + + +def main(): + # Resolve the repo root from this file's location (utils/db_migration/), + # not a hardcoded /opt/CAPEv2, so dev/custom installs work too. + sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) + from dev_utils.mongodb import mongo_create_index, mongo_find, mongo_update_one + from lib.cuckoo.core.database import Database, init_database + try: + init_database() + except Exception: + pass + db = Database() + # Ensure the tenant-scope index exists — on an existing install the + # reporting module only creates it at first-schema-init, so a backfilled + # collection would otherwise scan unindexed on every scoped aggregation. + try: + mongo_create_index( + "analysis", + [("info.tenant_id", 1), ("info.visibility", 1), ("info.user_id", 1)], + background=True, + name="tenant_scope_idx", + ) + except Exception as idx_err: + print(f"warning: could not create tenant_scope_idx: {idx_err}") + n = 0 + for doc in mongo_find("analysis", {"info.visibility": {"$exists": False}}, {"info.id": 1}): + mongo_update_one("analysis", {"_id": doc["_id"]}, {"$set": backfill_doc(doc, db.view_task)}) + n += 1 + print(f"backfilled {n} docs") + + +if __name__ == "__main__": + main() diff --git a/utils/db_migration/versions/3_add_tenant_visibility.py b/utils/db_migration/versions/3_add_tenant_visibility.py new file mode 100644 index 00000000000..1351fcbfb22 --- /dev/null +++ b/utils/db_migration/versions/3_add_tenant_visibility.py @@ -0,0 +1,40 @@ +"""add tenant_id + visibility to tasks + +Multi-tenant identity & job-visibility foundation (spec #1). Adds the +per-task tenant owner and the 3-level visibility (public/tenant/private). + +Back-compat (spec §9/§11): EXISTING rows are backfilled to visibility='public' +with tenant_id NULL so legacy jobs stay visible exactly as they are today if the +feature is later enabled. NEW rows that don't set it explicitly fall back to the +'private' server_default (fail-safe; the app always sets visibility via +submission_scope). With multitenancy disabled the predicate is bypassed +entirely, so the stored value is moot until the feature is turned on. + +Revision ID: 3a1b_tenant_visibility +Revises: 2b3c4d5e6f7g +Create Date: 2026-06-05 +""" +import sqlalchemy as sa +from alembic import op + +revision = "3a1b_tenant_visibility" +down_revision = "2b3c4d5e6f7g" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column("tasks", sa.Column("tenant_id", sa.Integer(), nullable=True)) + # Add nullable first so existing rows can be backfilled to 'public' (legacy + # jobs stay visible per spec §11), then enforce NOT NULL with a fail-safe + # 'private' default for any NEW row that omits it. + op.add_column("tasks", sa.Column("visibility", sa.String(length=16), nullable=True)) + op.execute("UPDATE tasks SET visibility = 'public' WHERE visibility IS NULL") + op.alter_column("tasks", "visibility", nullable=False, server_default="private") + op.create_index("ix_tasks_tenant_id", "tasks", ["tenant_id"]) + + +def downgrade(): + op.drop_index("ix_tasks_tenant_id", table_name="tasks") + op.drop_column("tasks", "visibility") + op.drop_column("tasks", "tenant_id") diff --git a/web/analysis/test_visibility.py b/web/analysis/test_visibility.py new file mode 100644 index 00000000000..b707f8f5dcb --- /dev/null +++ b/web/analysis/test_visibility.py @@ -0,0 +1,125 @@ +import pytest +from django.contrib.auth.models import User + + +class ForeignTask: + id = 1 + user_id = 999 # owned by someone else + tenant_id = 10 + visibility = "private" + + +def _report_url(): + try: + from django.urls import reverse + return reverse("report", kwargs={"task_id": 1}) + except Exception: + return "/analysis/1/" + + +@pytest.mark.django_db +def test_report_denies_cross_tenant_private(cape_db, mt_enabled, monkeypatch, client): + """A cross-tenant private task is not shown. The denial is the generic + "no analysis found" page (HTTP 200), INDISTINGUISHABLE from a missing task, + so another tenant's task IDs can't be enumerated by status code.""" + import analysis.views as av + + monkeypatch.setattr(av.db, "view_task", lambda *a, **k: ForeignTask()) + other = User.objects.create_user("b", "b@x.com", "x") + client.force_login(other) + + r = client.get(_report_url()) + assert r.status_code == 200 + assert b"No analysis found" in r.content + + +@pytest.mark.django_db +def test_report_missing_task_renders_error_200(cape_db, monkeypatch, client): + """A missing/deleted task renders the same generic error page at HTTP 200 as + a hidden task (upstream parity + indistinguishability) — not a 403.""" + import analysis.views as av + monkeypatch.setattr(av.db, "view_task", lambda *a, **k: None) + u = User.objects.create_user("c", "c@x.com", "x") + client.force_login(u) + r = client.get(_report_url()) + assert r.status_code == 200 + assert b"No analysis found" in r.content + + +@pytest.mark.django_db +def test_full_memory_denies_cross_tenant(cape_db, mt_enabled, monkeypatch, client): + """full_memory_dump_file routes via the analysis_number group (not task_id); + the guard decorator must resolve that group and deny a cross-tenant viewer.""" + import analysis.views as av + + monkeypatch.setattr(av.db, "view_task", lambda *a, **k: ForeignTask()) + client.force_login(User.objects.create_user("fm", "fm@x.com", "x")) + assert client.get("/full_memory/1/").status_code == 403 + + +@pytest.mark.django_db +def test_vtupload_denies_cross_tenant(cape_db, mt_enabled, monkeypatch, client): + """vtupload reads + exfiltrates a sample to VirusTotal — require_task_manage.""" + import analysis.views as av + + monkeypatch.setattr(av.db, "view_task", lambda *a, **k: ForeignTask()) + client.force_login(User.objects.create_user("vt", "vt@x.com", "x")) + assert client.get("/vtupload/CAPE/1/evil.bin/abc/").status_code == 403 + + +@pytest.mark.django_db +def test_tag_tasks_skips_unmanageable_cross_tenant(cape_db, mt_enabled, client): + """A tenant-less user must not be able to tag another tenant's private task.""" + import json as _json + import analysis.views as av + from lib.cuckoo.core.data.task import Task + + t = Task(target="x.exe") + t.category = "file" + t.user_id, t.tenant_id, t.visibility = 999, 10, "private" + av.db.session.add(t) + av.db.session.commit() + tid = t.id + + client.force_login(User.objects.create_user("tg", "tg@x.com", "x")) # tenant-less + r = client.post( + "/analysis/hunt/tag/", + data=_json.dumps({"task_ids": [tid], "tag": "pwned"}), + content_type="application/json", + ) + assert r.status_code == 200 + av.db.session.expire_all() + assert "pwned" not in (av.db.session.get(Task, tid).tags_tasks or "") + + +@pytest.mark.django_db +def test_file_search_all_files_drops_cross_tenant_paths(cape_db, mt_enabled, monkeypatch): + """CRITICAL leak regression (capeyarazipall): _file_search_all_files must NOT + return artifact paths belonging to analyses the requester can't read — else + file() streams another tenant's dropped/payload/sample bytes. The per-path + owning-task gate drops paths under storage/analyses//.""" + from django.test import RequestFactory + from django.contrib.auth.models import User + import analysis.views as av + + class OwnTask: # task 2 — visible to the requester (public) + id = 2; user_id = 0; tenant_id = 10; visibility = "public" + + class ForeignTask: # task 3 — another tenant's private analysis + id = 3; user_id = 999; tenant_id = 20; visibility = "private" + + monkeypatch.setattr(av, "perform_search", lambda *a, **k: [{"info": {"id": 2}}, {"info": {"id": 3}}]) + # yara_detected yields (kind, filepath, block, fileobj) — one own, one foreign + monkeypatch.setattr(av, "yara_detected", lambda term, recs: [ + ("dropped", "/opt/CAPEv2/storage/analyses/2/files/own.bin", {}, {}), + ("dropped", "/opt/CAPEv2/storage/analyses/3/files/secret.bin", {}, {}), + ]) + monkeypatch.setattr(av, "path_exists", lambda p: True) + monkeypatch.setattr(av.db, "view_task", lambda tid: OwnTask() if int(tid) == 2 else ForeignTask()) + + req = RequestFactory().get("/file/capeyarazipall/2/Emotet/") + req.user = User.objects.create_user("fs", "fs@x.com", "x") # tenant-less, non-admin + + paths = av._file_search_all_files("capeyara", "Emotet", req) + assert "/opt/CAPEv2/storage/analyses/2/files/own.bin" in paths # readable kept + assert "/opt/CAPEv2/storage/analyses/3/files/secret.bin" not in paths # foreign dropped diff --git a/web/apiv2/test_hash_isolation.py b/web/apiv2/test_hash_isolation.py new file mode 100644 index 00000000000..3f01e1d18d3 --- /dev/null +++ b/web/apiv2/test_hash_isolation.py @@ -0,0 +1,86 @@ +import pytest +from django.contrib.auth.models import User + + +class _Req: + def __init__(self, user): + self.user = user + + +def _patch_db(monkeypatch, *, find_sample=None, list_tasks=None): + """Patch the methods on the underlying _Database singleton. _deny_by_hash now + delegates to tenancy.can_view_sample, which resolves its own Database() proxy; + every Database() proxy delegates via __getattr__ to the same _DATABASE + singleton, so patch THAT (patching one proxy instance's attrs wouldn't reach + can_view_sample's proxy).""" + import lib.cuckoo.core.database as dbmod + + if find_sample is not None: + monkeypatch.setattr(dbmod._DATABASE, "find_sample", find_sample, raising=False) + if list_tasks is not None: + monkeypatch.setattr(dbmod._DATABASE, "list_tasks", list_tasks, raising=False) + + +class _Sample: + id = 7 + + +@pytest.mark.django_db +def test_deny_by_hash_noop_when_multitenancy_disabled(cape_db, monkeypatch): + # NO mt_enabled fixture -> multitenancy disabled -> viewer_for returns is_local_admin=True + import apiv2.views as views + called = {"find": False} + + def _find(**k): + called["find"] = True + return None + + _patch_db(monkeypatch, find_sample=_find) + u = User.objects.create_user("pub", "p@x.com", "x") + # disabled => allow (return None) without gating; can_view_sample short-circuits on is_local_admin + assert views._deny_by_hash(_Req(u), sha256="a" * 64) is None + assert called["find"] is False # short-circuited before any sample lookup + + +@pytest.mark.django_db +def test_deny_by_hash_blocks_when_no_visible_task(cape_db, mt_enabled, monkeypatch): + import apiv2.views as views + + _patch_db(monkeypatch, find_sample=lambda **k: _Sample(), list_tasks=lambda **k: []) # none visible + other = User.objects.create_user("b", "b@x.com", "x") + resp = views._deny_by_hash(_Req(other), sha256="a" * 64) + assert resp is not None and resp.status_code == 404 + + +@pytest.mark.django_db +def test_deny_by_hash_allows_when_visible_task_exists(cape_db, mt_enabled, monkeypatch): + import apiv2.views as views + + _patch_db(monkeypatch, find_sample=lambda **k: _Sample(), list_tasks=lambda **k: [object()]) # 1 visible + u = User.objects.create_user("o", "o@x.com", "x") + assert views._deny_by_hash(_Req(u), sha256="a" * 64) is None + + +@pytest.mark.django_db +def test_deny_by_hash_missing_sample_is_404(cape_db, mt_enabled, monkeypatch): + import apiv2.views as views + + _patch_db(monkeypatch, find_sample=lambda **k: None) + u = User.objects.create_user("o", "o@x.com", "x") + resp = views._deny_by_hash(_Req(u), sha256="a" * 64) + assert resp is not None and resp.status_code == 404 + + +@pytest.mark.django_db +def test_can_view_sample_centralizes_the_boundary(cape_db, mt_enabled, monkeypatch): + """The shared helper: a sample with a visible task -> True; a sample with no + visible task -> False; break-glass / MT-disabled -> True (no-op).""" + from users.tenancy import can_view_sample + + _patch_db(monkeypatch, find_sample=lambda **k: _Sample(), list_tasks=lambda **k: []) + u = User.objects.create_user("cv", "cv@x.com", "x") + assert can_view_sample(u, sha256="a" * 64) is False # exists but no visible task + _patch_db(monkeypatch, list_tasks=lambda **k: [object()]) + assert can_view_sample(u, sha256="a" * 64) is True # now visible + _patch_db(monkeypatch, find_sample=lambda **k: None) + assert can_view_sample(u, sha256="b" * 64) is False # absent diff --git a/web/apiv2/test_visibility.py b/web/apiv2/test_visibility.py new file mode 100644 index 00000000000..8f4db893d38 --- /dev/null +++ b/web/apiv2/test_visibility.py @@ -0,0 +1,585 @@ +import ast +import re + +import pytest +from django.contrib.auth.models import User + +# A view "enforces visibility" if its source references any of these — a read +# guard, the artifact preamble, the list filter, the web decorator, or a +# management guard (for mutation endpoints). +GUARD_MARKERS = ( + "_deny_if_hidden", "_deny_task", "_deny_manage", "_resolve_task_id", "visible_to", + "require_task_visibility", "require_task_manage", "can_view_task", "can_manage_task", + "can_toggle_task", + # scope-filtering primitives for aggregate / mongo surfaces (dashboard, + # statistics, hunt, compare): restrict an aggregation to the viewer's + # entitled scopes instead of gating a single task_id. + "scope_match", "entitled_scope_filter", +) + +# Routed task_id views that legitimately need NO per-task visibility guard. +# SECURITY ALLOWLIST — keep tiny; every entry needs a real justification. +ALLOWLIST = set() + + +# URL capture-group names that identify a single task/analysis — any of these in +# a route means the view resolves tenant-scoped data and needs a guard. Note: +# `sample_id` is intentionally excluded — sample/hash-addressed routes are owned +# by the hash gate (test_hash_routed_view_enforces_visibility, which knows about +# _deny_by_hash); putting it here would double-flag those under the task gate. +ID_GROUPS = ("task_id", "analysis_number", "left_id", "right_id") + + +def _routed_task_views(urls_module, alias="views"): + """{view_name} for every (live, non-commented) URL routed via ``.NAME`` + whose pattern captures one of ID_GROUPS. ``alias`` lets us scan the root + urlconf (web/web/urls.py), where analysis views are referenced as + ``analysis_views.NAME``. The negative lookbehind stops ``views.`` from also + matching inside ``analysis_views.`` when scanning with alias="views".""" + text = "\n".join( + ln for ln in open(urls_module.__file__).read().splitlines() if not ln.lstrip().startswith("#") + ) + out = set() + pat = r"(?:re_path|path)\((.*?)(? allowed (None == no denial) + assert views._deny_if_hidden(FakeReq(owner), FakeTask(user_id=owner.id, tenant_id=10, visibility="private")) is None + + +@pytest.mark.django_db +def test_deny_if_hidden_public_allowed(): + import apiv2.views as views + other = User.objects.create_user("b", "b@x.com", "x") + assert views._deny_if_hidden(FakeReq(other), FakeTask(user_id=999, tenant_id=10, visibility="public")) is None + + +@pytest.mark.django_db +def test_toggle_visibility_authz_and_indistinguishability(cape_db, mt_enabled, monkeypatch): + from rest_framework.test import APIClient + from users.models import Tenant, UserProfile + import apiv2.views as views + + ten = Tenant.objects.create(slug="t10", name="T10") + + def _in_tenant(username): + u = User.objects.create_user(username, f"{username}@x.com", "x") + p = UserProfile.objects.get(user=u) + p.tenant = ten + p.save() + return User.objects.get(pk=u.pk) # fresh, so request.user.userprofile is current + + owner = _in_tenant("o") + member = _in_tenant("m") # same tenant, not owner/admin + outsider = User.objects.create_user("b", "b@x.com", "x") # no tenant -> can't see tenant job + + state = {"vis": "tenant"} + + class T: + id = 1 + + def __init__(self): + self.user_id = owner.id + self.tenant_id = ten.id + + @property + def visibility(self): + return state["vis"] + + monkeypatch.setattr(views.db, "view_task", lambda *a, **k: T()) + monkeypatch.setattr(views.db, "set_task_visibility", + lambda tid, vis: state.__setitem__("vis", vis), raising=False) + + c = APIClient() + + # owner toggles their own job + c.force_authenticate(user=owner) + r = c.patch("/apiv2/tasks/visibility/1/", {"visibility": "public"}, format="json") + assert r.status_code == 200, r.content + assert state["vis"] == "public" + + # invalid visibility -> 400 (owner can already see it, so revealing this leaks nothing) + r = c.patch("/apiv2/tasks/visibility/1/", {"visibility": "bogus"}, format="json") + assert r.status_code == 400 and r.json().get("error") is True + state["vis"] = "tenant" # reset for the deny cases + + # same-tenant member CAN read the tenant job but isn't owner/admin -> 403 (no leak) + c.force_authenticate(user=member) + r = c.patch("/apiv2/tasks/visibility/1/", {"visibility": "public"}, format="json") + assert r.status_code == 403 + assert state["vis"] == "tenant" # unchanged + + # outsider can't even SEE the tenant job -> indistinguishable 404 (no enumeration oracle) + c.force_authenticate(user=outsider) + r = c.patch("/apiv2/tasks/visibility/1/", {"visibility": "public"}, format="json") + assert r.status_code == 404 + assert state["vis"] == "tenant" # unchanged + + +# Views that resolve/serve a sample or file by hash or sample_id (NOT routed by +# task_id, so the routed-task_id gate above can't see them). Each must reference +# _deny_by_hash (or _deny_task for a task-id variant) or it leaks samples/metadata +# across tenants. +HASH_SERVING_VIEWS = ("file", "files_view") + + +@pytest.mark.parametrize("name", HASH_SERVING_VIEWS) +def test_hash_addressed_view_enforces_visibility(name): + """SECURITY GATE: hash-addressed sample/file/metadata views must reference a + visibility guard (_deny_by_hash / _deny_task), or they leak across tenants.""" + import apiv2.views as views + src = _func_source(views, name) + assert src is not None, f"{name} not found in apiv2.views" + assert ("_deny_by_hash" in src) or ("_deny_task" in src), \ + f"apiv2.{name} serves by hash/sample but references no _deny_by_hash/_deny_task guard" + + +def _hash_routed_views(urls_module): + """Return the set of view names for every (live, non-commented) URL whose + pattern captures a hash or sample-id group: md5, sha1, sha256, or sample_id. + Mirrors _routed_task_views but for hash/sample-id groups instead of task_id.""" + HASH_GROUPS = ("md5", "sha1", "sha256", "sample_id") + text = "\n".join( + ln for ln in open(urls_module.__file__).read().splitlines() if not ln.lstrip().startswith("#") + ) + out = set() + for m in re.finditer(r"(?:re_path|path)\((.*?views\.([a-zA-Z_]+))", text, re.S): + pattern_span, name = m.group(1), m.group(2) + if any(f"(?P<{g}>" in pattern_span or f"<{g}>" in pattern_span for g in HASH_GROUPS): + out.add(name) + return out + + +def _all_hash_views(): + import apiv2.urls, apiv2.views + discovered = _hash_routed_views(apiv2.urls) + # Explicitly pin tasks_search (it filters via visible_to= in list_tasks, + # which is in GUARD_MARKERS) so it remains covered even if its URL pattern + # changes to a non-hash-group form in the future. + names = discovered | {"tasks_search"} + return [(apiv2.views, n) for n in sorted(names)] + + +_HASH_CASES = _all_hash_views() + + +HASH_GUARD_MARKERS = GUARD_MARKERS + ("_deny_by_hash",) + + +@pytest.mark.parametrize("views_mod,name", _HASH_CASES, ids=[n for _, n in _HASH_CASES]) +def test_hash_routed_view_enforces_visibility(views_mod, name): + """SECURITY GATE (auto-discover): every view whose URL pattern captures a + hash or sample-id group must reference a visibility guard from GUARD_MARKERS + or _deny_by_hash, or it leaks cross-tenant sample/task metadata. tasks_search + is pinned here regardless of future URL-pattern changes.""" + src = _func_source(views_mod, name) + if src is None: + pytest.skip(f"{name} not found in {views_mod.__name__}") + assert any(m in src for m in HASH_GUARD_MARKERS), ( + f"{views_mod.__name__}.{name} is hash/sample-id routed but references " + f"no guard {HASH_GUARD_MARKERS} — cross-tenant leak risk" + ) + + +def test_hash_routed_discovery_catches_unguarded(tmp_path, monkeypatch): + """Negative regression: confirm _hash_routed_views would flag a + fictitious unguarded view if one were added to urls.py.""" + fake_urls = tmp_path / "fake_urls.py" + # A URL that captures sha256 but calls a view with no guard + fake_urls.write_text( + 'from apiv2 import views\n' + 'urlpatterns = [\n' + ' __import__("django.urls", fromlist=["re_path"]).re_path(\n' + ' r"^unguarded/(?P[a-fA-F\\d]{64})/$", views.cuckoo_status\n' + ' ),\n' + ']\n' + ) + import types, importlib.util + spec = importlib.util.spec_from_file_location("fake_urls", fake_urls) + fake_mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(fake_mod) + discovered = _hash_routed_views(fake_mod) + assert "cuckoo_status" in discovered, ( + "_hash_routed_views failed to detect a sha256-routed unguarded view" + ) + + +@pytest.mark.django_db +def test_tasks_delete_many_skips_unmanageable_cross_tenant(cape_db, mt_enabled, monkeypatch): + """A tenant-less user POSTing another tenant's private task id to the bulk- + delete endpoint must NOT delete it (the worst confirmed critical).""" + from rest_framework.test import APIRequestFactory, force_authenticate + import apiv2.views as views + + deleted = [] + monkeypatch.setattr(views.db, "view_task", + lambda tid: FakeTask(user_id=999, tenant_id=10, visibility="private")) + monkeypatch.setattr(views.db, "delete_task", lambda tid: deleted.append(tid) or True) + monkeypatch.setattr(views, "mongo_delete_data", lambda *a, **k: None, raising=False) + + u = User.objects.create_user("dm", "dm@x.com", "x") # tenant-less -> can't manage + req = APIRequestFactory().post("/apiv2/tasks/delete_many/", {"ids": "1"}) + force_authenticate(req, user=u) + resp = views.tasks_delete_many(req) + + assert deleted == [] # cross-tenant task NOT deleted + assert resp.data.get(1) == "not exists" # indistinguishable from missing + + +def test_every_perform_search_caller_passes_viewer(): + """SECURITY GATE: perform_search() is unscoped by default (no tenant filter + at the mongo/ES layer). Every web caller MUST pass viewer= so the query is + tenant-scoped — otherwise it leaks cross-tenant task ids / hashes / detections + / artifact paths (this is exactly how the capeyarazipall + report-existent_tasks + leaks happened). Fails the build if any caller omits viewer=.""" + import ast + import importlib + + offenders = [] + for modname in ("analysis.views", "apiv2.views", "submission.views"): + mod = importlib.import_module(modname) + tree = ast.parse(open(mod.__file__).read()) + for node in ast.walk(tree): + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "perform_search": + if "viewer" not in {kw.arg for kw in node.keywords}: + offenders.append(f"{modname}:{node.lineno}") + assert not offenders, f"perform_search() called without viewer= (cross-tenant leak risk) at: {offenders}" + + +def test_viewer_scope_match_locked_vs_disabled(monkeypatch): + """The systemic perform_search scope helper: a locked-mode tenant viewer gets + a public/tenant/mine $or; break-glass and MT-disabled get None (no filter). + _viewer_scope_match reads lib.cuckoo.common.tenancy.multitenancy_config at + call time, so patch THAT (not users.tenancy's copy).""" + from lib.cuckoo.common import web_utils + from lib.cuckoo.common.tenancy import Viewer, MTConfig + import lib.cuckoo.common.tenancy as t + + monkeypatch.setattr(t, "multitenancy_config", lambda: MTConfig(True, "locked", "", True)) + v = Viewer(user_id=2, tenant_id=10) # locked, non-admin + f = web_utils._viewer_scope_match(v) + assert "$or" in f + assert {"info.visibility": "public"} in f["$or"] + assert {"info.tenant_id": 10, "info.visibility": "tenant"} in f["$or"] + assert {"info.user_id": 2} in f["$or"] + + # break-glass -> no filter + assert web_utils._viewer_scope_match(Viewer(user_id=9, tenant_id=None, is_local_admin=True)) is None + # MT disabled -> no filter + monkeypatch.setattr(t, "multitenancy_config", lambda: MTConfig(False, "shared", "", True)) + assert web_utils._viewer_scope_match(v) is None + # no viewer -> no filter + assert web_utils._viewer_scope_match(None) is None + + +def test_viewer_scope_es_filter_locked_vs_disabled(monkeypatch): + """ES analogue of the scope helper: locked-mode tenant viewer gets a + public/own-tenant/mine bool-should; break-glass / disabled / None -> no + filter (so the ES search branches are scoped the same as the mongo ones).""" + from lib.cuckoo.common import web_utils + from lib.cuckoo.common.tenancy import Viewer, MTConfig + import lib.cuckoo.common.tenancy as t + + monkeypatch.setattr(t, "multitenancy_config", lambda: MTConfig(True, "locked", "", True)) + v = Viewer(user_id=2, tenant_id=10) + f = web_utils._viewer_scope_es_filter(v) + shoulds = f["bool"]["should"] + assert {"term": {"info.visibility": "public"}} in shoulds + assert {"term": {"info.user_id": 2}} in shoulds + assert any(c.get("bool", {}).get("filter") == [{"term": {"info.tenant_id": 10}}, {"term": {"info.visibility": "tenant"}}] for c in shoulds) + assert f["bool"]["minimum_should_match"] == 1 + + # anon/tenant-less in locked mode -> public only (never global) + anon = web_utils._viewer_scope_es_filter(Viewer(user_id=None, tenant_id=None)) + assert anon["bool"]["should"] == [{"term": {"info.visibility": "public"}}] + + # break-glass / disabled / None -> no filter + assert web_utils._viewer_scope_es_filter(Viewer(user_id=9, tenant_id=None, is_local_admin=True)) is None + monkeypatch.setattr(t, "multitenancy_config", lambda: MTConfig(False, "shared", "", True)) + assert web_utils._viewer_scope_es_filter(v) is None + assert web_utils._viewer_scope_es_filter(None) is None + + +# Multi-doc mongo pivots (mongo_aggregate / mongo_find) can span tenants — unlike +# task_id-keyed mongo_find_one / es.search reads gated by the view decorator. Each +# web view issuing one MUST be reviewed to be tenant-scoped; a NEW caller trips +# the gate below so a *secondary* unscoped cross-task query (the leak class behind +# report()/existent_tasks and the compare/hunt pivots) can't land silently. The +# marker gates can't catch this — they pass as long as ANY guard string appears in +# the function, even when a second query in the same function is unscoped. +# perform_search pivots are covered by test_every_perform_search_caller_passes_viewer. +CROSS_TASK_MONGO_PIVOTS = {"mongo_aggregate", "mongo_find"} +REVIEWED_MONGO_PIVOTS = { + "analysis.views:index": "mongo_find by info.id $in IDs from list_tasks(visible_to=) — scoped upstream", + "analysis.views:search_behavior": "mongo_find('calls', _id $in) — ObjectIds from the gated task's own behavior doc", + "analysis.views:report": "mongo_aggregate $match info.id == the can_view_task-gated task_id", + "analysis.views:hunt": "mongo_aggregate $facet pinned by entitled_scope_filter()", + "apiv2.views:tasks_rollingsuri": "mongo_find then per-row can_view_task (+ is_local_admin fast-path)", + "compare.views:left": "mongo_find md5-pivot AND-ed with entitled_scope_filter()", + "compare.views:hash": "mongo_find md5-pivot AND-ed with entitled_scope_filter()", +} + + +def _functions_calling(modname, names): + import ast + import importlib + + mod = importlib.import_module(modname) + with open(mod.__file__, encoding="utf-8") as fh: + tree = ast.parse(fh.read()) + out = set() + # Include async views — an async def issuing an unscoped pivot must not bypass the gate. + for fn in [n for n in ast.walk(tree) if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))]: + for node in ast.walk(fn): + if isinstance(node, ast.Call): + nm = getattr(node.func, "id", None) or getattr(node.func, "attr", None) + if nm in names: + out.add(f"{modname}:{fn.name}") + return out + + +def test_cross_task_mongo_pivots_are_reviewed(): + """SECURITY GATE: every web view issuing a multi-doc mongo pivot + (mongo_aggregate/mongo_find) must be in REVIEWED_MONGO_PIVOTS — i.e. proven + tenant-scoped. A new (or newly-added) pivot trips this gate so a secondary + unscoped cross-task query can't ship without review. Closes the marker-gate + blind spot that let report()'s existent_tasks pivot leak while its primary + read was gated.""" + # Import directly (no silent skip): a module that can't be scanned is a + # coverage hole, not a pass — the gate must fail loudly rather than miss a + # module's pivots. These all import in the test env (same set the routed gate + # scans via _all_task_views()). + found = set() + for modname in ("analysis.views", "apiv2.views", "compare.views", "dashboard.views", "submission.views", "guac.views"): + found |= _functions_calling(modname, CROSS_TASK_MONGO_PIVOTS) + + unreviewed = sorted(found - set(REVIEWED_MONGO_PIVOTS)) + assert not unreviewed, ( + f"Unreviewed cross-task mongo pivot(s): {unreviewed}. A multi-doc " + f"mongo_aggregate/mongo_find can span tenants — verify it is tenant-scoped " + f"(scope_match / entitled_scope_filter / per-row can_view_task / task_id-keyed " + f"after a gate) and add it to REVIEWED_MONGO_PIVOTS with the reason." + ) + # Keep the allowlist tight: drop entries whose function no longer exists. + stale = sorted(set(REVIEWED_MONGO_PIVOTS) - found) + assert not stale, f"Stale REVIEWED_MONGO_PIVOTS entries (function gone): {stale}" + + +# By-hash access to the global content-addressed sample store (storage/binaries +# + db.sample_path_by_hash) is shared across tenants, so it MUST go through the +# visible-task boundary (tenancy.can_view_sample / _deny_by_hash / sample_path_ +# by_hash(visible_to=)). A web view that resolves a sample by attacker-supplied +# hash WITHOUT one of those markers streams another tenant's bytes (the deep-hunt +# capeyarazipall + resubmit + download-services criticals). This gate trips on any +# such function lacking a by-hash guard. +BYHASH_RESOLVERS = ("sample_path_by_hash",) # call markers +BYHASH_GUARDS = ("can_view_sample", "_deny_by_hash", "visible_to") + + +def test_byhash_sample_resolution_is_gated(): + """SECURITY GATE: any web view that resolves a sample by hash (calls + sample_path_by_hash, or builds a storage/binaries/ path) must reference + a by-hash entitlement guard (can_view_sample / _deny_by_hash / visible_to). + Locks the deep-hunt byte-exfil fixes so a new by-hash surface can't ship + ungated.""" + import ast + import importlib + + offenders = [] + for modname in ("analysis.views", "apiv2.views", "submission.views", "compare.views"): + mod = importlib.import_module(modname) + with open(mod.__file__, encoding="utf-8") as fh: + src = fh.read() + tree = ast.parse(src) + lines = src.splitlines() + for fn in [n for n in ast.walk(tree) if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))]: + body = "\n".join(lines[fn.lineno - 1: fn.end_lineno]) + resolves_byhash = any(r in body for r in BYHASH_RESOLVERS) or ( + '"binaries"' in body or "'binaries'" in body + ) + if resolves_byhash and not any(g in body for g in BYHASH_GUARDS): + offenders.append(f"{modname}:{fn.name}") + assert not offenders, ( + f"By-hash sample resolution without an entitlement guard {BYHASH_GUARDS}: " + f"{offenders}. Gate via can_view_sample / _deny_by_hash / sample_path_by_hash(visible_to=) " + f"— an attacker-supplied hash must not stream another tenant's sample bytes." + ) diff --git a/web/apiv2/urls.py b/web/apiv2/urls.py index 737c4116012..06f019b3f13 100644 --- a/web/apiv2/urls.py +++ b/web/apiv2/urls.py @@ -28,6 +28,7 @@ re_path(r"^tasks/list/(?P\d+)/(?P\d+)/$", views.tasks_list), re_path(r"^tasks/list/(?P\d+)/(?P\d+)/(?P\d+)/$", views.tasks_list), re_path(r"^tasks/view/(?P\d+)/$", views.tasks_view), + re_path(r"^tasks/visibility/(?P\d+)/$", views.tasks_set_visibility), re_path(r"^tasks/reschedule/(?P\d+)/$", views.tasks_reschedule), re_path(r"^tasks/reprocess/(?P\d+)/$", views.tasks_reprocess), re_path(r"^tasks/delete/(?P(\d+|[0-9,-]+))/$", views.tasks_delete), diff --git a/web/compare/test_visibility.py b/web/compare/test_visibility.py new file mode 100644 index 00000000000..4c5f848cf6a --- /dev/null +++ b/web/compare/test_visibility.py @@ -0,0 +1,95 @@ +import pytest +from django.contrib.auth.models import User + + +class ForeignTask: + id = 1 + user_id = 999 # owned by another tenant's user + tenant_id = 10 + visibility = "private" + + +def _public_task(tid): + class _T: + id = tid + user_id = 999 + tenant_id = 10 + visibility = "public" + return _T() + + +@pytest.mark.django_db +def test_compare_both_denies_cross_tenant(cape_db, mt_enabled, monkeypatch, client): + """compare.both reads two analyses by info.id; the seed gate must deny a + viewer who can't read them (hidden == "No analysis found").""" + import compare.views as cv + + class FakeDB: + def view_task(self, tid): + return ForeignTask() + + monkeypatch.setattr(cv, "Database", lambda: FakeDB()) + client.force_login(User.objects.create_user("cmp", "cmp@x.com", "x")) # tenant-less + + r = client.get("/compare/1/2/") + assert r.status_code == 200 + assert b"No analysis found" in r.content + + +@pytest.mark.django_db +def test_compare_left_denies_cross_tenant(cape_db, mt_enabled, monkeypatch, client): + import compare.views as cv + + class FakeDB: + def view_task(self, tid): + return ForeignTask() + + monkeypatch.setattr(cv, "Database", lambda: FakeDB()) + client.force_login(User.objects.create_user("cl", "cl@x.com", "x")) + + r = client.get("/compare/1/") + assert r.status_code == 200 + assert b"No analysis found" in r.content + + +@pytest.mark.django_db +def test_compare_left_es_backend_filters_cross_tenant(cape_db, mt_enabled, monkeypatch, client): + """Regression for the #3 review finding: the Elasticsearch backend path + (es_as_db) must post-filter the md5-pivot hits through can_view_task — the + mongo $match isn't applied there. A cross-tenant private hit must be dropped.""" + import compare.views as cv + + user = User.objects.create_user("es", "es@x.com", "x") # tenant-less + + # seed (left_id=1) is public -> viewable; pivot hit (task 2) is another + # tenant's private analysis -> must be filtered out of `records`. + def _view(tid): + return _public_task(1) if int(tid) == 1 else ForeignTask() + + class FakeDB: + view_task = staticmethod(_view) + + class FakeES: + def search(self, index=None, query=None, body=None): + if body is not None: # md5 pivot + return {"hits": {"hits": [{"_source": {"info": {"id": 2}, "target": {}}}]}} + # seed lookup by info.id + return {"hits": {"hits": [{"_source": {"info": {"id": 1}, "target": {"file": {"md5": "abc"}}}}]}} + + monkeypatch.setattr(cv, "Database", lambda: FakeDB()) + monkeypatch.setattr(cv, "es_as_db", True, raising=False) + monkeypatch.setattr(cv, "es", FakeES(), raising=False) + monkeypatch.setattr(cv, "enabledconf", {"mongodb": False, "elasticsearchdb": True}, raising=False) + monkeypatch.setattr(cv, "get_analysis_index", lambda: "idx", raising=False) + monkeypatch.setattr(cv, "get_query_by_info_id", lambda i: {}, raising=False) + + captured = {} + real_render = cv.render + monkeypatch.setattr(cv, "render", lambda req, tmpl, ctx=None: captured.update(ctx or {}) or real_render(req, tmpl, ctx)) + client.force_login(user) + + r = client.get("/compare/1/") + assert r.status_code == 200 + # the foreign tenant's analysis (task 2) must NOT survive the post-filter + assert all(rec.get("info", {}).get("id") != 2 for rec in captured.get("records", [])) + assert captured.get("records") == [] diff --git a/web/compare/views.py b/web/compare/views.py index 0ab3b65f048..16824f004ee 100644 --- a/web/compare/views.py +++ b/web/compare/views.py @@ -13,6 +13,8 @@ import lib.cuckoo.common.compare as compare from lib.cuckoo.common.config import Config +from lib.cuckoo.core.database import Database +from web.tenancy_optional import can_view_task enabledconf = {} confdata = Config("reporting").get_config() @@ -52,6 +54,11 @@ def __call__(self, func): @require_safe @conditional_login_required(login_required, settings.WEB_AUTHENTICATION) def left(request, left_id): + # tenant isolation: caller must be able to read the seed analysis (hidden == missing) + _seed = Database().view_task(int(left_id)) + if _seed is None or not can_view_task(request.user, _seed): + return render(request, "error.html", {"error": "No analysis found with specified ID"}) + if enabledconf["mongodb"]: left = mongo_find_one("analysis", {"info.id": int(left_id)}, {"target": 1, "info": 1}) if es_as_db: @@ -63,13 +70,16 @@ def left(request, left_id): if not left: return render(request, "error.html", {"error": "No analysis found with specified ID"}) - # Select all analyses with same file hash. + # Select all analyses with same file hash — scoped to the viewer's entitled + # tenants so the md5 pivot can't enumerate other tenants' analyses. + from dashboard.views import entitled_scope_filter + + _and = [{"target.file.md5": left["target"]["file"]["md5"]}, {"info.id": {"$ne": int(left_id)}}] + _scope = entitled_scope_filter(request.user) + if _scope: + _and.append(_scope) if enabledconf["mongodb"]: - records = mongo_find( - "analysis", - {"$and": [{"target.file.md5": left["target"]["file"]["md5"]}, {"info.id": {"$ne": int(left_id)}}]}, - {"target": 1, "info": 1}, - ) + records = mongo_find("analysis", {"$and": _and}, {"target": 1, "info": 1}) if es_as_db: records = [] q = { @@ -81,8 +91,21 @@ def left(request, left_id): } } results = es.search(index=get_analysis_index(), body=q)["hits"]["hits"] + # tenant isolation: the mongo path filters via entitled_scope_filter; the + # ES backend can't take that $match, so post-filter each hit through + # can_view_task (no-op for break-glass / shared / multitenancy disabled). + _db = Database() for item in results: - records.append(item["_source"]) + _source = item["_source"] + _tid = (_source.get("info") or {}).get("id") + if _tid is None: + continue + try: + _vt = _db.view_task(int(_tid)) + except (ValueError, TypeError): + continue # malformed id in a corrupt ES doc — skip, don't 500 + if _vt is not None and can_view_task(request.user, _vt): + records.append(_source) data = {"title": "Compare", "left": left, "records": records} return render(request, "compare/left.html", data) @@ -91,6 +114,11 @@ def left(request, left_id): @require_safe @conditional_login_required(login_required, settings.WEB_AUTHENTICATION) def hash(request, left_id, right_hash): + # tenant isolation: caller must be able to read the seed analysis (hidden == missing) + _seed = Database().view_task(int(left_id)) + if _seed is None or not can_view_task(request.user, _seed): + return render(request, "error.html", {"error": "No analysis found with specified ID"}) + if enabledconf["mongodb"]: left = mongo_find_one("analysis", {"info.id": int(left_id)}, {"target": 1, "info": 1}) if es_as_db: @@ -102,13 +130,16 @@ def hash(request, left_id, right_hash): if not left: return render(request, "error.html", {"error": "No analysis found with specified ID"}) - # Select all analyses with same file hash. + # Select all analyses with same file hash — scoped to the viewer's entitled + # tenants so the md5 pivot can't enumerate other tenants' analyses. + from dashboard.views import entitled_scope_filter + + _and = [{"target.file.md5": left["target"]["file"]["md5"]}, {"info.id": {"$ne": int(left_id)}}] + _scope = entitled_scope_filter(request.user) + if _scope: + _and.append(_scope) if enabledconf["mongodb"]: - records = mongo_find( - "analysis", - {"$and": [{"target.file.md5": left["target"]["file"]["md5"]}, {"info.id": {"$ne": int(left_id)}}]}, - {"target": 1, "info": 1}, - ) + records = mongo_find("analysis", {"$and": _and}, {"target": 1, "info": 1}) if es_as_db: records = [] q = { @@ -120,8 +151,21 @@ def hash(request, left_id, right_hash): } } results = es.search(index=get_analysis_index(), body=q)["hits"]["hits"] + # tenant isolation: the mongo path filters via entitled_scope_filter; the + # ES backend can't take that $match, so post-filter each hit through + # can_view_task (no-op for break-glass / shared / multitenancy disabled). + _db = Database() for item in results: - records.append(item["_source"]) + _source = item["_source"] + _tid = (_source.get("info") or {}).get("id") + if _tid is None: + continue + try: + _vt = _db.view_task(int(_tid)) + except (ValueError, TypeError): + continue # malformed id in a corrupt ES doc — skip, don't 500 + if _vt is not None and can_view_task(request.user, _vt): + records.append(_source) # Select all analyses with specified file hash. return render(request, "compare/hash.html", {"left": left, "records": records, "hash": right_hash}) @@ -130,6 +174,13 @@ def hash(request, left_id, right_hash): @require_safe @conditional_login_required(login_required, settings.WEB_AUTHENTICATION) def both(request, left_id, right_id): + # tenant isolation: caller must be able to read BOTH analyses (hidden == missing) + _db = Database() + for _tid in (left_id, right_id): + _seed = _db.view_task(int(_tid)) + if _seed is None or not can_view_task(request.user, _seed): + return render(request, "error.html", {"error": "No analysis found with specified ID"}) + if enabledconf["mongodb"]: left = mongo_find_one("analysis", {"info.id": int(left_id)}, {"target": 1, "info": 1, "summary": 1}) right = mongo_find_one("analysis", {"info.id": int(right_id)}, {"target": 1, "info": 1, "summary": 1}) diff --git a/web/conftest.py b/web/conftest.py new file mode 100644 index 00000000000..354ee84dc9a --- /dev/null +++ b/web/conftest.py @@ -0,0 +1,33 @@ +import pytest + +from lib.cuckoo.core.database import init_database, reset_database_FOR_TESTING_ONLY + + +@pytest.fixture +def mt_enabled(monkeypatch): + """Force multitenancy ON for isolation tests. With the feature disabled + (the default), viewer_for short-circuits to see-all (legacy behavior), so + denial assertions only hold when it's enabled.""" + from lib.cuckoo.common.tenancy import MTConfig + import users.tenancy as ut + + monkeypatch.setattr( + ut, "multitenancy_config", + lambda: MTConfig(enabled=True, mode="locked", default_visibility="", + local_admins_manage_all_tenants=True), + ) + yield + + +@pytest.fixture +def cape_db(): + """Initialize the SQLAlchemy CAPE database (in-memory) for web-view tests + that exercise the global `db` proxy (e.g. report/submit views). Named + distinctly from `db` to avoid colliding with pytest-django's `db` fixture + (which only sets up the Django DB, not CAPE's SQLAlchemy database).""" + reset_database_FOR_TESTING_ONLY() + try: + init_database(dsn="sqlite://") + yield + finally: + reset_database_FOR_TESTING_ONLY() diff --git a/web/dashboard/test_dashboard_scope.py b/web/dashboard/test_dashboard_scope.py new file mode 100644 index 00000000000..b75cf662173 --- /dev/null +++ b/web/dashboard/test_dashboard_scope.py @@ -0,0 +1,55 @@ +import pytest +from django.contrib.auth.models import User + + +@pytest.mark.django_db +def test_dashboard_entitled_scopes(cape_db, mt_enabled, monkeypatch): + from dashboard.views import entitled_scopes + from users.models import Tenant, UserProfile + t = Tenant.objects.create(slug="acme", name="Acme") + u = User.objects.create_user("a", "a@x.com", "x") + p = UserProfile.objects.get(user=u); p.tenant = t; p.save() + u = User.objects.get(pk=u.pk) + assert entitled_scopes(u) == ["public", "tenant", "mine"] + tl = User.objects.create_user("b", "b@x.com", "x") # tenant-less + assert entitled_scopes(tl) == ["public", "mine"] + + +@pytest.mark.django_db +def test_disabled_shows_single_global_scope(): + """Back-compat: with multitenancy disabled (the default / basic public install), + every user gets a single Global panel == today's dashboard. No mt_enabled fixture + here, so multitenancy_config() reads the default (disabled) -> viewer_for() returns + is_local_admin=True -> entitled_scopes short-circuits to ['global'].""" + from dashboard.views import entitled_scopes + u = User.objects.create_user("c", "c@x.com", "x") + assert entitled_scopes(u) == ["global"] + + +@pytest.mark.django_db +def test_entitled_scope_filter_builds_viewer_mongo_match(cape_db, mt_enabled): + """The combined mongo $match used by hunt + compare must select exactly the + viewer's entitled scopes (public OR own-tenant-tenant OR mine).""" + from dashboard.views import entitled_scope_filter + from users.models import Tenant, UserProfile + + t = Tenant.objects.create(slug="acme", name="Acme") + u = User.objects.create_user("sf", "sf@x.com", "x") + p = UserProfile.objects.get(user=u); p.tenant = t; p.save() + u = User.objects.get(pk=u.pk) + + f = entitled_scope_filter(u) + assert "$or" in f + assert {"info.visibility": "public"} in f["$or"] + assert {"info.tenant_id": t.id, "info.visibility": "tenant"} in f["$or"] + assert {"info.user_id": u.id} in f["$or"] + + +@pytest.mark.django_db +def test_entitled_scope_filter_disabled_is_none(): + """MT disabled (default) -> 'global' scope -> None (no filter), so aggregate + queries are unchanged in the public install.""" + from dashboard.views import entitled_scope_filter + + u = User.objects.create_user("sg", "sg@x.com", "x") + assert entitled_scope_filter(u) is None diff --git a/web/dashboard/views.py b/web/dashboard/views.py index 8d18cb2a0ca..2c86f8428a3 100644 --- a/web/dashboard/views.py +++ b/web/dashboard/views.py @@ -15,6 +15,7 @@ from lib.cuckoo.core.database import Database from lib.cuckoo.core.data.task import TASK_COMPLETED, TASK_REPORTED +import users.tenancy as _ut # Conditional decorator for web authentication @@ -35,39 +36,86 @@ def format_number_with_space(number): return f"{number:,}".replace(",", " ") +def entitled_scopes(user): + """Return the list of scope keys this user is entitled to see. + + When multitenancy is disabled or the user is a local-admin (break-glass), + returns ["global"] — the single panel that shows everything, preserving + legacy behaviour. Otherwise returns the per-scope panels appropriate for + the viewer's tenancy. + """ + v = _ut.viewer_for(user) + cfg = _ut.multitenancy_config() + if not cfg.enabled or cfg.mode != "locked" or v.is_local_admin: + return ["global"] + scopes = ["public"] + if v.tenant_id is not None: + scopes.append("tenant") + if v.user_id is not None: # anonymous/unauth has no "mine" — skip the empty panel + scopes.append("mine") + return scopes + + +_SCOPE_LABEL = { + "public": "Public", + "tenant": "My Tenant", + "mine": "Mine", + "global": "Global", +} + + +def entitled_scope_filter(user): + """Combined mongo ``$match`` (over the report's ``info.*``) restricting results + to the analyses ``user`` may read across all their entitled scopes. Returns + ``None`` when no filter applies (global / break-glass / multitenancy disabled) + so callers can leave their query unchanged, preserving the public install.""" + scopes = entitled_scopes(user) + if "global" in scopes: + return None + from lib.cuckoo.common.tenancy import scope_match + + v = _ut.viewer_for(user) + clauses = [] + for s in scopes: + sm = scope_match(s, v) + if sm is not None: + clauses.append(sm) + # No entitled scope resolved (e.g. tenant-less, unauth) -> match nothing. + return {"$or": clauses} if clauses else {"info.id": -1} + + @require_safe @conditional_login_required(login_required, settings.WEB_AUTHENTICATION) def index(request): db: TasksMixIn = Database() - - states_count = db.get_tasks_status_count() - report = dict( - total_samples=format_number_with_space(db.count_samples()), - total_tasks=format_number_with_space(db.count_tasks()), - states_count=states_count, - estimate_hour=None, - estimate_day=None, - ) - - # For the following stats we're only interested in completed tasks. - tasks = states_count.get(TASK_COMPLETED, 0) + states_count.get(TASK_REPORTED, 0) - - data = {"title": "Dashboard", "report": {}} - - if tasks: - # Get the time when the first task started and last one ended. - started, completed = db.minmax_tasks() - - # It has happened that for unknown reasons completed and started were - # equal in which case an exception is thrown, avoid this. - if started and completed and int(completed - started): - hourly = 60 * 60 * tasks / (completed - started) - else: - hourly = 0 - - report["estimate_hour"] = format_number_with_space(int(hourly)) - report["estimate_day"] = format_number_with_space(int(24 * hourly)) - # report["top_detections"] = top_detections() - - data["report"] = report + v = _ut.viewer_for(request.user) + + panels = [] + for scope in entitled_scopes(request.user): + states = db.get_tasks_status_count(scope=scope, viewer=v) + total_tasks = db.count_tasks(scope=scope, viewer=v) or 0 + total_samples = db.count_samples(scope=scope, viewer=v) or 0 + + # Estimate throughput for completed/reported tasks in this scope. + tasks_done = states.get(TASK_COMPLETED, 0) + states.get(TASK_REPORTED, 0) + estimate_hour = None + estimate_day = None + if tasks_done: + started, completed = db.minmax_tasks(scope=scope, viewer=v) + if started and completed and int(completed - started): + hourly = 60 * 60 * tasks_done / (completed - started) + estimate_hour = format_number_with_space(int(hourly)) + estimate_day = format_number_with_space(int(24 * hourly)) + + panels.append({ + "scope": scope, + "label": _SCOPE_LABEL[scope], + "total_tasks": format_number_with_space(total_tasks), + "total_samples": format_number_with_space(total_samples), + "states_count": states, + "estimate_hour": estimate_hour, + "estimate_day": estimate_day, + }) + + data = {"title": "Dashboard", "panels": panels} return render(request, "dashboard/index.html", data) diff --git a/web/guac/test_visibility.py b/web/guac/test_visibility.py new file mode 100644 index 00000000000..2c4617c4b2b --- /dev/null +++ b/web/guac/test_visibility.py @@ -0,0 +1,27 @@ +import pytest +from django.contrib.auth.models import User + + +class ForeignTask: + id = 1 + user_id = 999 # owned by another tenant's user + tenant_id = 10 + visibility = "private" + + +@pytest.mark.django_db +def test_guac_index_denies_cross_tenant(cape_db, mt_enabled, monkeypatch, client): + """guac.index mints a live-VM session token from task_id. A cross-tenant + viewer must be denied BEFORE any token is minted (no tunnel into another + tenant's running malware VM).""" + import guac.views as gv + + minted = [] + monkeypatch.setattr(gv.db, "view_task", lambda *a, **k: ForeignTask()) + monkeypatch.setattr(gv.db, "create_guac_session", + lambda **k: minted.append(k) or object(), raising=False) + client.force_login(User.objects.create_user("gu", "gu@x.com", "x")) # tenant-less + + r = client.get("/guac/1/AAAA/") + assert minted == [] # no session token minted for a non-viewable task + assert r.status_code == 200 # rendered the guac error page, not the session page diff --git a/web/submission/test_visibility.py b/web/submission/test_visibility.py new file mode 100644 index 00000000000..2ef2e603ebc --- /dev/null +++ b/web/submission/test_visibility.py @@ -0,0 +1,16 @@ +import pytest +from django.contrib.auth.models import User + + +@pytest.mark.django_db +def test_submit_form_renders_visibility_control(cape_db, client): + u = User.objects.create_user("a", "a@x.com", "x") + client.force_login(u) + try: + from django.urls import reverse + url = reverse("submission") + except Exception: + url = "/submit/" + r = client.get(url) + assert r.status_code == 200 + assert b'name="visibility"' in r.content diff --git a/web/submission/views.py b/web/submission/views.py index 04bcb8bf86a..61ca26399cb 100644 --- a/web/submission/views.py +++ b/web/submission/views.py @@ -14,6 +14,9 @@ from contextlib import suppress from django.conf import settings + +from web.tenancy_optional import submission_scope, can_view_task, can_view_sample, viewer_for +from web.tenancy_optional import multitenancy_config, default_visibility, PUBLIC, TENANT, PRIVATE from django.contrib.auth.decorators import login_required from django.shortcuts import redirect, render @@ -50,6 +53,29 @@ db = Database() + +def _scope_existent(request, records): + """Tenant isolation for the existent_tasks resubmit-by-hash display: + perform_search applies only the legacy TLP/public_searches filter, so in + locked mode it would surface other tenants' task id / sha256 / malware-family + for a known hash. Drop records the requester may not view — mirroring + analysis.search. No-op when MT disabled (can_view_task -> is_local_admin).""" + out = [] + for record in records or []: + if not isinstance(record, dict): + continue + rid = (record.get("info") or {}).get("id") + if rid is None: + continue + try: + vt = db.view_task(int(rid)) + except (ValueError, TypeError): + continue + if vt is not None and can_view_task(request.user, vt): + out.append(record) + return out + + from urllib3 import disable_warnings disable_warnings() @@ -268,6 +294,10 @@ def force_int(value): def index(request, task_id=None, resubmit_hash=None): remote_console = False if request.method == "POST": + try: + _tenant_id, _visibility = submission_scope(request) + except ValueError: + return render(request, "error.html", {"error": "Invalid visibility value"}) ( static, package, @@ -387,6 +417,8 @@ def index(request, task_id=None, resubmit_hash=None): "options": options, "only_extraction": False, "user_id": request.user.id or 0, + "tenant_id": _tenant_id, + "visibility": _visibility, "package": package, } if opt_apikey: @@ -448,8 +480,23 @@ def index(request, task_id=None, resubmit_hash=None): for hash in samples: paths = [] if len(hash) in (32, 40, 64): + # by-hash resubmit hits the global content-addressed store — + # enforce the visible-task-referencing-the-sample boundary + # before touching it, else a tenant exfiltrates another + # tenant's sample bytes AND gets a full fresh analysis they + # own. Deny path is byte-identical to genuinely-missing. + _kw = {"sha256": hash} if len(hash) == 64 else ({"sha1": hash} if len(hash) == 40 else {"md5": hash}) + if not can_view_sample(request.user, **_kw): + details["errors"].append({hash: "File not found on hdd for resubmission"}) + continue paths = db.sample_path_by_hash(hash) else: + # task_id-based resubmit: the submit view is not task-gated, + # so authorize the source task before reading its binary. + _src = db.view_task(task_id) + if _src is None or not can_view_task(request.user, _src): + details["errors"].append({hash: "Task not found for resubmission"}) + continue task_binary = os.path.join(settings.CUCKOO_PATH, "storage", "analyses", str(task_id), "binary") if path_exists(task_binary): paths.append(task_binary) @@ -523,7 +570,7 @@ def index(request, task_id=None, resubmit_hash=None): if tasks_details.get("errors"): details["errors"].extend(tasks_details["errors"]) if web_conf.web_reporting.get("enabled", False) and web_conf.general.get("existent_tasks", False): - records = perform_search("target_sha256", hash, search_limit=5) + records = _scope_existent(request, perform_search("target_sha256", hash, search_limit=5, viewer=viewer_for(request.user))) if records: for record in records or []: existent_tasks.setdefault(record["target"]["file"]["sha256"], []).append(record) @@ -544,7 +591,7 @@ def index(request, task_id=None, resubmit_hash=None): if tasks_details.get("errors"): details["errors"].extend(tasks_details["errors"]) if web_conf.general.get("existent_tasks", False): - records = perform_search("target_sha256", sha256, search_limit=5) + records = _scope_existent(request, perform_search("target_sha256", sha256, search_limit=5, viewer=viewer_for(request.user))) if records: for record in records: if record.get("target").get("file", {}).get("sha256"): @@ -552,7 +599,7 @@ def index(request, task_id=None, resubmit_hash=None): elif task_category == "static": for content, path, sha256 in list_of_tasks: - task_id = db.add_static(file_path=path, priority=priority, tlp=tlp, options=options, user_id=request.user.id or 0) + task_id = db.add_static(file_path=path, priority=priority, tlp=tlp, options=options, user_id=request.user.id or 0, tenant_id=_tenant_id, visibility=_visibility) if not task_id: return render(request, "error.html", {"error": "We don't have static extractor for this"}) details["task_ids"] += task_id @@ -569,7 +616,8 @@ def index(request, task_id=None, resubmit_hash=None): details["errors"].append({os.path.basename(path): "Conversion from SAZ to PCAP failed."}) continue - task_id = db.add_pcap(file_path=path, priority=priority, tlp=tlp, user_id=request.user.id or 0) + task_id = db.add_pcap(file_path=path, priority=priority, tlp=tlp, user_id=request.user.id or 0, + tenant_id=_tenant_id, visibility=_visibility) if task_id: details["task_ids"].append(task_id) @@ -607,6 +655,8 @@ def index(request, task_id=None, resubmit_hash=None): cape=cape, tags_tasks=tags_tasks, user_id=request.user.id or 0, + tenant_id=_tenant_id, + visibility=_visibility, ) details["task_ids"].append(task_id) @@ -625,6 +675,9 @@ def index(request, task_id=None, resubmit_hash=None): details["errors"].extend(tasks_details["errors"]) elif task_category == "downloading_service": + # viewer gates local-cache reuse inside download_from_3rdparty (no + # cross-tenant bytes via a "Local" hit). No-op when MT disabled. + details["viewer"] = viewer_for(request.user) details = download_from_3rdparty(samples, opt_filename, details) if details.get("task_ids"): @@ -754,7 +807,7 @@ def index(request, task_id=None, resubmit_hash=None): existent_tasks = {} if resubmit_hash: if web_conf.general.get("existent_tasks", False): - records = perform_search("target_sha256", resubmit_hash, search_limit=5) + records = _scope_existent(request, perform_search("target_sha256", resubmit_hash, search_limit=5, viewer=viewer_for(request.user))) if records: for record in records: existent_tasks.setdefault(record["target"]["file"]["sha256"], []) @@ -765,6 +818,10 @@ def index(request, task_id=None, resubmit_hash=None): "submission/index.html", { "title": "Submit", + "visibility_levels": ( + [PUBLIC, PRIVATE] if multitenancy_config().mode == "shared" else [PUBLIC, TENANT, PRIVATE] + ), + "default_visibility": default_visibility(multitenancy_config()), "packages": sorted(packages, key=lambda i: i["name"].lower()), "machines": machines, "vpns": vpns_data, @@ -788,7 +845,8 @@ def index(request, task_id=None, resubmit_hash=None): @conditional_login_required(login_required, settings.WEB_AUTHENTICATION) def status(request, task_id): task = db.view_task(task_id) - if not task: + # tenant isolation: hidden == missing (also gates the emitted guac session_data) + if not task or not can_view_task(request.user, task): return render(request, "error.html", {"error": "The specified task doesn't seem to exist."}) completed = False @@ -834,7 +892,8 @@ def status(request, task_id): @conditional_login_required(login_required, settings.WEB_AUTHENTICATION) def remote_session(request, task_id): task = db.view_task(task_id) - if not task: + # tenant isolation: hidden == missing (also gates the emitted guac session_data) + if not task or not can_view_task(request.user, task): return render(request, "error.html", {"error": "The specified task doesn't seem to exist."}) machine_status = False diff --git a/web/templates/analysis/report.html b/web/templates/analysis/report.html index a11702ecb59..6afebd459e1 100644 --- a/web/templates/analysis/report.html +++ b/web/templates/analysis/report.html @@ -1,5 +1,36 @@ {% extends "base.html" %} {% block content %} +{% if can_toggle_visibility %} +
+ + +
+ +{% endif %} {% endblock %} diff --git a/web/templates/submission/index.html b/web/templates/submission/index.html index 311f3c0c624..f3a36a7ab64 100644 --- a/web/templates/submission/index.html +++ b/web/templates/submission/index.html @@ -963,6 +963,16 @@
Advance {% endif %} + {% if visibility_levels %} +
+ + +
+ {% endif %}
diff --git a/web/users/admin.py b/web/users/admin.py index 7a0477aadf8..6683eb2b7fb 100644 --- a/web/users/admin.py +++ b/web/users/admin.py @@ -2,7 +2,7 @@ from django.contrib.auth.admin import UserAdmin from django.contrib.auth.models import User -from .models import UserProfile +from .models import Tenant, UserProfile # Django 3.2 @@ -48,3 +48,10 @@ def get_inline_instances(self, request, obj=None): admin.site.unregister(User) admin.site.register(User, CustomUserAdmin) + + +@admin.register(Tenant) +class TenantAdmin(admin.ModelAdmin): + list_display = ("slug", "name", "active", "created_at") + search_fields = ("slug", "name") + list_filter = ("active",) diff --git a/web/users/migrations/0004_tenant_userprofile_is_tenant_admin_and_more.py b/web/users/migrations/0004_tenant_userprofile_is_tenant_admin_and_more.py new file mode 100644 index 00000000000..e50c1e8edb9 --- /dev/null +++ b/web/users/migrations/0004_tenant_userprofile_is_tenant_admin_and_more.py @@ -0,0 +1,36 @@ +# Generated by Django 5.1.14 on 2026-06-05 21:16 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0003_rename_field_subscription'), + ] + + operations = [ + migrations.CreateModel( + name='Tenant', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('slug', models.SlugField(max_length=48, unique=True)), + ('name', models.CharField(max_length=128)), + ('idp_groups', models.JSONField(blank=True, default=list)), + ('admin_idp_groups', models.JSONField(blank=True, default=list)), + ('active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.AddField( + model_name='userprofile', + name='is_tenant_admin', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='userprofile', + name='tenant', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='members', to='users.tenant'), + ), + ] diff --git a/web/users/models.py b/web/users/models.py index bcc57aa535b..bbc8b7e87b0 100644 --- a/web/users/models.py +++ b/web/users/models.py @@ -4,10 +4,30 @@ from django.dispatch import receiver +class Tenant(models.Model): + """A customer/tenant — the isolation boundary jobs, rulesets and API keys + belong to. Membership + tenant-admin status are driven by IdP group claims + (see web/web/allauth_adapters.py); CAPE owns the row (hybrid model).""" + + slug = models.SlugField(max_length=48, unique=True) + name = models.CharField(max_length=128) + idp_groups = models.JSONField(default=list, blank=True) # groups -> membership + admin_idp_groups = models.JSONField(default=list, blank=True) # groups -> tenant-admin + active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.slug + + class UserProfile(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE) subscription = models.CharField(max_length=50, default="5/m") reports = models.BooleanField(default=False) + tenant = models.ForeignKey( + "Tenant", null=True, blank=True, on_delete=models.SET_NULL, related_name="members" + ) + is_tenant_admin = models.BooleanField(default=False) def __str__(self): return self.user.username diff --git a/web/users/tenancy.py b/web/users/tenancy.py new file mode 100644 index 00000000000..3157052658e --- /dev/null +++ b/web/users/tenancy.py @@ -0,0 +1,126 @@ +"""Bridge Django request.user -> the pure core predicate (lib.cuckoo.common.tenancy). + +The web/apiv2 layers call can_view_task / can_toggle_task; the actual policy lives +in the framework-neutral predicate so the broker can reuse it unchanged. +""" +from lib.cuckoo.common.tenancy import Viewer, Job, can_read, can_toggle, multitenancy_config + + +def viewer_for(user) -> Viewer: + """Build a predicate Viewer from a Django user, resolving the operator + break-glass: when local_admins_manage_all_tenants is on, ANY superuser + crosses tenants; when off, only IdP-provisioned superusers (those with a + linked allauth SocialAccount) do — a local createsuperuser does not. + """ + cfg = multitenancy_config() + is_super = bool(getattr(user, "is_superuser", False)) + if not cfg.enabled: + # Multitenancy off => legacy single-tenant behavior for EVERY principal, + # INCLUDING anonymous (a no-auth public install, or apiv2 with token-auth + # disabled => DRF AllowAny): see and manage everything, exactly like + # upstream. is_local_admin short-circuits the predicate and the list + # filter; existing/legacy tasks are NOT hidden. This MUST run BEFORE the + # is_authenticated check, or anonymous requests on a disabled install get + # is_local_admin=False and every can_read/visible_to guard denies the + # private-default tasks upstream served (back-compat regression). + return Viewer(user_id=getattr(user, "id", None), tenant_id=None, is_superuser=is_super, + is_tenant_admin=False, is_local_admin=True) + + if not getattr(user, "is_authenticated", False): + # MT enabled: an anonymous request stays public-only (no break-glass). + return Viewer(user_id=None, tenant_id=None) + + prof = getattr(user, "userprofile", None) + if not is_super: + is_local = False + elif cfg.local_admins_manage_all_tenants: + is_local = True + else: + # flag off -> force admin access through the IdP: only superusers with a + # SocialAccount (IdP-provisioned) keep cross-tenant reach. + try: + is_local = user.socialaccount_set.exists() + except Exception: + is_local = False + return Viewer( + user_id=user.id, + tenant_id=getattr(prof, "tenant_id", None), + is_superuser=is_super, + is_tenant_admin=bool(getattr(prof, "is_tenant_admin", False)), + is_local_admin=is_local, + ) + + +def _job_for(task) -> Job: + return Job( + owner_id=getattr(task, "user_id", None), + tenant_id=getattr(task, "tenant_id", None), + visibility=getattr(task, "visibility", "private"), + ) + + +def can_view_task(user, task) -> bool: + return can_read(viewer_for(user), _job_for(task)) + + +def can_toggle_task(user, task) -> bool: + return can_toggle(viewer_for(user), _job_for(task)) + + +def can_manage_task(user, task) -> bool: + """Authorize a mutation (delete/reschedule/reprocess/comment/remove) on a + task. Same policy as toggling visibility: owner, tenant-admin for the + tenant's public/tenant jobs, or break-glass superuser — never another + member's private job.""" + return can_toggle(viewer_for(user), _job_for(task)) + + +def can_view_sample(user, *, sha256=None, sha1=None, md5=None, sample_id=None) -> bool: + """True iff `user` may access a content-addressed sample identified by + hash/id — i.e. has >=1 VISIBLE task referencing it. Samples are shared across + tenants by sha256, so access follows the union of the viewer's visible tasks + (the same intended boundary apiv2 _deny_by_hash enforces). + + No-op (returns True) when multitenancy is disabled or for a break-glass admin + (viewer_for -> is_local_admin). THE single source of truth for every by-hash + surface (apiv2 _deny_by_hash, web file() sample/static, submission resubmit / + download-services) so they cannot drift apart. + """ + viewer = viewer_for(user) + if viewer.is_local_admin: + return True + from lib.cuckoo.core.database import Database + + db = Database() + if sample_id is not None: + sample = db.view_sample(sample_id) + elif sha256 or sha1 or md5: + sample = db.find_sample(sha256=sha256, sha1=sha1, md5=md5) + else: + sample = None + if sample is None: + return False + return bool(db.list_tasks(sample_id=sample.id, visible_to=viewer, limit=1)) + + +def submission_scope(request): + """Resolve (tenant_id, visibility) for a new submission from the request. + + Tenant comes from the submitting user; visibility is the explicit + ``visibility`` param when valid, else the per-mode default. Raises + ValueError on an invalid explicit visibility so the view can 400. + """ + from lib.cuckoo.common.tenancy import multitenancy_config, default_visibility, VISIBILITIES + + v = viewer_for(request.user) + data = getattr(request, "data", None) + if data is None: + data = getattr(request, "POST", None) or {} + requested = data.get("visibility") if hasattr(data, "get") else None + if requested: + if requested not in VISIBILITIES: + raise ValueError("invalid visibility") + visibility = requested + else: + visibility = default_visibility(multitenancy_config()) + return v.tenant_id, visibility diff --git a/web/users/test_tenancy.py b/web/users/test_tenancy.py new file mode 100644 index 00000000000..e26465712c9 --- /dev/null +++ b/web/users/test_tenancy.py @@ -0,0 +1,221 @@ +import pytest +from django.contrib.auth.models import User + + +@pytest.mark.django_db +def test_tenant_and_profile_fields(): + from users.models import Tenant, UserProfile + + t = Tenant.objects.create( + slug="acme", name="Acme", idp_groups=["acme-soc"], admin_idp_groups=["acme-admins"] + ) + u = User.objects.create_user("a", "a@acme.com", "x") + prof = UserProfile.objects.get(user=u) # auto-created by signal + prof.tenant = t + prof.is_tenant_admin = True + prof.save() + + refreshed = UserProfile.objects.get(user=u) + assert refreshed.tenant.slug == "acme" + assert refreshed.is_tenant_admin is True + + +@pytest.mark.django_db +def test_resolve_tenant_from_groups(): + from users.models import Tenant, UserProfile + from web.allauth_adapters import reconcile_tenant + + t = Tenant.objects.create( + slug="acme", name="Acme", idp_groups=["acme-soc"], admin_idp_groups=["acme-admins"] + ) + u = User.objects.create_user("a", "a@acme.com", "x") + + reconcile_tenant(u, {"acme-soc", "acme-admins"}) + p = UserProfile.objects.get(user=u) + assert p.tenant_id == t.id and p.is_tenant_admin is True + + reconcile_tenant(u, {"acme-soc"}) # demoted from admin, still a member + p.refresh_from_db() + assert p.tenant_id == t.id and p.is_tenant_admin is False + + reconcile_tenant(u, set()) # no matching groups -> no tenant + p.refresh_from_db() + assert p.tenant_id is None and p.is_tenant_admin is False + + +@pytest.mark.django_db +def test_resolve_tenant_multi_match_fails_closed(): + from users.models import Tenant, UserProfile + from web.allauth_adapters import reconcile_tenant + + Tenant.objects.create(slug="a", name="A", idp_groups=["shared-grp"]) + Tenant.objects.create(slug="b", name="B", idp_groups=["shared-grp"]) + u = User.objects.create_user("m", "m@x.com", "x") + reconcile_tenant(u, {"shared-grp"}) + p = UserProfile.objects.get(user=u) + assert p.tenant_id is None # ambiguous -> fail closed + + +@pytest.mark.django_db +def test_viewer_for_maps_user(mt_enabled): + from users.models import Tenant, UserProfile + from users.tenancy import viewer_for + + t = Tenant.objects.create(slug="acme", name="Acme") + u = User.objects.create_user("a", "a@acme.com", "x") + p = UserProfile.objects.get(user=u) + p.tenant = t + p.is_tenant_admin = True + p.save() + + # re-fetch so the user's cached userprofile reflects the saved tenant + # (a real request loads request.user.userprofile fresh) + fresh = User.objects.get(pk=u.pk) + v = viewer_for(fresh) + assert v.user_id == u.id + assert v.tenant_id == t.id + assert v.is_tenant_admin is True + + +@pytest.mark.django_db +def test_disabled_is_backcompat_see_all(monkeypatch): + """H1 back-compat: with multitenancy OFF (the default / existing `sb` + deployment), any authenticated user sees every task — including a legacy + private job owned by someone else — exactly like today. The feature must be + fully opt-in and must not retroactively hide existing rows.""" + from lib.cuckoo.common.tenancy import MTConfig + from users.tenancy import can_view_task, viewer_for + import users.tenancy as ut + + monkeypatch.setattr(ut, "multitenancy_config", lambda: MTConfig(False, "shared", "", True)) + + nonowner = User.objects.create_user("n", "n@x.com", "x") + + class LegacyTask: # owned by a different user, marked private + user_id = 999 + tenant_id = None + visibility = "private" + + v = viewer_for(nonowner) + assert v.is_local_admin is True # short-circuit to legacy see-all + assert v.tenant_id is None + assert can_view_task(nonowner, LegacyTask()) is True # not hidden + + +@pytest.mark.django_db +def test_disabled_anonymous_is_backcompat_see_all(monkeypatch): + """B0 back-compat (the headline gating regression): on a DISABLED install a + no-auth public deployment (WEB_AUTHENTICATION off) or apiv2 with token-auth + off (DRF AllowAny) serves every request as AnonymousUser. viewer_for MUST + short-circuit the disabled case BEFORE the is_authenticated check so an + anonymous viewer is is_local_admin=True and can_read a private-default task, + exactly like upstream. Regression guard: previously the anon branch returned + is_local_admin=False before reading cfg, so ~45 guards denied non-public + tasks on a plain public install.""" + from django.contrib.auth.models import AnonymousUser + from lib.cuckoo.common.tenancy import MTConfig + from users.tenancy import can_view_task, can_manage_task, viewer_for + import users.tenancy as ut + + monkeypatch.setattr(ut, "multitenancy_config", lambda: MTConfig(False, "shared", "", True)) + + class PrivateTask: # private-default, owned by someone (no anon owner) + user_id = 999 + tenant_id = 10 + visibility = "private" + + anon = AnonymousUser() + v = viewer_for(anon) + assert v.is_local_admin is True # disabled => see-all even for anon + assert v.user_id is None + assert can_view_task(anon, PrivateTask()) is True # not hidden (upstream parity) + assert can_manage_task(anon, PrivateTask()) is True # mutations also unblocked when disabled + + +@pytest.mark.django_db +def test_enabled_anonymous_stays_public_only(monkeypatch): + """Counterpart to B0: when MT is ENABLED (locked), an anonymous viewer must + remain public-only — the disabled short-circuit must NOT leak into enabled + mode.""" + from django.contrib.auth.models import AnonymousUser + from lib.cuckoo.common.tenancy import MTConfig + from users.tenancy import can_view_task, viewer_for + import users.tenancy as ut + + monkeypatch.setattr(ut, "multitenancy_config", lambda: MTConfig(True, "locked", "", True)) + + class PrivateTask: + user_id = 999 + tenant_id = 10 + visibility = "private" + + class PublicTask: + user_id = 999 + tenant_id = 10 + visibility = "public" + + anon = AnonymousUser() + v = viewer_for(anon) + assert v.is_local_admin is False + assert can_view_task(anon, PrivateTask()) is False # enabled => restricted + assert can_view_task(anon, PublicTask()) is True # public still readable + + +@pytest.mark.django_db +def test_viewer_for_local_admin_gate(monkeypatch): + from lib.cuckoo.common.tenancy import MTConfig + import users.tenancy as ut + + u = User.objects.create_superuser("root", "root@x.com", "x") # local superuser, no SocialAccount + + # flag ON -> local superuser is break-glass + monkeypatch.setattr(ut, "multitenancy_config", + lambda: MTConfig(True, "locked", "", True)) + assert ut.viewer_for(u).is_local_admin is True + + # flag OFF -> local (non-IdP) superuser is NOT break-glass + monkeypatch.setattr(ut, "multitenancy_config", + lambda: MTConfig(True, "locked", "", False)) + assert ut.viewer_for(u).is_local_admin is False + + # anonymous -> empty viewer + from django.contrib.auth.models import AnonymousUser + assert ut.viewer_for(AnonymousUser()).user_id is None + + +@pytest.mark.django_db +def test_submission_scope(mt_enabled, monkeypatch): + import pytest as _pytest + from lib.cuckoo.common.tenancy import MTConfig + from users.models import Tenant, UserProfile + import users.tenancy as ut + + t = Tenant.objects.create(slug="acme", name="Acme") + u = User.objects.create_user("a", "a@x.com", "x") + p = UserProfile.objects.get(user=u) + p.tenant = t + p.save() + u = User.objects.get(pk=u.pk) + + class Req: + pass + + # explicit visibility honoured + tenant from user + r = Req() + r.user = u + r.data = {"visibility": "tenant"} + assert ut.submission_scope(r) == (t.id, "tenant") + + # omitted -> per-mode default (shared -> public) + monkeypatch.setattr(ut, "multitenancy_config", lambda: MTConfig(True, "shared", "", True)) + r2 = Req() + r2.user = u + r2.data = {} + assert ut.submission_scope(r2)[1] == "public" + + # invalid -> ValueError (view turns this into a 400) + r3 = Req() + r3.user = u + r3.data = {"visibility": "bogus"} + with _pytest.raises(ValueError): + ut.submission_scope(r3) diff --git a/web/web/allauth_adapters.py b/web/web/allauth_adapters.py index 3896f26c9d8..a195aaa4d67 100644 --- a/web/web/allauth_adapters.py +++ b/web/web/allauth_adapters.py @@ -284,6 +284,37 @@ def _apply_idp_roles_and_email(user, extra: dict) -> bool: return changed +def reconcile_tenant(user, user_groups: set) -> None: + """Set UserProfile.tenant + is_tenant_admin from the user's IdP groups. + + One-tenant-per-user (v1): exactly one membership match is expected. + >1 match -> fail closed (unset + warn; a user mapped to multiple + tenant-groups is a v1 misconfiguration) + 0 match -> no tenant + The caller skips this entirely when the groups claim is ABSENT (misconfig + guard), mirroring role reconciliation; a present-but-empty claim is honoured. + """ + from users.models import Tenant, UserProfile + + matches = [t for t in Tenant.objects.filter(active=True) if user_groups & set(t.idp_groups or [])] + prof, _ = UserProfile.objects.get_or_create(user=user) + if len(matches) > 1: + log.warning( + "user %s matches multiple tenants %s; leaving tenant unset", + user.username, [t.slug for t in matches], + ) + prof.tenant = None + prof.is_tenant_admin = False + elif len(matches) == 1: + t = matches[0] + prof.tenant = t + prof.is_tenant_admin = bool(user_groups & set(t.admin_idp_groups or [])) + else: + prof.tenant = None + prof.is_tenant_admin = False + prof.save(update_fields=["tenant", "is_tenant_admin"]) + + # ── Account adapters ────────────────────────────────────────────────────────── disposable_domain_list = [] @@ -426,3 +457,9 @@ def _reconcile_sso_user_on_login(sender, request, user, **kwargs): extra = getattr(sociallogin.account, "extra_data", None) or {} if _apply_idp_roles_and_email(user, extra): user.save() + # Tenant + tenant-admin reconciliation — same absent-claim guard as roles: + # only touch tenant membership when the groups claim is actually present. + oidc_cfg = getattr(settings, "OIDC_CFG", None) or {} + claim = oidc_cfg.get("groups_claim") or "groups" + if claim in extra: + reconcile_tenant(user, _extract_groups(extra)) From ba36221066a870939e56bd006037f1047fb6bb20 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Mon, 13 Jul 2026 10:36:18 -0500 Subject: [PATCH 002/183] mt-upstream: append [multitenancy] conf stanza --- conf/default/cuckoo.conf.default | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/conf/default/cuckoo.conf.default b/conf/default/cuckoo.conf.default index 0b74a1501ab..7415585e320 100644 --- a/conf/default/cuckoo.conf.default +++ b/conf/default/cuckoo.conf.default @@ -283,3 +283,19 @@ 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 + +[multitenancy] +# Multi-tenant identity & job-visibility (off = current single-tenant behavior). +enabled = no +# shared = tenant-less collaborative pool (submit default public); +# locked = per-tenant isolation (submit default tenant). +mode = shared +# Which OIDC claim drives tenant membership (reuses the groups claim from SSO). +tenant_claim_source = groups +# 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 From b7e15c8e1bd5a51120350370f0183dde97313966 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Mon, 13 Jul 2026 10:40:29 -0500 Subject: [PATCH 003/183] mt-upstream: reconcile 4 churned view files onto master (facade-routed, hunt() dropped, guac 3b + central coexist) --- web/analysis/views.py | 225 ++++++++++++++++++++++++------ web/apiv2/views.py | 313 ++++++++++++++++++++++++++++++++++++------ web/guac/consumers.py | 15 ++ web/guac/views.py | 7 + 4 files changed, 473 insertions(+), 87 deletions(-) diff --git a/web/analysis/views.py b/web/analysis/views.py index eb430a372a4..428d9ecd2c0 100644 --- a/web/analysis/views.py +++ b/web/analysis/views.py @@ -23,7 +23,7 @@ from django.conf import settings from django.contrib.auth.decorators import login_required from django.core.exceptions import BadRequest, PermissionDenied -from django.http import HttpResponse, HttpResponseRedirect, JsonResponse, StreamingHttpResponse +from django.http import HttpResponse, HttpResponseForbidden, HttpResponseRedirect, JsonResponse, StreamingHttpResponse from django.shortcuts import redirect, render from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST, require_safe @@ -192,6 +192,48 @@ db: TasksMixIn = Database() +from web.tenancy_optional import can_view_task, can_toggle_task, can_manage_task, can_view_sample, viewer_for + + +def require_task_manage(view): + """Decorator for task-scoped MUTATION views (remove/comment/reprocess/etc.): + 403 (generic) unless the user may MANAGE the task (owner / tenant-admin for + public+tenant jobs / break-glass). Stricter than require_task_visibility.""" + from functools import wraps + + @wraps(view) + def _wrapped(request, *args, **kwargs): + tid = kwargs.get("task_id") or kwargs.get("analysis_number") + if tid is None and args: + tid = args[0] + task = db.view_task(tid) + if task is None or not can_manage_task(request.user, task): + return HttpResponseForbidden("Not found") + return view(request, *args, **kwargs) + + return _wrapped + + +def require_task_visibility(view): + """Decorator for task-scoped analysis views: 403 (generic) unless the + requesting user may see the task. task_id comes from the URL named-group + (passed as a kwarg), or the first positional arg as a fallback. A hidden + and a non-existent task are indistinguishable (no cross-tenant enumeration). + """ + from functools import wraps + + @wraps(view) + def _wrapped(request, *args, **kwargs): + tid = kwargs.get("task_id") or kwargs.get("analysis_number") + if tid is None and args: + tid = args[0] + task = db.view_task(tid) + if task is None or not can_view_task(request.user, task): + return HttpResponseForbidden("Not found") + return view(request, *args, **kwargs) + + return _wrapped + anon_not_viewable_func_list = ( "file", "remove", @@ -436,12 +478,13 @@ def index(request, page=1): analyses_pcaps = [] analyses_static = [] + _visible = viewer_for(request.user) tasks_files = db.list_tasks( - limit=TASK_LIMIT, offset=off, category="file", not_status=TASK_PENDING, tags_tasks_not_like="audit", include_hashes=True + limit=TASK_LIMIT, offset=off, category="file", not_status=TASK_PENDING, tags_tasks_not_like="audit", include_hashes=True, visible_to=_visible ) - tasks_static = db.list_tasks(limit=TASK_LIMIT, offset=off, category="static", not_status=TASK_PENDING, include_hashes=True) - tasks_urls = db.list_tasks(limit=TASK_LIMIT, offset=off, category="url", not_status=TASK_PENDING, include_hashes=True) - tasks_pcaps = db.list_tasks(limit=TASK_LIMIT, offset=off, category="pcap", not_status=TASK_PENDING, include_hashes=True) + tasks_static = db.list_tasks(limit=TASK_LIMIT, offset=off, category="static", not_status=TASK_PENDING, include_hashes=True, visible_to=_visible) + tasks_urls = db.list_tasks(limit=TASK_LIMIT, offset=off, category="url", not_status=TASK_PENDING, include_hashes=True, visible_to=_visible) + tasks_pcaps = db.list_tasks(limit=TASK_LIMIT, offset=off, category="pcap", not_status=TASK_PENDING, include_hashes=True, visible_to=_visible) mongo_map = {} if enabledconf["mongodb"]: @@ -488,10 +531,10 @@ def index(request, page=1): pages_urls_num = 0 pages_pcaps_num = 0 pages_static_num = 0 - tasks_files_number = db.count_matching_tasks(category="file", not_status=TASK_PENDING) or 0 - tasks_static_number = db.count_matching_tasks(category="static", not_status=TASK_PENDING) or 0 - tasks_urls_number = db.count_matching_tasks(category="url", not_status=TASK_PENDING) or 0 - tasks_pcaps_number = db.count_matching_tasks(category="pcap", not_status=TASK_PENDING) or 0 + tasks_files_number = db.count_matching_tasks(category="file", not_status=TASK_PENDING, visible_to=_visible) or 0 + tasks_static_number = db.count_matching_tasks(category="static", not_status=TASK_PENDING, visible_to=_visible) or 0 + tasks_urls_number = db.count_matching_tasks(category="url", not_status=TASK_PENDING, visible_to=_visible) or 0 + tasks_pcaps_number = db.count_matching_tasks(category="pcap", not_status=TASK_PENDING, visible_to=_visible) or 0 if tasks_files_number: pages_files_num = int(tasks_files_number / TASK_LIMIT + 1) if tasks_static_number: @@ -527,33 +570,29 @@ def index(request, page=1): first_pcap = 0 first_url = 0 # On a fresh install, we need handle where there are 0 tasks. - buf = db.list_tasks(limit=1, category="file", not_status=TASK_PENDING, order_by=Task.added_on.asc()) - if len(buf) == 1: - first_file = db.list_tasks(limit=1, category="file", not_status=TASK_PENDING, order_by=Task.added_on.asc())[0].to_dict()[ - "id" - ] + # One query per category (limit=1, visible_to-scoped): reuse buf[0] rather + # than re-querying for the id (halves the DB round-trips). + buf = db.list_tasks(limit=1, category="file", not_status=TASK_PENDING, order_by=Task.added_on.asc(), visible_to=_visible) + if buf: + first_file = buf[0].to_dict()["id"] paging["show_file_prev"] = "show" else: paging["show_file_prev"] = "hide" - buf = db.list_tasks(limit=1, category="static", not_status=TASK_PENDING, order_by=Task.added_on.asc()) - if len(buf) == 1: - first_static = db.list_tasks(limit=1, category="static", not_status=TASK_PENDING, order_by=Task.added_on.asc())[ - 0 - ].to_dict()["id"] + buf = db.list_tasks(limit=1, category="static", not_status=TASK_PENDING, order_by=Task.added_on.asc(), visible_to=_visible) + if buf: + first_static = buf[0].to_dict()["id"] paging["show_static_prev"] = "show" else: paging["show_static_prev"] = "hide" - buf = db.list_tasks(limit=1, category="url", not_status=TASK_PENDING, order_by=Task.added_on.asc()) - if len(buf) == 1: - first_url = db.list_tasks(limit=1, category="url", not_status=TASK_PENDING, order_by=Task.added_on.asc())[0].to_dict()["id"] + buf = db.list_tasks(limit=1, category="url", not_status=TASK_PENDING, order_by=Task.added_on.asc(), visible_to=_visible) + if buf: + first_url = buf[0].to_dict()["id"] paging["show_url_prev"] = "show" else: paging["show_url_prev"] = "hide" - buf = db.list_tasks(limit=1, category="pcap", not_status=TASK_PENDING, order_by=Task.added_on.asc()) - if len(buf) == 1: - first_pcap = db.list_tasks(limit=1, category="pcap", not_status=TASK_PENDING, order_by=Task.added_on.asc())[0].to_dict()[ - "id" - ] + buf = db.list_tasks(limit=1, category="pcap", not_status=TASK_PENDING, order_by=Task.added_on.asc(), visible_to=_visible) + if buf: + first_pcap = buf[0].to_dict()["id"] paging["show_pcap_prev"] = "show" else: paging["show_pcap_prev"] = "hide" @@ -646,7 +685,7 @@ def index(request, page=1): @conditional_login_required(login_required, settings.WEB_AUTHENTICATION) def pending(request): # db = Database() - tasks = db.list_tasks(status=TASK_PENDING, include_hashes=True) + tasks = db.list_tasks(status=TASK_PENDING, include_hashes=True, visible_to=viewer_for(request.user)) pending = [] for task in tasks: @@ -1721,6 +1760,7 @@ def _load_evtx_channel_page(zip_path, member, page, page_size=EVTX_PAGE_SIZE, se @conditional_login_required(login_required, settings.WEB_AUTHENTICATION) # @ratelimit(key="ip", rate=my_rate_seconds, block=rateblock) # @ratelimit(key="ip", rate=my_rate_minutes, block=rateblock) +@require_task_visibility def load_files(request, task_id, category): """Filters calls for call category. @param task_id: cuckoo task id @@ -2038,6 +2078,7 @@ def fetch_signature_call_data(task_id, requested_calls): @csrf_exempt @require_POST @conditional_login_required(login_required, settings.WEB_AUTHENTICATION) +@require_task_visibility def signature_calls(request, task_id): try: requested_calls = json.loads(request.body) @@ -2060,6 +2101,7 @@ def signature_calls(request, task_id): @require_safe @conditional_login_required(login_required, settings.WEB_AUTHENTICATION) +@require_task_visibility def chunk(request, task_id, pid, pagenum): try: pid, pagenum = int(pid), int(pagenum) - 1 @@ -2120,6 +2162,7 @@ def chunk(request, task_id, pid, pagenum): @require_safe @conditional_login_required(login_required, settings.WEB_AUTHENTICATION) +@require_task_visibility def filtered_chunk(request, task_id, pid, category, apilist, caller, tid): """Filters calls for call category. @param task_id: cuckoo task id @@ -2392,6 +2435,7 @@ def gen_moloch_from_antivirus(virustotal): @require_safe @conditional_login_required(login_required, settings.WEB_AUTHENTICATION) +@require_task_visibility def antivirus(request, task_id): if enabledconf["mongodb"]: rtmp = mongo_find_one( @@ -2434,6 +2478,7 @@ def antivirus(request, task_id): @require_safe @conditional_login_required(login_required, settings.WEB_AUTHENTICATION) +@require_task_visibility def surialert(request, task_id): if enabledconf["mongodb"]: report = mongo_find_one("analysis", {"info.id": int(task_id)}, {"suricata.alerts": 1, "_id": 0}, sort=[("_id", -1)]) @@ -2462,6 +2507,7 @@ def surialert(request, task_id): @require_safe @conditional_login_required(login_required, settings.WEB_AUTHENTICATION) +@require_task_visibility def surihttp(request, task_id): if enabledconf["mongodb"]: report = mongo_find_one("analysis", {"info.id": int(task_id)}, {"suricata.http": 1, "_id": 0}, sort=[("_id", -1)]) @@ -2492,6 +2538,7 @@ def surihttp(request, task_id): @require_safe @conditional_login_required(login_required, settings.WEB_AUTHENTICATION) +@require_task_visibility def suritls(request, task_id): if enabledconf["mongodb"]: report = mongo_find_one("analysis", {"info.id": int(task_id)}, {"suricata.tls": 1, "_id": 0}, sort=[("_id", -1)]) @@ -2522,6 +2569,7 @@ def suritls(request, task_id): @require_safe @conditional_login_required(login_required, settings.WEB_AUTHENTICATION) +@require_task_visibility def surifiles(request, task_id): if enabledconf["mongodb"]: report = mongo_find_one( @@ -2554,6 +2602,7 @@ def surifiles(request, task_id): @csrf_exempt @conditional_login_required(login_required, settings.WEB_AUTHENTICATION) +@require_task_visibility def search_behavior(request, task_id): if request.method == "POST": query = request.POST.get("search") @@ -2677,6 +2726,15 @@ def split_signature_calls(report): @require_safe @conditional_login_required(login_required, settings.WEB_AUTHENTICATION) def report(request, task_id): + # Tenant/visibility enforcement — deny BEFORE any (expensive) report loading OR central staging + # (never stage another tenant's S3 tree). A hidden task and a missing/deleted task are + # INDISTINGUISHABLE (no cross-tenant enumeration) and both render the generic "no analysis found" + # page — a true no-op when multitenancy is disabled (can_view_task is always True then). + _task = db.view_task(task_id) + if _task is None or not can_view_task(request.user, _task): + return render(request, "error.html", {"error": "No analysis found with specified ID"}) + can_toggle_visibility = can_toggle_task(request.user, _task) + # Central mode: the analysis tree lives in S3, not on this node's disk. Stage it # locally (once, cached, excluding huge memory dumps) so EVERY report feature that # reads the local filesystem renders against the original UI without porting each @@ -3047,11 +3105,19 @@ def report(request, task_id): report["target"]["file"]["sha256"], search_limit=10, projection={"info.id": 1, "detections": 1, "_id": 0}, + viewer=viewer_for(request.user), ) for record in records: - if record["info"]["id"] == report["info"]["id"]: + rid = (record.get("info") or {}).get("id") + if rid is None or rid == report["info"]["id"]: + continue + # tenant isolation: only surface other analyses of this sample that + # the requester may read (no-op when MT disabled). Without this, the + # report page leaks other tenants' task ids + detections for the hash. + _vt = db.view_task(rid) + if _vt is None or not can_view_task(request.user, _vt): continue - existent_tasks[record["info"]["id"]] = record.get("detections") + existent_tasks[rid] = record.get("detections") # process log per task if enabled: process_log_path = os.path.join(CUCKOO_ROOT, "storage", "analyses", str(task_id), "process.log") @@ -3071,6 +3137,8 @@ def report(request, task_id): "analysis/report.html", { "title": "Analysis Report", + "can_toggle_visibility": can_toggle_visibility, + "task_visibility": getattr(_task, "visibility", "private"), "analysis": report, # ToDo test "file": report.get("target", {}).get("file", {}), @@ -3096,6 +3164,7 @@ def report(request, task_id): @require_safe @conditional_login_required(login_required, settings.WEB_AUTHENTICATION) +@require_task_visibility def load_evtx_channel(request, task_id): if request.headers.get("x-requested-with") != "XMLHttpRequest": raise PermissionDenied @@ -3121,6 +3190,7 @@ def load_evtx_channel(request, task_id): @require_safe @conditional_login_required(login_required, settings.WEB_AUTHENTICATION) +@require_task_visibility def load_evtx_channel_count(request, task_id): if request.headers.get("x-requested-with") != "XMLHttpRequest": raise PermissionDenied @@ -3150,6 +3220,7 @@ def load_evtx_channel_count(request, task_id): # session-cookie auth here so the global API-key-only DRF chain (used # under SSO deployments) doesn't 401 the in-browser fetches. @authentication_classes([SessionAuthentication]) +@require_task_visibility def file_nl(request, category, task_id, dlfile): from lib.cuckoo.common.central_mode import central_mode_config @@ -3219,10 +3290,11 @@ def file_nl(request, category, task_id, dlfile): } -def _file_search_all_files(search_category: str, search_term: str) -> list: +def _file_search_all_files(search_category: str, search_term: str, request) -> list: path = [] try: projection = { + "info.id": 1, "info.parent_sample.path": 1, "info.parent_sample.cape_yara.name": 1, "target.file.path": 1, @@ -3244,11 +3316,24 @@ def _file_search_all_files(search_category: str, search_term: str) -> list: "CAPE.payloads.extracted_files_tool.path": 1, "CAPE.payloads.extracted_files_tool.cape_yara.name": 1, } - records = perform_search(search_category, search_term, projection=projection) + # Tenant isolation: scope the search to the requester's entitled + # analyses at the query layer (no-op when multitenancy disabled). Without + # this, capeyarazipall streams other tenants' artifact bytes for any file + # matching the supplied YARA rule name. + records = perform_search(search_category, search_term, projection=projection, viewer=viewer_for(request.user)) search_term = search_term.lower() for _, filepath, _, _ in yara_detected(search_term, records): if not path_exists(filepath): continue + # Defense-in-depth: drop any artifact whose owning analysis the + # requester may not read. The query scope above is the primary gate; + # this guards paths under storage/analyses// regardless of + # backend (no-op when MT disabled — can_view_task -> is_local_admin). + _m = re.search(r"/analyses/(\d+)/", filepath) + if _m: + _vt = db.view_task(int(_m.group(1))) + if _vt is None or not can_view_task(request.user, _vt): + continue path.append(filepath) except ValueError as e: print("mongodb load", e) @@ -3266,6 +3351,7 @@ def _file_search_all_files(search_category: str, search_term: str) -> list: # UI-internal: same rationale as file_nl — used for in-browser downloads # of dropped files, payloads, etc. via session cookie auth. @authentication_classes([SessionAuthentication]) +@require_task_visibility def file(request, category, task_id, dlfile): from lib.cuckoo.common.central_mode import central_mode_config @@ -3307,6 +3393,13 @@ def file(request, category, task_id, dlfile): return render(request, "error.html", {"error": "Missed pyzipper library: poetry install"}) if category in ("sample", "static", "staticzip"): + # By-hash access to the global content-addressed binary store. @require_ + # task_visibility only gates task_id, not the attacker-supplied hash, so + # enforce the SAME visible-task-referencing-the-sample boundary as apiv2 + # _deny_by_hash (no-op for break-glass / MT-disabled). Hidden == missing + # (generic error, no existence oracle). + if not can_view_sample(request.user, sha256=file_name): + return render(request, "error.html", {"error": "File not found"}) path = os.path.join(CUCKOO_ROOT, "storage", "binaries", file_name) elif category in ("dropped", "droppedzip"): path = os.path.join(CUCKOO_ROOT, "storage", "analyses", str(task_id), "files", file_name) @@ -3420,7 +3513,7 @@ def file(request, category, task_id, dlfile): elif category == "capeyarazipall": # search in mongo and get the path if enabledconf["mongodb"] and web_cfg.zipped_download.download_all: - path = _file_search_all_files(category.replace("zipall", ""), dlfile) + path = _file_search_all_files(category.replace("zipall", ""), dlfile, request) elif category == "logszipall": buf = os.path.join(CUCKOO_ROOT, "storage", "analyses", task_id, "logs") path = [] @@ -3497,6 +3590,7 @@ def file(request, category, task_id, dlfile): @require_safe @conditional_login_required(login_required, settings.WEB_AUTHENTICATION) +@require_task_visibility def procdump(request, task_id, process_id, start, end, zipped=False): origname = process_id + ".dmp" tmpdir = None @@ -3575,6 +3669,7 @@ def procdump(request, task_id, process_id, start, end, zipped=False): @require_safe @conditional_login_required(login_required, settings.WEB_AUTHENTICATION) +@require_task_visibility def filereport(request, task_id, category): # check if allowed to download to all + if no if user has permissions if not settings.ALLOW_DL_REPORTS_TO_ALL and ( @@ -3639,6 +3734,7 @@ def filereport(request, task_id, category): @require_safe @conditional_login_required(login_required, settings.WEB_AUTHENTICATION) +@require_task_visibility def full_memory_dump_file(request, analysis_number): from lib.cuckoo.common.central_mode import central_mode_config @@ -3666,6 +3762,7 @@ def full_memory_dump_file(request, analysis_number): @require_safe @conditional_login_required(login_required, settings.WEB_AUTHENTICATION) +@require_task_visibility def full_memory_dump_strings(request, analysis_number): from lib.cuckoo.common.central_mode import central_mode_config @@ -3759,7 +3856,7 @@ def search(request, searched=""): term_only, value_only = term, value try: - records = perform_search(term, value, user_id=request.user.id, privs=request.user.is_staff) + records = perform_search(term, value, user_id=request.user.id, privs=request.user.is_staff, viewer=viewer_for(request.user)) except ValueError: if term: return render( @@ -3776,13 +3873,26 @@ def search(request, searched=""): analyses = [] for result in records or []: - new = None + task_id = None if enabledconf["mongodb"] and enabledconf["elasticsearchdb"] and essearch and not term: - new = get_analysis_info(db, id=int(result["_source"]["task_id"])) - if enabledconf["mongodb"] and term and "info" in result: - new = get_analysis_info(db, id=int(result["info"]["id"])) - if es_as_db: - new = get_analysis_info(db, id=int(result["info"]["id"])) + task_id = (result.get("_source") or {}).get("task_id") + elif enabledconf["mongodb"] and term and "info" in result: + task_id = (result.get("info") or {}).get("id") + elif es_as_db: + task_id = (result.get("info") or {}).get("id") + if task_id is None: + continue + try: + task_id = int(task_id) + except (ValueError, TypeError): + continue # malformed id in a corrupt report — skip, don't 500 + # tenant isolation: gate BEFORE the heavy get_analysis_info() (mongo/es + # lookups + processing) — skip unauthorized tasks cheaply and reuse the + # resolved task so get_analysis_info doesn't re-query Postgres. + _vt = db.view_task(task_id) + if _vt is None or not can_view_task(request.user, _vt): + continue + new = get_analysis_info(db, task=_vt) if not new: continue analyses.append(new) @@ -3805,6 +3915,7 @@ def search(request, searched=""): @require_safe @conditional_login_required(login_required, settings.WEB_AUTHENTICATION) +@require_task_manage def remove(request, task_id): """Remove an analysis.""" if not enabledconf["delete"] and not request.user.is_staff: @@ -3866,6 +3977,7 @@ def remove(request, task_id): @require_safe @conditional_login_required(login_required, settings.WEB_AUTHENTICATION) +@require_task_visibility def pcapstream(request, task_id, conntuple): from lib.cuckoo.common.central_mode import central_mode_config @@ -3921,6 +4033,7 @@ def pcapstream(request, task_id, conntuple): @conditional_login_required(login_required, settings.WEB_AUTHENTICATION) +@require_task_manage def comments(request, task_id): if request.method == "POST" and settings.COMMENTS: comment = request.POST.get("commentbox", "") @@ -3962,6 +4075,7 @@ def comments(request, task_id): @conditional_login_required(login_required, settings.WEB_AUTHENTICATION) +@require_task_manage def vtupload(request, category, task_id, filename, dlfile): from lib.cuckoo.common.central_mode import central_mode_config @@ -3975,6 +4089,12 @@ def vtupload(request, category, task_id, filename, dlfile): folder_name = False path = False if category in ("sample", "static"): + # By-hash access to the global binary store — enforce the visible- + # task-referencing-the-sample boundary (else a tenant uploads + # another tenant's sample to VirusTotal by hash). No-op for + # break-glass / MT-disabled. + if not can_view_sample(request.user, sha256=dlfile): + return render(request, "error.html", {"error": "File not found"}) path = os.path.join(CUCKOO_ROOT, "storage", "binaries", dlfile) elif category == "dropped": folder_name = "files" @@ -4009,8 +4129,18 @@ def vtupload(request, category, task_id, filename, dlfile): @conditional_login_required(login_required, settings.WEB_AUTHENTICATION) def statistics_data(request, days=7): if days.isdigit(): + from dashboard.views import entitled_scopes, _SCOPE_LABEL + + v = viewer_for(request.user) try: - details = statistics(int(days)) + panels = [ + { + "scope": scope, + "label": _SCOPE_LABEL[scope], + "statistics": statistics(int(days), scope=scope, viewer=v), + } + for scope in entitled_scopes(request.user) + ] except Exception as e: # psycopg2.OperationalError print(e) @@ -4019,7 +4149,7 @@ def statistics_data(request, days=7): "error.html", {"title": "Statistics", "error": "Please restart your database. Probably it had an update or it just down"}, ) - return render(request, "statistics.html", {"title": "Statistics", "statistics": details, "days": days}) + return render(request, "statistics.html", {"title": "Statistics", "panels": panels, "days": days}) return render(request, "error.html", {"title": "Statistics", "error": "Provide days as number"}) @@ -4037,6 +4167,7 @@ def statistics_data(request, days=7): @conditional_login_required(login_required, settings.WEB_AUTHENTICATION) @ratelimit(key="ip", rate=my_rate_seconds, block=rateblock) @ratelimit(key="ip", rate=my_rate_minutes, block=rateblock) +@require_task_manage def on_demand(request, service: str, task_id: str, category: str, sha256): """ This aux function allows to generate some details on demand, this is specially useful for long running libraries and we don't need them in many cases due to scripted submissions @@ -4235,6 +4366,7 @@ def ban_user(request, user_id: int): @conditional_login_required(login_required, settings.WEB_AUTHENTICATION) +@require_task_manage def reprocess_tasks(request, task_id: int): if not settings.REPROCESS_TASKS: return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/")) @@ -4248,6 +4380,7 @@ def reprocess_tasks(request, task_id: int): @require_safe @conditional_login_required(login_required, settings.WEB_AUTHENTICATION) +@require_task_visibility def failed_processing(request, task_id): task = db.view_task(task_id) if not task: @@ -4328,8 +4461,9 @@ def hunt(request): start_date = (datetime.datetime.utcnow() - delta).strftime("%Y-%m-%d %H:%M:%S") match_query["info.started"] = {"$gte": start_date} - # Tenant isolation: restrict the docs the aggregation sees to the viewer's - # entitled scopes (no-op for break-glass / shared / multitenancy-disabled). + # Tenant isolation: restrict the docs the aggregation sees to the viewer's entitled scopes + # (no-op for break-glass / shared / multitenancy-disabled). viewer_scope wraps the MT predicate + # and degrades to None (see-all) when the MT layer is absent. from analysis.central_scope import viewer_scope from lib.cuckoo.common.central_mode import central_mode_config from lib.cuckoo.common.hunt_query import build_hunt_facets @@ -4412,7 +4546,8 @@ def tag_tasks(request): try: for tid in task_ids: task = db.session.get(Task, int(tid)) - if task: + # only the task's owner / tenant-admin (or break-glass) may tag it + if task and can_manage_task(request.user, task): existing_tags = task.tags_tasks or "" current_tags = [t.strip() for t in existing_tags.split(",") if t.strip()] if tag not in current_tags: diff --git a/web/apiv2/views.py b/web/apiv2/views.py index e024bbf2fd0..c70a3f94d4c 100644 --- a/web/apiv2/views.py +++ b/web/apiv2/views.py @@ -29,6 +29,75 @@ from apikey.authentication import ApiKeyAuthentication except ImportError: ApiKeyAuthentication = None + +from web.tenancy_optional import submission_scope, can_view_task, can_toggle_task, can_manage_task, can_view_sample, viewer_for +from web.tenancy_optional import VISIBILITIES + + +def _deny_if_hidden(request, task): + """Return a Response (to be returned by the caller) if request.user may not + see `task`, else None. Every per-task READ endpoint must route through this + (enforced by the endpoint-coverage test) to prevent cross-tenant leaks. + + A non-existent task and a hidden task return the SAME generic 404 response so + an attacker cannot enumerate which task IDs / states exist in other tenants. + Callers must invoke this BEFORE validate_task()/status/TLP checks so those + don't leak existence either.""" + if task is None or not can_view_task(request.user, task): + return Response({"error": True, "error_value": "Task not found"}, status=404) + return None + + +def _deny_task(request, task_id): + """Convenience: load the task and apply _deny_if_hidden. Used by endpoints + that don't otherwise hold the Task object.""" + return _deny_if_hidden(request, db.view_task(task_id)) + + +def _deny_manage(request, task_id): + """Like _deny_task but for MUTATIONS — requires can_manage (owner/tenant-admin/ + break-glass). Returns a generic 404 Response if not allowed, else None.""" + task = db.view_task(task_id) + if task is None or not can_manage_task(request.user, task): + return Response({"error": True, "error_value": "Task not found"}, status=404) + return None + + +def _deny_by_hash(request, *, sha256=None, sha1=None, md5=None, sample_id=None): + """Indistinguishable 404 unless the caller has >=1 VISIBLE task referencing the + sample identified by the hash/id. A sample can be shared across tenants, so access + follows the union of the caller's visible tasks. + + When multitenancy is DISABLED (or for a break-glass admin), viewer_for returns + is_local_admin=True and this function is a no-op — it must NOT gate the public + install, and must NOT 404 dropped/procdump payloads that have no Sample row.""" + # Delegate the entitlement decision to the shared tenancy.can_view_sample so + # this gate, web file()'s sample/static branch, and the submission resubmit / + # download-services paths all enforce the SAME by-hash boundary and can't + # drift (no-op for break-glass / MT-disabled — handled inside the helper). + if can_view_sample(request.user, sha256=sha256, sha1=sha1, md5=md5, sample_id=sample_id): + return None + return Response({"error": True, "error_value": "Sample not found"}, status=404) + + +@api_view(["PATCH"]) +def tasks_set_visibility(request, task_id): + """Owner (or tenant-admin for public/tenant jobs, or superuser) re-toggles a + task's visibility. Mirrors the can_toggle predicate.""" + task = db.view_task(task_id) + # Indistinguishable response (H3): a caller who can't even SEE the task gets + # the SAME generic 404 as a missing one, so this endpoint can't be used to + # enumerate other tenants' task IDs. A 403 below is only reachable once the + # caller can read the task (so it leaks nothing they don't already see). + if task is None or not can_view_task(request.user, task): + return Response({"error": True, "error_value": "Task not found"}, status=404) + vis = request.data.get("visibility") + if vis not in VISIBILITIES: + return Response({"error": True, "error_value": "invalid visibility"}, status=400) + if not can_toggle_task(request.user, task): + return Response({"error": True, "error_value": "Access denied"}, status=403) + db.set_task_visibility(task_id, vis) + return Response({"error": False, "data": {"task_id": int(task_id), "visibility": vis}}) from rest_framework.response import Response sys.path.append(settings.CUCKOO_PATH) @@ -234,6 +303,10 @@ def tasks_create_static(request): priority = force_int(request.data.get("priority")) resp["error"] = [] + try: + _tenant_id, _visibility = submission_scope(request) + except ValueError: + return Response({"error": True, "error_value": "invalid visibility"}) files = request.FILES.getlist("file") extra_details = {} task_ids = [] @@ -248,6 +321,8 @@ def tasks_create_static(request): static=1, only_extraction=True, user_id=request.user.id or 0, + tenant_id=_tenant_id, + visibility=_visibility, ) task_ids.extend(task_id) if extra_details.get("erros"): @@ -296,6 +371,10 @@ def tasks_create_file(request): resp = {"error": True, "error_value": "No file was submitted"} return Response(resp) resp["error"] = [] + try: + _tenant_id, _visibility = submission_scope(request) + except ValueError: + return Response({"error": True, "error_value": "invalid visibility"}) # Parse potential POST options (see submission/views.py) pcap = request.data.get("pcap", "") @@ -332,6 +411,8 @@ def tasks_create_file(request): "options": options, "only_extraction": False, "user_id": request.user.id or 0, + "tenant_id": _tenant_id, + "visibility": _visibility, } task_machines = [] @@ -382,7 +463,7 @@ def tasks_create_file(request): details["task_ids"].append(task_id) continue if static: - task_id = db.add_static(file_path=tmp_path, priority=priority, user_id=request.user.id or 0) + task_id = db.add_static(file_path=tmp_path, priority=priority, user_id=request.user.id or 0, tenant_id=_tenant_id, visibility=_visibility) details["task_ids"].append(task_id) continue if tmp_path: @@ -434,6 +515,10 @@ def tasks_create_url(request): resp = {} if request.method == "POST": resp["error"] = [] + try: + _tenant_id, _visibility = submission_scope(request) + except ValueError: + return Response({"error": True, "error_value": "invalid visibility"}) url = request.data.get("url") ( @@ -507,6 +592,8 @@ def tasks_create_url(request): tlp=tlp, tags_tasks=tags_tasks, user_id=request.user.id or 0, + tenant_id=_tenant_id, + visibility=_visibility, ) if task_id: task_ids.append(task_id) @@ -535,6 +622,10 @@ def tasks_create_dlnexec(request): return Response(resp) resp["error"] = [] + try: + _tenant_id, _visibility = submission_scope(request) + except ValueError: + return Response({"error": True, "error_value": "invalid visibility"}) url = request.data.get("dlnexec") if not url: resp = {"error": True, "error_value": "URL value is empty"} @@ -601,6 +692,8 @@ def tasks_create_dlnexec(request): "options": options, "only_extraction": False, "user_id": request.user.id or 0, + "tenant_id": _tenant_id, + "visibility": _visibility, } status, tasks_details = download_file(**details) @@ -641,6 +734,10 @@ def files_view(request, md5=None, sha1=None, sha256=None, sample_id=None): resp = {"error": True, "error_value": "File View API is Disabled"} return Response(resp) + _denied = _deny_by_hash(request, md5=md5, sha1=sha1, sha256=sha256, sample_id=sample_id) + if _denied is not None: + return _denied + resp = {} if md5 or sha1 or sha256 or sample_id: resp["error"] = [] @@ -721,7 +818,7 @@ def tasks_search(request, md5=None, sha1=None, sha256=None): sids = [sample.to_dict()["id"]] resp["data"] = [] for sid in sids: - tasks = db.list_tasks(sample_id=sid, include_hashes=True) + tasks = db.list_tasks(sample_id=sid, include_hashes=True, visible_to=viewer_for(request.user)) for task in tasks: buf = task.to_dict() # Remove path information, just grab the file name @@ -729,6 +826,12 @@ def tasks_search(request, md5=None, sha1=None, sha256=None): if task.sample: buf["sample"] = task.sample.to_dict() resp["data"].append(buf) + # No visible task for this sample => respond byte-identically to + # "sample absent" so the error-field doesn't become a cross-tenant + # existence oracle (mirror _deny_by_hash). Break-glass / MT-disabled + # keeps the {"error": []} shape (back-compat, no-op). + if not resp["data"] and not viewer_for(request.user).is_local_admin: + resp = {"data": [], "error": False} else: resp = {"data": [], "error": False} @@ -758,10 +861,10 @@ def ext_tasks_search(request): return Response(resp) if term == "tags_tasks": - value = [int(v.id) for v in db.list_tasks(tags_tasks_like=value, limit=int(search_limit))] + value = [int(v.id) for v in db.list_tasks(tags_tasks_like=value, limit=int(search_limit), visible_to=viewer_for(request.user))] term = "ids" elif term == "options": - value = [int(v.id) for v in db.list_tasks(options_like=value, limit=search_limit)] + value = [int(v.id) for v in db.list_tasks(options_like=value, limit=search_limit, visible_to=viewer_for(request.user))] term = "ids" elif term == "ids": if all([v.strip().isdigit() for v in value.split(",")]): @@ -769,7 +872,7 @@ def ext_tasks_search(request): else: return Response({"error": True, "error_value": "Not all values are integers"}) tmp_value = [] - for task in db.list_tasks(task_ids=value) or []: + for task in db.list_tasks(task_ids=value, visible_to=viewer_for(request.user)) or []: if task.status == "reported": tmp_value.append(task.id) else: @@ -778,7 +881,7 @@ def ext_tasks_search(request): del tmp_value try: projection = lean_search_filters if request.data.get("lean") else None - records = perform_search(term, value, user_id=request.user.id, privs=request.user.is_staff, web=False, projection=projection) + records = perform_search(term, value, user_id=request.user.id, privs=request.user.is_staff, web=False, projection=projection, viewer=viewer_for(request.user)) except ValueError: if not term: resp = {"error": True, "error_value": "No option provided."} @@ -789,6 +892,13 @@ def ext_tasks_search(request): if records: for results in records: + # Visibility filter: the mongo/ES report rows don't carry tenant + # info, so resolve each task and drop ones the viewer can't see. + _doc = results.get("_source", results) if es_as_db else results + _tid = (_doc.get("info") or {}).get("id") if isinstance(_doc, dict) else None + _t = db.view_task(_tid) if _tid is not None else None + if _t is None or not can_view_task(request.user, _t): + continue if repconf.mongodb.enabled: return_data.append(results) if es_as_db: @@ -860,6 +970,7 @@ def tasks_list(request, offset=None, limit=None, window=None): options_like=option, order_by=Task.completed_on.desc(), include_hashes=True, + visible_to=viewer_for(request.user), ) if not tasks: @@ -900,9 +1011,9 @@ def tasks_view(request, task_id): return Response(resp) task = db.view_task(task_id, details=True) - if not task: - resp = {"error": True, "error_value": "Task not found in database"} - return Response(resp) + _denied = _deny_if_hidden(request, task) + if _denied is not None: + return _denied resp = {"error": False} entry = task.to_dict() @@ -1043,9 +1154,9 @@ def tasks_reschedule(request, task_id): resp = {"error": True, "error_value": "Task Reschedule API is Disabled"} return Response(resp) - if not db.view_task(task_id): - resp = {"error": True, "error_value": "Task ID does not exist in the database"} - return Response(resp) + _denied = _deny_manage(request, task_id) + if _denied is not None: + return _denied resp = {} new_task_id = db.reschedule(task_id) @@ -1072,6 +1183,10 @@ def tasks_reprocess(request, task_id): resp["error_value"] = "Task Reprocess API is Disabled" return Response(resp) + _denied = _deny_manage(request, task_id) + if _denied is not None: + return _denied + error, msg, task_status = db.tasks_reprocess(task_id) if error: return Response({"error": True, "error_value": msg}) @@ -1111,6 +1226,10 @@ def tasks_delete(request, task_id, status=False): if check["error"]: f_deleted.append(str(task)) continue + # tenant isolation: only delete tasks the caller may manage + if not can_manage_task(request.user, db.view_task(task)): + f_deleted.append(str(task)) + continue if db.delete_task(task): delete_folder(os.path.join(CUCKOO_ROOT, "storage", "analyses", "%s" % task)) @@ -1142,9 +1261,9 @@ def tasks_status(request, task_id): resp = {} task = db.view_task(task_id) - if not task: - resp = {"error": True, "error_value": "Task does not exist"} - return Response(resp) + _denied = _deny_if_hidden(request, task) + if _denied is not None: + return _denied if request.method == "GET": status = task.to_dict()["status"] resp = {"error": False, "data": status} @@ -1184,6 +1303,9 @@ def tasks_report(request, task_id, report_format="json", make_zip=False): {"error": "You don't have permissions to download reports. Ask admin to enable it for you in user profile."}, ) + _denied = _deny_if_hidden(request, db.view_task(task_id)) + if _denied is not None: + return _denied check = validate_task(task_id) if check["error"]: return Response(check) @@ -1370,6 +1492,9 @@ def tasks_iocs(request, task_id, detail=None): resp = {"error": True, "error_value": "IOC download API is disabled"} return Response(resp) + _denied = _deny_if_hidden(request, db.view_task(task_id)) + if _denied is not None: + return _denied check = validate_task(task_id) if check["error"]: return Response(check) @@ -1606,6 +1731,9 @@ def tasks_screenshot(request, task_id, screenshot="all"): resp = {"error": True, "error_value": "Screenshot download API is disabled"} return Response(resp) + _denied = _deny_if_hidden(request, db.view_task(task_id)) + if _denied is not None: + return _denied check = validate_task(task_id) if check["error"]: return Response(check) @@ -1661,6 +1789,9 @@ def tasks_pcap(request, task_id): resp = {"error": True, "error_value": "PCAP download API is disabled"} return Response(resp) + _denied = _deny_if_hidden(request, db.view_task(task_id)) + if _denied is not None: + return _denied check = validate_task(task_id) if check["error"]: return Response(check) @@ -1690,17 +1821,21 @@ def tasks_pcap(request, task_id): return Response(resp) -def _resolve_task_id(task_id, enabled_key, check_tlp=True): +def _resolve_task_id(request, task_id, enabled_key, check_tlp=True): """Shared preamble for artifact-download endpoints. Returns ((task_id, None)) on success or ((None, Response(error))) on failure. `enabled_key` names the apiconf section that gates the endpoint; callers that want to share a gate (e.g. all pcap variants under [taskpcap]) reuse the same key. TLP:RED checks are skipped only for endpoints that need - to serve regardless (none at present).""" + to serve regardless (none at present). Enforces job visibility via + _deny_if_hidden so all artifact endpoints honor tenant boundaries.""" section = getattr(apiconf, enabled_key, None) if section is not None and not section.get("enabled"): return None, Response({"error": True, "error_value": "%s download API is disabled" % enabled_key}) + _denied = _deny_if_hidden(request, db.view_task(task_id)) + if _denied is not None: + return None, _denied check = validate_task(task_id) if check["error"]: return None, Response(check) @@ -1801,7 +1936,7 @@ def tasks_tlspcap(request, task_id): """Back-compat endpoint: originally served PolarProxy's tls.pcap. We've since moved to SSLproxy + GoGoRoboCap which produces dump_decrypted.pcap; prefer that, but fall back to the legacy path for old analyses.""" - task_id, err = _resolve_task_id(task_id, "tasktlspcap", check_tlp=False) + task_id, err = _resolve_task_id(request, task_id, "tasktlspcap", check_tlp=False) if err: return err @@ -1907,7 +2042,7 @@ def tasks_pcap_variant(request, task_id, variant): """Alternate PCAP artifacts for . variant ∈ {decrypted, mixed, sslproxy, zip, pcapng}. The bare tasks/get/pcap// remains for back-compat with existing callers (serves dump.pcap).""" - task_id, err = _resolve_task_id(task_id, "taskpcap") + task_id, err = _resolve_task_id(request, task_id, "taskpcap") if err: return err _central_stage(request, task_id) @@ -1929,7 +2064,7 @@ def tasks_keys(request, task_id, kind): different hook source (tls: MockSSL → tlsdump.log; ssl: bcrypt/NCrypt → aux/sslkeylogfile/sslkeys.log; master: SSLproxy → master_keys.log). All three are NSS-format keylogs.""" - task_id, err = _resolve_task_id(task_id, "tasktlskeys") + task_id, err = _resolve_task_id(request, task_id, "tasktlskeys") if err: return err k = (kind or "").lower() @@ -1944,7 +2079,7 @@ def tasks_keys(request, task_id, kind): def tasks_etw(request, task_id, kind): """ETW telemetry downloads. kind ∈ {dns, network, wmi} each map to an NDJSON stream; kind == amsi zips the per-buffer AMSI script captures.""" - task_id, err = _resolve_task_id(task_id, "tasketw") + task_id, err = _resolve_task_id(request, task_id, "tasketw") if err: return err k = (kind or "").lower() @@ -1963,7 +2098,7 @@ def tasks_bulkzip(request, task_id, folder): to {logs, network, memory, selfextracted}. Archive is AES-encrypted with ZIP_PWD for parity with tasks_dropped / tasks_payloadfiles / tasks_procdumpfiles.""" - task_id, err = _resolve_task_id(task_id, "taskbulkzip") + task_id, err = _resolve_task_id(request, task_id, "taskbulkzip") if err: return err f = (folder or "").lower() @@ -1979,6 +2114,9 @@ def tasks_evtx(request, task_id): resp = {"error": True, "error_value": "EVTX download API is disabled"} return Response(resp) + _denied = _deny_if_hidden(request, db.view_task(task_id)) + if _denied is not None: + return _denied check = validate_task(task_id) if check["error"]: return Response(check) @@ -2014,6 +2152,9 @@ def tasks_mitmdump(request, task_id): if not apiconf.mitmdump.get("enabled"): resp = {"error": True, "error_value": "Mitmdump HAR download API is disabled"} return Response(resp) + _denied = _deny_if_hidden(request, db.view_task(task_id)) + if _denied is not None: + return _denied check = validate_task(task_id) if check["error"]: return Response(check) @@ -2042,6 +2183,9 @@ def tasks_dropped(request, task_id): resp = {"error": True, "error_value": "Dropped File download API is disabled"} return Response(resp) + _denied = _deny_if_hidden(request, db.view_task(task_id)) + if _denied is not None: + return _denied check = validate_task(task_id) if check["error"]: return Response(check) @@ -2095,6 +2239,9 @@ def tasks_selfextracted(request, task_id, tool="all"): resp = {"error": True, "error_value": "Self Extracted File download API is disabled"} return Response(resp) + _denied = _deny_if_hidden(request, db.view_task(task_id)) + if _denied is not None: + return _denied check = validate_task(task_id) if check["error"]: return Response(check) @@ -2195,6 +2342,9 @@ def tasks_surifile(request, task_id): resp = {"error": True, "error_value": "Suricata File download API is disabled"} return Response(resp) + _denied = _deny_if_hidden(request, db.view_task(task_id)) + if _denied is not None: + return _denied check = validate_task(task_id) if check["error"]: return Response(check) @@ -2246,10 +2396,30 @@ def tasks_rollingsuri(request, window=60): {"suricata.alerts": 1, "info.id": 1}, ) ) + + # Tenant isolation: this is an aggregate feed across ALL recent analyses, so + # it must drop alerts (and task ids) for tasks the caller may not see — the + # task_id coverage gate can't catch this endpoint (no task_id in its route). + # When multitenancy is disabled, viewer.is_local_admin short-circuits to + # see-all, so this is a no-op and behavior is unchanged. + viewer = viewer_for(request.user) + _seen = {} + + def _can_see(tid): + if viewer.is_local_admin: + return True + if tid not in _seen: + t = db.view_task(tid) + _seen[tid] = bool(t) and can_view_task(request.user, t) + return _seen[tid] + resp = [] for e in result: + tid = e["info"]["id"] + if not _can_see(tid): + continue for alert in e["suricata"]["alerts"]: - alert["id"] = e["info"]["id"] + alert["id"] = tid resp.append(alert) return Response(resp) @@ -2262,6 +2432,9 @@ def tasks_procmemory(request, task_id, pid="all"): resp = {"error": True, "error_value": "Process memory download API is disabled"} return Response(resp) + _denied = _deny_if_hidden(request, db.view_task(task_id)) + if _denied is not None: + return _denied check = validate_task(task_id) if check["error"]: return Response(check) @@ -2341,6 +2514,9 @@ def tasks_fullmemory(request, task_id): resp = {"error": True, "error_value": "Full memory download API is disabled"} return Response(resp) + _denied = _deny_if_hidden(request, db.view_task(task_id)) + if _denied is not None: + return _denied check = validate_task(task_id) if check["error"]: return Response(check) @@ -2383,6 +2559,18 @@ def file(request, stype, value): resp = {"error": True, "error_value": "Sample download API is disabled"} return Response(resp) + if stype in ("md5", "sha1", "sha256"): + _denied = _deny_by_hash(request, **{stype: value}) + else: # stype == "task" — value is a string from the URL; coerce so the + # int task_id column comparison doesn't error on PostgreSQL. + try: + value = int(value) + except (ValueError, TypeError): + return Response({"error": True, "error_value": "Invalid task ID"}, status=400) + _denied = _deny_task(request, value) + if _denied is not None: + return _denied + # This Func is not Synced with views.py "def file()" file_hash = False @@ -2512,7 +2700,7 @@ def cuckoo_status(request): resp["error_value"] = "Cuckoo Status API is disabled" else: resp["error"] = [] - tasks_dict_with_counts = db.get_tasks_status_count() + tasks_dict_with_counts = db.get_tasks_status_count(visible_to=viewer_for(request.user)) total_sum = 0 if isinstance(tasks_dict_with_counts, dict): total_sum = sum(tasks_dict_with_counts.values()) @@ -2558,18 +2746,26 @@ def cuckoo_status(request): @api_view(["GET"]) def task_x_hours(request): session = db.Session() - res = ( - session.query(Task) - .filter(Task.added_on.between(datetime.datetime.now(), datetime.datetime.now() - datetime.timedelta(days=1))) - .all() - ) - results = {} - if res: - for date, samples in res: - results.setdefault(date.strftime("%Y-%m-%eT%H:%M:00"), samples) - session.close() - resp = {"error": False, "stats": results} - return Response(resp) + try: + # Query the bounded last-24h window FIRST (a small set), then filter by + # visibility in Python — avoids loading the whole visible set into memory + # (OOM). Tenant isolation via can_view_task is a no-op when multitenancy + # is disabled / break-glass. (Also fixes the pre-existing reversed + # between() args, which made this always return empty.) + tasks = ( + session.query(Task) + .filter(Task.added_on.between(datetime.datetime.now() - datetime.timedelta(days=1), datetime.datetime.now())) + .all() + ) + results = {} + for t in tasks: + if not can_view_task(request.user, t): + continue + bucket = t.added_on.strftime("%Y-%m-%eT%H:%M:00") + results[bucket] = results.get(bucket, 0) + 1 + finally: + session.close() + return Response({"error": False, "stats": results}) @csrf_exempt @@ -2578,7 +2774,7 @@ def tasks_latest(request, hours): resp = {} resp["error"] = [] timestamp = datetime.now() - timedelta(hours=int(hours)) - ids = db.list_tasks(completed_after=timestamp) + ids = db.list_tasks(completed_after=timestamp, visible_to=viewer_for(request.user)) resp["ids"] = [id.to_dict() for id in ids] return Response(resp) @@ -2590,6 +2786,9 @@ def tasks_payloadfiles(request, task_id): resp = {"error": True, "error_value": "CAPE payload file download API is disabled"} return Response(resp) + _denied = _deny_if_hidden(request, db.view_task(task_id)) + if _denied is not None: + return _denied check = validate_task(task_id) if check["error"]: return Response(check) @@ -2629,6 +2828,9 @@ def tasks_procdumpfiles(request, task_id): resp = {"error": True, "error_value": "Procdump file download API is disabled"} return Response(resp) + _denied = _deny_if_hidden(request, db.view_task(task_id)) + if _denied is not None: + return _denied check = validate_task(task_id) if check["error"]: return Response(check) @@ -2667,6 +2869,9 @@ def tasks_config(request, task_id, cape_name=False): if not apiconf.capeconfig.get("enabled"): resp = {"error": True, "error_value": "Config download API is disabled"} return Response(resp) + _denied = _deny_if_hidden(request, db.view_task(task_id)) + if _denied is not None: + return _denied check = validate_task(task_id) if check["error"]: @@ -2743,8 +2948,19 @@ def post_processing(request, category, task_id): def statistics_data(requests, days): resp = {} if days.isdigit(): - details = statistics(int(days)) - resp = {"Error": False, "data": details} + from dashboard.views import entitled_scopes + + v = viewer_for(requests.user) + scopes = entitled_scopes(requests.user) + # Back-compat: when only the global panel applies (MT disabled / shared / + # break-glass) return the legacy FLAT stats dict so existing API clients + # reading resp["data"]["signatures"] keep working. The per-scope dict is + # only used in locked mode where there are multiple entitled scopes. + if scopes == ["global"]: + data = statistics(int(days)) + else: + data = {scope: statistics(int(days), scope=scope, viewer=v) for scope in scopes} + resp = {"Error": False, "data": data} else: resp = {"Error": True, "error_value": "Provide days as number"} return Response(resp) @@ -2758,6 +2974,10 @@ def tasks_delete_many(request): task_id = int(task_id) task = db.view_task(task_id) if task: + if not can_manage_task(request.user, task): + # hidden == missing: no cross-tenant enumeration, no unauthorized delete + response.setdefault(task_id, "not exists") + continue if task.status == TASK_RUNNING: response.setdefault(task_id, "running") continue @@ -2792,6 +3012,10 @@ def tasks_download_services(request): if not hashes: return Response({"error": True, "error_value": "hashes value is empty"}) resp["error"] = [] + try: + _tenant_id, _visibility = submission_scope(request) + except ValueError: + return Response({"error": True, "error_value": "invalid visibility"}) # Parse potential POST options (see submission/views.py) options = request.POST.get("options", "") custom = request.POST.get("custom", "") @@ -2839,12 +3063,17 @@ def tasks_download_services(request): "options": options, "only_extraction": False, "service": "", + "tenant_id": _tenant_id, + "visibility": _visibility, "user_id": request.user.id or 0, } if opt_apikey: details["apikey"] = opt_apikey + # viewer gates the local-cache reuse inside download_from_3rdparty (no + # cross-tenant sample-bytes via a "Local" cache hit). No-op when MT disabled. + details["viewer"] = viewer_for(request.user) details = download_from_3rdparty(hashes, opt_filename, details) if isinstance(details.get("task_ids"), list): tasks_count = len(details["task_ids"]) @@ -2892,9 +3121,9 @@ def _stream_iterator(fp, guest_name, chunk_size=1024): return Response(resp) resp = {} task = db.view_task(task_id) - if not task: - resp = {"error": True, "error_value": "Task does not exist"} - return Response(resp) + _denied = _deny_if_hidden(request, task) + if _denied is not None: + return _denied machine = db.view_machine(task.guest.name) if machine.status != "running": resp = {"error": True, "error_value": "Machine is not running", "errors": machine.status} diff --git a/web/guac/consumers.py b/web/guac/consumers.py index d62175388bb..5167278da81 100644 --- a/web/guac/consumers.py +++ b/web/guac/consumers.py @@ -181,6 +181,21 @@ async def connect(self): await self.close() return + # 3b. Defense-in-depth: if the socket carries an authenticated user, confirm they + # may view this task. The token is mint-gated (guac index / submission status require + # can_view_task), but a leaked/shared cookie must not tunnel into another tenant's VM. + ws_user = self.scope.get("user") + if ws_user is not None and getattr(ws_user, "is_authenticated", False): + from web.tenancy_optional import can_view_task + + if not await sync_to_async(can_view_task)(ws_user, task): + logger.warning( + "WebSocket rejected: user not entitled to task %s", self.guac_task_id + ) + await self._delete_guac_session() + await self.close() + return + # 4. Central mode: a broker-dispatched job's VM lives on a worker, so # resolve that worker's libvirt DSN + IP and look up the VNC port from ITS # libvirt; the tunnel then targets the worker's guacd. None => single-node. diff --git a/web/guac/views.py b/web/guac/views.py index 48fff9ca415..c89c8272823 100644 --- a/web/guac/views.py +++ b/web/guac/views.py @@ -11,6 +11,7 @@ from lib.cuckoo.common.config import Config from lib.cuckoo.core.database import Database +from web.tenancy_optional import can_view_task logger = logging.getLogger("guac-session") @@ -53,6 +54,12 @@ def _error(request, task_id, msg): @conditional_login_required(login_required, settings.WEB_AUTHENTICATION) def index(request, task_id, session_data): + # tenant isolation: only mint a live-VM session token for a task the caller + # may view (hidden == "not found" — no cross-tenant enumeration). + _task = db.view_task(int(task_id)) + if _task is None or not can_view_task(request.user, _task): + return _error(request, task_id, "No analysis found with specified ID") + if not LIBVIRT_AVAILABLE: return _error(request, task_id, "Libvirt not available") From a622bb615ff8218e2ae3d244803d111d7c33fd16 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Mon, 13 Jul 2026 10:42:49 -0500 Subject: [PATCH 004/183] mt-upstream: ruff-clean the MT test files (E401/E702/F401/F841) --- tests/test_scope_stats.py | 1 + tests/test_task_visibility.py | 12 ++++++++---- web/analysis/test_visibility.py | 10 ++++++++-- web/apiv2/test_visibility.py | 19 +++++++++++++------ web/dashboard/test_dashboard_scope.py | 8 ++++++-- 5 files changed, 36 insertions(+), 14 deletions(-) diff --git a/tests/test_scope_stats.py b/tests/test_scope_stats.py index 0e69fafe9ba..16f69cf0eaa 100644 --- a/tests/test_scope_stats.py +++ b/tests/test_scope_stats.py @@ -48,6 +48,7 @@ def counting_agg(coll, cmd): # Second scoped call: scoped path must not have populated the cache, so aggregation runs again. result2 = wu.top_detections(date_since=False, scope_match=scope) + assert result2 != stale_data, "second scoped call returned stale cached data from the shared cache" assert call_count["n"] == 2, "expected aggregation to run again (scoped calls must not populate cache)" # The shared cache must still hold the original sentinel (scoped calls never overwrite it). diff --git a/tests/test_task_visibility.py b/tests/test_task_visibility.py index dd4a42b3dc7..5e3dd86f5da 100644 --- a/tests/test_task_visibility.py +++ b/tests/test_task_visibility.py @@ -99,10 +99,15 @@ def test_count_tasks_scope(db): from lib.cuckoo.common.tenancy import Viewer def mk(owner, tenant, vis): - t = _mk_task(); t.user_id, t.tenant_id, t.visibility = owner, tenant, vis - db.session.add(t); db.session.commit() + t = _mk_task() + t.user_id, t.tenant_id, t.visibility = owner, tenant, vis + db.session.add(t) + db.session.commit() - mk(1, 10, "public"); mk(1, 10, "tenant"); mk(2, 10, "private"); mk(3, 20, "public") + mk(1, 10, "public") + mk(1, 10, "tenant") + mk(2, 10, "private") + mk(3, 20, "public") v = Viewer(user_id=2, tenant_id=10) assert db.count_tasks(scope="public", viewer=v) == 2 # the two public ones assert db.count_tasks(scope="tenant", viewer=v) == 1 # tenant-vis in tenant 10 @@ -257,7 +262,6 @@ def test_check_file_uniq_scoped_even_with_hours_zero(db): ALL hours values — incl. hours=0 (all-time) — else it's a cross-tenant existence oracle. A tenant-B-only private hash must read 'not duplicate' for a tenant-A viewer; break-glass still sees it.""" - from lib.cuckoo.core.data.task import Task from lib.cuckoo.core.data.samples import Sample from lib.cuckoo.common.tenancy import Viewer diff --git a/web/analysis/test_visibility.py b/web/analysis/test_visibility.py index b707f8f5dcb..13f5e541a0f 100644 --- a/web/analysis/test_visibility.py +++ b/web/analysis/test_visibility.py @@ -103,10 +103,16 @@ def test_file_search_all_files_drops_cross_tenant_paths(cape_db, mt_enabled, mon import analysis.views as av class OwnTask: # task 2 — visible to the requester (public) - id = 2; user_id = 0; tenant_id = 10; visibility = "public" + id = 2 + user_id = 0 + tenant_id = 10 + visibility = "public" class ForeignTask: # task 3 — another tenant's private analysis - id = 3; user_id = 999; tenant_id = 20; visibility = "private" + id = 3 + user_id = 999 + tenant_id = 20 + visibility = "private" monkeypatch.setattr(av, "perform_search", lambda *a, **k: [{"info": {"id": 2}}, {"info": {"id": 3}}]) # yara_detected yields (kind, filepath, block, fileobj) — one own, one foreign diff --git a/web/apiv2/test_visibility.py b/web/apiv2/test_visibility.py index 8f4db893d38..2acc9d7151f 100644 --- a/web/apiv2/test_visibility.py +++ b/web/apiv2/test_visibility.py @@ -65,9 +65,14 @@ def _func_source(views_module, name): def _all_task_views(): - import apiv2.views, apiv2.urls, analysis.views, analysis.urls - import compare.views, compare.urls - import guac.views, guac.urls + import apiv2.views + import apiv2.urls + import analysis.views + import analysis.urls + import compare.views + import compare.urls + import guac.views + import guac.urls from web import urls as web_urls # (urls module, views module the matched names resolve to, alias used there). @@ -118,7 +123,8 @@ def test_aggregate_feed_filters_by_viewer(name): must reference a visibility guard, or it leaks cross-tenant task data/ids (the routed-task_id gate cannot catch these — no task_id in the route). Scans BOTH apiv2.views and analysis.views since feeds live in either.""" - import apiv2.views, analysis.views + import apiv2.views + import analysis.views src = _func_source(apiv2.views, name) or _func_source(analysis.views, name) assert src is not None, f"{name} not found in apiv2.views or analysis.views" @@ -330,7 +336,8 @@ def _hash_routed_views(urls_module): def _all_hash_views(): - import apiv2.urls, apiv2.views + import apiv2.urls + import apiv2.views discovered = _hash_routed_views(apiv2.urls) # Explicitly pin tasks_search (it filters via visible_to= in list_tasks, # which is in GUARD_MARKERS) so it remains covered even if its URL pattern @@ -373,7 +380,7 @@ def test_hash_routed_discovery_catches_unguarded(tmp_path, monkeypatch): ' ),\n' ']\n' ) - import types, importlib.util + import importlib.util spec = importlib.util.spec_from_file_location("fake_urls", fake_urls) fake_mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(fake_mod) diff --git a/web/dashboard/test_dashboard_scope.py b/web/dashboard/test_dashboard_scope.py index b75cf662173..1991aed16ac 100644 --- a/web/dashboard/test_dashboard_scope.py +++ b/web/dashboard/test_dashboard_scope.py @@ -8,7 +8,9 @@ def test_dashboard_entitled_scopes(cape_db, mt_enabled, monkeypatch): from users.models import Tenant, UserProfile t = Tenant.objects.create(slug="acme", name="Acme") u = User.objects.create_user("a", "a@x.com", "x") - p = UserProfile.objects.get(user=u); p.tenant = t; p.save() + p = UserProfile.objects.get(user=u) + p.tenant = t + p.save() u = User.objects.get(pk=u.pk) assert entitled_scopes(u) == ["public", "tenant", "mine"] tl = User.objects.create_user("b", "b@x.com", "x") # tenant-less @@ -35,7 +37,9 @@ def test_entitled_scope_filter_builds_viewer_mongo_match(cape_db, mt_enabled): t = Tenant.objects.create(slug="acme", name="Acme") u = User.objects.create_user("sf", "sf@x.com", "x") - p = UserProfile.objects.get(user=u); p.tenant = t; p.save() + p = UserProfile.objects.get(user=u) + p.tenant = t + p.save() u = User.objects.get(pk=u.pk) f = entitled_scope_filter(u) From a278471abb10993a4869932778734b2bbd90ef47 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Mon, 13 Jul 2026 10:52:50 -0500 Subject: [PATCH 005/183] mt-upstream: make dashboard _ut import-optional (mt_absent) + add viewer_scope to coverage-gate markers --- web/apiv2/test_visibility.py | 5 +++-- web/dashboard/views.py | 9 +++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/web/apiv2/test_visibility.py b/web/apiv2/test_visibility.py index 2acc9d7151f..1f8b4cac600 100644 --- a/web/apiv2/test_visibility.py +++ b/web/apiv2/test_visibility.py @@ -13,8 +13,9 @@ "can_toggle_task", # scope-filtering primitives for aggregate / mongo surfaces (dashboard, # statistics, hunt, compare): restrict an aggregation to the viewer's - # entitled scopes instead of gating a single task_id. - "scope_match", "entitled_scope_filter", + # entitled scopes instead of gating a single task_id. viewer_scope is the + # central-mode facade over entitled_scope_filter (hunt() uses it post-#3105). + "scope_match", "entitled_scope_filter", "viewer_scope", ) # Routed task_id views that legitimately need NO per-task visibility guard. diff --git a/web/dashboard/views.py b/web/dashboard/views.py index 2c86f8428a3..fb3375c933e 100644 --- a/web/dashboard/views.py +++ b/web/dashboard/views.py @@ -15,7 +15,10 @@ from lib.cuckoo.core.database import Database from lib.cuckoo.core.data.task import TASK_COMPLETED, TASK_REPORTED -import users.tenancy as _ut +try: + import users.tenancy as _ut +except ImportError: # MT layer not deployed -> dashboard degrades to the single "global" view + _ut = None # Conditional decorator for web authentication @@ -44,6 +47,8 @@ def entitled_scopes(user): legacy behaviour. Otherwise returns the per-scope panels appropriate for the viewer's tenancy. """ + if _ut is None: # MT layer absent -> single global panel (legacy behaviour) + return ["global"] v = _ut.viewer_for(user) cfg = _ut.multitenancy_config() if not cfg.enabled or cfg.mode != "locked" or v.is_local_admin: @@ -88,7 +93,7 @@ def entitled_scope_filter(user): @conditional_login_required(login_required, settings.WEB_AUTHENTICATION) def index(request): db: TasksMixIn = Database() - v = _ut.viewer_for(request.user) + v = _ut.viewer_for(request.user) if _ut is not None else None panels = [] for scope in entitled_scopes(request.user): From 386f5d270cda34aa916cd3b84eb335120d13f826 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Mon, 13 Jul 2026 15:10:06 -0500 Subject: [PATCH 006/183] mt: fix fail-open cross-tenant leaks from adversarial review An adversarial security review of the multitenancy feature found 10 verified cross-tenant leaks, all concentrated in the mongo-side scoping (the core predicate and the SQL/per-task read gates were already fail-closed). Fixes, each written test-first; 162 MT tests pass in the real CAPE env, ruff clean. Root causes: - #1 modules/reporting/mongodb.py: stamp_tenant_info fails CLOSED to private on an unresolved task (was public); run() resolves tenant context by the LOCAL task id, not the distributed-rewritten main_task_id, which misses on a worker and stamped every distributed analysis world-visible. - #2 lib/cuckoo/common/tenancy.py + web/dashboard/views.py: drop the mode-not-locked guard from viewer_scope_match, viewer_scope_es_filter and entitled_scopes so scoping is mode-independent, mirroring can_read and the SQL list filter; shared mode (the default) no longer degrades to see-all. - #6 multitenancy_config validates and normalizes mode, failing closed to locked on an unknown/typo value. - #9 stats aggregates auto-scope via the fixed entitled_scopes (stale comment corrected in web/apiv2/views.py). Per-record backstops and the rest: - #3 web/analysis/views.py: capeyarazipall gates records by owning-task can_view_task BEFORE resolving paths, covering content-addressed storage/binaries samples the old analyses-path regex missed. - #4 web/compare/views.py: the mongo md5-pivot gets the per-record can_view_task backstop the ES branch already had. - #5 lib/cuckoo/core/data/tasking.py: config-dedup re-verifies the deduped task against the authoritative SQL row before surfacing its id, closing the cross-tenant task-id / config-exists oracle. - #7 web/guac/consumers.py: gate the WebSocket socket user unconditionally (viewer_for resolves anonymous to public-only) so a replayed guac_session token cannot tunnel into another tenant's live VM. - #8 utils/db_migration/mongo_backfill_tenant.py: orphaned docs backfill to private, not public. - #10 set_task_visibility retries the mongo sync and raises on persistent failure (only when mongo is the enabled report store) instead of swallowing it, so a stale public stamp after a private toggle is surfaced. --- lib/cuckoo/common/tenancy.py | 27 ++++--- lib/cuckoo/core/data/tasking.py | 53 +++++++++++-- modules/reporting/mongodb.py | 24 ++++-- tests/test_mongo_backfill.py | 8 +- tests/test_mongo_stamp.py | 10 ++- tests/test_task_visibility.py | 35 +++++++++ tests/test_tenancy.py | 86 +++++++++++++++++++++ utils/db_migration/mongo_backfill_tenant.py | 7 +- web/analysis/test_visibility.py | 29 ++++--- web/analysis/views.py | 29 ++++--- web/apiv2/views.py | 8 +- web/compare/views.py | 36 ++++++++- web/dashboard/test_dashboard_scope.py | 24 ++++++ web/dashboard/views.py | 6 +- web/guac/consumers.py | 30 +++---- 15 files changed, 345 insertions(+), 67 deletions(-) diff --git a/lib/cuckoo/common/tenancy.py b/lib/cuckoo/common/tenancy.py index 8e37dbe2f80..39e069c86fb 100644 --- a/lib/cuckoo/common/tenancy.py +++ b/lib/cuckoo/common/tenancy.py @@ -107,9 +107,15 @@ def multitenancy_config() -> MTConfig: except Exception: sec = {} get = sec.get if hasattr(sec, "get") else (lambda k, d=None: d) + # Validate/normalize mode: an unknown/typo value must NOT silently disable + # scoping. Case/whitespace-normalize and fail closed to the more restrictive + # "locked" on anything unrecognized. + mode = str(get("mode", "shared") or "shared").strip().lower() + if mode not in ("shared", "locked"): + mode = "locked" return MTConfig( enabled=_as_bool(get("enabled", False), False), - mode=str(get("mode", "shared") or "shared"), + mode=mode, default_visibility=str(get("default_visibility", "") or ""), local_admins_manage_all_tenants=_as_bool(get("local_admins_manage_all_tenants", True), True), ) @@ -125,15 +131,17 @@ def default_visibility(cfg: MTConfig) -> str: def viewer_scope_match(viewer): """Mongo $match restricting an analysis-collection query to the viewer's entitled tenant scopes (public OR own-tenant TENANT OR mine), or None when no - filter applies — multitenancy disabled, shared mode, or break-glass - (is_local_admin). THE single source of truth (imported by web_utils, - cape_utils, …) so the search/dedup/stats by-scope query builders can't drift. - Keys target the report's stamped info.* fields. + filter applies — multitenancy disabled or break-glass (is_local_admin). THE + single source of truth (imported by web_utils, cape_utils, …) so the + search/dedup/stats by-scope query builders can't drift. Mode-INDEPENDENT, + mirroring can_read and the SQL list_tasks filter: shared mode still hides + explicitly-private and other-tenant TENANT analyses (only PUBLIC is the shared + pool) — it does NOT mean see-all. Keys target the report's stamped info.*. """ if viewer is None: return None cfg = multitenancy_config() - if not cfg.enabled or cfg.mode != "locked" or getattr(viewer, "is_local_admin", False): + if not cfg.enabled or getattr(viewer, "is_local_admin", False): return None clauses = [m for m in (scope_match(PUBLIC, viewer), scope_match(TENANT, viewer), scope_match(MINE, viewer)) if m is not None] # No entitled scope resolved (tenant-less/anon) -> match nothing, never global. @@ -142,13 +150,14 @@ def viewer_scope_match(viewer): def viewer_scope_es_filter(viewer): """Elasticsearch bool-filter analogue of viewer_scope_match (public OR - own-tenant TENANT OR mine), or None when no filter applies. Uses the term/ - info.* idiom. A tenant-less/anonymous locked-mode viewer sees only public. + own-tenant TENANT OR mine), or None when no filter applies (multitenancy + disabled or break-glass). Mode-INDEPENDENT, same as viewer_scope_match. Uses + the term/info.* idiom. A tenant-less/anonymous viewer sees only public. """ if viewer is None: return None cfg = multitenancy_config() - if not cfg.enabled or cfg.mode != "locked" or getattr(viewer, "is_local_admin", False): + if not cfg.enabled or getattr(viewer, "is_local_admin", False): return None shoulds = [{"term": {"info.visibility": PUBLIC}}] if getattr(viewer, "tenant_id", None) is not None: diff --git a/lib/cuckoo/core/data/tasking.py b/lib/cuckoo/core/data/tasking.py index 4e6092c8723..ddcd0ac918d 100644 --- a/lib/cuckoo/core/data/tasking.py +++ b/lib/cuckoo/core/data/tasking.py @@ -43,6 +43,18 @@ except Exception: # mongo optional mongo_update_one = None + +def _mongo_reporting_enabled() -> bool: + """True when mongodb is the enabled report store, so a visibility toggle must + sync the stamped info.visibility the aggregate/search/stats surfaces read. + Isolated (module-level) for testability.""" + try: + from lib.cuckoo.common.config import Config + + return bool(Config("reporting").mongodb.enabled) + except Exception: + return False + log = logging.getLogger(__name__) conf = Config("cuckoo") distconf = Config("distributed") @@ -517,7 +529,22 @@ def demux_sample_and_add_to_db( config = static_config_lookup(file, viewer=_Viewer(user_id=user_id or None, tenant_id=tenant_id)) if config: - task_ids.append(config["id"]) + # Defense-in-depth: re-verify against the AUTHORITATIVE SQL + # task (not the mongo stamp the dedup query trusted) that + # this submitter may read the deduped analysis before + # surfacing its task id — so a mongo stamp gap can't leak + # another tenant's task id / config-exists oracle. No-op + # when MT disabled (can_read -> is_local_admin break-glass). + from lib.cuckoo.common.tenancy import can_read as _can_read, Job as _Job + + _dt = self.session.get(Task, int(config["id"])) + if _dt is not None and _can_read( + _Viewer(user_id=user_id or None, tenant_id=tenant_id), + _Job(owner_id=_dt.user_id, tenant_id=_dt.tenant_id, visibility=_dt.visibility), + ): + task_ids.append(config["id"]) + else: + config = static_extraction(file) else: config = static_extraction(file) if config or only_extraction: @@ -836,11 +863,25 @@ def set_task_visibility(self, task_id: int, visibility: str) -> Optional[Task]: return None task.visibility = visibility self.session.commit() - if mongo_update_one is not None: - try: - mongo_update_one("analysis", {"info.id": task_id}, {"$set": {"info.visibility": visibility}}) - except Exception: - log.warning("failed to sync visibility to mongo for task %s", task_id) + # Sync the toggle to the mongo report so the aggregate/search/stats surfaces + # (which read the stamped info.visibility) reflect it — but only when mongo + # is the enabled report store. Retry transient blips; surface a persistent + # failure (raise) rather than silently diverging into a stale public stamp + # after a private toggle. SQL stays authoritative regardless. + if mongo_update_one is not None and _mongo_reporting_enabled(): + _synced = False + for _attempt in range(3): + try: + mongo_update_one("analysis", {"info.id": task_id}, {"$set": {"info.visibility": visibility}}) + _synced = True + break + except Exception as _e: + log.warning("visibility mongo sync attempt %d/3 failed for task %s: %s", _attempt + 1, task_id, _e) + if not _synced: + from lib.cuckoo.common.exceptions import CuckooOperationalError + + log.error("visibility mongo sync FAILED for task %s after retries (SQL=%s, mongo stale)", task_id, visibility) + raise CuckooOperationalError(f"task {task_id} visibility set in DB but mongo sync failed") return task def fetch_task(self, categories: list = None): diff --git a/modules/reporting/mongodb.py b/modules/reporting/mongodb.py index 9aa8d649505..ef153e1931b 100644 --- a/modules/reporting/mongodb.py +++ b/modules/reporting/mongodb.py @@ -29,15 +29,20 @@ def stamp_tenant_info(info: dict, task) -> None: """Write tenant_id/user_id/visibility into the report's info subdict so mongo - aggregations can be scoped. Missing task (deleted) -> public/legacy.""" + aggregations can be scoped. An unresolved task (deleted/orphan, transient DB + error, or a distributed main_task_id lookup miss) fails CLOSED to private with + no owner/tenant, so the doc matches no cross-tenant scope (public/tenant/mine) + and stays invisible to everyone but break-glass — never world-visible.""" if task is None: - info.setdefault("tenant_id", None) - info.setdefault("user_id", None) - info["visibility"] = "public" + info["tenant_id"] = None + info["user_id"] = None + info["visibility"] = "private" return info["tenant_id"] = getattr(task, "tenant_id", None) info["user_id"] = getattr(task, "user_id", None) - info["visibility"] = getattr(task, "visibility", "public") or "public" + # Fail closed on a null/blank visibility too (shouldn't happen — the column + # has a private server_default — but never default a real task to public). + info["visibility"] = getattr(task, "visibility", "private") or "private" def _task_tenant_ctx(task_id): @@ -195,9 +200,14 @@ def run(self, results): if multitenancy_config().enabled: try: - stamp_tenant_info(report["info"], _task_tenant_ctx(int(report["info"]["id"]))) + # Look up tenant context by the LOCAL task id. report["info"]["id"] + # may have been rewritten to the main node's main_task_id above + # (distributed path); that id does not exist in this worker's DB and + # would resolve to None -> fail-open public stamp. The local task + # row carries the authoritative tenant/user/visibility. + stamp_tenant_info(report["info"], _task_tenant_ctx(local_task_id)) except Exception as _db_err: - log.warning("Failed to look up task for tenant stamping (task %s): %s", report["info"].get("id"), _db_err) + log.warning("Failed to look up task for tenant stamping (task %s): %s", local_task_id, _db_err) stamp_tenant_info(report["info"], None) # Delete old data just before inserting new one to avoid "missing report" window diff --git a/tests/test_mongo_backfill.py b/tests/test_mongo_backfill.py index d17c27be776..b433b75e506 100644 --- a/tests/test_mongo_backfill.py +++ b/tests/test_mongo_backfill.py @@ -8,8 +8,12 @@ class T: assert update == {"info.tenant_id": 10, "info.user_id": 5, "info.visibility": "tenant"} -def test_backfill_orphan_defaults_public(): +def test_backfill_orphan_fails_closed_private(): + """Finding #8: an orphaned doc (Postgres task pruned, mongo doc retained) must + NOT be backfilled world-visible. Fail closed to private so it stays invisible + to every tenant — previously defaulted 'public', flipping previously-invisible + orphans to globally cross-tenant-readable when MT was first enabled.""" from utils.db_migration.mongo_backfill_tenant import backfill_doc doc = {"info": {"id": 9}} update = backfill_doc(doc, lambda tid: None) - assert update == {"info.tenant_id": None, "info.user_id": None, "info.visibility": "public"} + assert update == {"info.tenant_id": None, "info.user_id": None, "info.visibility": "private"} diff --git a/tests/test_mongo_stamp.py b/tests/test_mongo_stamp.py index 6269aa75368..e5b3159b604 100644 --- a/tests/test_mongo_stamp.py +++ b/tests/test_mongo_stamp.py @@ -9,9 +9,15 @@ class T: # stand-in for a Task row assert info["tenant_id"] == 10 and info["user_id"] == 7 and info["visibility"] == "tenant" -def test_stamp_missing_task_defaults_public(): +def test_stamp_missing_task_fails_closed_private(): + """Finding #1: an unresolved task (deleted/orphan, transient DB error, or the + distributed main_task_id lookup miss on a worker) must NOT be stamped + world-visible. Fail CLOSED to private with no owner/tenant so it matches no + cross-tenant scope (public/tenant/mine) and stays invisible to everyone but + break-glass. Previously defaulted to 'public' (fail-open leak).""" from modules.reporting.mongodb import stamp_tenant_info info = {"id": 42} stamp_tenant_info(info, None) - assert info["visibility"] == "public" and info["tenant_id"] is None and info["user_id"] is None + assert info["visibility"] == "private" + assert info["tenant_id"] is None and info["user_id"] is None diff --git a/tests/test_task_visibility.py b/tests/test_task_visibility.py index 5e3dd86f5da..a2ee0c26275 100644 --- a/tests/test_task_visibility.py +++ b/tests/test_task_visibility.py @@ -181,6 +181,8 @@ def mk(owner, tenant, vis): def test_set_task_visibility_syncs_mongo(db, monkeypatch): calls = [] import lib.cuckoo.core.data.tasking as tk + # Sync only runs when mongo is the enabled report store; force it on for the test. + monkeypatch.setattr(tk, "_mongo_reporting_enabled", lambda: True, raising=False) monkeypatch.setattr(tk, "mongo_update_one", lambda *a, **k: calls.append((a, k)), raising=False) tid = db.add_url("http://example.com", tenant_id=10, visibility="tenant") @@ -188,6 +190,39 @@ def test_set_task_visibility_syncs_mongo(db, monkeypatch): assert calls and calls[-1][0][0] == "analysis" # updated the analysis collection +def test_set_task_visibility_raises_on_persistent_mongo_failure(db, monkeypatch): + """Finding #10: a persistent mongo-sync failure must be surfaced (raised), not + swallowed — else a stale public stamp after a private toggle silently keeps the + analysis cross-tenant visible in the aggregate/search/stats surfaces.""" + import lib.cuckoo.core.data.tasking as tk + from lib.cuckoo.common.exceptions import CuckooOperationalError + + monkeypatch.setattr(tk, "_mongo_reporting_enabled", lambda: True, raising=False) + + def _boom(*a, **k): + raise RuntimeError("mongo down") + + monkeypatch.setattr(tk, "mongo_update_one", _boom, raising=False) + tid = db.add_url("http://example.com", tenant_id=10, visibility="tenant") + with pytest.raises(CuckooOperationalError): + db.set_task_visibility(tid, "public") + + +def test_set_task_visibility_skips_sync_when_mongo_disabled(db, monkeypatch): + """When mongo is NOT the report store, the toggle must succeed without any sync + attempt (so an ES/no-mongo install isn't broken by the sync path).""" + import lib.cuckoo.core.data.tasking as tk + + monkeypatch.setattr(tk, "_mongo_reporting_enabled", lambda: False, raising=False) + + def _boom(*a, **k): + raise AssertionError("mongo_update_one must not be called when mongo is disabled") + + monkeypatch.setattr(tk, "mongo_update_one", _boom, raising=False) + tid = db.add_url("http://example.com", tenant_id=10, visibility="tenant") + assert db.set_task_visibility(tid, "public") is not None + + @pytest.mark.usefixtures("tmp_cuckoo_root") def test_reschedule_propagates_tenant_visibility(db): """reschedule()/recovery must carry the source task's owner/tenant/visibility diff --git a/tests/test_tenancy.py b/tests/test_tenancy.py index f1bf55b814f..d4ee35d57a3 100644 --- a/tests/test_tenancy.py +++ b/tests/test_tenancy.py @@ -72,3 +72,89 @@ def test_scope_match_none_viewer(): assert scope_match("mine", None) == {"info.id": -1} assert scope_match("public", None) == {"info.visibility": "public"} assert scope_match("global", None) is None + + +# ── Adversarial-review regressions (2026-07-13): mongo-side fail-open ── + +def _mtcfg(mode, enabled=True): + from lib.cuckoo.common import tenancy + return tenancy.MTConfig(enabled=enabled, mode=mode, default_visibility="", + local_admins_manage_all_tenants=True) + + +def test_viewer_scope_match_scopes_in_shared_mode(monkeypatch): + """Finding #2: shared mode (the DEFAULT) must still restrict mongo aggregates + to public OR own-tenant TENANT OR mine. A private/other-tenant analysis must + not leak via search/compare/stats/hunt. Previously shared mode returned None + (see-all) while can_read enforced private in all modes.""" + from lib.cuckoo.common import tenancy + monkeypatch.setattr(tenancy, "multitenancy_config", lambda: _mtcfg("shared")) + m = tenancy.viewer_scope_match(tenancy.Viewer(user_id=7, tenant_id=10)) + assert m is not None, "shared mode must scope, not return see-all None" + clauses = m["$or"] + assert {"info.visibility": "public"} in clauses + assert {"info.tenant_id": 10, "info.visibility": "tenant"} in clauses + assert {"info.user_id": 7} in clauses + + +def test_viewer_scope_es_filter_scopes_in_shared_mode(monkeypatch): + """Finding #2 (ES analogue): shared-mode ES filter must scope, not be None.""" + from lib.cuckoo.common import tenancy + monkeypatch.setattr(tenancy, "multitenancy_config", lambda: _mtcfg("shared")) + f = tenancy.viewer_scope_es_filter(tenancy.Viewer(user_id=7, tenant_id=10)) + assert f is not None, "shared-mode ES filter must scope, not None" + assert f["bool"]["minimum_should_match"] == 1 + + +def test_viewer_scope_still_scopes_in_locked_mode(monkeypatch): + """Locked mode keeps scoping (no regression).""" + from lib.cuckoo.common import tenancy + monkeypatch.setattr(tenancy, "multitenancy_config", lambda: _mtcfg("locked")) + assert tenancy.viewer_scope_match(tenancy.Viewer(user_id=7, tenant_id=10)) is not None + + +def test_viewer_scope_none_when_disabled(monkeypatch): + """MT disabled -> no filter (legacy see-all), unchanged.""" + from lib.cuckoo.common import tenancy + monkeypatch.setattr(tenancy, "multitenancy_config", lambda: _mtcfg("shared", enabled=False)) + assert tenancy.viewer_scope_match(tenancy.Viewer(user_id=7, tenant_id=10)) is None + + +def test_local_admin_breakglass_still_sees_all(monkeypatch): + """Break-glass local admin keeps global visibility in any enabled mode.""" + from lib.cuckoo.common import tenancy + monkeypatch.setattr(tenancy, "multitenancy_config", lambda: _mtcfg("shared")) + v = tenancy.Viewer(user_id=1, tenant_id=None, is_superuser=True, is_local_admin=True) + assert tenancy.viewer_scope_match(v) is None + assert tenancy.viewer_scope_es_filter(v) is None + + +def _patch_conf(monkeypatch, mode): + from lib.cuckoo.common import config as _cfgmod + + class _FakeConf: + def __init__(self, name): + pass + + def get(self, section): + return {"enabled": True, "mode": mode, "default_visibility": "", + "local_admins_manage_all_tenants": True} + + monkeypatch.setattr(_cfgmod, "Config", _FakeConf) + + +def test_unknown_mode_fails_closed_to_locked(monkeypatch): + """Finding #6: an invalid/typo mode must not silently disable scoping; it must + fail closed to locked (the more restrictive mode).""" + from lib.cuckoo.common import tenancy + _patch_conf(monkeypatch, "bogusmode") + assert tenancy.multitenancy_config().mode == "locked" + + +def test_known_modes_normalized(monkeypatch): + """Finding #6: valid modes preserved, case/whitespace-normalized.""" + from lib.cuckoo.common import tenancy + for raw, want in (("shared", "shared"), ("locked", "locked"), + ("LOCKED", "locked"), (" Shared ", "shared")): + _patch_conf(monkeypatch, raw) + assert tenancy.multitenancy_config().mode == want, f"mode {raw!r}" diff --git a/utils/db_migration/mongo_backfill_tenant.py b/utils/db_migration/mongo_backfill_tenant.py index dac58bc044b..7645db9d2fa 100644 --- a/utils/db_migration/mongo_backfill_tenant.py +++ b/utils/db_migration/mongo_backfill_tenant.py @@ -7,11 +7,14 @@ def backfill_doc(doc, view_task) -> dict: task = view_task(int(doc.get("info", {}).get("id", 0))) if task is None: - return {"info.tenant_id": None, "info.user_id": None, "info.visibility": "public"} + # Orphan: the Postgres task was pruned but the mongo doc remains. Fail + # CLOSED to private (no owner/tenant) so it matches no cross-tenant scope + # and stays invisible to everyone but break-glass — never world-visible. + return {"info.tenant_id": None, "info.user_id": None, "info.visibility": "private"} return { "info.tenant_id": getattr(task, "tenant_id", None), "info.user_id": getattr(task, "user_id", None), - "info.visibility": getattr(task, "visibility", "public") or "public", + "info.visibility": getattr(task, "visibility", "private") or "private", } diff --git a/web/analysis/test_visibility.py b/web/analysis/test_visibility.py index 13f5e541a0f..1b5581d1170 100644 --- a/web/analysis/test_visibility.py +++ b/web/analysis/test_visibility.py @@ -96,8 +96,10 @@ def test_tag_tasks_skips_unmanageable_cross_tenant(cape_db, mt_enabled, client): def test_file_search_all_files_drops_cross_tenant_paths(cape_db, mt_enabled, monkeypatch): """CRITICAL leak regression (capeyarazipall): _file_search_all_files must NOT return artifact paths belonging to analyses the requester can't read — else - file() streams another tenant's dropped/payload/sample bytes. The per-path - owning-task gate drops paths under storage/analyses//.""" + file() streams another tenant's dropped/payload/sample bytes. The gate now + filters RECORDS by owning-task can_view_task BEFORE resolving paths, so it + covers ALL path shapes — including content-addressed storage/binaries/ + samples that have no /analyses// segment (the shape the old regex missed).""" from django.test import RequestFactory from django.contrib.auth.models import User import analysis.views as av @@ -115,11 +117,20 @@ class ForeignTask: # task 3 — another tenant's private analysis visibility = "private" monkeypatch.setattr(av, "perform_search", lambda *a, **k: [{"info": {"id": 2}}, {"info": {"id": 3}}]) - # yara_detected yields (kind, filepath, block, fileobj) — one own, one foreign - monkeypatch.setattr(av, "yara_detected", lambda term, recs: [ - ("dropped", "/opt/CAPEv2/storage/analyses/2/files/own.bin", {}, {}), - ("dropped", "/opt/CAPEv2/storage/analyses/3/files/secret.bin", {}, {}), - ]) + + # Real yara_detected yields file paths for the records it is GIVEN; the gate + # filters records before this call, so model that contract. The foreign task's + # artifact is a content-addressed sample path (no /analyses// segment). + def _fake_yara(term, recs): + ids = {r["info"]["id"] for r in recs} + out = [] + if 2 in ids: + out.append(("dropped", "/opt/CAPEv2/storage/analyses/2/files/own.bin", {}, {})) + if 3 in ids: + out.append(("target", "/opt/CAPEv2/storage/binaries/deadbeefdeadbeef", {}, {})) + return out + + monkeypatch.setattr(av, "yara_detected", _fake_yara) monkeypatch.setattr(av, "path_exists", lambda p: True) monkeypatch.setattr(av.db, "view_task", lambda tid: OwnTask() if int(tid) == 2 else ForeignTask()) @@ -127,5 +138,5 @@ class ForeignTask: # task 3 — another tenant's private analysis req.user = User.objects.create_user("fs", "fs@x.com", "x") # tenant-less, non-admin paths = av._file_search_all_files("capeyara", "Emotet", req) - assert "/opt/CAPEv2/storage/analyses/2/files/own.bin" in paths # readable kept - assert "/opt/CAPEv2/storage/analyses/3/files/secret.bin" not in paths # foreign dropped + assert "/opt/CAPEv2/storage/analyses/2/files/own.bin" in paths # readable kept + assert "/opt/CAPEv2/storage/binaries/deadbeefdeadbeef" not in paths # foreign content-addressed sample dropped diff --git a/web/analysis/views.py b/web/analysis/views.py index 428d9ecd2c0..d90e0c8705a 100644 --- a/web/analysis/views.py +++ b/web/analysis/views.py @@ -3321,19 +3321,28 @@ def _file_search_all_files(search_category: str, search_term: str, request) -> l # this, capeyarazipall streams other tenants' artifact bytes for any file # matching the supplied YARA rule name. records = perform_search(search_category, search_term, projection=projection, viewer=viewer_for(request.user)) + # Defense-in-depth: keep only records whose owning analysis the requester + # may read, BEFORE resolving file paths. The query scope above is the + # primary gate, but a content-addressed artifact path + # (storage/binaries/) has no /analyses// segment, so a + # path-regex backstop misses it and would stream another tenant's private + # sample bytes. Gate on info.id (in the projection) for ALL path shapes; + # no-op when MT disabled (can_view_task -> is_local_admin). + _viewable = [] + for _rec in records: + _rid = (_rec.get("info") or {}).get("id") + if _rid is None: + continue + try: + _vt = db.view_task(int(_rid)) + except (ValueError, TypeError): + continue + if _vt is not None and can_view_task(request.user, _vt): + _viewable.append(_rec) search_term = search_term.lower() - for _, filepath, _, _ in yara_detected(search_term, records): + for _, filepath, _, _ in yara_detected(search_term, _viewable): if not path_exists(filepath): continue - # Defense-in-depth: drop any artifact whose owning analysis the - # requester may not read. The query scope above is the primary gate; - # this guards paths under storage/analyses// regardless of - # backend (no-op when MT disabled — can_view_task -> is_local_admin). - _m = re.search(r"/analyses/(\d+)/", filepath) - if _m: - _vt = db.view_task(int(_m.group(1))) - if _vt is None or not can_view_task(request.user, _vt): - continue path.append(filepath) except ValueError as e: print("mongodb load", e) diff --git a/web/apiv2/views.py b/web/apiv2/views.py index c70a3f94d4c..1ffe16085bb 100644 --- a/web/apiv2/views.py +++ b/web/apiv2/views.py @@ -2952,10 +2952,10 @@ def statistics_data(requests, days): v = viewer_for(requests.user) scopes = entitled_scopes(requests.user) - # Back-compat: when only the global panel applies (MT disabled / shared / - # break-glass) return the legacy FLAT stats dict so existing API clients - # reading resp["data"]["signatures"] keep working. The per-scope dict is - # only used in locked mode where there are multiple entitled scopes. + # Back-compat: when only the global panel applies (MT disabled or + # break-glass local-admin) return the legacy FLAT stats dict so existing + # API clients reading resp["data"]["signatures"] keep working. Shared and + # locked modes both yield scoped panels, so they take the per-scope dict. if scopes == ["global"]: data = statistics(int(days)) else: diff --git a/web/compare/views.py b/web/compare/views.py index 16824f004ee..4a486785db1 100644 --- a/web/compare/views.py +++ b/web/compare/views.py @@ -79,7 +79,23 @@ def left(request, left_id): if _scope: _and.append(_scope) if enabledconf["mongodb"]: - records = mongo_find("analysis", {"$and": _and}, {"target": 1, "info": 1}) + _raw = mongo_find("analysis", {"$and": _and}, {"target": 1, "info": 1}) + # Defense-in-depth: post-filter each md5-pivot hit through can_view_task + # (SQL-authoritative), symmetric with the ES branch below, so a mongo stamp + # gap can't leak another tenant's analysis even if the query-layer scope + # regresses. No-op for break-glass / shared / multitenancy disabled. + _db = Database() + records = [] + for _rec in _raw: + _rid = (_rec.get("info") or {}).get("id") + if _rid is None: + continue + try: + _vt = _db.view_task(int(_rid)) + except (ValueError, TypeError): + continue + if _vt is not None and can_view_task(request.user, _vt): + records.append(_rec) if es_as_db: records = [] q = { @@ -139,7 +155,23 @@ def hash(request, left_id, right_hash): if _scope: _and.append(_scope) if enabledconf["mongodb"]: - records = mongo_find("analysis", {"$and": _and}, {"target": 1, "info": 1}) + _raw = mongo_find("analysis", {"$and": _and}, {"target": 1, "info": 1}) + # Defense-in-depth: post-filter each md5-pivot hit through can_view_task + # (SQL-authoritative), symmetric with the ES branch below, so a mongo stamp + # gap can't leak another tenant's analysis even if the query-layer scope + # regresses. No-op for break-glass / shared / multitenancy disabled. + _db = Database() + records = [] + for _rec in _raw: + _rid = (_rec.get("info") or {}).get("id") + if _rid is None: + continue + try: + _vt = _db.view_task(int(_rid)) + except (ValueError, TypeError): + continue + if _vt is not None and can_view_task(request.user, _vt): + records.append(_rec) if es_as_db: records = [] q = { diff --git a/web/dashboard/test_dashboard_scope.py b/web/dashboard/test_dashboard_scope.py index 1991aed16ac..d20280513b8 100644 --- a/web/dashboard/test_dashboard_scope.py +++ b/web/dashboard/test_dashboard_scope.py @@ -17,6 +17,30 @@ def test_dashboard_entitled_scopes(cape_db, mt_enabled, monkeypatch): assert entitled_scopes(tl) == ["public", "mine"] +@pytest.mark.django_db +def test_dashboard_entitled_scopes_shared_mode(cape_db, monkeypatch): + """Finding #2: shared mode (the DEFAULT) must still return the scoped panels + (public/tenant/mine), NOT the single see-all 'global' panel. Previously shared + mode returned ['global'], leaking other tenants' private analyses into the + dashboard/statistics aggregates while can_read enforced private in all modes.""" + from lib.cuckoo.common.tenancy import MTConfig + import users.tenancy as ut + monkeypatch.setattr( + ut, "multitenancy_config", + lambda: MTConfig(enabled=True, mode="shared", default_visibility="", + local_admins_manage_all_tenants=True), + ) + from dashboard.views import entitled_scopes + from users.models import Tenant, UserProfile + t = Tenant.objects.create(slug="acme-shared", name="AcmeShared") + u = User.objects.create_user("ash", "ash@x.com", "x") + p = UserProfile.objects.get(user=u) + p.tenant = t + p.save() + u = User.objects.get(pk=u.pk) + assert entitled_scopes(u) == ["public", "tenant", "mine"] + + @pytest.mark.django_db def test_disabled_shows_single_global_scope(): """Back-compat: with multitenancy disabled (the default / basic public install), diff --git a/web/dashboard/views.py b/web/dashboard/views.py index fb3375c933e..63d4b755d41 100644 --- a/web/dashboard/views.py +++ b/web/dashboard/views.py @@ -51,7 +51,11 @@ def entitled_scopes(user): return ["global"] v = _ut.viewer_for(user) cfg = _ut.multitenancy_config() - if not cfg.enabled or cfg.mode != "locked" or v.is_local_admin: + # Mode-INDEPENDENT, mirroring can_read / viewer_scope_match: the scoped panels + # apply in shared mode too (shared still hides other tenants' private/tenant + # analyses; only PUBLIC is the shared pool). Only a disabled feature or a + # break-glass local-admin collapses to the single see-all 'global' panel. + if not cfg.enabled or v.is_local_admin: return ["global"] scopes = ["public"] if v.tenant_id is not None: diff --git a/web/guac/consumers.py b/web/guac/consumers.py index 5167278da81..43f99445aa5 100644 --- a/web/guac/consumers.py +++ b/web/guac/consumers.py @@ -181,20 +181,24 @@ async def connect(self): await self.close() return - # 3b. Defense-in-depth: if the socket carries an authenticated user, confirm they - # may view this task. The token is mint-gated (guac index / submission status require - # can_view_task), but a leaked/shared cookie must not tunnel into another tenant's VM. - ws_user = self.scope.get("user") - if ws_user is not None and getattr(ws_user, "is_authenticated", False): - from web.tenancy_optional import can_view_task + # 3b. Defense-in-depth: confirm the socket's user may view this task. + # The token is mint-gated (guac index / submission status require + # can_view_task), but a leaked/replayed cookie must not tunnel into + # another tenant's VM. Check UNCONDITIONALLY — do NOT skip for an + # anonymous/absent socket user: viewer_for resolves anonymous to a + # public-only viewer when MT is on (and break-glass when MT is off), + # so an anonymous replay of a token for a private/tenant task is + # denied here rather than tunnelling on token possession alone. + from web.tenancy_optional import can_view_task - if not await sync_to_async(can_view_task)(ws_user, task): - logger.warning( - "WebSocket rejected: user not entitled to task %s", self.guac_task_id - ) - await self._delete_guac_session() - await self.close() - return + ws_user = self.scope.get("user") + if not await sync_to_async(can_view_task)(ws_user, task): + logger.warning( + "WebSocket rejected: user not entitled to task %s", self.guac_task_id + ) + await self._delete_guac_session() + await self.close() + return # 4. Central mode: a broker-dispatched job's VM lives on a worker, so # resolve that worker's libvirt DSN + IP and look up the VNC port from ITS From f30b5cc6465d2dac2292e160a628df4ca5ab670c Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Mon, 13 Jul 2026 15:49:06 -0500 Subject: [PATCH 007/183] mt: close reopened #1/#10 + rtid wrong-object authz (re-verify round 2) An adversarial re-verification of the first fix commit found two fixes that did not actually hold, plus a HIGH the review series had missed. This commit closes the ones with clear fixes; 164 MT tests pass, ruff clean. - #10 (was reopened): mongo_update_one is wrapped by graceful_auto_reconnect, which swallows AutoReconnect/ServerSelectionTimeoutError and RETURNS None with no re-raise on persistent mongo-down, so the earlier except-based raise never fired. Detect the swallowed failure by the None return instead; the apiv2 caller now returns 503 rather than a raw 500. The regression test models the real None-return path (the prior RuntimeError test never exercised the bug). - rtid wrong-object (new HIGH): _resolve_task_id and 14 hand-inlined apiv2 artifact endpoints ran _deny_if_hidden on the ORIGINAL task_id, then followed a user-influenced Recovery_ pointer to a DIFFERENT task and served its report/memory/pcap/dropped/config bytes with no re-gate. Re-run _deny_if_hidden on the resolved id at every pivot; add negative + positive regression tests. - hygiene: corrected stale "no-op in shared" comments on the perform_search scope gate in web_utils.py (the gate is mode-independent after the #2 fix). Tracked as follow-ups (see docs/superpowers/mt-upstream-adversarial-review): the distributed-worker stamp (#1) needs tenant propagation through utils/dist.py, plus the ES-store visibility-toggle sync and the shared-mode stats API shape. --- lib/cuckoo/common/web_utils.py | 6 +- lib/cuckoo/core/data/tasking.py | 25 ++++---- tests/test_task_visibility.py | 19 +++--- web/apiv2/test_visibility.py | 64 ++++++++++++++++++++ web/apiv2/views.py | 103 +++++++++++++++++++++++++++++++- 5 files changed, 195 insertions(+), 22 deletions(-) diff --git a/lib/cuckoo/common/web_utils.py b/lib/cuckoo/common/web_utils.py index 4b3f5bf4ad9..3246c92bb42 100644 --- a/lib/cuckoo/common/web_utils.py +++ b/lib/cuckoo/common/web_utils.py @@ -1528,7 +1528,8 @@ def perform_search( # Tenant scope — applied regardless of `privs` (Django is_staff is NOT # the tenancy break-glass; only is_local_admin is, handled inside - # _viewer_scope_match). No-op when multitenancy is disabled/shared. + # _viewer_scope_match). Mode-INDEPENDENT: scopes in shared mode too; + # None only when multitenancy is disabled or the viewer is break-glass. _scope = _viewer_scope_match(viewer) if _scope: pipeline.append({"$match": _scope}) @@ -1578,7 +1579,8 @@ def perform_search( mongo_search_query["info.user_id"] = user_id # Tenant scope — applied regardless of `privs` (is_staff is not the - # tenancy break-glass). No-op when multitenancy disabled/shared. + # tenancy break-glass). Mode-INDEPENDENT: scopes in shared mode too; + # None only when multitenancy is disabled or the viewer is break-glass. _scope = _viewer_scope_match(viewer) if _scope: mongo_search_query = {"$and": [mongo_search_query, _scope]} diff --git a/lib/cuckoo/core/data/tasking.py b/lib/cuckoo/core/data/tasking.py index ddcd0ac918d..6dc4f84f8ce 100644 --- a/lib/cuckoo/core/data/tasking.py +++ b/lib/cuckoo/core/data/tasking.py @@ -869,18 +869,23 @@ def set_task_visibility(self, task_id: int, visibility: str) -> Optional[Task]: # failure (raise) rather than silently diverging into a stale public stamp # after a private toggle. SQL stays authoritative regardless. if mongo_update_one is not None and _mongo_reporting_enabled(): - _synced = False - for _attempt in range(3): - try: - mongo_update_one("analysis", {"info.id": task_id}, {"$set": {"info.visibility": visibility}}) - _synced = True - break - except Exception as _e: - log.warning("visibility mongo sync attempt %d/3 failed for task %s: %s", _attempt + 1, task_id, _e) - if not _synced: + # mongo_update_one is wrapped by graceful_auto_reconnect, which retries + # AutoReconnect/ServerSelectionTimeoutError internally (the canonical + # mongo-down errors) and RETURNS None with no re-raise when mongo stays + # down. So detect a swallowed failure by the None return, not by + # catching an exception — a successful update returns an UpdateResult + # (even with matched_count=0). Surface a persistent failure so a stale + # public stamp after a private toggle can't silently keep the analysis + # cross-tenant visible in the aggregate/search/stats surfaces. + _res = None + try: + _res = mongo_update_one("analysis", {"info.id": task_id}, {"$set": {"info.visibility": visibility}}) + except Exception as _e: # a non-AutoReconnect error the wrapper does not swallow + log.warning("visibility mongo sync errored for task %s: %s", task_id, _e) + if _res is None: from lib.cuckoo.common.exceptions import CuckooOperationalError - log.error("visibility mongo sync FAILED for task %s after retries (SQL=%s, mongo stale)", task_id, visibility) + log.error("visibility mongo sync FAILED for task %s (mongo unreachable; SQL=%s, mongo stamp stale)", task_id, visibility) raise CuckooOperationalError(f"task {task_id} visibility set in DB but mongo sync failed") return task diff --git a/tests/test_task_visibility.py b/tests/test_task_visibility.py index a2ee0c26275..93fadab8386 100644 --- a/tests/test_task_visibility.py +++ b/tests/test_task_visibility.py @@ -183,8 +183,12 @@ def test_set_task_visibility_syncs_mongo(db, monkeypatch): import lib.cuckoo.core.data.tasking as tk # Sync only runs when mongo is the enabled report store; force it on for the test. monkeypatch.setattr(tk, "_mongo_reporting_enabled", lambda: True, raising=False) - monkeypatch.setattr(tk, "mongo_update_one", - lambda *a, **k: calls.append((a, k)), raising=False) + + def _rec(*a, **k): + calls.append((a, k)) + return object() # UpdateResult stand-in — a successful update returns non-None + + monkeypatch.setattr(tk, "mongo_update_one", _rec, raising=False) tid = db.add_url("http://example.com", tenant_id=10, visibility="tenant") db.set_task_visibility(tid, "public") assert calls and calls[-1][0][0] == "analysis" # updated the analysis collection @@ -193,16 +197,15 @@ def test_set_task_visibility_syncs_mongo(db, monkeypatch): def test_set_task_visibility_raises_on_persistent_mongo_failure(db, monkeypatch): """Finding #10: a persistent mongo-sync failure must be surfaced (raised), not swallowed — else a stale public stamp after a private toggle silently keeps the - analysis cross-tenant visible in the aggregate/search/stats surfaces.""" + analysis cross-tenant visible in the aggregate/search/stats surfaces. mongo's + graceful_auto_reconnect wrapper swallows AutoReconnect/ServerSelectionTimeoutError + and RETURNS None (no re-raise) when mongo stays down, so model that real path + (a None return), NOT a raw exception — the latter would never exercise the bug.""" import lib.cuckoo.core.data.tasking as tk from lib.cuckoo.common.exceptions import CuckooOperationalError monkeypatch.setattr(tk, "_mongo_reporting_enabled", lambda: True, raising=False) - - def _boom(*a, **k): - raise RuntimeError("mongo down") - - monkeypatch.setattr(tk, "mongo_update_one", _boom, raising=False) + monkeypatch.setattr(tk, "mongo_update_one", lambda *a, **k: None, raising=False) tid = db.add_url("http://example.com", tenant_id=10, visibility="tenant") with pytest.raises(CuckooOperationalError): db.set_task_visibility(tid, "public") diff --git a/web/apiv2/test_visibility.py b/web/apiv2/test_visibility.py index 1f8b4cac600..45f87b9bb26 100644 --- a/web/apiv2/test_visibility.py +++ b/web/apiv2/test_visibility.py @@ -591,3 +591,67 @@ def test_byhash_sample_resolution_is_gated(): f"{offenders}. Gate via can_view_sample / _deny_by_hash / sample_path_by_hash(visible_to=) " f"— an attacker-supplied hash must not stream another tenant's sample bytes." ) + + +@pytest.mark.django_db +def test_resolve_task_id_regates_recovery_rtid_pivot(cape_db, mt_enabled, monkeypatch): + """rtid wrong-object regression: after a Recovery_ pivot to a DIFFERENT task, + _resolve_task_id must re-gate visibility on the RESOLVED id — the initial + _deny_if_hidden only authorized the original task, so serving the resolved + task's artifacts without a re-check leaks another tenant's bytes.""" + import types + import apiv2.views as av + from django.contrib.auth.models import User + from django.test import RequestFactory + + class OwnTask: # original id 5 — public, readable by anyone + id = 5 + user_id = 0 + tenant_id = 10 + visibility = "public" + + class ForeignTask: # rtid 6 — another tenant's private analysis + id = 6 + user_id = 999 + tenant_id = 20 + visibility = "private" + + monkeypatch.setattr(av, "apiconf", types.SimpleNamespace()) # enabled_key -> None -> skip gate + monkeypatch.setattr(av.db, "view_task", lambda tid, *a, **k: OwnTask() if int(tid) == 5 else ForeignTask()) + monkeypatch.setattr(av, "validate_task", lambda tid, *a, **k: {"error": False, "rtid": 6, "tlp": ""}) + + req = RequestFactory().get("/x") + req.user = User.objects.create_user("rtid_neg", "rtid_neg@x.com", "x") # tenant-less, non-admin + resolved, err = av._resolve_task_id(req, 5, "taskpcap") + assert resolved is None and err is not None # denied on the resolved foreign id + + +@pytest.mark.django_db +def test_resolve_task_id_allows_readable_rtid_pivot(cape_db, mt_enabled, monkeypatch): + """Positive control: a Recovery pivot to a task the requester CAN read resolves + normally (the re-gate must not block legitimate recovery).""" + import types + import apiv2.views as av + from django.contrib.auth.models import User + from django.test import RequestFactory + + class OwnTask: + id = 5 + user_id = 0 + tenant_id = 10 + visibility = "public" + + class OwnTask2: + id = 6 + user_id = 0 + tenant_id = 10 + visibility = "public" + + monkeypatch.setattr(av, "apiconf", types.SimpleNamespace()) + monkeypatch.setattr(av.db, "view_task", lambda tid, *a, **k: OwnTask() if int(tid) == 5 else OwnTask2()) + monkeypatch.setattr(av, "validate_task", lambda tid, *a, **k: {"error": False, "rtid": 6, "tlp": ""}) + + req = RequestFactory().get("/x") + req.user = User.objects.create_user("rtid_pos", "rtid_pos@x.com", "x") + resolved, err = av._resolve_task_id(req, 5, "taskpcap") + assert resolved == 6 and err is None diff --git a/web/apiv2/views.py b/web/apiv2/views.py index 1ffe16085bb..21e8b23a127 100644 --- a/web/apiv2/views.py +++ b/web/apiv2/views.py @@ -96,7 +96,16 @@ def tasks_set_visibility(request, task_id): return Response({"error": True, "error_value": "invalid visibility"}, status=400) if not can_toggle_task(request.user, task): return Response({"error": True, "error_value": "Access denied"}, status=403) - db.set_task_visibility(task_id, vis) + try: + db.set_task_visibility(task_id, vis) + except CuckooOperationalError: + # SQL was updated but the mongo-stamp sync failed (mongo unreachable), so + # the aggregate/search surfaces would be stale. Report 503 so the caller + # retries instead of assuming the change fully propagated. + return Response( + {"error": True, "error_value": "visibility updated in DB but the report store sync failed; retry"}, + status=503, + ) return Response({"error": False, "data": {"task_id": int(task_id), "visibility": vis}}) from rest_framework.response import Response @@ -104,7 +113,7 @@ def tasks_set_visibility(request, task_id): from lib.cuckoo.common.config import Config from lib.cuckoo.common.constants import ANALYSIS_BASE_PATH, CUCKOO_ROOT, CUCKOO_VERSION -from lib.cuckoo.common.exceptions import CuckooDemuxError +from lib.cuckoo.common.exceptions import CuckooDemuxError, CuckooOperationalError from lib.cuckoo.common.path_utils import path_delete, path_exists from lib.cuckoo.common.saztopcap import saz_to_pcap from lib.cuckoo.common.utils import ( @@ -1317,6 +1326,12 @@ def tasks_report(request, task_id, report_format="json", make_zip=False): rtid = check.get("rtid", 0) if rtid: task_id = rtid + # Recovery_ pivots to a DIFFERENT task; the earlier _deny_if_hidden + # authorized the ORIGINAL id, so re-gate visibility on the RESOLVED id + # before serving its artifacts (wrong-object authorization otherwise). + _rtid_denied = _deny_if_hidden(request, db.view_task(task_id)) + if _rtid_denied is not None: + return _rtid_denied _central_stage(request, task_id) @@ -1506,6 +1521,12 @@ def tasks_iocs(request, task_id, detail=None): rtid = check.get("rtid", 0) if rtid: task_id = rtid + # Recovery_ pivots to a DIFFERENT task; the earlier _deny_if_hidden + # authorized the ORIGINAL id, so re-gate visibility on the RESOLVED id + # before serving its artifacts (wrong-object authorization otherwise). + _rtid_denied = _deny_if_hidden(request, db.view_task(task_id)) + if _rtid_denied is not None: + return _rtid_denied _central_stage(request, task_id) @@ -1745,6 +1766,12 @@ def tasks_screenshot(request, task_id, screenshot="all"): rtid = check.get("rtid", 0) if rtid: task_id = rtid + # Recovery_ pivots to a DIFFERENT task; the earlier _deny_if_hidden + # authorized the ORIGINAL id, so re-gate visibility on the RESOLVED id + # before serving its artifacts (wrong-object authorization otherwise). + _rtid_denied = _deny_if_hidden(request, db.view_task(task_id)) + if _rtid_denied is not None: + return _rtid_denied _central_stage(request, task_id) @@ -1803,6 +1830,12 @@ def tasks_pcap(request, task_id): rtid = check.get("rtid", 0) if rtid: task_id = rtid + # Recovery_ pivots to a DIFFERENT task; the earlier _deny_if_hidden + # authorized the ORIGINAL id, so re-gate visibility on the RESOLVED id + # before serving its artifacts (wrong-object authorization otherwise). + _rtid_denied = _deny_if_hidden(request, db.view_task(task_id)) + if _rtid_denied is not None: + return _rtid_denied _central_stage(request, task_id) @@ -1844,6 +1877,12 @@ def _resolve_task_id(request, task_id, enabled_key, check_tlp=True): rtid = check.get("rtid", 0) if rtid: task_id = rtid + # Recovery_ pivots to a DIFFERENT task; the earlier _deny_if_hidden + # authorized the ORIGINAL id, so re-gate visibility on the RESOLVED id + # before serving its artifacts (wrong-object authorization otherwise). + _rtid_denied = _deny_if_hidden(request, db.view_task(task_id)) + if _rtid_denied is not None: + return None, _rtid_denied return task_id, None @@ -2128,6 +2167,12 @@ def tasks_evtx(request, task_id): rtid = check.get("rtid", 0) if rtid: task_id = rtid + # Recovery_ pivots to a DIFFERENT task; the earlier _deny_if_hidden + # authorized the ORIGINAL id, so re-gate visibility on the RESOLVED id + # before serving its artifacts (wrong-object authorization otherwise). + _rtid_denied = _deny_if_hidden(request, db.view_task(task_id)) + if _rtid_denied is not None: + return _rtid_denied _central_stage(request, task_id) @@ -2161,6 +2206,12 @@ def tasks_mitmdump(request, task_id): rtid = check.get("rtid", 0) if rtid: task_id = rtid + # Recovery_ pivots to a DIFFERENT task; the earlier _deny_if_hidden + # authorized the ORIGINAL id, so re-gate visibility on the RESOLVED id + # before serving its artifacts (wrong-object authorization otherwise). + _rtid_denied = _deny_if_hidden(request, db.view_task(task_id)) + if _rtid_denied is not None: + return _rtid_denied _central_stage(request, task_id) harfile = os.path.join(CUCKOO_ROOT, "storage", "analyses", "%s" % task_id, "mitmdump", "dump.har") if not os.path.normpath(harfile).startswith(ANALYSIS_BASE_PATH): @@ -2197,6 +2248,12 @@ def tasks_dropped(request, task_id): rtid = check.get("rtid", 0) if rtid: task_id = rtid + # Recovery_ pivots to a DIFFERENT task; the earlier _deny_if_hidden + # authorized the ORIGINAL id, so re-gate visibility on the RESOLVED id + # before serving its artifacts (wrong-object authorization otherwise). + _rtid_denied = _deny_if_hidden(request, db.view_task(task_id)) + if _rtid_denied is not None: + return _rtid_denied _central_stage(request, task_id) @@ -2252,6 +2309,12 @@ def tasks_selfextracted(request, task_id, tool="all"): rtid = check.get("rtid", 0) if rtid: task_id = rtid + # Recovery_ pivots to a DIFFERENT task; the earlier _deny_if_hidden + # authorized the ORIGINAL id, so re-gate visibility on the RESOLVED id + # before serving its artifacts (wrong-object authorization otherwise). + _rtid_denied = _deny_if_hidden(request, db.view_task(task_id)) + if _rtid_denied is not None: + return _rtid_denied _central_stage(request, task_id) @@ -2356,6 +2419,12 @@ def tasks_surifile(request, task_id): rtid = check.get("rtid", 0) if rtid: task_id = rtid + # Recovery_ pivots to a DIFFERENT task; the earlier _deny_if_hidden + # authorized the ORIGINAL id, so re-gate visibility on the RESOLVED id + # before serving its artifacts (wrong-object authorization otherwise). + _rtid_denied = _deny_if_hidden(request, db.view_task(task_id)) + if _rtid_denied is not None: + return _rtid_denied _central_stage(request, task_id) @@ -2446,6 +2515,12 @@ def tasks_procmemory(request, task_id, pid="all"): rtid = check.get("rtid", 0) if rtid: task_id = rtid + # Recovery_ pivots to a DIFFERENT task; the earlier _deny_if_hidden + # authorized the ORIGINAL id, so re-gate visibility on the RESOLVED id + # before serving its artifacts (wrong-object authorization otherwise). + _rtid_denied = _deny_if_hidden(request, db.view_task(task_id)) + if _rtid_denied is not None: + return _rtid_denied _central_stage(request, task_id, include_memory=True) # Check if any process memory dumps exist @@ -2528,6 +2603,12 @@ def tasks_fullmemory(request, task_id): rtid = check.get("rtid", 0) if rtid: task_id = rtid + # Recovery_ pivots to a DIFFERENT task; the earlier _deny_if_hidden + # authorized the ORIGINAL id, so re-gate visibility on the RESOLVED id + # before serving its artifacts (wrong-object authorization otherwise). + _rtid_denied = _deny_if_hidden(request, db.view_task(task_id)) + if _rtid_denied is not None: + return _rtid_denied _central_stage(request, task_id, include_memory=True) filename = "" @@ -2800,6 +2881,12 @@ def tasks_payloadfiles(request, task_id): rtid = check.get("rtid", 0) if rtid: task_id = rtid + # Recovery_ pivots to a DIFFERENT task; the earlier _deny_if_hidden + # authorized the ORIGINAL id, so re-gate visibility on the RESOLVED id + # before serving its artifacts (wrong-object authorization otherwise). + _rtid_denied = _deny_if_hidden(request, db.view_task(task_id)) + if _rtid_denied is not None: + return _rtid_denied _central_stage(request, task_id) @@ -2842,6 +2929,12 @@ def tasks_procdumpfiles(request, task_id): rtid = check.get("rtid", 0) if rtid: task_id = rtid + # Recovery_ pivots to a DIFFERENT task; the earlier _deny_if_hidden + # authorized the ORIGINAL id, so re-gate visibility on the RESOLVED id + # before serving its artifacts (wrong-object authorization otherwise). + _rtid_denied = _deny_if_hidden(request, db.view_task(task_id)) + if _rtid_denied is not None: + return _rtid_denied _central_stage(request, task_id) @@ -2884,6 +2977,12 @@ def tasks_config(request, task_id, cape_name=False): rtid = check.get("rtid", 0) if rtid: task_id = rtid + # Recovery_ pivots to a DIFFERENT task; the earlier _deny_if_hidden + # authorized the ORIGINAL id, so re-gate visibility on the RESOLVED id + # before serving its artifacts (wrong-object authorization otherwise). + _rtid_denied = _deny_if_hidden(request, db.view_task(task_id)) + if _rtid_denied is not None: + return _rtid_denied _central_stage(request, task_id) From 201f6ea441f493589e12f82d79e9081afe4273a2 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Mon, 13 Jul 2026 16:01:53 -0500 Subject: [PATCH 008/183] mt: fail-close unsupported modes + gate direct-VNC + document boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the support-boundary decision — multitenancy is generalized for the modes we actually run (MongoDB report store + single-node / broker-central / worker / Guacamole), not for the rarely-used Elasticsearch or legacy utils/dist.py distributed paths. Unsupported modes fail closed (safe but limited) rather than leak, and the boundary is documented. - #1 distributed-worker stamp: fail CLOSED to private when a legacy-distributed worker processes a task (options.main_task_id set) — the worker-local task carries no submitter tenancy, so stamp it invisible rather than world-visible. Our central/broker path keys by job_id and never sets main_task_id, so it is unaffected. - direct VNC console (task_id=0, caller-chosen host:port, no tenant scoping): restrict to superusers, in addition to the existing config gate. - docs/MULTITENANCY-SUPPORT.md: the supported matrix (mongo + our modes), the fail-closed unsupported modes (ES, dist.py), the shared-mode stats API shape change, and the direct-VNC admin gate. ES report-store visibility sync and full utils/dist.py tenant propagation are documented as not-yet-supported (fail-closed), tracked for later. 164 MT tests pass; ruff clean. --- docs/MULTITENANCY-SUPPORT.md | 55 ++++++++++++++++++++++++++++++++++++ modules/reporting/mongodb.py | 28 ++++++++++++------ web/guac/views.py | 6 ++++ 3 files changed, 80 insertions(+), 9 deletions(-) create mode 100644 docs/MULTITENANCY-SUPPORT.md diff --git a/docs/MULTITENANCY-SUPPORT.md b/docs/MULTITENANCY-SUPPORT.md new file mode 100644 index 00000000000..3fb9efe5209 --- /dev/null +++ b/docs/MULTITENANCY-SUPPORT.md @@ -0,0 +1,55 @@ +# 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. + +## 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 and scoping work across this path. +- **Guacamole interactive sessions** for task-backed analyses: the live-VM tunnel + is gated by `can_view_task`, so a token cannot tunnel into another 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 console.** The direct host:port VNC console (`task_id=0`, not tied + to a task) is an operator tool with no tenant scoping; it is restricted to + **superusers** (in addition to the existing config gate). +- **Modes:** `shared` (public pool + own tenant + own tasks) and `locked` + (tenant-isolated). An unknown/typo `mode` fails closed to `locked`. diff --git a/modules/reporting/mongodb.py b/modules/reporting/mongodb.py index ef153e1931b..69143d7297d 100644 --- a/modules/reporting/mongodb.py +++ b/modules/reporting/mongodb.py @@ -199,16 +199,26 @@ def run(self, results): from lib.cuckoo.common.tenancy import multitenancy_config if multitenancy_config().enabled: - try: - # Look up tenant context by the LOCAL task id. report["info"]["id"] - # may have been rewritten to the main node's main_task_id above - # (distributed path); that id does not exist in this worker's DB and - # would resolve to None -> fail-open public stamp. The local task - # row carries the authoritative tenant/user/visibility. - stamp_tenant_info(report["info"], _task_tenant_ctx(local_task_id)) - except Exception as _db_err: - log.warning("Failed to look up task for tenant stamping (task %s): %s", local_task_id, _db_err) + if main_task_id: + # Legacy distributed (utils/dist.py) worker path: the worker-local + # task does NOT carry the submitter's tenancy (dist.py forwards + # none), so it cannot be stamped correctly here. Fail CLOSED to + # private (invisible) rather than leak a world-visible stamp. + # Multitenancy is supported with the mongo report store + the + # broker/central path; legacy distributed (dist.py) is a documented, + # not-yet-supported mode — see docs/MULTITENANCY-SUPPORT.md. Our + # central/broker path keys by job_id and never sets main_task_id, so + # this branch does not affect it. stamp_tenant_info(report["info"], None) + else: + try: + # Look up tenant context by the LOCAL task id. report["info"]["id"] + # may have been rewritten to a main node's id above; the local + # row carries the authoritative tenant/user/visibility. + stamp_tenant_info(report["info"], _task_tenant_ctx(local_task_id)) + except Exception as _db_err: + log.warning("Failed to look up task for tenant stamping (task %s): %s", local_task_id, _db_err) + stamp_tenant_info(report["info"], None) # Delete old data just before inserting new one to avoid "missing report" window # or data loss if insertion fails during preparation (e.g. OOM) diff --git a/web/guac/views.py b/web/guac/views.py index c89c8272823..56b76a9bddc 100644 --- a/web/guac/views.py +++ b/web/guac/views.py @@ -163,6 +163,12 @@ def index(request, task_id, session_data): def direct_vnc_host_port(request, host, port): if not is_vnc_console_enabled(): return _error(request, 0, "VNC Console is disabled in configuration") + # Direct VNC opens a raw tunnel to a caller-chosen host:port with no task/tenant + # scoping (task_id=0). Restrict to superusers — it is an operator console, never + # a tenant-user surface; without this a logged-in tenant user could reach any + # reachable host:port. Config-gated + admin-gated. + if not getattr(request.user, "is_superuser", False): + return _error(request, 0, "VNC Console is restricted to administrators") token = uuid.uuid4() try: From b7aee49502a668631dc0121209d0f24a3bd0d1c6 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Mon, 13 Jul 2026 16:26:42 -0500 Subject: [PATCH 009/183] mt: close final re-verification findings (rtid inline pivot, all direct-VNC, hunt doc) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A final adversarial re-verification confirmed the round-2/3 fixes hold, but found two gaps and a doc overstatement: - rtid wrong-object (16th pivot): tasks_view has its OWN inline Recovery_ re.match pivot (separate from the 15 check.get("rtid") sites fixed earlier); it re-fetched the pivoted task and served its data/sample/mongo doc without a re-gate. Re-run _deny_if_hidden on the resolved id + a regression test that exercises the actual view. - direct-VNC (was gated on only 1 of 8 endpoints): the 7 sibling task-less operator endpoints (direct_vnc_vm + start/shutdown/route/snapshots-list/ snapshot-create/snapshot-delete) mint task_id=0 sessions that bypass the per-task can_view_task tunnel gate and control arbitrary live VMs by name. Gate ALL of them on viewer_for(request.user).is_local_admin (break-glass admin; usable on MT-disabled/no-auth installs), and upgrade the host:port endpoint from the raw is_superuser check to the same break-glass gate. Add a regression test. - hunt(): its facet task_ids rely solely on the viewer_scope $match (a $facet count can't be per-id SQL-backstopped) — documented as safe given the tenant stamp is now written fail-closed on every path. - docs/MULTITENANCY-SUPPORT.md: corrected the direct-VNC note (all endpoints, not just host:port) and documented the hunt() facet scoping. 166 MT tests pass; ruff clean. Independently corroborated by a Codex security scan (F-003) run separately. --- docs/MULTITENANCY-SUPPORT.md | 15 ++++++++--- web/analysis/views.py | 10 ++++++-- web/apiv2/test_visibility.py | 49 ++++++++++++++++++++++++++++++++++++ web/apiv2/views.py | 6 +++++ web/guac/test_visibility.py | 36 ++++++++++++++++++++++++++ web/guac/views.py | 18 +++++++++++-- 6 files changed, 127 insertions(+), 7 deletions(-) diff --git a/docs/MULTITENANCY-SUPPORT.md b/docs/MULTITENANCY-SUPPORT.md index 3fb9efe5209..d51911aef49 100644 --- a/docs/MULTITENANCY-SUPPORT.md +++ b/docs/MULTITENANCY-SUPPORT.md @@ -48,8 +48,17 @@ will simply not be visible. Adding real support is tracked as future work. 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 console.** The direct host:port VNC console (`task_id=0`, not tied - to a task) is an operator tool with no tenant scoping; it is restricted to - **superusers** (in addition to the existing config gate). +- **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/web/analysis/views.py b/web/analysis/views.py index d90e0c8705a..6fedae578f6 100644 --- a/web/analysis/views.py +++ b/web/analysis/views.py @@ -4471,8 +4471,14 @@ def hunt(request): match_query["info.started"] = {"$gte": start_date} # Tenant isolation: restrict the docs the aggregation sees to the viewer's entitled scopes - # (no-op for break-glass / shared / multitenancy-disabled). viewer_scope wraps the MT predicate - # and degrades to None (see-all) when the MT layer is absent. + # (mode-independent; None only for break-glass / multitenancy-disabled). viewer_scope wraps + # the MT predicate and degrades to None (see-all) when the MT layer is absent. + # NOTE: the facet task_ids ($addToSet $info.id) are scoped SOLELY by this $match on the + # stamped info.* — unlike the per-record surfaces (search/compare/capeyara) there is no + # per-id can_view_task SQL backstop here, because a $facet count can't be post-filtered + # per task without changing its semantics. This is safe given the report stamp is written + # fail-closed on every path (see modules/reporting/mongodb.py stamp_tenant_info + the + # backfill), so a doc can never carry a spoofed cross-tenant stamp. See docs/MULTITENANCY-SUPPORT.md. from analysis.central_scope import viewer_scope from lib.cuckoo.common.central_mode import central_mode_config from lib.cuckoo.common.hunt_query import build_hunt_facets diff --git a/web/apiv2/test_visibility.py b/web/apiv2/test_visibility.py index 45f87b9bb26..12afd0f4a06 100644 --- a/web/apiv2/test_visibility.py +++ b/web/apiv2/test_visibility.py @@ -655,3 +655,52 @@ class OwnTask2: req.user = User.objects.create_user("rtid_pos", "rtid_pos@x.com", "x") resolved, err = av._resolve_task_id(req, 5, "taskpcap") assert resolved == 6 and err is None + + +@pytest.mark.django_db +def test_tasks_view_regates_recovery_pivot(cape_db, mt_enabled, monkeypatch): + """rtid wrong-object (tasks_view INLINE pivot, separate from _resolve_task_id): + a TASK_RECOVERED task whose `custom` points Recovery_ at another tenant's + task must NOT serve that task's data — re-gate on the resolved id.""" + import types + import apiv2.views as av + from django.contrib.auth.models import User + from rest_framework.test import APIRequestFactory, force_authenticate + + class OwnRecovered: # id 5 — public + readable; recovers to id 6 + id = 5 + user_id = 0 + tenant_id = 10 + visibility = "public" + status = av.TASK_RECOVERED + custom = "Recovery_6" + guest = None + errors = [] + sample_id = None + + def to_dict(self): + return {"category": "file", "target": "/tmp/own"} + + class ForeignTask: # id 6 — another tenant's private analysis + id = 6 + user_id = 999 + tenant_id = 20 + visibility = "private" + status = av.TASK_RECOVERED + custom = None + guest = None + errors = [] + sample_id = None + + def to_dict(self): + return {"category": "file", "target": "/tmp/secret"} + + monkeypatch.setattr(av, "apiconf", types.SimpleNamespace(taskview={"enabled": True})) + monkeypatch.setattr(av.db, "view_task", lambda tid, **k: OwnRecovered() if int(tid) == 5 else ForeignTask()) + + req = APIRequestFactory().get("/apiv2/tasks/view/5/") + u = User.objects.create_user("tv_neg", "tv_neg@x.com", "x") # tenant-less, non-admin + force_authenticate(req, user=u) + req.user = u + resp = av.tasks_view(req, 5) + assert resp.status_code == 404 # denied on the resolved foreign task, not served diff --git a/web/apiv2/views.py b/web/apiv2/views.py index 21e8b23a127..50fe98f063c 100644 --- a/web/apiv2/views.py +++ b/web/apiv2/views.py @@ -1046,6 +1046,12 @@ def tasks_view(request, task_id): if m: task_id = int(m.group("taskid")) task = db.view_task(task_id, details=True) + # Recovery_ can point at another tenant's task; re-gate the RESOLVED + # task before rebuilding/serving its data, sample, and mongo doc — the + # gate at the top only authorized the originally-requested id. + _denied = _deny_if_hidden(request, task) + if _denied is not None: + return _denied resp["error"] = [] if task: entry = task.to_dict() diff --git a/web/guac/test_visibility.py b/web/guac/test_visibility.py index 2c4617c4b2b..08226095900 100644 --- a/web/guac/test_visibility.py +++ b/web/guac/test_visibility.py @@ -25,3 +25,39 @@ def test_guac_index_denies_cross_tenant(cape_db, mt_enabled, monkeypatch, client r = client.get("/guac/1/AAAA/") assert minted == [] # no session token minted for a non-viewable task assert r.status_code == 200 # rendered the guac error page, not the session page + + +@pytest.mark.django_db +def test_direct_vnc_endpoints_deny_non_superuser(cape_db, mt_enabled, monkeypatch): + """Direct VNC/VM operator endpoints mint task_id=0 sessions that bypass the + per-task can_view_task tunnel gate (consumers.py gates only guac_task_id>0), + and control arbitrary live VMs by name. They must be break-glass-admin only — + a tenant user must not reach them, or they could VNC into / shut down / delete + snapshots of another tenant's live VM.""" + import guac.views as gv + from django.contrib.auth.models import User + from django.test import RequestFactory + + monkeypatch.setattr(gv, "is_vnc_console_enabled", lambda: True) + monkeypatch.setattr(gv, "_error", lambda request, tid, msg: ("ERR", msg)) + created = [] + monkeypatch.setattr(gv.db, "create_guac_session", lambda **k: created.append(k) or object()) + + req = RequestFactory().get("/guac/vnc/vm/somevm/") + req.user = User.objects.create_user("vncdeny", "vncdeny@x.com", "x") # tenant-less, non-admin + + # _error-style endpoint (HTML) + assert gv.direct_vnc_vm(req, "somevm") == ("ERR", "VNC Console is restricted to administrators") + # JSON-style mutating endpoint + resp = gv.direct_vnc_vm_shutdown(req, "somevm") + assert getattr(resp, "status_code", None) == 403 + assert not created # no session minted for a non-admin + + # positive control: a superuser (break-glass) passes the admin gate + su = User.objects.create_user("vncadmin", "vncadmin@x.com", "x") + su.is_superuser = True + su.save() + su = User.objects.get(pk=su.pk) + req2 = RequestFactory().get("/guac/vnc/vm/somevm/") + req2.user = su + assert gv.direct_vnc_vm(req2, "somevm") != ("ERR", "VNC Console is restricted to administrators") diff --git a/web/guac/views.py b/web/guac/views.py index 56b76a9bddc..d6f1bdaa96c 100644 --- a/web/guac/views.py +++ b/web/guac/views.py @@ -11,7 +11,7 @@ from lib.cuckoo.common.config import Config from lib.cuckoo.core.database import Database -from web.tenancy_optional import can_view_task +from web.tenancy_optional import can_view_task, viewer_for logger = logging.getLogger("guac-session") @@ -167,7 +167,7 @@ def direct_vnc_host_port(request, host, port): # scoping (task_id=0). Restrict to superusers — it is an operator console, never # a tenant-user surface; without this a logged-in tenant user could reach any # reachable host:port. Config-gated + admin-gated. - if not getattr(request.user, "is_superuser", False): + if not viewer_for(request.user).is_local_admin: return _error(request, 0, "VNC Console is restricted to administrators") token = uuid.uuid4() @@ -208,6 +208,8 @@ def direct_vnc_host_port(request, host, port): def direct_vnc_vm(request, vm_name): if not is_vnc_console_enabled(): return _error(request, 0, "VNC Console is disabled in configuration") + if not viewer_for(request.user).is_local_admin: + return _error(request, 0, "VNC Console is restricted to administrators") if not LIBVIRT_AVAILABLE: return _error(request, 0, "Libvirt not available") @@ -598,6 +600,8 @@ class SYSTEMTIME(ctypes.Structure): def direct_vnc_vm_start(request, vm_name): if not is_vnc_console_enabled(): return _error(request, 0, "VNC Console is disabled in configuration") + if not viewer_for(request.user).is_local_admin: + return _error(request, 0, "VNC Console is restricted to administrators") if not LIBVIRT_AVAILABLE: return _error(request, 0, "Libvirt not available") @@ -709,6 +713,8 @@ def direct_vnc_vm_start(request, vm_name): def direct_vnc_vm_shutdown(request, vm_name): if not is_vnc_console_enabled(): return JsonResponse({"status": "error", "message": "VNC Console is disabled in configuration"}, status=403) + if not viewer_for(request.user).is_local_admin: + return JsonResponse({"status": "error", "message": "VNC Console is restricted to administrators"}, status=403) if not LIBVIRT_AVAILABLE: return JsonResponse({"status": "error", "message": "Libvirt not available"}, status=500) @@ -811,6 +817,8 @@ def get_route_params(route_name, routing, configured_vpns): def direct_vnc_vm_route(request, vm_name): if not is_vnc_console_enabled(): return JsonResponse({"status": "error", "message": "VNC Console is disabled in configuration"}, status=403) + if not viewer_for(request.user).is_local_admin: + return JsonResponse({"status": "error", "message": "VNC Console is restricted to administrators"}, status=403) if request.method != "POST": return JsonResponse({"status": "error", "message": "Invalid request method"}, status=405) @@ -897,6 +905,8 @@ def direct_vnc_vm_route(request, vm_name): def direct_vnc_vm_snapshots_list(request, vm_name): if not is_vnc_console_enabled(): return JsonResponse({"status": "error", "message": "VNC Console is disabled in configuration"}, status=403) + if not viewer_for(request.user).is_local_admin: + return JsonResponse({"status": "error", "message": "VNC Console is restricted to administrators"}, status=403) if not LIBVIRT_AVAILABLE: return JsonResponse({"status": "error", "message": "Libvirt not available"}, status=500) @@ -957,6 +967,8 @@ def direct_vnc_vm_snapshots_list(request, vm_name): def direct_vnc_vm_snapshot_create(request, vm_name): if not is_vnc_console_enabled(): return JsonResponse({"status": "error", "message": "VNC Console is disabled in configuration"}, status=403) + if not viewer_for(request.user).is_local_admin: + return JsonResponse({"status": "error", "message": "VNC Console is restricted to administrators"}, status=403) if not LIBVIRT_AVAILABLE: return JsonResponse({"status": "error", "message": "Libvirt not available"}, status=500) @@ -1046,6 +1058,8 @@ def direct_vnc_vm_snapshot_create(request, vm_name): def direct_vnc_vm_snapshot_delete(request, vm_name): if not is_vnc_console_enabled(): return JsonResponse({"status": "error", "message": "VNC Console is disabled in configuration"}, status=403) + if not viewer_for(request.user).is_local_admin: + return JsonResponse({"status": "error", "message": "VNC Console is restricted to administrators"}, status=403) if not LIBVIRT_AVAILABLE: return JsonResponse({"status": "error", "message": "Libvirt not available"}, status=500) From 6f1a6b74235efd7d4499cf64fd7541255bb7f075 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Mon, 13 Jul 2026 16:57:26 -0500 Subject: [PATCH 010/183] mt: close final-review non-blocking items (TLP-before-regate, toggle atomicity, stamp coverage) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the non-blocking open items from the final adversarial re-verification: - rtid re-gate now runs BEFORE the TLP:RED check at all 14 pivot sites (_resolve_task_id + 13 inline apiv2 endpoints), so a hidden resolved task returns a generic 404 instead of a distinguishable "TLP of RED" message — closing a narrow existence oracle (no bytes were served either way). - set_task_visibility now rolls the SQL change back if the mongo stamp sync fails, so the two stores can never diverge (previously a mongo-down toggle left SQL private + mongo stale-public until retry). The regression test asserts the rollback. - extracted the run() distributed-vs-local stamp decision into _stamp_report_for_task and unit-tested both branches (distributed main_task_id -> fail-closed private; local -> stamp from the local task), closing the coverage gap the reviewer noted. - (the cosmetic hunt() comment was already corrected in the prior commit.) 168 MT tests pass; ruff clean. --- lib/cuckoo/core/data/tasking.py | 12 +++- modules/reporting/mongodb.py | 43 +++++------ tests/test_mongo_stamp.py | 23 ++++++ tests/test_task_visibility.py | 3 + web/apiv2/views.py | 122 ++++++++++++++------------------ 5 files changed, 114 insertions(+), 89 deletions(-) diff --git a/lib/cuckoo/core/data/tasking.py b/lib/cuckoo/core/data/tasking.py index 6dc4f84f8ce..1ed69ddf7eb 100644 --- a/lib/cuckoo/core/data/tasking.py +++ b/lib/cuckoo/core/data/tasking.py @@ -861,6 +861,7 @@ def set_task_visibility(self, task_id: int, visibility: str) -> Optional[Task]: task = self.session.get(Task, task_id) if not task: return None + _prev_visibility = task.visibility task.visibility = visibility self.session.commit() # Sync the toggle to the mongo report so the aggregate/search/stats surfaces @@ -885,8 +886,15 @@ def set_task_visibility(self, task_id: int, visibility: str) -> Optional[Task]: if _res is None: from lib.cuckoo.common.exceptions import CuckooOperationalError - log.error("visibility mongo sync FAILED for task %s (mongo unreachable; SQL=%s, mongo stamp stale)", task_id, visibility) - raise CuckooOperationalError(f"task {task_id} visibility set in DB but mongo sync failed") + # Roll the SQL change back so the two stores can't diverge: a stale + # public mongo stamp after a private SQL toggle would keep the + # analysis cross-tenant visible in the aggregate/search/stats + # surfaces. Both stores end at the previous value and the caller + # gets a clear failure to retry. + task.visibility = _prev_visibility + self.session.commit() + log.error("visibility mongo sync FAILED for task %s (mongo unreachable); rolled SQL back to %r", task_id, _prev_visibility) + raise CuckooOperationalError(f"task {task_id} visibility change aborted: report store sync failed") return task def fetch_task(self, categories: list = None): diff --git a/modules/reporting/mongodb.py b/modules/reporting/mongodb.py index 69143d7297d..1367fceb0b0 100644 --- a/modules/reporting/mongodb.py +++ b/modules/reporting/mongodb.py @@ -65,6 +65,24 @@ def _task_tenant_ctx(task_id): })() +def _stamp_report_for_task(report_info: dict, main_task_id, local_task_id) -> None: + """Stamp tenant context onto a report's info subdict (called only when MT is on). + + On the legacy distributed worker path (``main_task_id`` set — only utils/dist.py + sets it, never the broker/central path) the worker-local task does NOT carry the + submitter's tenancy, so fail CLOSED to private/invisible rather than leak. + Otherwise stamp from the LOCAL task (report["info"]["id"] may have been rewritten + to a main id), failing closed to private if that lookup errors.""" + if main_task_id: + stamp_tenant_info(report_info, None) + return + try: + stamp_tenant_info(report_info, _task_tenant_ctx(local_task_id)) + except Exception as _db_err: + log.warning("Failed to look up task for tenant stamping (task %s): %s", local_task_id, _db_err) + stamp_tenant_info(report_info, None) + + class MongoDB(Report): """Stores report in MongoDB.""" @@ -199,26 +217,11 @@ def run(self, results): from lib.cuckoo.common.tenancy import multitenancy_config if multitenancy_config().enabled: - if main_task_id: - # Legacy distributed (utils/dist.py) worker path: the worker-local - # task does NOT carry the submitter's tenancy (dist.py forwards - # none), so it cannot be stamped correctly here. Fail CLOSED to - # private (invisible) rather than leak a world-visible stamp. - # Multitenancy is supported with the mongo report store + the - # broker/central path; legacy distributed (dist.py) is a documented, - # not-yet-supported mode — see docs/MULTITENANCY-SUPPORT.md. Our - # central/broker path keys by job_id and never sets main_task_id, so - # this branch does not affect it. - stamp_tenant_info(report["info"], None) - else: - try: - # Look up tenant context by the LOCAL task id. report["info"]["id"] - # may have been rewritten to a main node's id above; the local - # row carries the authoritative tenant/user/visibility. - stamp_tenant_info(report["info"], _task_tenant_ctx(local_task_id)) - except Exception as _db_err: - log.warning("Failed to look up task for tenant stamping (task %s): %s", local_task_id, _db_err) - stamp_tenant_info(report["info"], None) + # Fail-closed on the legacy distributed worker path (main_task_id set), + # else stamp from the LOCAL task. MT is supported on the mongo store + + # the broker/central path; legacy dist.py is a documented not-yet- + # supported mode (see docs/MULTITENANCY-SUPPORT.md). See _stamp_report_for_task. + _stamp_report_for_task(report["info"], main_task_id, local_task_id) # Delete old data just before inserting new one to avoid "missing report" window # or data loss if insertion fails during preparation (e.g. OOM) diff --git a/tests/test_mongo_stamp.py b/tests/test_mongo_stamp.py index e5b3159b604..7354acb6fb8 100644 --- a/tests/test_mongo_stamp.py +++ b/tests/test_mongo_stamp.py @@ -21,3 +21,26 @@ def test_stamp_missing_task_fails_closed_private(): assert info["visibility"] == "private" assert info["tenant_id"] is None and info["user_id"] is None + + +def test_stamp_report_distributed_fails_closed(): + """run()'s distributed branch (main_task_id set) fails closed to private — the + worker-local task does not carry the submitter's tenancy (dist.py forwards none).""" + from modules.reporting.mongodb import _stamp_report_for_task + info = {"id": 5} + _stamp_report_for_task(info, main_task_id=99, local_task_id=5) + assert info["visibility"] == "private" + assert info["tenant_id"] is None and info["user_id"] is None + + +def test_stamp_report_local_path_uses_local_task(monkeypatch): + """Non-distributed (no main_task_id): stamp from the LOCAL task's tenancy.""" + from modules.reporting import mongodb as m + + class T: + user_id, tenant_id, visibility = 7, 10, "tenant" + + monkeypatch.setattr(m, "_task_tenant_ctx", lambda tid: T() if tid == 5 else None) + info = {"id": 5} + m._stamp_report_for_task(info, main_task_id=None, local_task_id=5) + assert info["visibility"] == "tenant" and info["tenant_id"] == 10 and info["user_id"] == 7 diff --git a/tests/test_task_visibility.py b/tests/test_task_visibility.py index 93fadab8386..a0d1157bc6f 100644 --- a/tests/test_task_visibility.py +++ b/tests/test_task_visibility.py @@ -209,6 +209,9 @@ def test_set_task_visibility_raises_on_persistent_mongo_failure(db, monkeypatch) tid = db.add_url("http://example.com", tenant_id=10, visibility="tenant") with pytest.raises(CuckooOperationalError): db.set_task_visibility(tid, "public") + # SQL rolled back to the previous value so the two stores can't diverge. + from lib.cuckoo.core.data.task import Task + assert db.session.get(Task, tid).visibility == "tenant" def test_set_task_visibility_skips_sync_when_mongo_disabled(db, monkeypatch): diff --git a/web/apiv2/views.py b/web/apiv2/views.py index 50fe98f063c..96f932db556 100644 --- a/web/apiv2/views.py +++ b/web/apiv2/views.py @@ -1325,20 +1325,19 @@ def tasks_report(request, task_id, report_format="json", make_zip=False): if check["error"]: return Response(check) - if check.get("tlp", "") in ("red", "Red"): - return Response({"error": True, "error_value": "Task has a TLP of RED"}) - - rtid = check.get("rtid", 0) if rtid: task_id = rtid # Recovery_ pivots to a DIFFERENT task; the earlier _deny_if_hidden # authorized the ORIGINAL id, so re-gate visibility on the RESOLVED id - # before serving its artifacts (wrong-object authorization otherwise). + # BEFORE the TLP/serving checks (wrong-object authz + no TLP existence oracle). _rtid_denied = _deny_if_hidden(request, db.view_task(task_id)) if _rtid_denied is not None: return _rtid_denied + if check.get("tlp", "") in ("red", "Red"): + return Response({"error": True, "error_value": "Task has a TLP of RED"}) + _central_stage(request, task_id) resp = {} @@ -1520,20 +1519,19 @@ def tasks_iocs(request, task_id, detail=None): if check["error"]: return Response(check) - if check.get("tlp", "") in ("red", "Red"): - return Response({"error": True, "error_value": "Task has a TLP of RED"}) - - rtid = check.get("rtid", 0) if rtid: task_id = rtid # Recovery_ pivots to a DIFFERENT task; the earlier _deny_if_hidden # authorized the ORIGINAL id, so re-gate visibility on the RESOLVED id - # before serving its artifacts (wrong-object authorization otherwise). + # BEFORE the TLP/serving checks (wrong-object authz + no TLP existence oracle). _rtid_denied = _deny_if_hidden(request, db.view_task(task_id)) if _rtid_denied is not None: return _rtid_denied + if check.get("tlp", "") in ("red", "Red"): + return Response({"error": True, "error_value": "Task has a TLP of RED"}) + _central_stage(request, task_id) buf = {} @@ -1765,20 +1763,19 @@ def tasks_screenshot(request, task_id, screenshot="all"): if check["error"]: return Response(check) - if check.get("tlp", "") in ("red", "Red"): - return Response({"error": True, "error_value": "Task has a TLP of RED"}) - - rtid = check.get("rtid", 0) if rtid: task_id = rtid # Recovery_ pivots to a DIFFERENT task; the earlier _deny_if_hidden # authorized the ORIGINAL id, so re-gate visibility on the RESOLVED id - # before serving its artifacts (wrong-object authorization otherwise). + # BEFORE the TLP/serving checks (wrong-object authz + no TLP existence oracle). _rtid_denied = _deny_if_hidden(request, db.view_task(task_id)) if _rtid_denied is not None: return _rtid_denied + if check.get("tlp", "") in ("red", "Red"): + return Response({"error": True, "error_value": "Task has a TLP of RED"}) + _central_stage(request, task_id) srcdir = os.path.join(CUCKOO_ROOT, "storage", "analyses", "%s" % task_id, "shots") @@ -1829,20 +1826,19 @@ def tasks_pcap(request, task_id): if check["error"]: return Response(check) - if check.get("tlp", "") in ("red", "Red"): - return Response({"error": True, "error_value": "Task has a TLP of RED"}) - - rtid = check.get("rtid", 0) if rtid: task_id = rtid # Recovery_ pivots to a DIFFERENT task; the earlier _deny_if_hidden # authorized the ORIGINAL id, so re-gate visibility on the RESOLVED id - # before serving its artifacts (wrong-object authorization otherwise). + # BEFORE the TLP/serving checks (wrong-object authz + no TLP existence oracle). _rtid_denied = _deny_if_hidden(request, db.view_task(task_id)) if _rtid_denied is not None: return _rtid_denied + if check.get("tlp", "") in ("red", "Red"): + return Response({"error": True, "error_value": "Task has a TLP of RED"}) + _central_stage(request, task_id) srcfile = os.path.join(CUCKOO_ROOT, "storage", "analyses", "%s" % task_id, "dump.pcap") @@ -1878,17 +1874,17 @@ def _resolve_task_id(request, task_id, enabled_key, check_tlp=True): check = validate_task(task_id) if check["error"]: return None, Response(check) - if check_tlp and (check.get("tlp") or "").lower() == "red": - return None, Response({"error": True, "error_value": "Task has a TLP of RED"}) rtid = check.get("rtid", 0) if rtid: task_id = rtid # Recovery_ pivots to a DIFFERENT task; the earlier _deny_if_hidden # authorized the ORIGINAL id, so re-gate visibility on the RESOLVED id - # before serving its artifacts (wrong-object authorization otherwise). + # BEFORE the TLP/serving checks (wrong-object authz + no TLP existence oracle). _rtid_denied = _deny_if_hidden(request, db.view_task(task_id)) if _rtid_denied is not None: return None, _rtid_denied + if check_tlp and (check.get("tlp") or "").lower() == "red": + return None, Response({"error": True, "error_value": "Task has a TLP of RED"}) return task_id, None @@ -2166,20 +2162,19 @@ def tasks_evtx(request, task_id): if check["error"]: return Response(check) - if check.get("tlp", "") in ("red", "Red"): - return Response({"error": True, "error_value": "Task has a TLP of RED"}) - - rtid = check.get("rtid", 0) if rtid: task_id = rtid # Recovery_ pivots to a DIFFERENT task; the earlier _deny_if_hidden # authorized the ORIGINAL id, so re-gate visibility on the RESOLVED id - # before serving its artifacts (wrong-object authorization otherwise). + # BEFORE the TLP/serving checks (wrong-object authz + no TLP existence oracle). _rtid_denied = _deny_if_hidden(request, db.view_task(task_id)) if _rtid_denied is not None: return _rtid_denied + if check.get("tlp", "") in ("red", "Red"): + return Response({"error": True, "error_value": "Task has a TLP of RED"}) + _central_stage(request, task_id) evtxfile = os.path.join(CUCKOO_ROOT, "storage", "analyses", "%s" % task_id, "evtx", "evtx.zip") @@ -2247,20 +2242,19 @@ def tasks_dropped(request, task_id): if check["error"]: return Response(check) - if check.get("tlp", "") in ("red", "Red"): - return Response({"error": True, "error_value": "Task has a TLP of RED"}) - - rtid = check.get("rtid", 0) if rtid: task_id = rtid # Recovery_ pivots to a DIFFERENT task; the earlier _deny_if_hidden # authorized the ORIGINAL id, so re-gate visibility on the RESOLVED id - # before serving its artifacts (wrong-object authorization otherwise). + # BEFORE the TLP/serving checks (wrong-object authz + no TLP existence oracle). _rtid_denied = _deny_if_hidden(request, db.view_task(task_id)) if _rtid_denied is not None: return _rtid_denied + if check.get("tlp", "") in ("red", "Red"): + return Response({"error": True, "error_value": "Task has a TLP of RED"}) + _central_stage(request, task_id) srcdir = os.path.join(CUCKOO_ROOT, "storage", "analyses", "%s" % task_id, "files") @@ -2309,19 +2303,19 @@ def tasks_selfextracted(request, task_id, tool="all"): if check["error"]: return Response(check) - if check.get("tlp", "") in ("red", "Red"): - return Response({"error": True, "error_value": "Task has a TLP of RED"}) - rtid = check.get("rtid", 0) if rtid: task_id = rtid # Recovery_ pivots to a DIFFERENT task; the earlier _deny_if_hidden # authorized the ORIGINAL id, so re-gate visibility on the RESOLVED id - # before serving its artifacts (wrong-object authorization otherwise). + # BEFORE the TLP/serving checks (wrong-object authz + no TLP existence oracle). _rtid_denied = _deny_if_hidden(request, db.view_task(task_id)) if _rtid_denied is not None: return _rtid_denied + if check.get("tlp", "") in ("red", "Red"): + return Response({"error": True, "error_value": "Task has a TLP of RED"}) + _central_stage(request, task_id) srcdir = os.path.join(CUCKOO_ROOT, "storage", "analyses", "%s" % task_id, "selfextracted") @@ -2418,20 +2412,19 @@ def tasks_surifile(request, task_id): if check["error"]: return Response(check) - if check.get("tlp", "") in ("red", "Red"): - return Response({"error": True, "error_value": "Task has a TLP of RED"}) - - rtid = check.get("rtid", 0) if rtid: task_id = rtid # Recovery_ pivots to a DIFFERENT task; the earlier _deny_if_hidden # authorized the ORIGINAL id, so re-gate visibility on the RESOLVED id - # before serving its artifacts (wrong-object authorization otherwise). + # BEFORE the TLP/serving checks (wrong-object authz + no TLP existence oracle). _rtid_denied = _deny_if_hidden(request, db.view_task(task_id)) if _rtid_denied is not None: return _rtid_denied + if check.get("tlp", "") in ("red", "Red"): + return Response({"error": True, "error_value": "Task has a TLP of RED"}) + _central_stage(request, task_id) srcfile = os.path.join(CUCKOO_ROOT, "storage", "analyses", "%s" % task_id, "logs", "files.zip") @@ -2514,20 +2507,19 @@ def tasks_procmemory(request, task_id, pid="all"): if check["error"]: return Response(check) - if check.get("tlp", "") in ("red", "Red"): - return Response({"error": True, "error_value": "Task has a TLP of RED"}) - - rtid = check.get("rtid", 0) if rtid: task_id = rtid # Recovery_ pivots to a DIFFERENT task; the earlier _deny_if_hidden # authorized the ORIGINAL id, so re-gate visibility on the RESOLVED id - # before serving its artifacts (wrong-object authorization otherwise). + # BEFORE the TLP/serving checks (wrong-object authz + no TLP existence oracle). _rtid_denied = _deny_if_hidden(request, db.view_task(task_id)) if _rtid_denied is not None: return _rtid_denied + if check.get("tlp", "") in ("red", "Red"): + return Response({"error": True, "error_value": "Task has a TLP of RED"}) + _central_stage(request, task_id, include_memory=True) # Check if any process memory dumps exist srcdir = os.path.join(CUCKOO_ROOT, "storage", "analyses", f"{task_id}", "memory") @@ -2602,20 +2594,19 @@ def tasks_fullmemory(request, task_id): if check["error"]: return Response(check) - if check.get("tlp", "") in ("red", "Red"): - return Response({"error": True, "error_value": "Task has a TLP of RED"}) - - rtid = check.get("rtid", 0) if rtid: task_id = rtid # Recovery_ pivots to a DIFFERENT task; the earlier _deny_if_hidden # authorized the ORIGINAL id, so re-gate visibility on the RESOLVED id - # before serving its artifacts (wrong-object authorization otherwise). + # BEFORE the TLP/serving checks (wrong-object authz + no TLP existence oracle). _rtid_denied = _deny_if_hidden(request, db.view_task(task_id)) if _rtid_denied is not None: return _rtid_denied + if check.get("tlp", "") in ("red", "Red"): + return Response({"error": True, "error_value": "Task has a TLP of RED"}) + _central_stage(request, task_id, include_memory=True) filename = "" file_path = os.path.join(CUCKOO_ROOT, "storage", "analyses", str(task_id), "memory.dmp") @@ -2880,20 +2871,19 @@ def tasks_payloadfiles(request, task_id): if check["error"]: return Response(check) - if check.get("tlp", "") in ("red", "Red"): - return Response({"error": True, "error_value": "Task has a TLP of RED"}) - - rtid = check.get("rtid", 0) if rtid: task_id = rtid # Recovery_ pivots to a DIFFERENT task; the earlier _deny_if_hidden # authorized the ORIGINAL id, so re-gate visibility on the RESOLVED id - # before serving its artifacts (wrong-object authorization otherwise). + # BEFORE the TLP/serving checks (wrong-object authz + no TLP existence oracle). _rtid_denied = _deny_if_hidden(request, db.view_task(task_id)) if _rtid_denied is not None: return _rtid_denied + if check.get("tlp", "") in ("red", "Red"): + return Response({"error": True, "error_value": "Task has a TLP of RED"}) + _central_stage(request, task_id) srcdir = os.path.join(CUCKOO_ROOT, "storage", "analyses", task_id, "CAPE") @@ -2928,20 +2918,19 @@ def tasks_procdumpfiles(request, task_id): if check["error"]: return Response(check) - if check.get("tlp", "") in ("red", "Red"): - return Response({"error": True, "error_value": "Task has a TLP of RED"}) - - rtid = check.get("rtid", 0) if rtid: task_id = rtid # Recovery_ pivots to a DIFFERENT task; the earlier _deny_if_hidden # authorized the ORIGINAL id, so re-gate visibility on the RESOLVED id - # before serving its artifacts (wrong-object authorization otherwise). + # BEFORE the TLP/serving checks (wrong-object authz + no TLP existence oracle). _rtid_denied = _deny_if_hidden(request, db.view_task(task_id)) if _rtid_denied is not None: return _rtid_denied + if check.get("tlp", "") in ("red", "Red"): + return Response({"error": True, "error_value": "Task has a TLP of RED"}) + _central_stage(request, task_id) # ToDo add all/one @@ -2976,20 +2965,19 @@ def tasks_config(request, task_id, cape_name=False): if check["error"]: return Response(check) - if check.get("tlp", "") in ("red", "Red"): - return Response({"error": True, "error_value": "Task has a TLP of RED"}) - - rtid = check.get("rtid", 0) if rtid: task_id = rtid # Recovery_ pivots to a DIFFERENT task; the earlier _deny_if_hidden # authorized the ORIGINAL id, so re-gate visibility on the RESOLVED id - # before serving its artifacts (wrong-object authorization otherwise). + # BEFORE the TLP/serving checks (wrong-object authz + no TLP existence oracle). _rtid_denied = _deny_if_hidden(request, db.view_task(task_id)) if _rtid_denied is not None: return _rtid_denied + if check.get("tlp", "") in ("red", "Red"): + return Response({"error": True, "error_value": "Task has a TLP of RED"}) + _central_stage(request, task_id) buf = {} From b1ed79dd2fdd7105a8069864517dc25de19ed0f0 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Mon, 13 Jul 2026 17:14:09 -0500 Subject: [PATCH 011/183] mt: correct stale 503 message + comments after the round-5 rollback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The final re-verification (clean) flagged three non-leak accuracy nits introduced by the round-5 changes: - tasks_set_visibility now reports the change was ABORTED and rolled back (round-5 makes set_task_visibility roll SQL back on a mongo-down sync failure), not "updated in DB" — so an operator retries instead of assuming a public->private toggle applied. - direct_vnc_host_port comment: the gate is viewer_for().is_local_admin (break-glass admin, config-aware), not raw is_superuser. - set_task_visibility comment: retries live in graceful_auto_reconnect; on a persistent failure we roll back + raise (the old 3-attempt loop is gone). Comment/message-only; no logic change. --- lib/cuckoo/core/data/tasking.py | 7 ++++--- web/apiv2/views.py | 8 ++++---- web/guac/views.py | 7 ++++--- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/lib/cuckoo/core/data/tasking.py b/lib/cuckoo/core/data/tasking.py index 1ed69ddf7eb..4d798d1e5cc 100644 --- a/lib/cuckoo/core/data/tasking.py +++ b/lib/cuckoo/core/data/tasking.py @@ -866,9 +866,10 @@ def set_task_visibility(self, task_id: int, visibility: str) -> Optional[Task]: self.session.commit() # Sync the toggle to the mongo report so the aggregate/search/stats surfaces # (which read the stamped info.visibility) reflect it — but only when mongo - # is the enabled report store. Retry transient blips; surface a persistent - # failure (raise) rather than silently diverging into a stale public stamp - # after a private toggle. SQL stays authoritative regardless. + # is the enabled report store. mongo_update_one retries transient blips + # internally (graceful_auto_reconnect); on a persistent failure we roll the + # SQL change back (below) so the two stores never diverge into a stale public + # stamp after a private toggle, and raise so the caller can retry. if mongo_update_one is not None and _mongo_reporting_enabled(): # mongo_update_one is wrapped by graceful_auto_reconnect, which retries # AutoReconnect/ServerSelectionTimeoutError internally (the canonical diff --git a/web/apiv2/views.py b/web/apiv2/views.py index 96f932db556..b13889a1d8b 100644 --- a/web/apiv2/views.py +++ b/web/apiv2/views.py @@ -99,11 +99,11 @@ def tasks_set_visibility(request, task_id): try: db.set_task_visibility(task_id, vis) except CuckooOperationalError: - # SQL was updated but the mongo-stamp sync failed (mongo unreachable), so - # the aggregate/search surfaces would be stale. Report 503 so the caller - # retries instead of assuming the change fully propagated. + # The report store (mongo) was unreachable, so set_task_visibility rolled + # the SQL change back to keep the two stores consistent — NOTHING changed. + # Report 503 so the caller retries; the task is still at its prior visibility. return Response( - {"error": True, "error_value": "visibility updated in DB but the report store sync failed; retry"}, + {"error": True, "error_value": "visibility change aborted (report store unreachable); no change made, retry"}, status=503, ) return Response({"error": False, "data": {"task_id": int(task_id), "visibility": vis}}) diff --git a/web/guac/views.py b/web/guac/views.py index d6f1bdaa96c..02ecd19cedb 100644 --- a/web/guac/views.py +++ b/web/guac/views.py @@ -164,9 +164,10 @@ def direct_vnc_host_port(request, host, port): if not is_vnc_console_enabled(): return _error(request, 0, "VNC Console is disabled in configuration") # Direct VNC opens a raw tunnel to a caller-chosen host:port with no task/tenant - # scoping (task_id=0). Restrict to superusers — it is an operator console, never - # a tenant-user surface; without this a logged-in tenant user could reach any - # reachable host:port. Config-gated + admin-gated. + # scoping (task_id=0). Restrict to break-glass admins (viewer_for().is_local_admin + # — config-aware: a plain tenant user or non-break-glass superuser is denied) — + # it is an operator console, never a tenant-user surface; without this a logged-in + # tenant user could reach any reachable host:port. Config-gated + admin-gated. if not viewer_for(request.user).is_local_admin: return _error(request, 0, "VNC Console is restricted to administrators") From 14d88b5c6a7077f85384a13beb8c13dab6d4cefa Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Mon, 13 Jul 2026 17:44:56 -0500 Subject: [PATCH 012/183] =?UTF-8?q?mt:=20address=20Gemini=20review=20?= =?UTF-8?q?=E2=80=94=20batch=20N+1=20visibility=20+=20fix=20cache=20poison?= =?UTF-8?q?ing=20+=20sample=20oracle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gemini's review of the fork-internal PR flagged 7 valid items; all fixed. Security: - top_detections shared-cache: bypass the 10-minute cache when EITHER scope_match OR viewer is set (the ES branch scopes via viewer, not scope_match) — keying only on scope_match served/poisoned the global cache with tenant-scoped data. - sample_path_by_hash(task_id=...): gate the SPECIFIC task_id (it must itself be visible) before resolving its sample, instead of "any visible task for the sample" — the latter leaks whether another tenant's task_id analyzed a shared sample (existence oracle). Performance (N+1 -> one query each): - compare left()/hash() mongo md5-pivot, capeyarazipall record filter, and search() now batch the visibility check via a single list_tasks(task_ids=..., visible_to=...) instead of a view_task per record. Behaviour-equivalent (the existing visibility tests still pass; the capeyara regression test now mocks list_tasks). 168 MT tests pass; ruff clean. --- lib/cuckoo/common/web_utils.py | 12 ++++--- lib/cuckoo/core/data/samples.py | 14 +++++++-- web/analysis/test_visibility.py | 4 ++- web/analysis/views.py | 55 ++++++++++++++++++++------------- web/compare/views.py | 38 ++++++++++++++++------- 5 files changed, 83 insertions(+), 40 deletions(-) diff --git a/lib/cuckoo/common/web_utils.py b/lib/cuckoo/common/web_utils.py index 3246c92bb42..d1d994746b9 100644 --- a/lib/cuckoo/common/web_utils.py +++ b/lib/cuckoo/common/web_utils.py @@ -332,8 +332,11 @@ def top_detections(date_since: datetime = False, results_limit: int = 20, scope_ t = int(time.time()) # caches results for 10 minutes; scoped calls bypass the cache entirely to - # prevent cross-tenant leaks (cache is shared across all callers). - if not scope_match and hasattr(top_detections, "cache"): + # prevent cross-tenant leaks (cache is shared across all callers). Bypass when + # EITHER scope_match OR viewer is set — the ES branch scopes via `viewer` + # (not scope_match), so keying only on scope_match would serve/poison the + # shared global cache with tenant-scoped data. + if not scope_match and not viewer and hasattr(top_detections, "cache"): ct, data = top_detections.cache if t - ct < 600: return data @@ -389,8 +392,9 @@ def top_detections(date_since: datetime = False, results_limit: int = 20, scope_ if data: data = list(data) - # save to cache only for unscoped (global) calls - if not scope_match: + # save to cache only for unscoped (global) calls — never when scope_match OR + # viewer is set, or a tenant-scoped result would poison the shared cache. + if not scope_match and not viewer: top_detections.cache = (t, data) return data diff --git a/lib/cuckoo/core/data/samples.py b/lib/cuckoo/core/data/samples.py index 8fd23cbab19..b95e838b26a 100644 --- a/lib/cuckoo/core/data/samples.py +++ b/lib/cuckoo/core/data/samples.py @@ -299,13 +299,21 @@ def sample_path_by_hash(self, sample_hash: str = False, task_id: int = False, vi if visible_to is not None and not getattr(visible_to, "is_local_admin", False): if task_id: - # task_id already gives us the Sample via the join — no extra query. + # A specific task_id must itself be visible to the caller. Checking + # only "any visible task for this sample" would leak whether ANOTHER + # tenant's task_id analyzed a sample the caller also has (an + # existence oracle) — so gate the task_id directly, then resolve + # its sample. + if not self.list_tasks(task_ids=[task_id], visible_to=visible_to, limit=1): + return [] _samp = self.session.scalar(select(Sample).join(Task, Sample.id == Task.sample_id).where(Task.id == task_id)) + if _samp is None: + return [] elif sample_hash and sizes.get(len(sample_hash)): _samp = self.session.scalar(select(Sample).where(sizes[len(sample_hash)] == sample_hash)) + if _samp is None or not self.list_tasks(sample_id=_samp.id, visible_to=visible_to, limit=1): + return [] else: - _samp = None - if _samp is None or not self.list_tasks(sample_id=_samp.id, visible_to=visible_to, limit=1): return [] hashlib_sizes = { diff --git a/web/analysis/test_visibility.py b/web/analysis/test_visibility.py index 1b5581d1170..e73c15f9083 100644 --- a/web/analysis/test_visibility.py +++ b/web/analysis/test_visibility.py @@ -132,7 +132,9 @@ def _fake_yara(term, recs): monkeypatch.setattr(av, "yara_detected", _fake_yara) monkeypatch.setattr(av, "path_exists", lambda p: True) - monkeypatch.setattr(av.db, "view_task", lambda tid: OwnTask() if int(tid) == 2 else ForeignTask()) + # The gate batch-resolves visible tasks via list_tasks (one query, no N+1); + # only task 2 (own/public) is visible, task 3 (foreign/private) is not. + monkeypatch.setattr(av.db, "list_tasks", lambda *a, **k: [OwnTask()]) req = RequestFactory().get("/file/capeyarazipall/2/Emotet/") req.user = User.objects.create_user("fs", "fs@x.com", "x") # tenant-less, non-admin diff --git a/web/analysis/views.py b/web/analysis/views.py index 6fedae578f6..76b6b0025cc 100644 --- a/web/analysis/views.py +++ b/web/analysis/views.py @@ -3328,17 +3328,25 @@ def _file_search_all_files(search_category: str, search_term: str, request) -> l # path-regex backstop misses it and would stream another tenant's private # sample bytes. Gate on info.id (in the projection) for ALL path shapes; # no-op when MT disabled (can_view_task -> is_local_admin). + _rids = [] + for _rec in records: + _rid = (_rec.get("info") or {}).get("id") + if _rid is not None: + try: + _rids.append(int(_rid)) + except (ValueError, TypeError): + pass + # Batch the visibility check in ONE SQL query (avoid an N+1 view_task per + # search record); list_tasks(visible_to=) returns only readable tasks. + _visible = {t.id for t in db.list_tasks(task_ids=_rids, visible_to=viewer_for(request.user))} if _rids else set() _viewable = [] for _rec in records: _rid = (_rec.get("info") or {}).get("id") - if _rid is None: - continue try: - _vt = db.view_task(int(_rid)) + if _rid is not None and int(_rid) in _visible: + _viewable.append(_rec) except (ValueError, TypeError): continue - if _vt is not None and can_view_task(request.user, _vt): - _viewable.append(_rec) search_term = search_term.lower() for _, filepath, _, _ in yara_detected(search_term, _viewable): if not path_exists(filepath): @@ -3880,28 +3888,33 @@ def search(request, searched=""): {"title": "Search", "analyses": None, "term": None, "error": "Unable to recognize the search syntax"}, ) - analyses = [] - for result in records or []: - task_id = None + def _result_task_id(result): if enabledconf["mongodb"] and enabledconf["elasticsearchdb"] and essearch and not term: - task_id = (result.get("_source") or {}).get("task_id") + tid = (result.get("_source") or {}).get("task_id") elif enabledconf["mongodb"] and term and "info" in result: - task_id = (result.get("info") or {}).get("id") + tid = (result.get("info") or {}).get("id") elif es_as_db: - task_id = (result.get("info") or {}).get("id") - if task_id is None: - continue + tid = (result.get("info") or {}).get("id") + else: + tid = None + if tid is None: + return None try: - task_id = int(task_id) + return int(tid) except (ValueError, TypeError): - continue # malformed id in a corrupt report — skip, don't 500 - # tenant isolation: gate BEFORE the heavy get_analysis_info() (mongo/es - # lookups + processing) — skip unauthorized tasks cheaply and reuse the - # resolved task so get_analysis_info doesn't re-query Postgres. - _vt = db.view_task(task_id) - if _vt is None or not can_view_task(request.user, _vt): + return None + + # tenant isolation: batch-resolve the caller's visible tasks in ONE SQL + # query (avoid an N+1 view_task per result) and gate BEFORE the heavy + # get_analysis_info(); reuse the resolved Task so it doesn't re-query. + _tids = [t for t in (_result_task_id(r) for r in (records or [])) if t is not None] + _visible = {t.id: t for t in db.list_tasks(task_ids=_tids, visible_to=viewer_for(request.user))} if _tids else {} + analyses = [] + for result in records or []: + tid = _result_task_id(result) + if tid is None or tid not in _visible: continue - new = get_analysis_info(db, task=_vt) + new = get_analysis_info(db, task=_visible[tid]) if not new: continue analyses.append(new) diff --git a/web/compare/views.py b/web/compare/views.py index 4a486785db1..2a21d58ac45 100644 --- a/web/compare/views.py +++ b/web/compare/views.py @@ -14,7 +14,7 @@ import lib.cuckoo.common.compare as compare from lib.cuckoo.common.config import Config from lib.cuckoo.core.database import Database -from web.tenancy_optional import can_view_task +from web.tenancy_optional import can_view_task, viewer_for enabledconf = {} confdata = Config("reporting").get_config() @@ -85,17 +85,25 @@ def left(request, left_id): # gap can't leak another tenant's analysis even if the query-layer scope # regresses. No-op for break-glass / shared / multitenancy disabled. _db = Database() + _rids = [] + for _rec in _raw: + _rid = (_rec.get("info") or {}).get("id") + if _rid is not None: + try: + _rids.append(int(_rid)) + except (ValueError, TypeError): + pass + # Batch the visibility check in ONE SQL query (avoid an N+1 view_task per + # md5-pivot record); list_tasks(visible_to=) returns only readable tasks. + _visible = {t.id for t in _db.list_tasks(task_ids=_rids, visible_to=viewer_for(request.user))} if _rids else set() records = [] for _rec in _raw: _rid = (_rec.get("info") or {}).get("id") - if _rid is None: - continue try: - _vt = _db.view_task(int(_rid)) + if _rid is not None and int(_rid) in _visible: + records.append(_rec) except (ValueError, TypeError): continue - if _vt is not None and can_view_task(request.user, _vt): - records.append(_rec) if es_as_db: records = [] q = { @@ -161,17 +169,25 @@ def hash(request, left_id, right_hash): # gap can't leak another tenant's analysis even if the query-layer scope # regresses. No-op for break-glass / shared / multitenancy disabled. _db = Database() + _rids = [] + for _rec in _raw: + _rid = (_rec.get("info") or {}).get("id") + if _rid is not None: + try: + _rids.append(int(_rid)) + except (ValueError, TypeError): + pass + # Batch the visibility check in ONE SQL query (avoid an N+1 view_task per + # md5-pivot record); list_tasks(visible_to=) returns only readable tasks. + _visible = {t.id for t in _db.list_tasks(task_ids=_rids, visible_to=viewer_for(request.user))} if _rids else set() records = [] for _rec in _raw: _rid = (_rec.get("info") or {}).get("id") - if _rid is None: - continue try: - _vt = _db.view_task(int(_rid)) + if _rid is not None and int(_rid) in _visible: + records.append(_rec) except (ValueError, TypeError): continue - if _vt is not None and can_view_task(request.user, _vt): - records.append(_rec) if es_as_db: records = [] q = { From 61160942d02840414093251b9c9d2573cccf6bec Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Mon, 13 Jul 2026 19:20:49 -0500 Subject: [PATCH 013/183] =?UTF-8?q?mt:=20address=20Codex=20+=20Copilot=20r?= =?UTF-8?q?eview=20=E2=80=94=20PCAP=20tenancy,=20OIDC=20claims,=20finish?= =?UTF-8?q?=20guard,=20toggle=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex + Copilot reviews on the PR flagged 5 valid MT items; all fixed + tested. - (Codex P1) API PCAP submit: the tasks/create/file pcap branch called add_pcap() directly, bypassing the details tenancy dict, so it used defaults (user_id=0, tenant_id=None, visibility=private) — a tenant user's own PCAP task was invisible to them in locked mode. Pass the submitter's user/tenant/visibility. - (Codex + Copilot P2) OIDC login tenant guard checked the raw extra_data, but the openid_connect provider nests claims under extra["userinfo"], so the groups claim read as absent and reconcile_tenant() was skipped — SSO users logged in with no tenant/tenant-admin membership. Guard on _claims(extra). (+ regression test) - (Codex P2) tasks_status POST status=finish was gated only by read visibility, so a same-tenant read-only user could stop another user's running analysis. Require can_manage_task on the mutation. - (Copilot) reconcile_tenant could TypeError on non-string JSONField group entries and wrote UserProfile on every login. String-filter the groups + save only when the tenant/admin values actually change. (+ regression test) - (Copilot) report.html visibility dropdown never reverted the FIRST failed update (no initial data-prev; onchange fires after the value changed). Seed data-prev from the server-rendered visibility. 170 MT tests pass; ruff clean. --- web/apiv2/views.py | 16 +++++++++++- web/templates/analysis/report.html | 2 +- web/users/test_tenancy.py | 37 +++++++++++++++++++++++++++ web/web/allauth_adapters.py | 41 +++++++++++++++++++----------- 4 files changed, 79 insertions(+), 17 deletions(-) diff --git a/web/apiv2/views.py b/web/apiv2/views.py index b13889a1d8b..7dbebca5097 100644 --- a/web/apiv2/views.py +++ b/web/apiv2/views.py @@ -468,7 +468,16 @@ def tasks_create_file(request): else: details["error"].append({os.path.basename(tmp_path): "Failed to convert SAZ to PCAP"}) continue - task_id = db.add_pcap(file_path=tmp_path) + # Carry the submitter's tenancy (same scope the other create + # branches use via details) — otherwise add_pcap's defaults + # (user_id=0, tenant_id=None, visibility=private) make a tenant + # user's own PCAP task invisible to them in locked mode. + task_id = db.add_pcap( + file_path=tmp_path, + user_id=details["user_id"], + tenant_id=details["tenant_id"], + visibility=details["visibility"], + ) details["task_ids"].append(task_id) continue if static: @@ -1283,6 +1292,11 @@ def tasks_status(request, task_id): status = task.to_dict()["status"] resp = {"error": False, "data": status} elif request.method == "POST" and apiconf.user_stop.enabled and request.data.get("status", "") == "finish": + # Stopping/finishing a running analysis is a MUTATION — require manage + # rights (owner / tenant-admin / break-glass), not just read visibility, + # so a same-tenant read-only user can't end another user's analysis. + if not can_manage_task(request.user, task): + return Response({"error": True, "error_value": "Access denied"}, status=403) machine = db.view_machine(task.guest.name) # Todo probably add task status if pending if machine.status == "running": diff --git a/web/templates/analysis/report.html b/web/templates/analysis/report.html index 6afebd459e1..2af3c004ed5 100644 --- a/web/templates/analysis/report.html +++ b/web/templates/analysis/report.html @@ -3,7 +3,7 @@ {% if can_toggle_visibility %}
- diff --git a/web/users/test_tenancy.py b/web/users/test_tenancy.py index e26465712c9..f2fd5ace238 100644 --- a/web/users/test_tenancy.py +++ b/web/users/test_tenancy.py @@ -219,3 +219,40 @@ class Req: r3.data = {"visibility": "bogus"} with _pytest.raises(ValueError): ut.submission_scope(r3) + + +@pytest.mark.django_db +def test_reconcile_tenant_tolerates_non_string_groups(): + """Copilot: a tenant's idp_groups JSONField may contain non-string junk; the + set intersection must not TypeError (and must still match on the valid names).""" + from web.allauth_adapters import reconcile_tenant + from users.models import Tenant, UserProfile + from django.contrib.auth.models import User + + Tenant.objects.create(slug="acme", name="Acme", idp_groups=["acme-soc", {"bad": 1}, None]) + u = User.objects.create_user("ns", "ns@x.com", "x") + reconcile_tenant(u, {"acme-soc"}) # must not raise despite the non-string entries + assert UserProfile.objects.get(user=u).tenant.slug == "acme" + + +@pytest.mark.django_db +def test_sso_login_reconciles_tenant_from_userinfo_claims(monkeypatch, settings): + """Codex/Copilot: the openid_connect provider nests claims under + extra['userinfo']; the login guard must normalize (via _claims) before deciding + the groups claim is absent, else SSO users get no tenant/admin membership.""" + import types + from web import allauth_adapters as aa + from users.models import Tenant, UserProfile + from django.contrib.auth.models import User + + Tenant.objects.create(slug="acme", name="Acme", idp_groups=["acme-soc"]) + settings.OIDC_CFG = {"groups_claim": "groups"} + # isolate the tenant path (role/email reconciliation is tested elsewhere) + monkeypatch.setattr(aa, "_apply_idp_roles_and_email", lambda user, extra: False) + + u = User.objects.create_user("sso", "sso@x.com", "x") + extra = {"id_token": "jwt", "userinfo": {"groups": ["acme-soc"]}} # nested claim shape + sl = types.SimpleNamespace(account=types.SimpleNamespace(extra_data=extra)) + aa._reconcile_sso_user_on_login(sender=None, request=None, user=u, sociallogin=sl) + + assert UserProfile.objects.get(user=u).tenant.slug == "acme" diff --git a/web/web/allauth_adapters.py b/web/web/allauth_adapters.py index a195aaa4d67..33a0975769b 100644 --- a/web/web/allauth_adapters.py +++ b/web/web/allauth_adapters.py @@ -296,23 +296,30 @@ def reconcile_tenant(user, user_groups: set) -> None: """ from users.models import Tenant, UserProfile - matches = [t for t in Tenant.objects.filter(active=True) if user_groups & set(t.idp_groups or [])] + def _g(vals): + # A tenant's idp_groups/admin_idp_groups come from a JSONField; keep only + # hashable strings so a malformed config (e.g. a nested dict) can't + # TypeError the set intersection and 500 the login. + return {g for g in (vals or []) if isinstance(g, str)} + + matches = [t for t in Tenant.objects.filter(active=True) if user_groups & _g(t.idp_groups)] prof, _ = UserProfile.objects.get_or_create(user=user) - if len(matches) > 1: - log.warning( - "user %s matches multiple tenants %s; leaving tenant unset", - user.username, [t.slug for t in matches], - ) - prof.tenant = None - prof.is_tenant_admin = False - elif len(matches) == 1: + if len(matches) == 1: t = matches[0] - prof.tenant = t - prof.is_tenant_admin = bool(user_groups & set(t.admin_idp_groups or [])) + new_tenant, new_admin = t, bool(user_groups & _g(t.admin_idp_groups)) else: - prof.tenant = None - prof.is_tenant_admin = False - prof.save(update_fields=["tenant", "is_tenant_admin"]) + if len(matches) > 1: + log.warning( + "user %s matches multiple tenants %s; leaving tenant unset", + user.username, [t.slug for t in matches], + ) + new_tenant, new_admin = None, False + # Only write when something actually changed — avoid a needless UPDATE on + # every SSO login. + if prof.tenant_id != getattr(new_tenant, "id", None) or bool(prof.is_tenant_admin) != new_admin: + prof.tenant = new_tenant + prof.is_tenant_admin = new_admin + prof.save(update_fields=["tenant", "is_tenant_admin"]) # ── Account adapters ────────────────────────────────────────────────────────── @@ -461,5 +468,9 @@ def _reconcile_sso_user_on_login(sender, request, user, **kwargs): # only touch tenant membership when the groups claim is actually present. oidc_cfg = getattr(settings, "OIDC_CFG", None) or {} claim = oidc_cfg.get("groups_claim") or "groups" - if claim in extra: + # Normalize first: the openid_connect provider nests claims under + # extra["userinfo"], so checking the raw wrapper would treat the groups claim + # as absent and skip tenant reconciliation entirely — SSO users would log in + # with no tenant/tenant-admin membership. _extract_groups already normalizes. + if claim in _claims(extra): reconcile_tenant(user, _extract_groups(extra)) From 3d587902414696fd62a0bdc70d841d45c4ce7600 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Mon, 13 Jul 2026 22:21:45 -0500 Subject: [PATCH 014/183] =?UTF-8?q?mt:=20address=20Copilot=20re-review=20?= =?UTF-8?q?=E2=80=94=20parse=20task=5Fid=20+=20atomic=20visibility=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot re-review of 61160942: - tasks_set_visibility now parses task_id to int once up front, so view_task() and set_task_visibility() get a consistent value and a non-numeric id fails as the same generic 404 instead of relying on implicit SQLAlchemy coercion. - set_task_visibility now FLUSHes the SQL change, syncs the mongo stamp, then commits on success / rolls back on failure — removing the transient window where SQL reads "private" while the mongo aggregate/search/stats surfaces still read "public" (and the commit-then-rollback churn when mongo is down). --- lib/cuckoo/core/data/tasking.py | 37 +++++++++++++-------------------- web/apiv2/views.py | 6 ++++++ 2 files changed, 20 insertions(+), 23 deletions(-) diff --git a/lib/cuckoo/core/data/tasking.py b/lib/cuckoo/core/data/tasking.py index 4d798d1e5cc..dc0642808bd 100644 --- a/lib/cuckoo/core/data/tasking.py +++ b/lib/cuckoo/core/data/tasking.py @@ -861,24 +861,18 @@ def set_task_visibility(self, task_id: int, visibility: str) -> Optional[Task]: task = self.session.get(Task, task_id) if not task: return None - _prev_visibility = task.visibility task.visibility = visibility - self.session.commit() - # Sync the toggle to the mongo report so the aggregate/search/stats surfaces - # (which read the stamped info.visibility) reflect it — but only when mongo - # is the enabled report store. mongo_update_one retries transient blips - # internally (graceful_auto_reconnect); on a persistent failure we roll the - # SQL change back (below) so the two stores never diverge into a stale public - # stamp after a private toggle, and raise so the caller can retry. + # Two-store consistency: FLUSH (don't commit) the SQL change, sync the mongo + # stamp, then commit only if mongo succeeded — so there is no window where + # SQL reads "private" while the mongo aggregate/search/stats surfaces still + # read "public", and no commit-then-rollback churn when mongo is down. When + # mongo isn't the enabled report store, just commit the SQL change. + self.session.flush() if mongo_update_one is not None and _mongo_reporting_enabled(): # mongo_update_one is wrapped by graceful_auto_reconnect, which retries - # AutoReconnect/ServerSelectionTimeoutError internally (the canonical - # mongo-down errors) and RETURNS None with no re-raise when mongo stays - # down. So detect a swallowed failure by the None return, not by - # catching an exception — a successful update returns an UpdateResult - # (even with matched_count=0). Surface a persistent failure so a stale - # public stamp after a private toggle can't silently keep the analysis - # cross-tenant visible in the aggregate/search/stats surfaces. + # AutoReconnect/ServerSelectionTimeoutError internally and RETURNS None + # with no re-raise when mongo stays down. Detect a swallowed failure by + # the None return (a success returns an UpdateResult, even matched_count=0). _res = None try: _res = mongo_update_one("analysis", {"info.id": task_id}, {"$set": {"info.visibility": visibility}}) @@ -887,15 +881,12 @@ def set_task_visibility(self, task_id: int, visibility: str) -> Optional[Task]: if _res is None: from lib.cuckoo.common.exceptions import CuckooOperationalError - # Roll the SQL change back so the two stores can't diverge: a stale - # public mongo stamp after a private SQL toggle would keep the - # analysis cross-tenant visible in the aggregate/search/stats - # surfaces. Both stores end at the previous value and the caller - # gets a clear failure to retry. - task.visibility = _prev_visibility - self.session.commit() - log.error("visibility mongo sync FAILED for task %s (mongo unreachable); rolled SQL back to %r", task_id, _prev_visibility) + # Mongo unreachable — roll back the (uncommitted) SQL change so the + # two stores never diverge, and surface it so the caller retries. + self.session.rollback() + log.error("visibility mongo sync FAILED for task %s (mongo unreachable); rolled back", task_id) raise CuckooOperationalError(f"task {task_id} visibility change aborted: report store sync failed") + self.session.commit() return task def fetch_task(self, categories: list = None): diff --git a/web/apiv2/views.py b/web/apiv2/views.py index 7dbebca5097..516d18ab80c 100644 --- a/web/apiv2/views.py +++ b/web/apiv2/views.py @@ -84,6 +84,12 @@ def _deny_by_hash(request, *, sha256=None, sha1=None, md5=None, sample_id=None): def tasks_set_visibility(request, task_id): """Owner (or tenant-admin for public/tenant jobs, or superuser) re-toggles a task's visibility. Mirrors the can_toggle predicate.""" + # Parse once so view_task() and set_task_visibility() get a consistent int and + # a non-numeric id fails as the same generic 404 (no implicit-coercion no-op). + try: + task_id = int(task_id) + except (ValueError, TypeError): + return Response({"error": True, "error_value": "Task not found"}, status=404) task = db.view_task(task_id) # Indistinguishable response (H3): a caller who can't even SEE the task gets # the SAME generic 404 as a missing one, so this endpoint can't be used to From 1d18e29e8cc3aafa726121f68b86ffab1e08b1b2 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Tue, 14 Jul 2026 00:59:14 -0500 Subject: [PATCH 015/183] mt: session-auth the report visibility PATCH endpoint (Codex) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex: in SSO/OIDC deployments settings.py drops SessionAuthentication from the DRF default chain, so the report-page visibility toggle (browser session + CSRF, no API token) was rejected — owners/admins couldn't change visibility from the UI. Opt tasks_set_visibility into @authentication_classes([SessionAuthentication, ApiKeyAuthentication]) (the per-view pattern settings.py documents) so both the in-browser control and scripted API-token clients authenticate. --- web/apiv2/test_visibility.py | 13 +++++++++++++ web/apiv2/views.py | 11 ++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/web/apiv2/test_visibility.py b/web/apiv2/test_visibility.py index 12afd0f4a06..80dcb7093b9 100644 --- a/web/apiv2/test_visibility.py +++ b/web/apiv2/test_visibility.py @@ -704,3 +704,16 @@ def to_dict(self): req.user = u resp = av.tasks_view(req, 5) assert resp.status_code == 404 # denied on the resolved foreign task, not served + + +def test_visibility_endpoint_opts_into_session_auth(): + """Codex: the report-page visibility toggle authenticates with a browser + session; SessionAuthentication is dropped from the DRF default in SSO mode, so + the endpoint must opt back into it (while keeping API-token auth).""" + import apiv2.views as av + from rest_framework.authentication import SessionAuthentication + + cls = getattr(av.tasks_set_visibility, "cls", None) + assert cls is not None, "tasks_set_visibility should be a DRF @api_view" + assert SessionAuthentication in cls.authentication_classes, \ + "visibility toggle must accept browser session auth (SSO drops it from the default)" diff --git a/web/apiv2/views.py b/web/apiv2/views.py index 516d18ab80c..7c2fc836797 100644 --- a/web/apiv2/views.py +++ b/web/apiv2/views.py @@ -24,12 +24,20 @@ from django.shortcuts import render from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_safe -from rest_framework.decorators import api_view +from rest_framework.decorators import api_view, authentication_classes +from rest_framework.authentication import SessionAuthentication try: from apikey.authentication import ApiKeyAuthentication except ImportError: ApiKeyAuthentication = None +# Auth chain for UI-internal DRF endpoints (e.g. the report-page visibility +# toggle): SessionAuthentication is dropped from the DRF default in SSO/OIDC +# mode (see settings.py), so a browser session + CSRF can't hit /apiv2/. Opt +# these endpoints back into session auth WHILE keeping API-token auth, so both +# the in-browser control and scripted API clients work. +_UI_INTERNAL_AUTH = [SessionAuthentication] + ([ApiKeyAuthentication] if ApiKeyAuthentication else []) + from web.tenancy_optional import submission_scope, can_view_task, can_toggle_task, can_manage_task, can_view_sample, viewer_for from web.tenancy_optional import VISIBILITIES @@ -81,6 +89,7 @@ def _deny_by_hash(request, *, sha256=None, sha1=None, md5=None, sample_id=None): @api_view(["PATCH"]) +@authentication_classes(_UI_INTERNAL_AUTH) def tasks_set_visibility(request, task_id): """Owner (or tenant-admin for public/tenant jobs, or superuser) re-toggles a task's visibility. Mirrors the can_toggle predicate.""" From 6bf4ac69eff90add05868a8d24d6bb4913af6dd8 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Tue, 14 Jul 2026 01:04:17 -0500 Subject: [PATCH 016/183] =?UTF-8?q?mt:=20set=5Ftask=5Fvisibility=20?= =?UTF-8?q?=E2=80=94=20sync=20mongo=20before=20SQL=20commit,=20revert=20in?= =?UTF-8?q?-place=20on=20failure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the Copilot atomicity fix: session.rollback() discarded more than the visibility change under the test's transaction model (broke the mongo-down regression test). Instead update the mongo stamp first while the SQL change is still uncommitted, then commit; on a persistent mongo failure revert the in-memory visibility to the previous value and commit that, then raise. Result: no committed SQL-private / mongo-public window, and no session.rollback() discarding unrelated pending work. --- lib/cuckoo/core/data/tasking.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/lib/cuckoo/core/data/tasking.py b/lib/cuckoo/core/data/tasking.py index dc0642808bd..cf4e87dd3b8 100644 --- a/lib/cuckoo/core/data/tasking.py +++ b/lib/cuckoo/core/data/tasking.py @@ -861,13 +861,16 @@ def set_task_visibility(self, task_id: int, visibility: str) -> Optional[Task]: task = self.session.get(Task, task_id) if not task: return None + _prev_visibility = task.visibility task.visibility = visibility - # Two-store consistency: FLUSH (don't commit) the SQL change, sync the mongo - # stamp, then commit only if mongo succeeded — so there is no window where - # SQL reads "private" while the mongo aggregate/search/stats surfaces still - # read "public", and no commit-then-rollback churn when mongo is down. When - # mongo isn't the enabled report store, just commit the SQL change. - self.session.flush() + # Two-store consistency: sync the mongo stamp FIRST (still uncommitted in + # SQL), then commit — so there's never a COMMITTED window where SQL reads + # "private" while the mongo aggregate/search/stats surfaces still read + # "public" (the leaky direction). If mongo is the enabled store and the sync + # fails, revert the in-memory SQL change to the previous value and commit + # that (leaving both stores at the old value), then raise so the caller + # retries. We revert in-place rather than session.rollback() so we don't + # discard unrelated pending work in a shared/scoped session. if mongo_update_one is not None and _mongo_reporting_enabled(): # mongo_update_one is wrapped by graceful_auto_reconnect, which retries # AutoReconnect/ServerSelectionTimeoutError internally and RETURNS None @@ -881,10 +884,9 @@ def set_task_visibility(self, task_id: int, visibility: str) -> Optional[Task]: if _res is None: from lib.cuckoo.common.exceptions import CuckooOperationalError - # Mongo unreachable — roll back the (uncommitted) SQL change so the - # two stores never diverge, and surface it so the caller retries. - self.session.rollback() - log.error("visibility mongo sync FAILED for task %s (mongo unreachable); rolled back", task_id) + task.visibility = _prev_visibility + self.session.commit() + log.error("visibility mongo sync FAILED for task %s (mongo unreachable); left at %r", task_id, _prev_visibility) raise CuckooOperationalError(f"task {task_id} visibility change aborted: report store sync failed") self.session.commit() return task From e148cc4847af6ed8a5d8ff60d9d06a79db7fde81 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Tue, 14 Jul 2026 08:26:23 -0500 Subject: [PATCH 017/183] mt: address Copilot re-review of 6bf4ac69 (visibility 400, csrf escapejs, ES stats note) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - submission index(): an invalid `visibility` now returns HTTP 400 (was 200) so the client-input failure is machine-detectable, per the submission_scope() contract. - report.html: escapejs the CSRF token interpolated into the JS string. - web_utils.top_detections: corrected the ES-branch comment — it applies the viewer's entitled UNION (not per-scope like the mongo branch), so ES-backed per-scope panels show the union; it never exposes another tenant's data (the viewer's own union) and ES + multitenancy is a documented not-yet-supported combination (mongo is the supported store). --- lib/cuckoo/common/web_utils.py | 13 +++++++++---- web/submission/views.py | 4 +++- web/templates/analysis/report.html | 2 +- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/lib/cuckoo/common/web_utils.py b/lib/cuckoo/common/web_utils.py index d1d994746b9..760a68c7fe0 100644 --- a/lib/cuckoo/common/web_utils.py +++ b/lib/cuckoo/common/web_utils.py @@ -376,10 +376,15 @@ def top_detections(date_since: datetime = False, results_limit: int = 20, scope_ if date_since: q["query"]["bool"]["must"].append({"range": {"info.started": {"gte": date_since.isoformat()}}}) - # Tenant scope (parity with the mongo branch's scope_match): without this - # a locked-mode tenant's My-Tenant/Mine stat panels return the GLOBAL - # per-family malware landscape on an ES-backed install. No-op for - # break-glass / MT-disabled. + # Tenant scope on Elasticsearch: apply the viewer's ENTITLED-UNION filter + # (public OR own-tenant OR mine) so an ES-backed install doesn't return the + # GLOBAL per-family landscape. NOTE: unlike the mongo branch (which applies + # the per-scope scope_match), this is the union, so per-scope stat panels on + # ES show the viewer's union rather than strictly public/tenant/mine. It + # never exposes another tenant's data (it is the viewer's own entitled + # union), and ES + multitenancy is a documented not-yet-supported combo + # (see docs/MULTITENANCY-SUPPORT.md) — mongo is the supported store. No-op + # for break-glass / MT-disabled. _esf = _viewer_scope_es_filter(viewer) if _esf: q["query"]["bool"].setdefault("filter", []).append(_esf) diff --git a/web/submission/views.py b/web/submission/views.py index 61ca26399cb..a3058e14904 100644 --- a/web/submission/views.py +++ b/web/submission/views.py @@ -297,7 +297,9 @@ def index(request, task_id=None, resubmit_hash=None): try: _tenant_id, _visibility = submission_scope(request) except ValueError: - return render(request, "error.html", {"error": "Invalid visibility value"}) + # Client input validation failure -> 400 (machine-detectable), per the + # submission_scope() contract that the view turns the bad value into a 400. + return render(request, "error.html", {"error": "Invalid visibility value"}, status=400) ( static, package, diff --git a/web/templates/analysis/report.html b/web/templates/analysis/report.html index 2af3c004ed5..65375da9507 100644 --- a/web/templates/analysis/report.html +++ b/web/templates/analysis/report.html @@ -14,7 +14,7 @@ var tid = sel.closest('.visibility-toggle').dataset.taskId; // Use Django's template token, not document.cookie — the cookie read fails // when CSRF_COOKIE_HTTPONLY is on. - var csrf = '{{ csrf_token }}'; + var csrf = '{{ csrf_token|escapejs }}'; var prev = sel.dataset.prev || sel.value; fetch('/apiv2/tasks/visibility/' + tid + '/', { method: 'PATCH', From f4db397a59566e748680a73011f7a92e70fbffe3 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Tue, 14 Jul 2026 08:33:08 -0500 Subject: [PATCH 018/183] =?UTF-8?q?mt:=20address=20Codex=20re-review=20?= =?UTF-8?q?=E2=80=94=20direct-VNC=20replay=20gate=20+=20inactive-tenant=20?= =?UTF-8?q?fail-closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex re-review of 6bf4ac69: - web/guac/consumers.py: the socket-side replay re-check only ran for task-backed sessions (guac_task_id > 0). Direct-VNC sessions (task_id=0) fell through with no check, so a replayed break-glass-admin guac_session cookie from a tenant/anon socket could open the raw VM/host console on token possession. The direct branch now re-validates viewer_for(ws_user).is_local_admin before resolving VNC. - web/users/tenancy.py: viewer_for kept trusting prof.tenant_id/is_tenant_admin after a Tenant was marked inactive, so deactivated-tenant members kept tenant-scoped read/submit access until the next SSO login reconciled. viewer_for now drops the tenant when its Tenant row is inactive (fail closed), matching reconcile_tenant's active=True filter. (+ regression test) --- web/guac/consumers.py | 16 ++++++++++++++++ web/users/tenancy.py | 15 +++++++++++++-- web/users/test_tenancy.py | 22 ++++++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/web/guac/consumers.py b/web/guac/consumers.py index 43f99445aa5..b9ed95c3618 100644 --- a/web/guac/consumers.py +++ b/web/guac/consumers.py @@ -216,6 +216,22 @@ async def connect(self): await self.close() return else: + # Direct VNC connection (task_id=0): no per-task gate applies and + # the mint endpoints are break-glass-admin-only, so re-validate that + # here too — a replayed direct-VNC guac_session cookie from a tenant + # or anonymous socket must not open the raw VM/host console on token + # possession alone. viewer_for resolves anonymous to non-admin when + # MT is on, and to local-admin when MT is off (legacy console works). + from web.tenancy_optional import viewer_for as _viewer_for + + ws_user = self.scope.get("user") + _is_admin = await sync_to_async(lambda: _viewer_for(ws_user).is_local_admin)() + if not _is_admin: + logger.warning("WebSocket rejected: direct VNC requires a break-glass admin") + await self._delete_guac_session() + await self.close() + return + # Direct VNC connection guest_ip = session_data.get("guest_ip") if not guest_ip: diff --git a/web/users/tenancy.py b/web/users/tenancy.py index 3157052658e..3570d900141 100644 --- a/web/users/tenancy.py +++ b/web/users/tenancy.py @@ -42,11 +42,22 @@ def viewer_for(user) -> Viewer: is_local = user.socialaccount_set.exists() except Exception: is_local = False + tenant_id = getattr(prof, "tenant_id", None) + is_tenant_admin = bool(getattr(prof, "is_tenant_admin", False)) + # Fail closed for a deactivated tenant: once a Tenant is marked inactive its + # members must NOT keep tenant-scoped read/submit access until the next SSO + # login reconciles (reconcile_tenant already filters active=True). Drop the + # tenant from the viewer if its Tenant row is inactive. + if tenant_id is not None and prof is not None: + _t = getattr(prof, "tenant", None) + if _t is not None and not getattr(_t, "active", True): + tenant_id = None + is_tenant_admin = False return Viewer( user_id=user.id, - tenant_id=getattr(prof, "tenant_id", None), + tenant_id=tenant_id, is_superuser=is_super, - is_tenant_admin=bool(getattr(prof, "is_tenant_admin", False)), + is_tenant_admin=is_tenant_admin, is_local_admin=is_local, ) diff --git a/web/users/test_tenancy.py b/web/users/test_tenancy.py index f2fd5ace238..63b3ccef0b6 100644 --- a/web/users/test_tenancy.py +++ b/web/users/test_tenancy.py @@ -256,3 +256,25 @@ def test_sso_login_reconciles_tenant_from_userinfo_claims(monkeypatch, settings) aa._reconcile_sso_user_on_login(sender=None, request=None, user=u, sociallogin=sl) assert UserProfile.objects.get(user=u).tenant.slug == "acme" + + +@pytest.mark.django_db +def test_viewer_for_inactive_tenant_fails_closed(mt_enabled): + """Codex: a deactivated Tenant must drop from the viewer immediately (not wait + for the next SSO login) — else its members keep tenant-scoped read/submit + access via the stale profile until reconcile.""" + from users.models import Tenant, UserProfile + from users.tenancy import viewer_for + + t = Tenant.objects.create(slug="acme-inact", name="AcmeInact") + u = User.objects.create_user("iat", "iat@x.com", "x") + p = UserProfile.objects.get(user=u) + p.tenant = t + p.is_tenant_admin = True + p.save() + + assert viewer_for(User.objects.get(pk=u.pk)).tenant_id == t.id # active -> tenant present + t.active = False + t.save() + v = viewer_for(User.objects.get(pk=u.pk)) # fresh load + assert v.tenant_id is None and v.is_tenant_admin is False # inactive -> fail closed From 93c69edaa3c164fda6af7fac88074debb6122351 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Tue, 14 Jul 2026 09:07:14 -0500 Subject: [PATCH 019/183] =?UTF-8?q?mt:=20address=20Copilot=20re-review=20?= =?UTF-8?q?=E2=80=94=20coerce=20task=20ids=20in=20analysis=20authz=20decor?= =?UTF-8?q?ators=20+=20drop=20dead=20config=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot re-review of f4db397a: - web/analysis/views.py: require_task_manage / require_task_visibility forwarded a URL-supplied task id straight to db.view_task() without coercion. On the \w+ analysis routes (filereport, full_memory_dump_file/_strings) a non-numeric id (e.g. /full_memory/abc/) reached the postgres task DB as "abc" and raised a DataError -> an uncaught 500 that also leaked a task-vs-no-task signal, breaking the decorators' "hidden and missing are indistinguishable" contract. Added a shared _coerce_task_id() helper used by both decorators: a non-numeric id now fails closed with the same generic 403, before any DB hit. (+ regression test) - conf/default/cuckoo.conf.default: removed the dead `tenant_claim_source` key — it had zero code references; tenant membership derives from the OIDC groups claim (OIDC_CFG['groups_claim']). Leaving it in was misleading to operators. Not changed (verified against actual code): - reconcile_tenant O(n) tenant scan: the suggested idp_groups__contains pushdown is unsupported on the sqlite auth DB (supports_json_field_contains=False -> NotSupportedError); the Python-side filter is the correct portable approach. Documented the constraint inline. - submission/views.py resubmit int-coercion: the str(task_id)/find_sample lines flagged are pre-existing CAPE code and the route is \d+ (always numeric), so the new authz gate already behaves correctly; no MT-scoped change needed. --- conf/default/cuckoo.conf.default | 2 -- web/analysis/test_visibility.py | 15 +++++++++++++++ web/analysis/views.py | 22 ++++++++++++++++++++++ web/web/allauth_adapters.py | 4 ++++ 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/conf/default/cuckoo.conf.default b/conf/default/cuckoo.conf.default index 7415585e320..170dcb76da6 100644 --- a/conf/default/cuckoo.conf.default +++ b/conf/default/cuckoo.conf.default @@ -290,8 +290,6 @@ enabled = no # shared = tenant-less collaborative pool (submit default public); # locked = per-tenant isolation (submit default tenant). mode = shared -# Which OIDC claim drives tenant membership (reuses the groups claim from SSO). -tenant_claim_source = groups # Blank = per-mode default (shared->public, locked->tenant). Override with one # of: public | tenant | private. default_visibility = diff --git a/web/analysis/test_visibility.py b/web/analysis/test_visibility.py index e73c15f9083..64dd659be1b 100644 --- a/web/analysis/test_visibility.py +++ b/web/analysis/test_visibility.py @@ -57,6 +57,21 @@ def test_full_memory_denies_cross_tenant(cape_db, mt_enabled, monkeypatch, clien assert client.get("/full_memory/1/").status_code == 403 +@pytest.mark.django_db +def test_non_numeric_task_id_denied_before_db(cape_db, monkeypatch, client): + """A non-numeric id on a \\w+ analysis route (full_memory) is coerced-and-denied + (403) BEFORE db.view_task runs — so a bad id can't raise a DB DataError -> 500 + (which would also leak a task-vs-no-task signal). Mode-independent hardening.""" + import analysis.views as av + + def _boom(*a, **k): + raise AssertionError("db.view_task must not be called for a non-numeric id") + + monkeypatch.setattr(av.db, "view_task", _boom) + client.force_login(User.objects.create_user("nn", "nn@x.com", "x")) + assert client.get("/full_memory/abc/").status_code == 403 + + @pytest.mark.django_db def test_vtupload_denies_cross_tenant(cape_db, mt_enabled, monkeypatch, client): """vtupload reads + exfiltrates a sample to VirusTotal — require_task_manage.""" diff --git a/web/analysis/views.py b/web/analysis/views.py index 76b6b0025cc..f71460f6ebb 100644 --- a/web/analysis/views.py +++ b/web/analysis/views.py @@ -195,6 +195,22 @@ from web.tenancy_optional import can_view_task, can_toggle_task, can_manage_task, can_view_sample, viewer_for +def _coerce_task_id(tid): + """Coerce a URL-supplied task id to int, or None if it isn't numeric. + + Task.id is an integer PK, so a non-numeric id can never match a real task. + Some analysis routes capture the id as ``\\w+`` (e.g. filereport, full_memory), + so a request like ``/full_memory/abc/`` would otherwise forward ``"abc"`` to + db.view_task() and raise a DB DataError -> an uncaught 500 that also leaks a + task-vs-no-task signal. Returning None lets the task-scoped decorators fail + closed with the same generic 403 as a missing/hidden task (no enumeration). + """ + try: + return int(tid) + except (TypeError, ValueError): + return None + + def require_task_manage(view): """Decorator for task-scoped MUTATION views (remove/comment/reprocess/etc.): 403 (generic) unless the user may MANAGE the task (owner / tenant-admin for @@ -206,6 +222,9 @@ def _wrapped(request, *args, **kwargs): tid = kwargs.get("task_id") or kwargs.get("analysis_number") if tid is None and args: tid = args[0] + tid = _coerce_task_id(tid) + if tid is None: + return HttpResponseForbidden("Not found") task = db.view_task(tid) if task is None or not can_manage_task(request.user, task): return HttpResponseForbidden("Not found") @@ -227,6 +246,9 @@ def _wrapped(request, *args, **kwargs): tid = kwargs.get("task_id") or kwargs.get("analysis_number") if tid is None and args: tid = args[0] + tid = _coerce_task_id(tid) + if tid is None: + return HttpResponseForbidden("Not found") task = db.view_task(tid) if task is None or not can_view_task(request.user, task): return HttpResponseForbidden("Not found") diff --git a/web/web/allauth_adapters.py b/web/web/allauth_adapters.py index 33a0975769b..8211574b1a9 100644 --- a/web/web/allauth_adapters.py +++ b/web/web/allauth_adapters.py @@ -302,6 +302,10 @@ def _g(vals): # TypeError the set intersection and 500 the login. return {g for g in (vals or []) if isinstance(g, str)} + # Filter in Python rather than an idp_groups__contains query: the Django auth + # DB is sqlite, where JSONField contains/contained_by lookups are unsupported + # (supports_json_field_contains=False -> NotSupportedError). Tenant counts are + # small per deployment, so this O(n) scan is negligible. matches = [t for t in Tenant.objects.filter(active=True) if user_groups & _g(t.idp_groups)] prof, _ = UserProfile.objects.get_or_create(user=user) if len(matches) == 1: From 9cce8e50f0ded7aca21791a4b8dfffc28c2a6e3d Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Tue, 14 Jul 2026 09:42:02 -0500 Subject: [PATCH 020/183] mt: submission_scope must not mint a tenant-visibility job without a tenant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot re-review of 93c69eda (web/users/tenancy.py:137): submission_scope could return visibility='tenant' with tenant_id=None — a tenant-less user in locked mode, or an anonymous submitter (WEB_AUTHENTICATION off + MT on), or an explicit visibility=tenant from a tenant-less user. can_read's _same_tenant requires a non-None job tenant, so such a job is readable only by its owner (via _is_owner) and, for an anonymous submitter whose viewer has user_id=None, by nobody but break-glass — a self-lockout / orphaned job. Fix at the source: submission_scope refuses an explicit 'tenant' from a tenant-less viewer (ValueError -> the caller returns 400) and downgrades a per-mode default that resolves to 'tenant' to PRIVATE (owner still reads via _is_owner; fail closed rather than world-exposing an anon job to PUBLIC under the most-restrictive locked mode). Preserves the (tenant_id, visibility) 2-tuple + ValueError-on-invalid contract, so no caller/test changes. (+ regression test) --- web/users/tenancy.py | 12 +++++++++++- web/users/test_tenancy.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/web/users/tenancy.py b/web/users/tenancy.py index 3570d900141..e3390d3510e 100644 --- a/web/users/tenancy.py +++ b/web/users/tenancy.py @@ -121,7 +121,7 @@ def submission_scope(request): ``visibility`` param when valid, else the per-mode default. Raises ValueError on an invalid explicit visibility so the view can 400. """ - from lib.cuckoo.common.tenancy import multitenancy_config, default_visibility, VISIBILITIES + from lib.cuckoo.common.tenancy import multitenancy_config, default_visibility, VISIBILITIES, TENANT, PRIVATE v = viewer_for(request.user) data = getattr(request, "data", None) @@ -134,4 +134,14 @@ def submission_scope(request): visibility = requested else: visibility = default_visibility(multitenancy_config()) + # A 'tenant'-visibility job needs a tenant to scope to. With tenant_id=None it + # is readable only by its owner (or nobody, for an anonymous submitter) — never + # the intended tenant pool (can_read's _same_tenant requires a non-None tenant). + # Refuse an explicit request (the caller turns ValueError into a 400) and + # downgrade a per-mode default to PRIVATE: the owner still reads via _is_owner, + # and it fails closed rather than world-exposing an anon job in locked mode. + if visibility == TENANT and v.tenant_id is None: + if requested: + raise ValueError("tenant visibility requires tenant membership") + visibility = PRIVATE return v.tenant_id, visibility diff --git a/web/users/test_tenancy.py b/web/users/test_tenancy.py index 63b3ccef0b6..f155ae414f4 100644 --- a/web/users/test_tenancy.py +++ b/web/users/test_tenancy.py @@ -221,6 +221,43 @@ class Req: ut.submission_scope(r3) +@pytest.mark.django_db +def test_submission_scope_tenant_without_tenant_fails_closed(mt_enabled, monkeypatch): + """A tenant-less submitter must never mint a ('tenant', tenant_id=None) job — it + would be readable only by its owner (or nobody, for anon), never a tenant pool. + Explicit 'tenant' -> ValueError (400); a per-mode default resolving to 'tenant' + downgrades to 'private' (owner still reads via _is_owner; fail closed, not + world-public in locked mode).""" + import pytest as _pytest + from lib.cuckoo.common import tenancy as core_ten + from lib.cuckoo.common.tenancy import MTConfig + import users.tenancy as ut + + u = User.objects.create_user("nt", "nt@x.com", "x") # tenant-less + u = User.objects.get(pk=u.pk) + + class Req: + pass + + # explicit 'tenant' from a tenant-less user -> 400 (ValueError) + r = Req() + r.user = u + r.data = {"visibility": "tenant"} + with _pytest.raises(ValueError): + ut.submission_scope(r) + + # locked-mode default resolves to 'tenant' but user has no tenant -> downgrade to private + def locked(): + return MTConfig(True, "locked", "", True) + + monkeypatch.setattr(ut, "multitenancy_config", locked) # viewer_for (module-level name) + monkeypatch.setattr(core_ten, "multitenancy_config", locked) # submission_scope (in-func import) + r2 = Req() + r2.user = u + r2.data = {} + assert ut.submission_scope(r2) == (None, "private") + + @pytest.mark.django_db def test_reconcile_tenant_tolerates_non_string_groups(): """Copilot: a tenant's idp_groups JSONField may contain non-string junk; the From 38ef08ee519d5b029c187856955faa2cc1420fb6 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Tue, 14 Jul 2026 09:42:19 -0500 Subject: [PATCH 021/183] mt: gate live-VM access on manage rights, not read visibility (Codex P1s) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex re-review of 93c69eda flagged two P1s: minting a Guacamole session (guac/views.py) and streaming a file off the running guest (apiv2 tasks_file_stream) were gated on can_view_task. Those are task ACTIONS — live keyboard/mouse/framebuffer control and arbitrary live-VM file pull — not passive report viewing, so a read-only viewer of a public/tenant task could hijack another user's/tenant's running VM. They must follow the same owner/tenant-admin/break-glass boundary (can_manage_task) as other task mutations. Upgraded the ENTIRE live-VM path to the manage boundary (MT-off = is_local_admin, so the single-analyst console is unchanged): - web/guac/views.py index(): mint gated on can_manage_task. - web/guac/consumers.py: websocket re-check now can_manage_task (defense-in-depth). - web/submission/views.py: remote_session() gated on can_manage_task; status() still readable (can_view_task) but emits the guac session_data only to a manager. - web/apiv2/views.py tasks_file_stream(): now _deny_manage (was _deny_if_hidden). Tests: guac mint denies a read-only PUBLIC-task viewer; websocket-recheck gate now asserts can_manage_task; remote_session denies a read-only viewer. --- web/apiv2/test_visibility.py | 9 +++++---- web/apiv2/views.py | 8 ++++++-- web/guac/consumers.py | 22 +++++++++++---------- web/guac/test_visibility.py | 26 ++++++++++++++++++++++++ web/guac/views.py | 11 +++++++---- web/submission/test_visibility.py | 33 +++++++++++++++++++++++++++++++ web/submission/views.py | 16 ++++++++++----- 7 files changed, 100 insertions(+), 25 deletions(-) diff --git a/web/apiv2/test_visibility.py b/web/apiv2/test_visibility.py index 80dcb7093b9..c8696d14f3a 100644 --- a/web/apiv2/test_visibility.py +++ b/web/apiv2/test_visibility.py @@ -183,13 +183,14 @@ def test_guac_session_view_enforces_visibility(modname, name): def test_guac_websocket_consumer_rechecks_visibility(): """SECURITY GATE (websocket): the guac tunnel consumer is not URL-routed, so - the routed gates can't see it. It must re-check task visibility (defense in - depth behind the mint-time gate).""" + the routed gates can't see it. Opening the tunnel is live-VM control (a task + ACTION), so it must re-check MANAGE rights (defense in depth behind the + manage-gated mint), not mere read visibility.""" import guac.consumers src = open(guac.consumers.__file__).read() - assert "can_view_task" in src, \ - "guac websocket consumer must re-check task visibility (can_view_task) — defense-in-depth for the live-VM tunnel" + assert "can_manage_task" in src, \ + "guac websocket consumer must re-check manage rights (can_manage_task) — defense-in-depth for the live-VM tunnel" class FakeTask: diff --git a/web/apiv2/views.py b/web/apiv2/views.py index 7c2fc836797..41809350483 100644 --- a/web/apiv2/views.py +++ b/web/apiv2/views.py @@ -3242,10 +3242,14 @@ def _stream_iterator(fp, guest_name, chunk_size=1024): resp = {"error": True, "error_value": "Task status API is disabled"} return Response(resp) resp = {} - task = db.view_task(task_id) - _denied = _deny_if_hidden(request, task) + # Pulling a file off the RUNNING guest (or its live analysis dir) is a task + # ACTION, not passive report viewing — a read-only viewer of a public/tenant + # task must not fetch arbitrary live-VM files. Require manage rights (owner / + # tenant-admin / break-glass); hidden == generic 404 (no enumeration). + _denied = _deny_manage(request, task_id) if _denied is not None: return _denied + task = db.view_task(task_id) machine = db.view_machine(task.guest.name) if machine.status != "running": resp = {"error": True, "error_value": "Machine is not running", "errors": machine.status} diff --git a/web/guac/consumers.py b/web/guac/consumers.py index b9ed95c3618..60f15ad5eb0 100644 --- a/web/guac/consumers.py +++ b/web/guac/consumers.py @@ -181,18 +181,20 @@ async def connect(self): await self.close() return - # 3b. Defense-in-depth: confirm the socket's user may view this task. - # The token is mint-gated (guac index / submission status require - # can_view_task), but a leaked/replayed cookie must not tunnel into - # another tenant's VM. Check UNCONDITIONALLY — do NOT skip for an - # anonymous/absent socket user: viewer_for resolves anonymous to a - # public-only viewer when MT is on (and break-glass when MT is off), - # so an anonymous replay of a token for a private/tenant task is - # denied here rather than tunnelling on token possession alone. - from web.tenancy_optional import can_view_task + # 3b. Defense-in-depth: confirm the socket's user may MANAGE this task. + # Opening the tunnel grants live keyboard/mouse/framebuffer control, a + # task ACTION — so it follows the same owner/tenant-admin/break-glass + # boundary as the mint (guac index / submission status), NOT mere read + # visibility. A leaked/replayed cookie must not tunnel into another + # tenant's (or another user's public) VM. Check UNCONDITIONALLY — do + # NOT skip for an anonymous/absent socket user: viewer_for resolves + # anonymous to a non-manager when MT is on (and break-glass when MT is + # off), so a replay is denied here rather than tunnelling on token + # possession alone. + from web.tenancy_optional import can_manage_task ws_user = self.scope.get("user") - if not await sync_to_async(can_view_task)(ws_user, task): + if not await sync_to_async(can_manage_task)(ws_user, task): logger.warning( "WebSocket rejected: user not entitled to task %s", self.guac_task_id ) diff --git a/web/guac/test_visibility.py b/web/guac/test_visibility.py index 08226095900..036e2a80c1f 100644 --- a/web/guac/test_visibility.py +++ b/web/guac/test_visibility.py @@ -9,6 +9,13 @@ class ForeignTask: visibility = "private" +class PublicForeignTask: + id = 1 + user_id = 999 # owned by someone else, but PUBLIC (readable by everyone) + tenant_id = 10 + visibility = "public" + + @pytest.mark.django_db def test_guac_index_denies_cross_tenant(cape_db, mt_enabled, monkeypatch, client): """guac.index mints a live-VM session token from task_id. A cross-tenant @@ -27,6 +34,25 @@ def test_guac_index_denies_cross_tenant(cape_db, mt_enabled, monkeypatch, client assert r.status_code == 200 # rendered the guac error page, not the session page +@pytest.mark.django_db +def test_guac_index_denies_readonly_viewer(cape_db, mt_enabled, monkeypatch, client): + """A read-only VIEWER of a PUBLIC task (can_view_task=True) is NOT a manager, + so must be denied the live-VM mint: keyboard/mouse/framebuffer control is a + task action, gated on can_manage_task (owner/tenant-admin/break-glass), not + passive report visibility.""" + import guac.views as gv + + minted = [] + monkeypatch.setattr(gv.db, "view_task", lambda *a, **k: PublicForeignTask()) + monkeypatch.setattr(gv.db, "create_guac_session", + lambda **k: minted.append(k) or object(), raising=False) + client.force_login(User.objects.create_user("ro", "ro@x.com", "x")) # can read public, can't manage + + r = client.get("/guac/1/AAAA/") + assert minted == [] # a viewer who can SEE but not MANAGE mints nothing + assert r.status_code == 200 # guac error page, not a live session + + @pytest.mark.django_db def test_direct_vnc_endpoints_deny_non_superuser(cape_db, mt_enabled, monkeypatch): """Direct VNC/VM operator endpoints mint task_id=0 sessions that bypass the diff --git a/web/guac/views.py b/web/guac/views.py index 02ecd19cedb..f717b168867 100644 --- a/web/guac/views.py +++ b/web/guac/views.py @@ -11,7 +11,7 @@ from lib.cuckoo.common.config import Config from lib.cuckoo.core.database import Database -from web.tenancy_optional import can_view_task, viewer_for +from web.tenancy_optional import can_manage_task, viewer_for logger = logging.getLogger("guac-session") @@ -54,10 +54,13 @@ def _error(request, task_id, msg): @conditional_login_required(login_required, settings.WEB_AUTHENTICATION) def index(request, task_id, session_data): - # tenant isolation: only mint a live-VM session token for a task the caller - # may view (hidden == "not found" — no cross-tenant enumeration). + # tenant isolation: minting a live-VM session grants keyboard/mouse/framebuffer + # control of the running analysis VM — a task ACTION, not passive report viewing. + # Gate it on can_manage_task (owner / tenant-admin / break-glass), NOT mere read + # visibility, so a read-only viewer of a public/tenant task can't tunnel into the + # live VM. hidden == "not found" (no cross-tenant enumeration). _task = db.view_task(int(task_id)) - if _task is None or not can_view_task(request.user, _task): + if _task is None or not can_manage_task(request.user, _task): return _error(request, task_id, "No analysis found with specified ID") if not LIBVIRT_AVAILABLE: diff --git a/web/submission/test_visibility.py b/web/submission/test_visibility.py index 2ef2e603ebc..9e2f1d549cf 100644 --- a/web/submission/test_visibility.py +++ b/web/submission/test_visibility.py @@ -14,3 +14,36 @@ def test_submit_form_renders_visibility_control(cape_db, client): r = client.get(url) assert r.status_code == 200 assert b'name="visibility"' in r.content + + +class PublicRunningTask: + id = 1 + user_id = 999 # owned by someone else + tenant_id = 10 + visibility = "public" # a read-only viewer CAN see it + status = "running" + machine = "m1" + + +@pytest.mark.django_db +def test_remote_session_denies_readonly_viewer(cape_db, mt_enabled, monkeypatch, client): + """remote_session mints the live-VM guac session_data (keyboard/mouse/frame- + buffer control). A read-only VIEWER of a PUBLIC task (can_view=True but NOT a + manager) must be denied — live-VM control follows can_manage_task, not read + visibility.""" + import submission.views as sv + + monkeypatch.setattr(sv.db, "view_task", lambda *a, **k: PublicRunningTask()) + client.force_login(User.objects.create_user("rs", "rs@x.com", "x")) # tenant-less, non-owner + + try: + from django.urls import reverse + url = reverse("remote_session", kwargs={"task_id": 1}) + except Exception: + url = "/remote_session/1/" + r = client.get(url) + # manage-denied -> the generic error page (error.html), never the live-session + # page. (The message apostrophe is HTML-escaped, so match escape-safe substrings.) + assert b"ERROR :-(" in r.content # error.html marker + assert b"seem to exist" in r.content # "...task doesn't seem to exist." + assert b"session_data" not in r.content # no live-VM token handed to a non-manager diff --git a/web/submission/views.py b/web/submission/views.py index a3058e14904..2b200651dc6 100644 --- a/web/submission/views.py +++ b/web/submission/views.py @@ -15,7 +15,7 @@ from django.conf import settings -from web.tenancy_optional import submission_scope, can_view_task, can_view_sample, viewer_for +from web.tenancy_optional import submission_scope, can_view_task, can_manage_task, can_view_sample, viewer_for from web.tenancy_optional import multitenancy_config, default_visibility, PUBLIC, TENANT, PRIVATE from django.contrib.auth.decorators import login_required from django.shortcuts import redirect, render @@ -847,7 +847,8 @@ def index(request, task_id=None, resubmit_hash=None): @conditional_login_required(login_required, settings.WEB_AUTHENTICATION) def status(request, task_id): task = db.view_task(task_id) - # tenant isolation: hidden == missing (also gates the emitted guac session_data) + # tenant isolation: hidden == missing. The status body is a READ (can_view_task); + # the live-VM guac session_data is emitted only to a MANAGER below. if not task or not can_view_task(request.user, task): return render(request, "error.html", {"error": "The specified task doesn't seem to exist."}) @@ -867,7 +868,10 @@ def status(request, task_id): "session_data": "", "target": task.sample.sha256 if getattr(task, "sample") else task.target, } - if web_conf.guacamole.enabled and get_options(task.options).get("interactive") == "1": + # Live-VM session token: only for a caller who may MANAGE the task (owner / + # tenant-admin / break-glass). A read-only viewer sees status but no session_data, + # so they can't drive another user's/tenant's live VM. + if web_conf.guacamole.enabled and get_options(task.options).get("interactive") == "1" and can_manage_task(request.user, task): machine = db.view_machine_by_label(task.machine) if task.machine else None vm_label, guest_ip = (task.machine, machine.ip) if machine else (None, None) if not machine: @@ -894,8 +898,10 @@ def status(request, task_id): @conditional_login_required(login_required, settings.WEB_AUTHENTICATION) def remote_session(request, task_id): task = db.view_task(task_id) - # tenant isolation: hidden == missing (also gates the emitted guac session_data) - if not task or not can_view_task(request.user, task): + # This endpoint exists only to mint the live-VM guac session_data, i.e. keyboard/ + # mouse/framebuffer control — a task ACTION. Gate it on can_manage_task (owner / + # tenant-admin / break-glass), not read visibility; hidden == missing. + if not task or not can_manage_task(request.user, task): return render(request, "error.html", {"error": "The specified task doesn't seem to exist."}) machine_status = False From a52a881ecffb8381530433758d744310c455ef1d Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Tue, 14 Jul 2026 10:40:06 -0500 Subject: [PATCH 022/183] mt: align submit-form visibility options with API + doc/docstring accuracy (Copilot) Copilot re-review of 38ef08ee (all low-severity, verified against the code): - web/submission/views.py: the submit form offered the 'tenant' visibility option based on mode == "shared" (hidden in shared), but submission_scope honors an explicit 'tenant' for any tenant MEMBER in both modes (and rejects it for a tenant-less user). Key the option on viewer_for(request.user).tenant_id instead, so the UI matches the API/predicate: tenant members see 'tenant', tenant-less users don't. (+ regression test) - docs/MULTITENANCY-SUPPORT.md: the Guacamole bullet still said the tunnel is gated by can_view_task; it's now can_manage_task (owner/tenant-admin/break-glass) after 30ce167e. Updated the doc to the stricter boundary. - web/apiv2/views.py: tasks_set_visibility docstring said "superuser"; the actual authz is can_toggle (break-glass = viewer.is_local_admin, a cuckoo.conf-gated superuser, not any superuser). Clarified the wording. --- docs/MULTITENANCY-SUPPORT.md | 7 +++++-- web/apiv2/views.py | 5 +++-- web/submission/test_visibility.py | 27 +++++++++++++++++++++++++++ web/submission/views.py | 7 ++++++- 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/docs/MULTITENANCY-SUPPORT.md b/docs/MULTITENANCY-SUPPORT.md index d51911aef49..d3f08120523 100644 --- a/docs/MULTITENANCY-SUPPORT.md +++ b/docs/MULTITENANCY-SUPPORT.md @@ -17,8 +17,11 @@ support is added. - **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 and scoping work across this path. -- **Guacamole interactive sessions** for task-backed analyses: the live-VM tunnel - is gated by `can_view_task`, so a token cannot tunnel into another tenant's VM. +- **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) diff --git a/web/apiv2/views.py b/web/apiv2/views.py index 41809350483..4d35fad3263 100644 --- a/web/apiv2/views.py +++ b/web/apiv2/views.py @@ -91,8 +91,9 @@ def _deny_by_hash(request, *, sha256=None, sha1=None, md5=None, sample_id=None): @api_view(["PATCH"]) @authentication_classes(_UI_INTERNAL_AUTH) def tasks_set_visibility(request, task_id): - """Owner (or tenant-admin for public/tenant jobs, or superuser) re-toggles a - task's visibility. Mirrors the can_toggle predicate.""" + """Owner (or tenant-admin for public/tenant jobs, or a break-glass admin) + re-toggles a task's visibility. Mirrors the can_toggle predicate (break-glass = + viewer.is_local_admin, i.e. a superuser gated by cuckoo.conf, not any superuser).""" # Parse once so view_task() and set_task_visibility() get a consistent int and # a non-numeric id fails as the same generic 404 (no implicit-coercion no-op). try: diff --git a/web/submission/test_visibility.py b/web/submission/test_visibility.py index 9e2f1d549cf..4ea9131b87c 100644 --- a/web/submission/test_visibility.py +++ b/web/submission/test_visibility.py @@ -16,6 +16,33 @@ def test_submit_form_renders_visibility_control(cape_db, client): assert b'name="visibility"' in r.content +@pytest.mark.django_db +def test_submit_form_tenant_option_matches_membership(cape_db, mt_enabled, client): + """The submit form must offer the 'tenant' visibility option iff the user has a + tenant — matching submission_scope (which honors explicit 'tenant' for a tenant + member and rejects it for a tenant-less user). Prevents UI/API divergence.""" + from users.models import Tenant, UserProfile + try: + from django.urls import reverse + url = reverse("submission") + except Exception: + url = "/submit/" + + # tenant-less user -> no 'tenant' option + tl = User.objects.create_user("tl2", "tl2@x.com", "x") + client.force_login(tl) + assert b'value="tenant"' not in client.get(url).content + + # tenant member -> 'tenant' option present + t = Tenant.objects.create(slug="acmez", name="AcmeZ") + tu = User.objects.create_user("tu2", "tu2@x.com", "x") + p = UserProfile.objects.get(user=tu) + p.tenant = t + p.save() + client.force_login(User.objects.get(pk=tu.pk)) + assert b'value="tenant"' in client.get(url).content + + class PublicRunningTask: id = 1 user_id = 999 # owned by someone else diff --git a/web/submission/views.py b/web/submission/views.py index 2b200651dc6..73f4943dc5f 100644 --- a/web/submission/views.py +++ b/web/submission/views.py @@ -820,8 +820,13 @@ def index(request, task_id=None, resubmit_hash=None): "submission/index.html", { "title": "Submit", + # Offer TENANT iff the submitter actually has a tenant — this matches + # submission_scope, which honors an explicit 'tenant' for a tenant + # member (in BOTH modes) and rejects it for a tenant-less user. Keying + # on mode instead diverged the UI from the API (a tenant member could + # pick 'tenant' via the API but not the form in shared mode). "visibility_levels": ( - [PUBLIC, PRIVATE] if multitenancy_config().mode == "shared" else [PUBLIC, TENANT, PRIVATE] + [PUBLIC, TENANT, PRIVATE] if viewer_for(request.user).tenant_id is not None else [PUBLIC, PRIVATE] ), "default_visibility": default_visibility(multitenancy_config()), "packages": sorted(packages, key=lambda i: i["name"].lower()), From c36a4b1ede62ed4ad080fe6dd5cdd80d588b2622 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Tue, 14 Jul 2026 10:45:16 -0500 Subject: [PATCH 023/183] mt: materialize compare mongo cursor + gate resubmit-by-hash original_filename (Codex P2s) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex re-review of 38ef08ee: - web/compare/views.py: the md5-pivot result `_raw` (a PyMongo cursor) is iterated twice — first to collect info.ids for the batched visibility check, then to build `records`. A cursor is single-pass, so the first loop exhausted it and Mongo-backed compare always returned empty `records`. Materialize with list() (both the both() and single-compare paths). Introduced by the earlier N+1 batching change. (+ regression test: mongo_find mocked as a single-pass iterator.) - web/submission/views.py: the resubmit original_filename recovery ran for the by-HASH branch too, where the route task_id is NOT can_view_task-gated (only the task_id branch gates it). A by-hash resubmit carrying another tenant's task_id in the URL (/submission/resubmit///) copied that hidden task's target basename into the new submission — a cross-tenant filename leak. Require can_view_task before trusting the route task_id; otherwise fall back to the hash. --- web/compare/test_visibility.py | 36 ++++++++++++++++++++++++++++++++++ web/compare/views.py | 10 ++++++++-- web/submission/views.py | 9 +++++++-- 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/web/compare/test_visibility.py b/web/compare/test_visibility.py index 4c5f848cf6a..7bf8a42fd2b 100644 --- a/web/compare/test_visibility.py +++ b/web/compare/test_visibility.py @@ -52,6 +52,42 @@ def view_task(self, tid): assert b"No analysis found" in r.content +@pytest.mark.django_db +def test_compare_left_mongo_materializes_cursor(cape_db, mt_enabled, monkeypatch, client): + """Regression: the mongo md5-pivot cursor is iterated TWICE (collect ids, then + build records). A PyMongo cursor is single-pass, so it must be materialized — + else `records` is always empty for Mongo-backed compare. Mock mongo_find with a + single-pass iterator so the bug reproduces without the list() fix.""" + import compare.views as cv + + user = User.objects.create_user("cmpm", "cmpm@x.com", "x") + + class FakeDB: + view_task = staticmethod(lambda tid: _public_task(int(tid))) + + def list_tasks(self, task_ids=None, visible_to=None, **k): + return [_public_task(t) for t in (task_ids or [])] # all visible + + monkeypatch.setattr(cv, "Database", lambda: FakeDB()) + monkeypatch.setattr(cv, "es_as_db", False, raising=False) + monkeypatch.setattr(cv, "enabledconf", {"mongodb": True, "elasticsearchdb": False}, raising=False) + monkeypatch.setattr(cv, "mongo_find_one", + lambda *a, **k: {"info": {"id": 1}, "target": {"file": {"md5": "abc"}}}, raising=False) + # single-pass iterator, exactly like a real PyMongo cursor + monkeypatch.setattr(cv, "mongo_find", + lambda *a, **k: iter([{"info": {"id": 2}, "target": {}}]), raising=False) + + captured = {} + real_render = cv.render + monkeypatch.setattr(cv, "render", lambda req, tmpl, ctx=None: captured.update(ctx or {}) or real_render(req, tmpl, ctx)) + client.force_login(user) + + r = client.get("/compare/1/") + assert r.status_code == 200 + # the visible md5-pivot hit (task 2) must survive — non-empty despite the double loop + assert [rec.get("info", {}).get("id") for rec in captured.get("records", [])] == [2] + + @pytest.mark.django_db def test_compare_left_es_backend_filters_cross_tenant(cape_db, mt_enabled, monkeypatch, client): """Regression for the #3 review finding: the Elasticsearch backend path diff --git a/web/compare/views.py b/web/compare/views.py index 2a21d58ac45..bdc3e61eb96 100644 --- a/web/compare/views.py +++ b/web/compare/views.py @@ -79,7 +79,10 @@ def left(request, left_id): if _scope: _and.append(_scope) if enabledconf["mongodb"]: - _raw = mongo_find("analysis", {"$and": _and}, {"target": 1, "info": 1}) + # Materialize the cursor: it is iterated TWICE below (collect ids, then + # build records), and a PyMongo cursor is single-pass — leaving it lazy + # exhausts it in the first loop and yields an always-empty `records`. + _raw = list(mongo_find("analysis", {"$and": _and}, {"target": 1, "info": 1})) # Defense-in-depth: post-filter each md5-pivot hit through can_view_task # (SQL-authoritative), symmetric with the ES branch below, so a mongo stamp # gap can't leak another tenant's analysis even if the query-layer scope @@ -163,7 +166,10 @@ def hash(request, left_id, right_hash): if _scope: _and.append(_scope) if enabledconf["mongodb"]: - _raw = mongo_find("analysis", {"$and": _and}, {"target": 1, "info": 1}) + # Materialize the cursor: it is iterated TWICE below (collect ids, then + # build records), and a PyMongo cursor is single-pass — leaving it lazy + # exhausts it in the first loop and yields an always-empty `records`. + _raw = list(mongo_find("analysis", {"$and": _and}, {"target": 1, "info": 1})) # Defense-in-depth: post-filter each md5-pivot hit through can_view_task # (SQL-authoritative), symmetric with the ES branch below, so a mongo stamp # gap can't leak another tenant's analysis even if the query-layer scope diff --git a/web/submission/views.py b/web/submission/views.py index 73f4943dc5f..30501857b8d 100644 --- a/web/submission/views.py +++ b/web/submission/views.py @@ -546,11 +546,16 @@ def index(request, task_id=None, resubmit_hash=None): if opt_filename: filename = base_dir + "/" + opt_filename else: - # Try to recover the original filename from the task + # Try to recover the original filename from the task. This runs + # for the by-HASH branch too, where the route task_id is NOT + # can_view_task-gated — so require read access before trusting it, + # else a by-hash resubmit carrying another tenant's task_id in the + # URL would leak that hidden task's target basename into the new + # submission. No read access -> fall back to the hash. original_filename = "" if task_id: task = db.view_task(task_id) - if task and task.target: + if task and task.target and can_view_task(request.user, task): original_filename = sanitize_filename(os.path.basename(task.target)) filename = base_dir + "/" + (original_filename or sanitize_filename(hash)) path = store_temp_file(content, filename) From 309da2d669b74376d391b7e0c14ac3919d620924 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Tue, 14 Jul 2026 11:11:00 -0500 Subject: [PATCH 024/183] mt: revert mongo stamp if the SQL commit fails after a successful sync (Copilot) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot re-review of c36a4b1e (lib/cuckoo/core/data/tasking.py): set_task_visibility syncs the mongo stamp FIRST, then commits SQL — which closes the mongo-fails window, but not the complementary one. If the final self.session.commit() fails AFTER a successful mongo sync (transient DB error), mongo already reflects the new (possibly more-permissive) visibility while SQL rolls back to the old value — a fail-open window where the aggregate/search/stats surfaces read more permissively than SQL authorizes. Wrap the SQL commit: on failure after a successful mongo sync, best-effort revert the mongo stamp to the previous value, roll back the poisoned session, and raise CuckooOperationalError (the caller retries / returns 503). Now BOTH stores fail closed regardless of which write fails. (+ regression test) --- lib/cuckoo/core/data/tasking.py | 28 ++++++++++++++++++++++- tests/test_task_visibility.py | 40 +++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/lib/cuckoo/core/data/tasking.py b/lib/cuckoo/core/data/tasking.py index cf4e87dd3b8..6cb93db2059 100644 --- a/lib/cuckoo/core/data/tasking.py +++ b/lib/cuckoo/core/data/tasking.py @@ -871,6 +871,7 @@ def set_task_visibility(self, task_id: int, visibility: str) -> Optional[Task]: # that (leaving both stores at the old value), then raise so the caller # retries. We revert in-place rather than session.rollback() so we don't # discard unrelated pending work in a shared/scoped session. + _synced_mongo = False if mongo_update_one is not None and _mongo_reporting_enabled(): # mongo_update_one is wrapped by graceful_auto_reconnect, which retries # AutoReconnect/ServerSelectionTimeoutError internally and RETURNS None @@ -888,7 +889,32 @@ def set_task_visibility(self, task_id: int, visibility: str) -> Optional[Task]: self.session.commit() log.error("visibility mongo sync FAILED for task %s (mongo unreachable); left at %r", task_id, _prev_visibility) raise CuckooOperationalError(f"task {task_id} visibility change aborted: report store sync failed") - self.session.commit() + _synced_mongo = True + # Commit the SQL change. If THIS fails after a successful mongo sync, the two + # stores would disagree in the leaky direction (mongo already shows the new, + # possibly more-permissive, visibility while SQL rolls back to the old value). + # Best-effort revert the mongo stamp to the previous value, roll back the + # poisoned session, and raise so the caller retries — never leave a committed + # window where the aggregate/search surfaces are more permissive than SQL. + try: + self.session.commit() + except Exception as _ce: + from lib.cuckoo.common.exceptions import CuckooOperationalError + + try: + self.session.rollback() + except Exception: + pass + if _synced_mongo: + try: + mongo_update_one("analysis", {"info.id": task_id}, {"$set": {"info.visibility": _prev_visibility}}) + log.error("visibility SQL commit failed for task %s; reverted mongo stamp to %r", task_id, _prev_visibility) + except Exception as _re: + log.error( + "visibility SQL commit failed AND mongo revert FAILED for task %s (stores may disagree): %s", + task_id, _re, + ) + raise CuckooOperationalError(f"task {task_id} visibility change aborted: SQL commit failed") from _ce return task def fetch_task(self, categories: list = None): diff --git a/tests/test_task_visibility.py b/tests/test_task_visibility.py index a0d1157bc6f..63b07ea1622 100644 --- a/tests/test_task_visibility.py +++ b/tests/test_task_visibility.py @@ -214,6 +214,46 @@ def test_set_task_visibility_raises_on_persistent_mongo_failure(db, monkeypatch) assert db.session.get(Task, tid).visibility == "tenant" +def test_set_task_visibility_reverts_mongo_when_sql_commit_fails(db, monkeypatch): + """Complementary to the mongo-fail case: if the SQL commit fails AFTER a + successful mongo sync, the stores would disagree in the leaky direction (mongo + already shows the new, possibly more-permissive, visibility while SQL keeps the + old). The setter must best-effort revert the mongo stamp to the previous value + and raise, so the aggregate/search surfaces never read more permissively than + SQL authorizes.""" + import lib.cuckoo.core.data.tasking as tk + from lib.cuckoo.common.exceptions import CuckooOperationalError + + monkeypatch.setattr(tk, "_mongo_reporting_enabled", lambda: True, raising=False) + calls = [] + + def _rec(coll, q, upd, *a, **k): + calls.append(upd["$set"]["info.visibility"]) + return object() # UpdateResult stand-in (success) + + monkeypatch.setattr(tk, "mongo_update_one", _rec, raising=False) + tid = db.add_url("http://example.com", tenant_id=10, visibility="private") + + # make ONLY the new-value commit inside set_task_visibility fail (add_url above + # already committed the task with the real commit). + orig_commit = db.session.commit + state = {"boom": True} + + def _commit(): + if state["boom"]: + state["boom"] = False + raise RuntimeError("transient SQL commit failure") + return orig_commit() + + monkeypatch.setattr(db.session, "commit", _commit) + + with pytest.raises(CuckooOperationalError): + db.set_task_visibility(tid, "public") + + # forward sync to 'public', then a best-effort revert back to 'private' + assert calls == ["public", "private"] + + def test_set_task_visibility_skips_sync_when_mongo_disabled(db, monkeypatch): """When mongo is NOT the report store, the toggle must succeed without any sync attempt (so an ES/no-mongo install isn't broken by the sync path).""" From 6f9de566f7b2e948b922666d9f370350b19b11f3 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Tue, 14 Jul 2026 11:18:25 -0500 Subject: [PATCH 025/183] mt: scope perform_search tags/options SQL prequery by viewer before the limit (Codex) Codex re-review of c36a4b1e (lib/cuckoo/common/web_utils.py:1595): the tags_tasks / options / user_tasks searches build the id set with db.list_tasks(..., limit= search_limit) WITHOUT visible_to=viewer, and the tenant scope is only applied later at the mongo layer. In a multi-tenant deployment other tenants' matching tasks fill search_limit before the scope is applied, so the (scoped) mongo query returns no results even when the viewer has older visible matches (fail-closed, but the viewer loses their own results). Thread visible_to=viewer into those SQL prequeries so the limit applies to the viewer's VISIBLE matches. visible_to=None is a no-op (multitenancy disabled / break-glass), preserving legacy behavior. (+ regression test) --- lib/cuckoo/common/web_utils.py | 11 ++++++++--- web/analysis/test_visibility.py | 27 +++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/lib/cuckoo/common/web_utils.py b/lib/cuckoo/common/web_utils.py index 760a68c7fe0..bba47045ca4 100644 --- a/lib/cuckoo/common/web_utils.py +++ b/lib/cuckoo/common/web_utils.py @@ -1462,15 +1462,20 @@ def perform_search( if term == "ids": ids = value elif term == "tags_tasks": - ids = [int(v.id) for v in db.list_tasks(tags_tasks_like=value, limit=search_limit)] + # Scope the SQL prequery to the viewer's VISIBLE tasks BEFORE the + # limit — otherwise other tenants' matches fill search_limit and the + # (tenant-scoped) mongo query below returns nothing even when the + # viewer has older visible matches. visible_to=None is a no-op + # (multitenancy disabled / break-glass), preserving legacy behavior. + ids = [int(v.id) for v in db.list_tasks(tags_tasks_like=value, limit=search_limit, visible_to=viewer)] elif term == "user_tasks": if not user_id: ids = 0 else: # ToDo allow to admin search by user tasks - ids = [int(v.id) for v in db.list_tasks(user_id=user_id, limit=search_limit)] + ids = [int(v.id) for v in db.list_tasks(user_id=user_id, limit=search_limit, visible_to=viewer)] else: - ids = [int(v.id) for v in db.list_tasks(options_like=value, limit=search_limit)] + ids = [int(v.id) for v in db.list_tasks(options_like=value, limit=search_limit, visible_to=viewer)] if ids: if len(ids) > 1: term = "ids" diff --git a/web/analysis/test_visibility.py b/web/analysis/test_visibility.py index 64dd659be1b..eebea9d7511 100644 --- a/web/analysis/test_visibility.py +++ b/web/analysis/test_visibility.py @@ -157,3 +157,30 @@ def _fake_yara(term, recs): paths = av._file_search_all_files("capeyara", "Emotet", req) assert "/opt/CAPEv2/storage/analyses/2/files/own.bin" in paths # readable kept assert "/opt/CAPEv2/storage/binaries/deadbeefdeadbeef" not in paths # foreign content-addressed sample dropped + + +@pytest.mark.django_db +def test_perform_search_tags_scopes_prequery_by_viewer(cape_db, monkeypatch): + """Codex: the tags_tasks/options SQL prequery must scope by visible_to BEFORE + the search_limit — else other tenants' matches fill the limit and the tenant- + scoped mongo query returns none of the viewer's own (older) visible matches.""" + import lib.cuckoo.common.web_utils as wu + from lib.cuckoo.common.tenancy import Viewer + + captured = {} + + class _T: + def __init__(self, i): + self.id = i + + def _list_tasks(*a, **k): + captured.update(k) + return [_T(1), _T(2)] + + monkeypatch.setattr(wu.db, "list_tasks", _list_tasks) + monkeypatch.setattr(wu, "mongo_find", lambda *a, **k: [], raising=False) + monkeypatch.setattr(wu, "es_as_db", False, raising=False) + + v = Viewer(user_id=2, tenant_id=10) + wu.perform_search("tags_tasks", "sometag", viewer=v) + assert captured.get("visible_to") is v # prequery scoped by the viewer before the limit From 32d930e98c5e3d386fa7d85dd4f04bfeb5567b91 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Tue, 14 Jul 2026 11:42:05 -0500 Subject: [PATCH 026/183] mt: keep tenant-visibility coherent for tenantless submitters/tasks (Codex P2s) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex re-review of 6f9de566, both fallout from the earlier UI/API-alignment change: - web/submission/views.py: in locked mode default_visibility resolves to 'tenant', but a tenant-less user isn't offered 'tenant', so the template selected nothing and the browser fell back to the FIRST option (public) and submitted it EXPLICITLY — bypassing submission_scope's fail-closed private downgrade, so an unchanged form created a PUBLIC job. Compute the form default with the same tenantless downgrade (tenant -> private) so the preselected value is always offered and matches what submission_scope persists. (+ test) - web/templates/analysis/report.html + web/analysis/views.py + web/apiv2/views.py: the report visibility toggle offered 'tenant' for every toggleable task, incl. tenant_id=NULL (legacy/tenantless) tasks; selecting it makes the task readable by nobody but owner/break-glass (can_read's tenant branch needs a non-null tenant). Hide the 'tenant' option unless the task has a tenant (task_has_tenant context), AND reject a tenant transition for a tenantless task in tasks_set_visibility (400) as the authoritative guard. (+ test) --- web/analysis/views.py | 4 ++++ web/apiv2/test_visibility.py | 38 ++++++++++++++++++++++++++++++ web/apiv2/views.py | 10 +++++++- web/submission/test_visibility.py | 21 +++++++++++++++++ web/submission/views.py | 25 +++++++++++++------- web/templates/analysis/report.html | 2 ++ 6 files changed, 90 insertions(+), 10 deletions(-) diff --git a/web/analysis/views.py b/web/analysis/views.py index f71460f6ebb..9da15c71a9c 100644 --- a/web/analysis/views.py +++ b/web/analysis/views.py @@ -3161,6 +3161,10 @@ def report(request, task_id): "title": "Analysis Report", "can_toggle_visibility": can_toggle_visibility, "task_visibility": getattr(_task, "visibility", "private"), + # Only offer the 'tenant' toggle for a task that actually belongs to a + # tenant — 'tenant' on a tenant_id=NULL task makes it readable by nobody + # but owner/break-glass (can_read's tenant branch needs a non-null tenant). + "task_has_tenant": getattr(_task, "tenant_id", None) is not None, "analysis": report, # ToDo test "file": report.get("target", {}).get("file", {}), diff --git a/web/apiv2/test_visibility.py b/web/apiv2/test_visibility.py index c8696d14f3a..40c884518ed 100644 --- a/web/apiv2/test_visibility.py +++ b/web/apiv2/test_visibility.py @@ -718,3 +718,41 @@ def test_visibility_endpoint_opts_into_session_auth(): assert cls is not None, "tasks_set_visibility should be a DRF @api_view" assert SessionAuthentication in cls.authentication_classes, \ "visibility toggle must accept browser session auth (SSO drops it from the default)" + + +@pytest.mark.django_db +def test_toggle_visibility_rejects_tenant_for_tenantless_task(cape_db, mt_enabled, monkeypatch): + """A task with tenant_id=None cannot be 'tenant'-visible (can_read's tenant branch + needs a non-null job tenant) — the toggle API must reject that transition with a + 400 rather than persist an owner/break-glass-only invisible state.""" + from rest_framework.test import APIClient + import apiv2.views as views + + owner = User.objects.create_user("ownt", "ownt@x.com", "x") # tenant-less owner + state = {"vis": "private"} + + class T: + id = 1 + + def __init__(self): + self.user_id = owner.id + self.tenant_id = None + + @property + def visibility(self): + return state["vis"] + + monkeypatch.setattr(views.db, "view_task", lambda *a, **k: T()) + monkeypatch.setattr(views.db, "set_task_visibility", + lambda tid, vis: state.__setitem__("vis", vis), raising=False) + + c = APIClient() + c.force_authenticate(user=owner) + r = c.patch("/apiv2/tasks/visibility/1/", {"visibility": "tenant"}, format="json") + assert r.status_code == 400 and r.json().get("error") is True + assert state["vis"] == "private" # unchanged — set_task_visibility never invoked with tenant + + # sanity: the owner can still set public/private on their tenantless task + r = c.patch("/apiv2/tasks/visibility/1/", {"visibility": "public"}, format="json") + assert r.status_code == 200, r.content + assert state["vis"] == "public" diff --git a/web/apiv2/views.py b/web/apiv2/views.py index 4d35fad3263..bf7ebd8a41d 100644 --- a/web/apiv2/views.py +++ b/web/apiv2/views.py @@ -39,7 +39,7 @@ _UI_INTERNAL_AUTH = [SessionAuthentication] + ([ApiKeyAuthentication] if ApiKeyAuthentication else []) from web.tenancy_optional import submission_scope, can_view_task, can_toggle_task, can_manage_task, can_view_sample, viewer_for -from web.tenancy_optional import VISIBILITIES +from web.tenancy_optional import VISIBILITIES, TENANT def _deny_if_hidden(request, task): @@ -112,6 +112,14 @@ def tasks_set_visibility(request, task_id): return Response({"error": True, "error_value": "invalid visibility"}, status=400) if not can_toggle_task(request.user, task): return Response({"error": True, "error_value": "Access denied"}, status=403) + # A task with no tenant can't be 'tenant'-visible: can_read's tenant branch + # requires a non-null job tenant, so this would make the task readable by nobody + # but its owner / break-glass (a broken, invisible state). Reject the transition. + if vis == TENANT and getattr(task, "tenant_id", None) is None: + return Response( + {"error": True, "error_value": "tenant visibility requires the task to belong to a tenant"}, + status=400, + ) try: db.set_task_visibility(task_id, vis) except CuckooOperationalError: diff --git a/web/submission/test_visibility.py b/web/submission/test_visibility.py index 4ea9131b87c..c5948444fc3 100644 --- a/web/submission/test_visibility.py +++ b/web/submission/test_visibility.py @@ -74,3 +74,24 @@ def test_remote_session_denies_readonly_viewer(cape_db, mt_enabled, monkeypatch, assert b"ERROR :-(" in r.content # error.html marker assert b"seem to exist" in r.content # "...task doesn't seem to exist." assert b"session_data" not in r.content # no live-VM token handed to a non-manager + + +@pytest.mark.django_db +def test_submit_form_tenantless_locked_default_is_private(cape_db, mt_enabled, monkeypatch, client): + """A tenant-less user in locked mode must see 'private' preselected (not public): + the form default must match submission_scope's fail-closed downgrade AND be a + level that's actually offered — else the browser submits the first option (public) + on an unchanged form, silently creating a public job.""" + import submission.views as sv + from lib.cuckoo.common.tenancy import MTConfig + + monkeypatch.setattr(sv, "multitenancy_config", lambda: MTConfig(True, "locked", "", True)) + client.force_login(User.objects.create_user("tll", "tll@x.com", "x")) # tenant-less + try: + from django.urls import reverse + url = reverse("submission") + except Exception: + url = "/submit/" + content = client.get(url).content + assert b'value="tenant"' not in content # tenant not offered to a tenant-less user + assert b'value="private" selected' in content # private preselected (not public) diff --git a/web/submission/views.py b/web/submission/views.py index 30501857b8d..25dab723068 100644 --- a/web/submission/views.py +++ b/web/submission/views.py @@ -820,20 +820,27 @@ def index(request, task_id=None, resubmit_hash=None): existent_tasks.setdefault(record["target"]["file"]["sha256"], []) existent_tasks[record["target"]["file"]["sha256"]].append(record) + # Offer TENANT iff the submitter actually has a tenant — this matches + # submission_scope, which honors an explicit 'tenant' for a tenant member (in + # BOTH modes) and rejects it for a tenant-less user. The default must also be + # a level that is actually OFFERED and must match what submission_scope would + # persist for an unchanged form: a tenant-less user in locked mode resolves to + # 'tenant' (not offered), so the browser would select the first option (public) + # and submit it explicitly — bypassing submission_scope's fail-closed private + # downgrade. Downgrade that default to PRIVATE here to keep the two in sync. + _sub_viewer = viewer_for(request.user) + _has_tenant = _sub_viewer.tenant_id is not None + _visibility_levels = [PUBLIC, TENANT, PRIVATE] if _has_tenant else [PUBLIC, PRIVATE] + _form_default_visibility = default_visibility(multitenancy_config()) + if _form_default_visibility == TENANT and not _has_tenant: + _form_default_visibility = PRIVATE return render( request, "submission/index.html", { "title": "Submit", - # Offer TENANT iff the submitter actually has a tenant — this matches - # submission_scope, which honors an explicit 'tenant' for a tenant - # member (in BOTH modes) and rejects it for a tenant-less user. Keying - # on mode instead diverged the UI from the API (a tenant member could - # pick 'tenant' via the API but not the form in shared mode). - "visibility_levels": ( - [PUBLIC, TENANT, PRIVATE] if viewer_for(request.user).tenant_id is not None else [PUBLIC, PRIVATE] - ), - "default_visibility": default_visibility(multitenancy_config()), + "visibility_levels": _visibility_levels, + "default_visibility": _form_default_visibility, "packages": sorted(packages, key=lambda i: i["name"].lower()), "machines": machines, "vpns": vpns_data, diff --git a/web/templates/analysis/report.html b/web/templates/analysis/report.html index 65375da9507..113df86c287 100644 --- a/web/templates/analysis/report.html +++ b/web/templates/analysis/report.html @@ -5,7 +5,9 @@
From 35cccb17a1e396e68bcee78737952dce911ad94a Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Tue, 14 Jul 2026 13:38:43 -0500 Subject: [PATCH 027/183] mt: direction-aware two-store visibility ordering + type/import cleanups (Codex P1 + Copilot) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P1 (lib/cuckoo/core/data/tasking.py:882): set_task_visibility synced the mongo stamp BEFORE the SQL commit for ALL directions. That closes the restrictive leak but OPENS the permissive one: a private->public toggle publishes mongo=public before SQL is durable, so a crash or a concurrent aggregate in the window sees mongo more permissive than SQL (cross-tenant leak), and a crash means it never reverts. Fix: order the two writes by the DIRECTION of the change so the invariant "mongo is never more permissive than SQL" always holds: - MORE permissive (rank public>tenant>private): commit SQL FIRST, then publish the mongo stamp; a mongo lag is fail-closed (mongo less permissive) and reconciles on reprocess, so it logs rather than rolling back a durable authorized change. - MORE restrictive / unchanged: mongo-first (existing behavior) — restrictive value reaches the mongo surfaces before SQL drops the restriction; revert+raise on mongo failure, and best-effort mongo revert if the SQL commit then fails. Updated the two existing mongo-fail/commit-fail tests to the restrictive direction (that's the mongo-first path now) + added two permissive-direction regression tests. Copilot (low-severity): - web/cuckoo/common/web_utils.py top_asn(): annotation/docstring said `-> dict` but it returns a list of rows or False; corrected to `list | bool` (+ documented scope_match). - web/apiv2/views.py: hoisted the mid-module `from rest_framework.response import Response` to the top DRF imports (the new MT helpers use it before that line). --- lib/cuckoo/common/web_utils.py | 5 +- lib/cuckoo/core/data/tasking.py | 101 +++++++++++++++++++------------- tests/test_task_visibility.py | 93 +++++++++++++++++++++++------ web/apiv2/views.py | 2 +- 4 files changed, 138 insertions(+), 63 deletions(-) diff --git a/lib/cuckoo/common/web_utils.py b/lib/cuckoo/common/web_utils.py index bba47045ca4..41874b3e86c 100644 --- a/lib/cuckoo/common/web_utils.py +++ b/lib/cuckoo/common/web_utils.py @@ -251,7 +251,7 @@ def load_vms_tags(force: bool = False): return _all_vms_tags or [] -def top_asn(date_since: datetime = False, results_limit: int = 20, scope_match: dict = None) -> dict: +def top_asn(date_since: datetime = False, results_limit: int = 20, scope_match: dict = None) -> "list | bool": """ Retrieves the top Autonomous System Numbers (ASNs) based on the number of occurrences in the database. @@ -261,9 +261,10 @@ def top_asn(date_since: datetime = False, results_limit: int = 20, scope_match: Args: date_since (datetime, optional): A datetime object to filter results starting from this date. Defaults to False. results_limit (int, optional): The maximum number of ASNs to return. Defaults to 20. + scope_match (dict, optional): A mongo $match restricting the aggregation to the caller's entitled tenant scope (multitenancy). Defaults to None (no scoping / break-glass). Returns: - dict: A dictionary containing the top ASNs and their counts. Returns False if the MongoDB is not enabled or if the "top_asn" configuration is disabled. + list | bool: A list of {"total": count, "asn": asn} rows, or False if MongoDB is not enabled or the "top_asn" configuration is disabled. """ if web_cfg.general.get("top_asn", False) is False: return False diff --git a/lib/cuckoo/core/data/tasking.py b/lib/cuckoo/core/data/tasking.py index 6cb93db2059..cb30585fe94 100644 --- a/lib/cuckoo/core/data/tasking.py +++ b/lib/cuckoo/core/data/tasking.py @@ -863,57 +863,76 @@ def set_task_visibility(self, task_id: int, visibility: str) -> Optional[Task]: return None _prev_visibility = task.visibility task.visibility = visibility - # Two-store consistency: sync the mongo stamp FIRST (still uncommitted in - # SQL), then commit — so there's never a COMMITTED window where SQL reads - # "private" while the mongo aggregate/search/stats surfaces still read - # "public" (the leaky direction). If mongo is the enabled store and the sync - # fails, revert the in-memory SQL change to the previous value and commit - # that (leaving both stores at the old value), then raise so the caller - # retries. We revert in-place rather than session.rollback() so we don't - # discard unrelated pending work in a shared/scoped session. - _synced_mongo = False - if mongo_update_one is not None and _mongo_reporting_enabled(): - # mongo_update_one is wrapped by graceful_auto_reconnect, which retries - # AutoReconnect/ServerSelectionTimeoutError internally and RETURNS None - # with no re-raise when mongo stays down. Detect a swallowed failure by - # the None return (a success returns an UpdateResult, even matched_count=0). - _res = None + + # Two-store (SQL + mongo) consistency without a distributed transaction. The + # ONE invariant we must never violate is "mongo more permissive than SQL" — + # the cross-tenant leak, since the mongo aggregate/search/stats surfaces scope + # on the info.visibility stamp. So we order the two writes by the DIRECTION of + # the change: make the RESTRICTIVE change first and the PERMISSIVE change last, + # so a crash / concurrent read in the window fails CLOSED either way. + from lib.cuckoo.common.exceptions import CuckooOperationalError + + _RANK = {"private": 0, "tenant": 1, "public": 2} + _mongo_on = mongo_update_one is not None and _mongo_reporting_enabled() + + def _sync_mongo(_vis): + # mongo_update_one is wrapped by graceful_auto_reconnect, which swallows + # AutoReconnect/ServerSelectionTimeoutError and RETURNS None (no re-raise) + # when mongo stays down; a success returns an UpdateResult (even + # matched_count=0). Treat None or a raised error as failure. try: - _res = mongo_update_one("analysis", {"info.id": task_id}, {"$set": {"info.visibility": visibility}}) - except Exception as _e: # a non-AutoReconnect error the wrapper does not swallow + return mongo_update_one("analysis", {"info.id": task_id}, {"$set": {"info.visibility": _vis}}) is not None + except Exception as _e: log.warning("visibility mongo sync errored for task %s: %s", task_id, _e) - if _res is None: - from lib.cuckoo.common.exceptions import CuckooOperationalError - - task.visibility = _prev_visibility + return False + + if _RANK.get(visibility, 0) > _RANK.get(_prev_visibility, 0): + # MORE PERMISSIVE (e.g. private->public): make SQL durable FIRST, then + # publish the mongo stamp. A crash or concurrent aggregate in the window + # sees mongo still at the OLD, less-permissive value -> fail closed. If the + # mongo publish then fails, the stores are momentarily inconsistent but + # STILL fail closed (mongo less permissive than SQL); it reconciles on + # reprocess, so log rather than roll back a durable, authorized change. + try: self.session.commit() - log.error("visibility mongo sync FAILED for task %s (mongo unreachable); left at %r", task_id, _prev_visibility) - raise CuckooOperationalError(f"task {task_id} visibility change aborted: report store sync failed") - _synced_mongo = True - # Commit the SQL change. If THIS fails after a successful mongo sync, the two - # stores would disagree in the leaky direction (mongo already shows the new, - # possibly more-permissive, visibility while SQL rolls back to the old value). - # Best-effort revert the mongo stamp to the previous value, roll back the - # poisoned session, and raise so the caller retries — never leave a committed - # window where the aggregate/search surfaces are more permissive than SQL. + except Exception as _ce: + try: + self.session.rollback() + except Exception: + pass + raise CuckooOperationalError(f"task {task_id} visibility change aborted: SQL commit failed") from _ce + if _mongo_on and not _sync_mongo(visibility): + log.error( + "visibility mongo publish lagging for task %s (SQL committed %r, mongo still %r); " + "fail-closed, reconciles on reprocess", task_id, visibility, _prev_visibility, + ) + return task + + # MORE RESTRICTIVE or unchanged (e.g. public->private): update the mongo stamp + # FIRST (still uncommitted in SQL) so the restrictive value reaches the mongo + # surfaces before SQL drops the restriction. If the sync fails, revert the + # in-memory SQL change to the previous value and commit that (both stores stay + # consistent), then raise so the caller retries. Revert in-place rather than + # session.rollback() so unrelated pending work in a shared session survives. + if _mongo_on and not _sync_mongo(visibility): + task.visibility = _prev_visibility + self.session.commit() + log.error("visibility mongo sync FAILED for task %s (mongo unreachable); left at %r", task_id, _prev_visibility) + raise CuckooOperationalError(f"task {task_id} visibility change aborted: report store sync failed") + # Commit SQL; if THIS fails after a successful sync, best-effort revert the + # mongo stamp to the previous value (so mongo is never left more permissive + # than the un-committed SQL), roll back the poisoned session, and raise. try: self.session.commit() except Exception as _ce: - from lib.cuckoo.common.exceptions import CuckooOperationalError - try: self.session.rollback() except Exception: pass - if _synced_mongo: - try: - mongo_update_one("analysis", {"info.id": task_id}, {"$set": {"info.visibility": _prev_visibility}}) - log.error("visibility SQL commit failed for task %s; reverted mongo stamp to %r", task_id, _prev_visibility) - except Exception as _re: - log.error( - "visibility SQL commit failed AND mongo revert FAILED for task %s (stores may disagree): %s", - task_id, _re, - ) + if _mongo_on and not _sync_mongo(_prev_visibility): + log.error("visibility SQL commit failed AND mongo revert FAILED for task %s (stores may disagree)", task_id) + elif _mongo_on: + log.error("visibility SQL commit failed for task %s; reverted mongo stamp to %r", task_id, _prev_visibility) raise CuckooOperationalError(f"task {task_id} visibility change aborted: SQL commit failed") from _ce return task diff --git a/tests/test_task_visibility.py b/tests/test_task_visibility.py index 63b07ea1622..c8c088c4a5d 100644 --- a/tests/test_task_visibility.py +++ b/tests/test_task_visibility.py @@ -195,32 +195,31 @@ def _rec(*a, **k): def test_set_task_visibility_raises_on_persistent_mongo_failure(db, monkeypatch): - """Finding #10: a persistent mongo-sync failure must be surfaced (raised), not - swallowed — else a stale public stamp after a private toggle silently keeps the - analysis cross-tenant visible in the aggregate/search/stats surfaces. mongo's - graceful_auto_reconnect wrapper swallows AutoReconnect/ServerSelectionTimeoutError - and RETURNS None (no re-raise) when mongo stays down, so model that real path - (a None return), NOT a raw exception — the latter would never exercise the bug.""" + """Finding #10: on the RESTRICTIVE path (public->private, mongo-first) a persistent + mongo-sync failure must be surfaced (raised), not swallowed — else a stale public + stamp after a private toggle silently keeps the analysis cross-tenant visible in + the aggregate/search/stats surfaces. mongo's graceful_auto_reconnect wrapper + swallows AutoReconnect/ServerSelectionTimeoutError and RETURNS None (no re-raise) + when mongo stays down, so model that real path (a None return), NOT a raw + exception — the latter would never exercise the bug.""" import lib.cuckoo.core.data.tasking as tk from lib.cuckoo.common.exceptions import CuckooOperationalError monkeypatch.setattr(tk, "_mongo_reporting_enabled", lambda: True, raising=False) monkeypatch.setattr(tk, "mongo_update_one", lambda *a, **k: None, raising=False) - tid = db.add_url("http://example.com", tenant_id=10, visibility="tenant") + tid = db.add_url("http://example.com", tenant_id=10, visibility="public") with pytest.raises(CuckooOperationalError): - db.set_task_visibility(tid, "public") + db.set_task_visibility(tid, "private") # SQL rolled back to the previous value so the two stores can't diverge. from lib.cuckoo.core.data.task import Task - assert db.session.get(Task, tid).visibility == "tenant" + assert db.session.get(Task, tid).visibility == "public" def test_set_task_visibility_reverts_mongo_when_sql_commit_fails(db, monkeypatch): - """Complementary to the mongo-fail case: if the SQL commit fails AFTER a - successful mongo sync, the stores would disagree in the leaky direction (mongo - already shows the new, possibly more-permissive, visibility while SQL keeps the - old). The setter must best-effort revert the mongo stamp to the previous value - and raise, so the aggregate/search surfaces never read more permissively than - SQL authorizes.""" + """RESTRICTIVE path (public->private, mongo-first): if the SQL commit fails AFTER a + successful mongo sync, mongo would be left MORE RESTRICTIVE than the un-committed + SQL — the setter must best-effort revert the mongo stamp to the previous value and + raise, so the two stores stay consistent.""" import lib.cuckoo.core.data.tasking as tk from lib.cuckoo.common.exceptions import CuckooOperationalError @@ -232,7 +231,7 @@ def _rec(coll, q, upd, *a, **k): return object() # UpdateResult stand-in (success) monkeypatch.setattr(tk, "mongo_update_one", _rec, raising=False) - tid = db.add_url("http://example.com", tenant_id=10, visibility="private") + tid = db.add_url("http://example.com", tenant_id=10, visibility="public") # make ONLY the new-value commit inside set_task_visibility fail (add_url above # already committed the task with the real commit). @@ -248,10 +247,66 @@ def _commit(): monkeypatch.setattr(db.session, "commit", _commit) with pytest.raises(CuckooOperationalError): - db.set_task_visibility(tid, "public") + db.set_task_visibility(tid, "private") + + # forward sync to 'private', then a best-effort revert back to 'public' + assert calls == ["private", "public"] + + +def test_set_task_visibility_permissive_commits_sql_before_mongo(db, monkeypatch): + """Codex P1: a MORE-permissive change (private->public) must make SQL durable + BEFORE publishing the mongo stamp — so a crash / concurrent aggregate in the + window can never see mongo more permissive than committed SQL. A mongo publish + lag must NOT roll back the durable, authorized SQL change.""" + import lib.cuckoo.core.data.tasking as tk + from lib.cuckoo.core.data.task import Task - # forward sync to 'public', then a best-effort revert back to 'private' - assert calls == ["public", "private"] + monkeypatch.setattr(tk, "_mongo_reporting_enabled", lambda: True, raising=False) + order = [] + # publish "lags" (returns None) AND records that it ran after the commit + monkeypatch.setattr(tk, "mongo_update_one", lambda *a, **k: order.append("mongo") or None, raising=False) + tid = db.add_url("http://example.com", tenant_id=10, visibility="private") + + orig_commit = db.session.commit + + def _commit(): + order.append("commit") + return orig_commit() + + monkeypatch.setattr(db.session, "commit", _commit) + + # a permissive change must NOT raise on a mongo lag (SQL is authoritative + durable) + assert db.set_task_visibility(tid, "public") is not None + assert order[:2] == ["commit", "mongo"] # SQL durable BEFORE mongo published + db.session.expire_all() + assert db.session.get(Task, tid).visibility == "public" # durable despite mongo lag + + +def test_set_task_visibility_permissive_sql_fail_never_publishes_mongo(db, monkeypatch): + """A permissive change whose SQL commit fails must raise and NEVER publish the + mongo stamp — nothing becomes more permissive if SQL didn't commit.""" + import lib.cuckoo.core.data.tasking as tk + from lib.cuckoo.common.exceptions import CuckooOperationalError + + monkeypatch.setattr(tk, "_mongo_reporting_enabled", lambda: True, raising=False) + mongo_calls = [] + monkeypatch.setattr(tk, "mongo_update_one", lambda *a, **k: mongo_calls.append(a) or object(), raising=False) + tid = db.add_url("http://example.com", tenant_id=10, visibility="private") + + orig_commit = db.session.commit + state = {"boom": True} + + def _commit(): + if state["boom"]: + state["boom"] = False + raise RuntimeError("transient SQL commit failure") + return orig_commit() + + monkeypatch.setattr(db.session, "commit", _commit) + + with pytest.raises(CuckooOperationalError): + db.set_task_visibility(tid, "public") + assert mongo_calls == [] # never published the more-permissive stamp def test_set_task_visibility_skips_sync_when_mongo_disabled(db, monkeypatch): diff --git a/web/apiv2/views.py b/web/apiv2/views.py index bf7ebd8a41d..260dab68bbe 100644 --- a/web/apiv2/views.py +++ b/web/apiv2/views.py @@ -26,6 +26,7 @@ from django.views.decorators.http import require_safe from rest_framework.decorators import api_view, authentication_classes from rest_framework.authentication import SessionAuthentication +from rest_framework.response import Response try: from apikey.authentication import ApiKeyAuthentication except ImportError: @@ -131,7 +132,6 @@ def tasks_set_visibility(request, task_id): status=503, ) return Response({"error": False, "data": {"task_id": int(task_id), "visibility": vis}}) -from rest_framework.response import Response sys.path.append(settings.CUCKOO_PATH) From 5435fd56d21ed7fe81fc1ff928e9ace499b31704 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Tue, 14 Jul 2026 13:56:22 -0500 Subject: [PATCH 028/183] mt: don't commit on the visibility mongo-fail path + harden _g() group parsing (Copilot) Copilot re-review of 35cccb17: - lib/cuckoo/core/data/tasking.py: on the RESTRICTIVE mongo-sync-fail path the setter reverted task.visibility then called self.session.commit(). On a shared/scoped session that could persist UNRELATED pending work. No SQL was committed on this path, so it needs neither a commit nor a rollback (rollback would DISCARD unrelated pending work): just revert the in-memory change and raise. (The SQL-commit-fail path still rolls back, because there a failed commit must be recovered.) - web/web/allauth_adapters.py: _g() did `{g for g in (vals or [])}`, so a JSONField idp_groups accidentally stored as a bare STRING ("acme-soc") iterated as characters and could char-match the wrong tenant. Normalize: a string is ONE group, a list/tuple/set is filtered to strings, any other type fails closed to empty. (+ test) --- lib/cuckoo/core/data/tasking.py | 6 +++++- web/users/test_tenancy.py | 22 ++++++++++++++++++++++ web/web/allauth_adapters.py | 15 +++++++++++---- 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/lib/cuckoo/core/data/tasking.py b/lib/cuckoo/core/data/tasking.py index cb30585fe94..cbefe2a9515 100644 --- a/lib/cuckoo/core/data/tasking.py +++ b/lib/cuckoo/core/data/tasking.py @@ -915,8 +915,12 @@ def _sync_mongo(_vis): # consistent), then raise so the caller retries. Revert in-place rather than # session.rollback() so unrelated pending work in a shared session survives. if _mongo_on and not _sync_mongo(visibility): + # Abort WITHOUT committing or rolling back: revert only our in-memory change + # (no SQL was committed, so SQL stays at the old value) and raise. Do NOT + # commit here — this is a shared/scoped session and a commit could persist + # unrelated pending work; and no rollback is needed (there's no failed commit + # to recover from), which would instead DISCARD that unrelated pending work. task.visibility = _prev_visibility - self.session.commit() log.error("visibility mongo sync FAILED for task %s (mongo unreachable); left at %r", task_id, _prev_visibility) raise CuckooOperationalError(f"task {task_id} visibility change aborted: report store sync failed") # Commit SQL; if THIS fails after a successful sync, best-effort revert the diff --git a/web/users/test_tenancy.py b/web/users/test_tenancy.py index f155ae414f4..122a2343cd8 100644 --- a/web/users/test_tenancy.py +++ b/web/users/test_tenancy.py @@ -56,6 +56,28 @@ def test_resolve_tenant_multi_match_fails_closed(): assert p.tenant_id is None # ambiguous -> fail closed +@pytest.mark.django_db +def test_resolve_tenant_string_idp_groups_not_char_matched(): + """A misconfigured idp_groups stored as a bare STRING (e.g. "acme-soc" instead of + ["acme-soc"]) must be treated as ONE group, not iterated character-by-character — + else a user in group 'a'/'c'/… would spuriously match and get the wrong tenant.""" + from users.models import Tenant, UserProfile + from web.allauth_adapters import reconcile_tenant + + t = Tenant.objects.create(slug="acmestr", name="AcmeStr", idp_groups="acme-soc") # bare string (misconfig) + u = User.objects.create_user("astr", "astr@x.com", "x") + + # a single-character group must NOT char-match the string + reconcile_tenant(u, {"a"}) + p = UserProfile.objects.get(user=u) + assert p.tenant_id is None + + # the whole string, treated as one group, matches + reconcile_tenant(u, {"acme-soc"}) + p.refresh_from_db() + assert p.tenant_id == t.id + + @pytest.mark.django_db def test_viewer_for_maps_user(mt_enabled): from users.models import Tenant, UserProfile diff --git a/web/web/allauth_adapters.py b/web/web/allauth_adapters.py index 8211574b1a9..421af62bf97 100644 --- a/web/web/allauth_adapters.py +++ b/web/web/allauth_adapters.py @@ -297,10 +297,17 @@ def reconcile_tenant(user, user_groups: set) -> None: from users.models import Tenant, UserProfile def _g(vals): - # A tenant's idp_groups/admin_idp_groups come from a JSONField; keep only - # hashable strings so a malformed config (e.g. a nested dict) can't - # TypeError the set intersection and 500 the login. - return {g for g in (vals or []) if isinstance(g, str)} + # A tenant's idp_groups/admin_idp_groups come from a JSONField. Normalize + # defensively so a MALFORMED config can't mis-match a tenant: a bare string is + # ONE group (NOT an iterable of characters — else a member of group "a"/"c"/… + # would spuriously match), a list/tuple/set is filtered to hashable strings (so + # a nested dict can't TypeError the set intersection and 500 the login), and any + # other type fails closed to the empty set. + if isinstance(vals, str): + return {vals} + if isinstance(vals, (list, tuple, set)): + return {g for g in vals if isinstance(g, str)} + return set() # Filter in Python rather than an idp_groups__contains query: the Django auth # DB is sqlite, where JSONField contains/contained_by lookups are unsupported From 82ad865466a3c881effb04efb1ec1804308da519 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Tue, 14 Jul 2026 14:11:32 -0500 Subject: [PATCH 029/183] mt: gate the visibility toggle on enabled multitenancy (Codex P2) Codex re-review of 35cccb17 (web/apiv2/views.py:114): with [multitenancy] enabled=no (the default/legacy mode) viewer_for marks every principal is_local_admin, so can_toggle_task authorized ANY allowed API caller (or anonymous under DRF AllowAny) to PATCH a task's visibility. The value is ignored while MT is off, but it writes SQL and (mongo store) info.visibility; if MT is later enabled the backfill skips already-stamped docs, so these user-written values could hide/expose legacy analyses. Gate the write surfaces on multitenancy_config().enabled: - web/apiv2/views.py tasks_set_visibility: reject with 400 when MT is disabled (before any parse/lookup/write). (+ regression test) - web/analysis/views.py report(): can_toggle_visibility is now False when MT is disabled, so the report page doesn't render the (meaningless) toggle control. --- web/analysis/views.py | 8 ++++++-- web/apiv2/test_visibility.py | 34 ++++++++++++++++++++++++++++++++++ web/apiv2/views.py | 8 +++++++- 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/web/analysis/views.py b/web/analysis/views.py index 9da15c71a9c..dc4aeea5358 100644 --- a/web/analysis/views.py +++ b/web/analysis/views.py @@ -192,7 +192,7 @@ db: TasksMixIn = Database() -from web.tenancy_optional import can_view_task, can_toggle_task, can_manage_task, can_view_sample, viewer_for +from web.tenancy_optional import can_view_task, can_toggle_task, can_manage_task, can_view_sample, viewer_for, multitenancy_config def _coerce_task_id(tid): @@ -2755,7 +2755,11 @@ def report(request, task_id): _task = db.view_task(task_id) if _task is None or not can_view_task(request.user, _task): return render(request, "error.html", {"error": "No analysis found with specified ID"}) - can_toggle_visibility = can_toggle_task(request.user, _task) + # Only show the visibility toggle when multitenancy is ON. With MT off every + # principal is a break-glass local-admin (can_toggle == True), but the control + # would be meaningless and writing a value could plant a backfill landmine if MT + # is later enabled — the apiv2 endpoint also rejects the write when disabled. + can_toggle_visibility = multitenancy_config().enabled and can_toggle_task(request.user, _task) # Central mode: the analysis tree lives in S3, not on this node's disk. Stage it # locally (once, cached, excluding huge memory dumps) so EVERY report feature that diff --git a/web/apiv2/test_visibility.py b/web/apiv2/test_visibility.py index 40c884518ed..4e161d3c2bb 100644 --- a/web/apiv2/test_visibility.py +++ b/web/apiv2/test_visibility.py @@ -756,3 +756,37 @@ def visibility(self): r = c.patch("/apiv2/tasks/visibility/1/", {"visibility": "public"}, format="json") assert r.status_code == 200, r.content assert state["vis"] == "public" + + +@pytest.mark.django_db +def test_toggle_visibility_rejected_when_mt_disabled(cape_db, monkeypatch): + """Visibility is an MT feature: with MT disabled the toggle endpoint must reject + (400) and never write — with MT off every principal is is_local_admin, so + can_toggle would otherwise authorize a write whose value could become a backfill + landmine (hide/expose legacy analyses) if MT is later enabled.""" + from rest_framework.test import APIClient + import apiv2.views as views + import users.tenancy as ut + from lib.cuckoo.common.tenancy import MTConfig + + # force MT OFF deterministically (the facade delegates to users.tenancy) + monkeypatch.setattr(ut, "multitenancy_config", lambda: MTConfig(False, "shared", "", True)) + + wrote = {"set": False} + monkeypatch.setattr(views.db, "set_task_visibility", + lambda *a, **k: wrote.__setitem__("set", True), raising=False) + + class T: + id = 1 + user_id = 1 + tenant_id = None + visibility = "public" + + monkeypatch.setattr(views.db, "view_task", lambda *a, **k: T()) + + u = User.objects.create_user("mtoff", "mtoff@x.com", "x") + c = APIClient() + c.force_authenticate(user=u) + r = c.patch("/apiv2/tasks/visibility/1/", {"visibility": "private"}, format="json") + assert r.status_code == 400 and r.json().get("error") is True + assert wrote["set"] is False # never wrote SQL/mongo while MT off diff --git a/web/apiv2/views.py b/web/apiv2/views.py index 260dab68bbe..9fc44dac4f4 100644 --- a/web/apiv2/views.py +++ b/web/apiv2/views.py @@ -40,7 +40,7 @@ _UI_INTERNAL_AUTH = [SessionAuthentication] + ([ApiKeyAuthentication] if ApiKeyAuthentication else []) from web.tenancy_optional import submission_scope, can_view_task, can_toggle_task, can_manage_task, can_view_sample, viewer_for -from web.tenancy_optional import VISIBILITIES, TENANT +from web.tenancy_optional import VISIBILITIES, TENANT, multitenancy_config def _deny_if_hidden(request, task): @@ -95,6 +95,12 @@ def tasks_set_visibility(request, task_id): """Owner (or tenant-admin for public/tenant jobs, or a break-glass admin) re-toggles a task's visibility. Mirrors the can_toggle predicate (break-glass = viewer.is_local_admin, i.e. a superuser gated by cuckoo.conf, not any superuser).""" + # Visibility is a multitenancy feature. With MT OFF, viewer_for marks every + # principal is_local_admin, so can_toggle would authorize ANY caller to write a + # value that is ignored now but can hide/expose LEGACY analyses if MT is later + # enabled (the mongo backfill skips already-stamped docs). Reject when disabled. + if not multitenancy_config().enabled: + return Response({"error": True, "error_value": "multitenancy is not enabled"}, status=400) # Parse once so view_task() and set_task_visibility() get a consistent int and # a non-numeric id fails as the same generic 404 (no implicit-coercion no-op). try: From 7f7d6613ee2cf5d5be7ae515eb535aa9217ba056 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Tue, 14 Jul 2026 14:37:32 -0500 Subject: [PATCH 030/183] mt: fail-closed on corrupt/non-numeric analysis ids (Copilot) Copilot re-review of 82ad8654 (robustness against corrupt/partial docs): - utils/db_migration/mongo_backfill_tenant.py: backfill_doc int()-cast info.id unconditionally, so a doc with a missing/non-numeric id would raise and abort the whole one-shot backfill mid-run. Now wraps the coercion: a bad id fails CLOSED to private (orphan stamp) instead of crashing, without ever calling view_task with a value that would raise. (+ regression test) - web/analysis/views.py report(): the existent_tasks loop passed rid straight from a mongo/ES record to db.view_task(rid); a non-numeric rid would raise in SQLAlchemy parameter binding (-> 500). Coerce rid to int and skip on failure (also covers a missing/None id), then compare against the current task id. --- tests/test_mongo_backfill.py | 20 ++++++++++++++++++++ utils/db_migration/mongo_backfill_tenant.py | 16 +++++++++++----- web/analysis/views.py | 10 ++++++++-- 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/tests/test_mongo_backfill.py b/tests/test_mongo_backfill.py index b433b75e506..699bcb3a000 100644 --- a/tests/test_mongo_backfill.py +++ b/tests/test_mongo_backfill.py @@ -17,3 +17,23 @@ def test_backfill_orphan_fails_closed_private(): doc = {"info": {"id": 9}} update = backfill_doc(doc, lambda tid: None) assert update == {"info.tenant_id": None, "info.user_id": None, "info.visibility": "private"} + + +def test_backfill_corrupt_id_fails_closed_private(): + """Copilot: a doc with a missing or non-numeric info.id (corrupt/partial data) + must fail CLOSED to private rather than raise and abort the whole backfill run. + A non-numeric id must be rejected BEFORE view_task (which would raise on bind).""" + from utils.db_migration.mongo_backfill_tenant import backfill_doc + + private = {"info.tenant_id": None, "info.user_id": None, "info.visibility": "private"} + + # non-numeric id -> int() raises -> orphan WITHOUT ever calling view_task + def _boom(tid): + raise AssertionError("view_task must not be called for a non-numeric id") + + assert backfill_doc({"info": {"id": "abc"}}, _boom) == private + assert backfill_doc({"info": {"id": None}}, _boom) == private + + # missing id defaults to 0 -> view_task(0) -> None (pruned) -> orphan + assert backfill_doc({"info": {}}, lambda t: None) == private + assert backfill_doc({}, lambda t: None) == private diff --git a/utils/db_migration/mongo_backfill_tenant.py b/utils/db_migration/mongo_backfill_tenant.py index 7645db9d2fa..62aa34ed7a7 100644 --- a/utils/db_migration/mongo_backfill_tenant.py +++ b/utils/db_migration/mongo_backfill_tenant.py @@ -5,12 +5,18 @@ def backfill_doc(doc, view_task) -> dict: - task = view_task(int(doc.get("info", {}).get("id", 0))) + # Orphan / corrupt: the Postgres task was pruned (or the doc has a missing or + # non-numeric info.id) — fail CLOSED to private (no owner/tenant) so it matches + # no cross-tenant scope and stays invisible to everyone but break-glass, never + # world-visible. A bad id must NOT abort the whole one-shot backfill run. + _orphan = {"info.tenant_id": None, "info.user_id": None, "info.visibility": "private"} + try: + _rid = int(doc.get("info", {}).get("id", 0)) + except (TypeError, ValueError): + return _orphan + task = view_task(_rid) if task is None: - # Orphan: the Postgres task was pruned but the mongo doc remains. Fail - # CLOSED to private (no owner/tenant) so it matches no cross-tenant scope - # and stays invisible to everyone but break-glass — never world-visible. - return {"info.tenant_id": None, "info.user_id": None, "info.visibility": "private"} + return _orphan return { "info.tenant_id": getattr(task, "tenant_id", None), "info.user_id": getattr(task, "user_id", None), diff --git a/web/analysis/views.py b/web/analysis/views.py index dc4aeea5358..1fa9dba4292 100644 --- a/web/analysis/views.py +++ b/web/analysis/views.py @@ -3134,8 +3134,14 @@ def report(request, task_id): viewer=viewer_for(request.user), ) for record in records: - rid = (record.get("info") or {}).get("id") - if rid is None or rid == report["info"]["id"]: + # rid comes from a mongo/ES record; a corrupt non-numeric id would raise + # in db.view_task's SQL parameter bind (-> 500). Coerce and skip on + # failure (also covers a missing/None id). + try: + rid = int((record.get("info") or {}).get("id")) + except (TypeError, ValueError): + continue + if rid == report["info"]["id"]: continue # tenant isolation: only surface other analyses of this sample that # the requester may read (no-op when MT disabled). Without this, the From a8a726f69adf357aaf719177eae713c37a6dd912 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Tue, 14 Jul 2026 14:43:52 -0500 Subject: [PATCH 031/183] mt: submission_scope ignores visibility when multitenancy is off (Codex P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex re-review of 82ad8654 (web/users/tenancy.py:134): with [multitenancy] enabled=no (the default) submission_scope still honored a caller-supplied visibility, so the submit form/API could persist private/tenant on new tasks even though the feature is off. The mongo backfill skips already-stamped docs, so those rows would unexpectedly hide analyses when MT is later enabled — and it contradicted the visibility-toggle endpoint's disabled-MT guard (82ad8654). Short-circuit when disabled: submission_scope now returns the legacy (None, public) scope and IGNORES the request parameter when multitenancy_config().enabled is false. (+ regression test). Consistent with the tasks_set_visibility disabled-MT guard. --- web/users/tenancy.py | 16 ++++++++++++++-- web/users/test_tenancy.py | 24 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/web/users/tenancy.py b/web/users/tenancy.py index e3390d3510e..f853a23e459 100644 --- a/web/users/tenancy.py +++ b/web/users/tenancy.py @@ -121,7 +121,19 @@ def submission_scope(request): ``visibility`` param when valid, else the per-mode default. Raises ValueError on an invalid explicit visibility so the view can 400. """ - from lib.cuckoo.common.tenancy import multitenancy_config, default_visibility, VISIBILITIES, TENANT, PRIVATE + from lib.cuckoo.common.tenancy import default_visibility, VISIBILITIES, TENANT, PRIVATE, PUBLIC + + # Multitenancy OFF (default/legacy): visibility is meaningless (every principal is + # a break-glass local-admin). IGNORE any caller-supplied value and return the + # legacy (no tenant, public) scope. Persisting private/tenant here would plant a + # backfill landmine (the migration skips already-stamped docs, so those rows would + # unexpectedly hide analyses when MT is later enabled) and contradict the + # visibility-toggle endpoint's disabled-MT guard. Use the MODULE-LEVEL + # multitenancy_config (the same binding viewer_for uses and the test fixtures + # patch) — an in-function re-import from the core module would bypass those. + cfg = multitenancy_config() + if not cfg.enabled: + return None, PUBLIC v = viewer_for(request.user) data = getattr(request, "data", None) @@ -133,7 +145,7 @@ def submission_scope(request): raise ValueError("invalid visibility") visibility = requested else: - visibility = default_visibility(multitenancy_config()) + visibility = default_visibility(cfg) # A 'tenant'-visibility job needs a tenant to scope to. With tenant_id=None it # is readable only by its owner (or nobody, for an anonymous submitter) — never # the intended tenant pool (can_read's _same_tenant requires a non-None tenant). diff --git a/web/users/test_tenancy.py b/web/users/test_tenancy.py index 122a2343cd8..6cfb218768e 100644 --- a/web/users/test_tenancy.py +++ b/web/users/test_tenancy.py @@ -337,3 +337,27 @@ def test_viewer_for_inactive_tenant_fails_closed(mt_enabled): t.save() v = viewer_for(User.objects.get(pk=u.pk)) # fresh load assert v.tenant_id is None and v.is_tenant_admin is False # inactive -> fail closed + + +@pytest.mark.django_db +def test_submission_scope_ignores_visibility_when_mt_disabled(monkeypatch): + """With MT disabled the real submission_scope must IGNORE a caller-supplied + visibility and return the legacy (None, public) scope — persisting private/tenant + would plant a backfill landmine and contradict the disabled-MT toggle guard.""" + from lib.cuckoo.common import tenancy as core_ten + from lib.cuckoo.common.tenancy import MTConfig + import users.tenancy as ut + + def _off(): + return MTConfig(False, "shared", "", True) + + monkeypatch.setattr(ut, "multitenancy_config", _off) # viewer_for (not reached) + monkeypatch.setattr(core_ten, "multitenancy_config", _off) # submission_scope in-func import + + class Req: + pass + + r = Req() + r.user = User.objects.create_user("mtoffsub", "mtoffsub@x.com", "x") + r.data = {"visibility": "private"} # explicit request must be IGNORED when MT is off + assert ut.submission_scope(r) == (None, "public") From 6f22387ecc9eb940075099d93c859c62ef66dc5a Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Tue, 14 Jul 2026 15:44:31 -0500 Subject: [PATCH 032/183] mt: serialize concurrent visibility toggles + document the mongo backfill (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues from a cross-model review (codex/nemotron verified) of a8a726f6: [HIGH] lib/cuckoo/core/data/tasking.py set_task_visibility: the per-request direction ordering keeps each write crash/read-safe, but two CONCURRENT toggles of the same task could interleave (A commits SQL then stalls; B writes mongo+SQL; A resumes publishing its STALE mongo value) leaving SQL=tenant / mongo=public — the exact "mongo never more permissive than SQL" invariant broken. Serialize concurrent toggles of the SAME task with a Postgres SESSION-level advisory lock held across BOTH the SQL commit and the mongo publish (re-reading the task under the lock). No-op on sqlite (single-writer / tests) so single-node/legacy behavior is unchanged. (+ helper wiring test) [MEDIUM] utils/db_migration/mongo_backfill_tenant.py was invoked by nothing and documented nowhere, so enabling MT on a populated install left all pre-existing mongo reports unstamped => invisible (fail-closed) on search/stats/compare until an operator happened to find the script. Documented it: a prominent note on [multitenancy] enabled in cuckoo.conf.default + an "Enabling on an existing (populated) install" section in docs/MULTITENANCY-SUPPORT.md (run the one-shot backfill; idempotent; SQL migration backfills columns only). --- conf/default/cuckoo.conf.default | 6 ++ docs/MULTITENANCY-SUPPORT.md | 21 +++++ lib/cuckoo/core/data/tasking.py | 138 ++++++++++++++++++++----------- tests/test_task_visibility.py | 35 ++++++++ 4 files changed, 150 insertions(+), 50 deletions(-) diff --git a/conf/default/cuckoo.conf.default b/conf/default/cuckoo.conf.default index 170dcb76da6..31f25fa74a1 100644 --- a/conf/default/cuckoo.conf.default +++ b/conf/default/cuckoo.conf.default @@ -286,6 +286,12 @@ worker_ssh_keyfile = /home/cape/.ssh/id_ed25519 [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). diff --git a/docs/MULTITENANCY-SUPPORT.md b/docs/MULTITENANCY-SUPPORT.md index d3f08120523..79938e497ed 100644 --- a/docs/MULTITENANCY-SUPPORT.md +++ b/docs/MULTITENANCY-SUPPORT.md @@ -7,6 +7,27 @@ 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 un-stamped `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 is +idempotent (it only touches docs missing `info.visibility`) and safe to re-run. 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/ diff --git a/lib/cuckoo/core/data/tasking.py b/lib/cuckoo/core/data/tasking.py index cbefe2a9515..d1e2e98c7ce 100644 --- a/lib/cuckoo/core/data/tasking.py +++ b/lib/cuckoo/core/data/tasking.py @@ -32,6 +32,7 @@ not_, or_, select, + text, update, ) from sqlalchemy.orm import joinedload, subqueryload @@ -57,6 +58,34 @@ def _mongo_reporting_enabled() -> bool: log = logging.getLogger(__name__) conf = Config("cuckoo") + + +def _advisory_lock(session, key) -> bool: + """Best-effort cross-process serialization of per-task visibility toggles. + + Postgres SESSION-level advisory lock: unlike a transaction (xact) lock it survives + the mid-operation SQL commit, so it brackets BOTH the SQL commit and the mongo + publish — the window a concurrent toggle would otherwise interleave to leave mongo + more permissive than SQL. Returns True if a lock was taken (caller MUST unlock in a + finally). No-op (returns False) on backends without advisory locks — sqlite (tests) + is single-writer, so serialization isn't needed there.""" + try: + if session.get_bind().dialect.name != "postgresql": + return False + session.execute(text("SELECT pg_advisory_lock(:k)"), {"k": int(key)}) + return True + except Exception as _e: + log.warning("advisory lock unavailable for task %s: %s", key, _e) + return False + + +def _advisory_unlock(session, key) -> None: + """Release the session-level advisory lock taken by _advisory_lock (best effort — + a missed release is bounded: the lock auto-releases when the connection closes).""" + try: + session.execute(text("SELECT pg_advisory_unlock(:k)"), {"k": int(key)}) + except Exception as _e: + log.warning("advisory unlock failed for task %s: %s", key, _e) distconf = Config("distributed") web_conf = Config("web") @@ -861,15 +890,16 @@ def set_task_visibility(self, task_id: int, visibility: str) -> Optional[Task]: task = self.session.get(Task, task_id) if not task: return None - _prev_visibility = task.visibility - task.visibility = visibility - # Two-store (SQL + mongo) consistency without a distributed transaction. The # ONE invariant we must never violate is "mongo more permissive than SQL" — # the cross-tenant leak, since the mongo aggregate/search/stats surfaces scope - # on the info.visibility stamp. So we order the two writes by the DIRECTION of - # the change: make the RESTRICTIVE change first and the PERMISSIVE change last, - # so a crash / concurrent read in the window fails CLOSED either way. + # on the info.visibility stamp. Per request we order the two writes by the + # DIRECTION of the change (RESTRICTIVE first, PERMISSIVE last) so a crash / + # concurrent READ in the window fails CLOSED. Concurrent WRITES to the SAME + # task are serialized by a session-level advisory lock held across BOTH the SQL + # commit and the mongo publish, so two interleaved toggles (A commits SQL and + # stalls; B writes both stores; A resumes publishing its STALE mongo value) + # can't leave mongo more permissive than SQL either. from lib.cuckoo.common.exceptions import CuckooOperationalError _RANK = {"private": 0, "tenant": 1, "public": 2} @@ -886,13 +916,51 @@ def _sync_mongo(_vis): log.warning("visibility mongo sync errored for task %s: %s", task_id, _e) return False - if _RANK.get(visibility, 0) > _RANK.get(_prev_visibility, 0): - # MORE PERMISSIVE (e.g. private->public): make SQL durable FIRST, then - # publish the mongo stamp. A crash or concurrent aggregate in the window - # sees mongo still at the OLD, less-permissive value -> fail closed. If the - # mongo publish then fails, the stores are momentarily inconsistent but - # STILL fail closed (mongo less permissive than SQL); it reconciles on - # reprocess, so log rather than roll back a durable, authorized change. + _locked = _advisory_lock(self.session, task_id) + try: + if _locked: + # Re-read the latest committed value now that we hold the lock — a + # concurrent toggle may have changed it between load and lock, and both + # the direction decision and _prev must reflect the CURRENT truth. + self.session.refresh(task) + _prev_visibility = task.visibility + task.visibility = visibility + + if _RANK.get(visibility, 0) > _RANK.get(_prev_visibility, 0): + # MORE PERMISSIVE (e.g. private->public): make SQL durable FIRST, then + # publish the mongo stamp. A crash or concurrent aggregate in the window + # sees mongo still at the OLD, less-permissive value -> fail closed. If + # the mongo publish then fails, the stores are momentarily inconsistent + # but STILL fail closed (mongo less permissive than SQL); it reconciles + # on reprocess, so log rather than roll back a durable authorized change. + try: + self.session.commit() + except Exception as _ce: + try: + self.session.rollback() + except Exception: + pass + raise CuckooOperationalError(f"task {task_id} visibility change aborted: SQL commit failed") from _ce + if _mongo_on and not _sync_mongo(visibility): + log.error( + "visibility mongo publish lagging for task %s (SQL committed %r, mongo still %r); " + "fail-closed, reconciles on reprocess", task_id, visibility, _prev_visibility, + ) + return task + + # MORE RESTRICTIVE or unchanged (e.g. public->private): update the mongo + # stamp FIRST (still uncommitted in SQL) so the restrictive value reaches + # the mongo surfaces before SQL drops the restriction. If the sync fails, + # revert the in-memory SQL change and raise WITHOUT committing (no SQL was + # committed; a commit could persist unrelated pending work, a rollback would + # discard it). + if _mongo_on and not _sync_mongo(visibility): + task.visibility = _prev_visibility + log.error("visibility mongo sync FAILED for task %s (mongo unreachable); left at %r", task_id, _prev_visibility) + raise CuckooOperationalError(f"task {task_id} visibility change aborted: report store sync failed") + # Commit SQL; if THIS fails after a successful sync, best-effort revert the + # mongo stamp to the previous value (so mongo is never left more permissive + # than the un-committed SQL), roll back the poisoned session, and raise. try: self.session.commit() except Exception as _ce: @@ -900,45 +968,15 @@ def _sync_mongo(_vis): self.session.rollback() except Exception: pass + if _mongo_on and not _sync_mongo(_prev_visibility): + log.error("visibility SQL commit failed AND mongo revert FAILED for task %s (stores may disagree)", task_id) + elif _mongo_on: + log.error("visibility SQL commit failed for task %s; reverted mongo stamp to %r", task_id, _prev_visibility) raise CuckooOperationalError(f"task {task_id} visibility change aborted: SQL commit failed") from _ce - if _mongo_on and not _sync_mongo(visibility): - log.error( - "visibility mongo publish lagging for task %s (SQL committed %r, mongo still %r); " - "fail-closed, reconciles on reprocess", task_id, visibility, _prev_visibility, - ) return task - - # MORE RESTRICTIVE or unchanged (e.g. public->private): update the mongo stamp - # FIRST (still uncommitted in SQL) so the restrictive value reaches the mongo - # surfaces before SQL drops the restriction. If the sync fails, revert the - # in-memory SQL change to the previous value and commit that (both stores stay - # consistent), then raise so the caller retries. Revert in-place rather than - # session.rollback() so unrelated pending work in a shared session survives. - if _mongo_on and not _sync_mongo(visibility): - # Abort WITHOUT committing or rolling back: revert only our in-memory change - # (no SQL was committed, so SQL stays at the old value) and raise. Do NOT - # commit here — this is a shared/scoped session and a commit could persist - # unrelated pending work; and no rollback is needed (there's no failed commit - # to recover from), which would instead DISCARD that unrelated pending work. - task.visibility = _prev_visibility - log.error("visibility mongo sync FAILED for task %s (mongo unreachable); left at %r", task_id, _prev_visibility) - raise CuckooOperationalError(f"task {task_id} visibility change aborted: report store sync failed") - # Commit SQL; if THIS fails after a successful sync, best-effort revert the - # mongo stamp to the previous value (so mongo is never left more permissive - # than the un-committed SQL), roll back the poisoned session, and raise. - try: - self.session.commit() - except Exception as _ce: - try: - self.session.rollback() - except Exception: - pass - if _mongo_on and not _sync_mongo(_prev_visibility): - log.error("visibility SQL commit failed AND mongo revert FAILED for task %s (stores may disagree)", task_id) - elif _mongo_on: - log.error("visibility SQL commit failed for task %s; reverted mongo stamp to %r", task_id, _prev_visibility) - raise CuckooOperationalError(f"task {task_id} visibility change aborted: SQL commit failed") from _ce - return task + finally: + if _locked: + _advisory_unlock(self.session, task_id) def fetch_task(self, categories: list = None): """Fetches a task waiting to be processed and locks it for running. diff --git a/tests/test_task_visibility.py b/tests/test_task_visibility.py index c8c088c4a5d..6c078a5d73a 100644 --- a/tests/test_task_visibility.py +++ b/tests/test_task_visibility.py @@ -419,3 +419,38 @@ def test_check_file_uniq_scoped_even_with_hours_zero(db): # break-glass sees it (no-op); also the unscoped call (no viewer) preserves legacy behavior assert db.check_file_uniq(h, hours=0, visible_to=admin) is True assert db.check_file_uniq(h, hours=0) is True + + +def test_advisory_lock_noop_on_sqlite_and_serializes_on_postgres(): + """Concurrent-toggle serialization: a Postgres session-level advisory lock is + taken/released around the two-store write; a NO-OP on sqlite (single-writer, + tests) so it never alters single-node/legacy behavior.""" + import lib.cuckoo.core.data.tasking as tk + + calls = [] + + class _Bind: + def __init__(self, name): + self.dialect = type("D", (), {"name": name})() + + class _Sess: + def __init__(self, name): + self._name = name + + def get_bind(self): + return _Bind(self._name) + + def execute(self, stmt, params=None): + calls.append((str(stmt), params)) + + # sqlite -> no-op, no SQL issued + assert tk._advisory_lock(_Sess("sqlite"), 7) is False + assert calls == [] + + # postgres -> lock taken (True) + lock/unlock SQL issued, keyed by task id + s_pg = _Sess("postgresql") + assert tk._advisory_lock(s_pg, 7) is True + tk._advisory_unlock(s_pg, 7) + assert any("pg_advisory_lock" in c[0] for c in calls) + assert any("pg_advisory_unlock" in c[0] for c in calls) + assert all(c[1] == {"k": 7} for c in calls) From e167e8530219325f3d963d5f08d23d53f9396a90 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Tue, 14 Jul 2026 16:13:53 -0500 Subject: [PATCH 033/183] mt: pin the visibility advisory lock to a dedicated connection (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-review of 6f22387e: the session-level advisory lock had the right shape but was taken through the pooled ORM scoped_session, and set_task_visibility commit()s mid-region — which returns that connection to the QueuePool while the lock is still held on its backend. Two failure modes: 1. re-entrant / not serialized: a concurrent toggle handed the SAME pooled connection re-acquires pg_advisory_lock(key) immediately (advisory locks are re-entrant per backend) -> the interleaving race is still reachable. 2. lock leak -> hang: the finally-unlock may run on a DIFFERENT pooled connection, so the lock stays held on the idle one and the next toggle blocks forever. Fix: _advisory_lock now checks out a DEDICATED connection (engine.connect()), runs pg_advisory_lock on it, and returns that Connection; _advisory_unlock runs pg_advisory_unlock on the SAME connection and closes it (invalidating it to force backend termination if the explicit unlock can't run, so a still-locked physical connection is never returned to the pool). Lock+unlock are now guaranteed to land on one backend regardless of the mid-method commit. Session-level (not xact) is still required because the lock must outlive that commit. sqlite stays a no-op (returns None). Replaced the sqlite-only test with a Postgres wiring test that asserts a dedicated connection is used and locked/unlocked/closed together. --- lib/cuckoo/core/data/tasking.py | 68 +++++++++++++++++++++---------- tests/test_task_visibility.py | 71 +++++++++++++++++++++------------ 2 files changed, 92 insertions(+), 47 deletions(-) diff --git a/lib/cuckoo/core/data/tasking.py b/lib/cuckoo/core/data/tasking.py index d1e2e98c7ce..ff4cae8741d 100644 --- a/lib/cuckoo/core/data/tasking.py +++ b/lib/cuckoo/core/data/tasking.py @@ -60,32 +60,59 @@ def _mongo_reporting_enabled() -> bool: conf = Config("cuckoo") -def _advisory_lock(session, key) -> bool: +def _advisory_lock(session, key): """Best-effort cross-process serialization of per-task visibility toggles. - Postgres SESSION-level advisory lock: unlike a transaction (xact) lock it survives - the mid-operation SQL commit, so it brackets BOTH the SQL commit and the mongo - publish — the window a concurrent toggle would otherwise interleave to leave mongo - more permissive than SQL. Returns True if a lock was taken (caller MUST unlock in a - finally). No-op (returns False) on backends without advisory locks — sqlite (tests) - is single-writer, so serialization isn't needed there.""" + Acquires a Postgres SESSION-level advisory lock on a DEDICATED connection (its own + backend) — NOT the pooled ORM session's connection. set_task_visibility commit()s + mid-operation, which returns the ORM session's connection to the QueuePool; a lock + taken through that session would then (a) be re-entrant if the pool later hands the + same physical connection to a concurrent toggle of the same key (returns + immediately -> NO serialization) and (b) leak if the finally-unlock runs on a + different pooled connection (the lock stays held on the idle one -> next toggle + hangs). Holding a dedicated connection for the whole critical section pins the lock + to one backend so lock+unlock always land together. A session-level (not xact) lock + is required because it must outlive the mid-operation commit. + + Returns the held Connection (release + close via _advisory_unlock), or None on + non-Postgres backends (sqlite tests are single-writer — no serialization needed).""" try: - if session.get_bind().dialect.name != "postgresql": - return False - session.execute(text("SELECT pg_advisory_lock(:k)"), {"k": int(key)}) - return True + engine = session.get_bind() + engine = getattr(engine, "engine", engine) + if engine.dialect.name != "postgresql": + return None + conn = engine.connect() + try: + conn.execute(text("SELECT pg_advisory_lock(:k)"), {"k": int(key)}) + return conn + except Exception: + conn.close() + raise except Exception as _e: log.warning("advisory lock unavailable for task %s: %s", key, _e) - return False + return None -def _advisory_unlock(session, key) -> None: - """Release the session-level advisory lock taken by _advisory_lock (best effort — - a missed release is bounded: the lock auto-releases when the connection closes).""" +def _advisory_unlock(conn, key) -> None: + """Release + close the dedicated connection taken by _advisory_lock. Returning a + still-locked physical connection to the pool would leak the lock (pooling keeps the + backend alive), so if the explicit unlock can't run, invalidate the connection to + force backend termination (which releases the session-level lock).""" + if conn is None: + return try: - session.execute(text("SELECT pg_advisory_unlock(:k)"), {"k": int(key)}) + conn.execute(text("SELECT pg_advisory_unlock(:k)"), {"k": int(key)}) except Exception as _e: - log.warning("advisory unlock failed for task %s: %s", key, _e) + log.warning("advisory unlock failed for task %s (invalidating connection): %s", key, _e) + try: + conn.invalidate() + except Exception: + pass + finally: + try: + conn.close() + except Exception: + pass distconf = Config("distributed") web_conf = Config("web") @@ -916,9 +943,9 @@ def _sync_mongo(_vis): log.warning("visibility mongo sync errored for task %s: %s", task_id, _e) return False - _locked = _advisory_lock(self.session, task_id) + _lock_conn = _advisory_lock(self.session, task_id) try: - if _locked: + if _lock_conn is not None: # Re-read the latest committed value now that we hold the lock — a # concurrent toggle may have changed it between load and lock, and both # the direction decision and _prev must reflect the CURRENT truth. @@ -975,8 +1002,7 @@ def _sync_mongo(_vis): raise CuckooOperationalError(f"task {task_id} visibility change aborted: SQL commit failed") from _ce return task finally: - if _locked: - _advisory_unlock(self.session, task_id) + _advisory_unlock(_lock_conn, task_id) def fetch_task(self, categories: list = None): """Fetches a task waiting to be processed and locks it for running. diff --git a/tests/test_task_visibility.py b/tests/test_task_visibility.py index 6c078a5d73a..d2d1df90074 100644 --- a/tests/test_task_visibility.py +++ b/tests/test_task_visibility.py @@ -421,36 +421,55 @@ def test_check_file_uniq_scoped_even_with_hours_zero(db): assert db.check_file_uniq(h, hours=0) is True -def test_advisory_lock_noop_on_sqlite_and_serializes_on_postgres(): - """Concurrent-toggle serialization: a Postgres session-level advisory lock is - taken/released around the two-store write; a NO-OP on sqlite (single-writer, - tests) so it never alters single-node/legacy behavior.""" +def test_advisory_lock_noop_on_sqlite(): + """No-op on sqlite (single-writer, tests) so single-node/legacy behavior is + unchanged: _advisory_lock returns None and issues no SQL.""" import lib.cuckoo.core.data.tasking as tk - calls = [] + class _Sess: + def get_bind(self): + return type("E", (), {"dialect": type("D", (), {"name": "sqlite"})()})() - class _Bind: - def __init__(self, name): - self.dialect = type("D", (), {"name": name})() + assert tk._advisory_lock(_Sess(), 7) is None - class _Sess: - def __init__(self, name): - self._name = name - def get_bind(self): - return _Bind(self._name) +def test_advisory_lock_uses_dedicated_connection_on_postgres(): + """The concurrent-toggle lock must be taken on a DEDICATED connection (engine. + connect()), NOT the pooled ORM session whose connection the mid-method commit + returns to the pool — otherwise the lock is re-entrant on a reused connection (no + serialization) or leaks on a different one (hang). The SAME connection must be + locked, unlocked, and closed.""" + import lib.cuckoo.core.data.tasking as tk + + events = [] + class _Conn: def execute(self, stmt, params=None): - calls.append((str(stmt), params)) - - # sqlite -> no-op, no SQL issued - assert tk._advisory_lock(_Sess("sqlite"), 7) is False - assert calls == [] - - # postgres -> lock taken (True) + lock/unlock SQL issued, keyed by task id - s_pg = _Sess("postgresql") - assert tk._advisory_lock(s_pg, 7) is True - tk._advisory_unlock(s_pg, 7) - assert any("pg_advisory_lock" in c[0] for c in calls) - assert any("pg_advisory_unlock" in c[0] for c in calls) - assert all(c[1] == {"k": 7} for c in calls) + events.append(("execute", str(stmt), params)) + + def close(self): + events.append(("close", None, None)) + + def invalidate(self): + events.append(("invalidate", None, None)) + + the_conn = _Conn() + + class _Engine: + dialect = type("D", (), {"name": "postgresql"})() + + def connect(self): + events.append(("connect", None, None)) + return the_conn + + class _Sess: + def get_bind(self): + return _Engine() + + conn = tk._advisory_lock(_Sess(), 42) + assert conn is the_conn # a DEDICATED connection, not the session + tk._advisory_unlock(conn, 42) + + assert [e[0] for e in events] == ["connect", "execute", "execute", "close"] + assert "pg_advisory_lock" in events[1][1] and events[1][2] == {"k": 42} # lock on that conn + assert "pg_advisory_unlock" in events[2][1] and events[2][2] == {"k": 42} # unlock on the SAME conn From c2f1c2a40ff493c015e173850aef537bb6b11139 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Tue, 14 Jul 2026 17:11:54 -0500 Subject: [PATCH 034/183] mt: isolate the visibility lock connection + fail closed on acquire failure (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups on e167e853 (the dedicated lock connection is held across the mongo round-trip, which can be slow): [MEDIUM] pool starvation: the lock connection came from the app's shared QueuePool and is pinned across _sync_mongo. Under a mongo outage graceful_auto_reconnect retries ~40s, so a burst of concurrent toggles could exhaust the 15-connection pool and stall unrelated DB work. Source the lock connection from a SEPARATE NullPool engine (_lock_engine, cached module-level) so advisory locks can never starve the app pool. NullPool also fully closes the physical connection on release (belt-and-suspenders lock release). [MEDIUM] fail-open on acquire failure: _advisory_lock swallowed ALL failures to None, which conflated "sqlite / no lock needed" (safe to proceed) with "Postgres, couldn't acquire" (must fail closed) — the latter proceeded UNSERIALIZED, reopening the race under exactly the pool pressure that triggers it. Now: non-Postgres -> None (proceed); Postgres acquire failure -> RAISE, and set_task_visibility catches it and aborts with CuckooOperationalError (caller retries) instead of proceeding without the lock. Tests: dedicated-lock-engine wiring (lock/unlock/close on the same connection from the separate pool); helper fails closed (raises) on a Postgres connect failure; and set_task_visibility raises CuckooOperationalError when the lock is unavailable. --- lib/cuckoo/core/data/tasking.py | 78 ++++++++++++++++++++++----------- tests/test_task_visibility.py | 57 +++++++++++++++++++----- 2 files changed, 98 insertions(+), 37 deletions(-) diff --git a/lib/cuckoo/core/data/tasking.py b/lib/cuckoo/core/data/tasking.py index ff4cae8741d..7d4cf3aac67 100644 --- a/lib/cuckoo/core/data/tasking.py +++ b/lib/cuckoo/core/data/tasking.py @@ -60,37 +60,54 @@ def _mongo_reporting_enabled() -> bool: conf = Config("cuckoo") +_LOCK_ENGINE = None + + +def _lock_engine(app_engine): + """A dedicated NullPool engine for advisory-lock connections, kept SEPARATE from the + application's shared QueuePool. The lock connection is held across the (possibly + slow) mongo round-trip; during a mongo outage graceful_auto_reconnect can pin it for + tens of seconds, so sourcing it from the app pool would let a burst of concurrent + toggles exhaust the pool and stall unrelated DB work (scheduling, web queries). + NullPool = one fresh physical connection per lock, fully closed on release (which + also releases the advisory lock via backend termination). Cached module-level; + rebuilt only if the app engine URL changes.""" + global _LOCK_ENGINE + if _LOCK_ENGINE is None or str(_LOCK_ENGINE.url) != str(app_engine.url): + from sqlalchemy import create_engine + from sqlalchemy.pool import NullPool + + _LOCK_ENGINE = create_engine(app_engine.url, poolclass=NullPool) + return _LOCK_ENGINE + + def _advisory_lock(session, key): """Best-effort cross-process serialization of per-task visibility toggles. - Acquires a Postgres SESSION-level advisory lock on a DEDICATED connection (its own - backend) — NOT the pooled ORM session's connection. set_task_visibility commit()s - mid-operation, which returns the ORM session's connection to the QueuePool; a lock - taken through that session would then (a) be re-entrant if the pool later hands the - same physical connection to a concurrent toggle of the same key (returns - immediately -> NO serialization) and (b) leak if the finally-unlock runs on a - different pooled connection (the lock stays held on the idle one -> next toggle - hangs). Holding a dedicated connection for the whole critical section pins the lock - to one backend so lock+unlock always land together. A session-level (not xact) lock - is required because it must outlive the mid-operation commit. + Acquires a Postgres SESSION-level advisory lock on a DEDICATED connection from a + separate NullPool engine (_lock_engine): NOT the pooled ORM session (whose + connection the mid-operation commit returns to the pool — that would make the lock + re-entrant on a reused connection or leak on a different one) and NOT the shared app + QueuePool (which the lock, held across the slow mongo round-trip, could otherwise + starve). The connection is pinned for the whole critical section so lock+unlock land + on one backend. Session-level (not xact) is required because it must outlive the + mid-operation commit. Returns the held Connection (release + close via _advisory_unlock), or None on - non-Postgres backends (sqlite tests are single-writer — no serialization needed).""" - try: - engine = session.get_bind() - engine = getattr(engine, "engine", engine) - if engine.dialect.name != "postgresql": - return None - conn = engine.connect() - try: - conn.execute(text("SELECT pg_advisory_lock(:k)"), {"k": int(key)}) - return conn - except Exception: - conn.close() - raise - except Exception as _e: - log.warning("advisory lock unavailable for task %s: %s", key, _e) + non-Postgres backends (sqlite tests are single-writer — no serialization needed). On + Postgres a failure to acquire the lock is RAISED (fail closed — the caller must + abort rather than proceed unserialized), NOT swallowed to None.""" + engine = session.get_bind() + engine = getattr(engine, "engine", engine) + if engine.dialect.name != "postgresql": return None + conn = _lock_engine(engine).connect() + try: + conn.execute(text("SELECT pg_advisory_lock(:k)"), {"k": int(key)}) + return conn + except Exception: + conn.close() + raise def _advisory_unlock(conn, key) -> None: @@ -943,7 +960,16 @@ def _sync_mongo(_vis): log.warning("visibility mongo sync errored for task %s: %s", task_id, _e) return False - _lock_conn = _advisory_lock(self.session, task_id) + try: + _lock_conn = _advisory_lock(self.session, task_id) + except Exception as _le: + # Postgres and the serialization lock could NOT be acquired (e.g. pool / + # connection exhaustion): fail CLOSED rather than proceed unserialized, + # which would reopen the concurrent-toggle race this lock exists to close. + log.error("visibility serialization lock unavailable for task %s: %s", task_id, _le) + raise CuckooOperationalError( + f"task {task_id} visibility change aborted: serialization lock unavailable" + ) from _le try: if _lock_conn is not None: # Re-read the latest committed value now that we hold the lock — a diff --git a/tests/test_task_visibility.py b/tests/test_task_visibility.py index d2d1df90074..57ba9485feb 100644 --- a/tests/test_task_visibility.py +++ b/tests/test_task_visibility.py @@ -433,12 +433,12 @@ def get_bind(self): assert tk._advisory_lock(_Sess(), 7) is None -def test_advisory_lock_uses_dedicated_connection_on_postgres(): - """The concurrent-toggle lock must be taken on a DEDICATED connection (engine. - connect()), NOT the pooled ORM session whose connection the mid-method commit - returns to the pool — otherwise the lock is re-entrant on a reused connection (no - serialization) or leaks on a different one (hang). The SAME connection must be - locked, unlocked, and closed.""" +def test_advisory_lock_uses_dedicated_pool_connection_on_postgres(monkeypatch): + """The concurrent-toggle lock must be taken on a DEDICATED connection from a + SEPARATE pool (_lock_engine), NOT the pooled ORM session (re-entrant/leak across + the mid-method commit) and NOT the shared app pool (which the lock, held across the + slow mongo round-trip, could starve). The SAME connection is locked, unlocked, and + closed.""" import lib.cuckoo.core.data.tasking as tk events = [] @@ -455,21 +455,56 @@ def invalidate(self): the_conn = _Conn() - class _Engine: - dialect = type("D", (), {"name": "postgresql"})() - + class _LockEngine: # the SEPARATE lock-engine (NullPool) source def connect(self): events.append(("connect", None, None)) return the_conn class _Sess: def get_bind(self): - return _Engine() + return type("E", (), {"dialect": type("D", (), {"name": "postgresql"})()})() + + monkeypatch.setattr(tk, "_lock_engine", lambda app_engine: _LockEngine()) conn = tk._advisory_lock(_Sess(), 42) - assert conn is the_conn # a DEDICATED connection, not the session + assert conn is the_conn # dedicated connection from the lock engine tk._advisory_unlock(conn, 42) assert [e[0] for e in events] == ["connect", "execute", "execute", "close"] assert "pg_advisory_lock" in events[1][1] and events[1][2] == {"k": 42} # lock on that conn assert "pg_advisory_unlock" in events[2][1] and events[2][2] == {"k": 42} # unlock on the SAME conn + + +def test_advisory_lock_fails_closed_on_postgres_acquire_failure(monkeypatch): + """Pool/connection exhaustion on Postgres must FAIL CLOSED (raise), never return + None and proceed unserialized (which would reopen the concurrent-toggle race).""" + import pytest as _pytest + import lib.cuckoo.core.data.tasking as tk + + class _Sess: + def get_bind(self): + return type("E", (), {"dialect": type("D", (), {"name": "postgresql"})()})() + + class _BoomEngine: + def connect(self): + raise RuntimeError("pool exhausted") + + monkeypatch.setattr(tk, "_lock_engine", lambda app_engine: _BoomEngine()) + with _pytest.raises(RuntimeError): + tk._advisory_lock(_Sess(), 7) + + +@pytest.mark.usefixtures("tmp_cuckoo_root") +def test_set_task_visibility_fails_closed_when_lock_unavailable(db, monkeypatch): + """If the serialization lock can't be acquired (Postgres pool exhausted) the toggle + must fail CLOSED (raise), never proceed unserialized.""" + import lib.cuckoo.core.data.tasking as tk + from lib.cuckoo.common.exceptions import CuckooOperationalError + + def _boom(session, key): + raise RuntimeError("pool exhausted") + + monkeypatch.setattr(tk, "_advisory_lock", _boom) + tid = db.add_url("http://example.com", tenant_id=10, visibility="public") + with pytest.raises(CuckooOperationalError): + db.set_task_visibility(tid, "private") From 335f11dd9a448290b02dee7af8772bf8e9ade503 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Tue, 14 Jul 2026 20:23:41 -0500 Subject: [PATCH 035/183] mt: lock engine inherits app connect_args + built with the app engine (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-review of c2f1c2a4 confirmed both prior follow-ups resolved; one new MEDIUM + two minor notes on the new lock engine: [MEDIUM] the lock engine dropped the app's connect_args. _lock_engine did create_engine(url, poolclass=NullPool) copying only the URL, so it lost connect_args (postgres sslmode / client certs). With acquire now failing CLOSED, a lock-engine connect failure aborts the toggle — turning "SSL configured" into "visibility changes stop working". Fix: build the dedicated NullPool lock engine in database.py alongside the app engine (Database.lock_engine), carrying the SAME connect_args=engine_args ["connect_args"]. Postgres only; None on sqlite. _advisory_lock now takes that engine (getattr(self, "lock_engine", None)) instead of building its own. This also removes the two minor issues the reviewers flagged: no module-level _LOCK_ENGINE lockless cache (race / redundant creation) and no str(URL) compare that would miss a password-only rotation — the lock engine now shares the app engine's construction + lifecycle. Tests updated: no-lock-engine -> None; sqlite Database.lock_engine is None; dedicated lock-engine connection is locked/unlocked/closed together; acquire failure raises (fail closed); set_task_visibility raises CuckooOperationalError when the lock is unavailable. --- lib/cuckoo/core/data/tasking.py | 58 ++++++++++----------------------- lib/cuckoo/core/database.py | 16 +++++++++ tests/test_task_visibility.py | 51 ++++++++++++----------------- 3 files changed, 55 insertions(+), 70 deletions(-) diff --git a/lib/cuckoo/core/data/tasking.py b/lib/cuckoo/core/data/tasking.py index 7d4cf3aac67..965bd37abb3 100644 --- a/lib/cuckoo/core/data/tasking.py +++ b/lib/cuckoo/core/data/tasking.py @@ -60,48 +60,26 @@ def _mongo_reporting_enabled() -> bool: conf = Config("cuckoo") -_LOCK_ENGINE = None - - -def _lock_engine(app_engine): - """A dedicated NullPool engine for advisory-lock connections, kept SEPARATE from the - application's shared QueuePool. The lock connection is held across the (possibly - slow) mongo round-trip; during a mongo outage graceful_auto_reconnect can pin it for - tens of seconds, so sourcing it from the app pool would let a burst of concurrent - toggles exhaust the pool and stall unrelated DB work (scheduling, web queries). - NullPool = one fresh physical connection per lock, fully closed on release (which - also releases the advisory lock via backend termination). Cached module-level; - rebuilt only if the app engine URL changes.""" - global _LOCK_ENGINE - if _LOCK_ENGINE is None or str(_LOCK_ENGINE.url) != str(app_engine.url): - from sqlalchemy import create_engine - from sqlalchemy.pool import NullPool - - _LOCK_ENGINE = create_engine(app_engine.url, poolclass=NullPool) - return _LOCK_ENGINE - - -def _advisory_lock(session, key): +def _advisory_lock(lock_engine, key): """Best-effort cross-process serialization of per-task visibility toggles. - Acquires a Postgres SESSION-level advisory lock on a DEDICATED connection from a - separate NullPool engine (_lock_engine): NOT the pooled ORM session (whose - connection the mid-operation commit returns to the pool — that would make the lock - re-entrant on a reused connection or leak on a different one) and NOT the shared app - QueuePool (which the lock, held across the slow mongo round-trip, could otherwise - starve). The connection is pinned for the whole critical section so lock+unlock land - on one backend. Session-level (not xact) is required because it must outlive the - mid-operation commit. - - Returns the held Connection (release + close via _advisory_unlock), or None on - non-Postgres backends (sqlite tests are single-writer — no serialization needed). On - Postgres a failure to acquire the lock is RAISED (fail closed — the caller must - abort rather than proceed unserialized), NOT swallowed to None.""" - engine = session.get_bind() - engine = getattr(engine, "engine", engine) - if engine.dialect.name != "postgresql": + Acquires a Postgres SESSION-level advisory lock on a DEDICATED connection from + ``lock_engine`` — the Database's dedicated NullPool engine (built in database.py + alongside the app engine, carrying the same connect_args). It is deliberately NOT + the pooled ORM session (whose connection the mid-operation commit returns to the + pool — that would make the lock re-entrant on a reused connection or leak on a + different one) and NOT the shared app QueuePool (which the lock, held across the slow + mongo round-trip, could otherwise starve). The connection is pinned for the whole + critical section so lock+unlock land on one backend. Session-level (not xact) is + required because it must outlive the mid-operation commit. + + ``lock_engine`` is None on non-Postgres backends (sqlite is single-writer — no + serialization needed) -> returns None. On Postgres a failure to acquire the lock is + RAISED (fail closed — the caller must abort rather than proceed unserialized), NOT + swallowed to None.""" + if lock_engine is None: return None - conn = _lock_engine(engine).connect() + conn = lock_engine.connect() try: conn.execute(text("SELECT pg_advisory_lock(:k)"), {"k": int(key)}) return conn @@ -961,7 +939,7 @@ def _sync_mongo(_vis): return False try: - _lock_conn = _advisory_lock(self.session, task_id) + _lock_conn = _advisory_lock(getattr(self, "lock_engine", None), task_id) except Exception as _le: # Postgres and the serialization lock could NOT be acquired (e.g. pool / # connection exhaustion): fail CLOSED rather than proceed unserialized, diff --git a/lib/cuckoo/core/database.py b/lib/cuckoo/core/database.py index bd8cbcf4fb4..be0ea2f9eb1 100644 --- a/lib/cuckoo/core/database.py +++ b/lib/cuckoo/core/database.py @@ -186,6 +186,22 @@ def _connect_database(self, connection_string): # A single, clean call to create the engine self.engine = create_engine(connection_string, **engine_args) + # Dedicated engine for per-task advisory locks (visibility toggles): a + # NullPool source kept OFF the shared app pool, so a lock held across a slow + # mongo round-trip can't exhaust it and stall unrelated DB work. Carries the + # SAME connect_args (e.g. postgres sslmode / client certs) so its + # connections match the app's DB connection policy. Postgres only — advisory + # locks are a no-op on sqlite (single-writer), so no lock engine is needed. + self.lock_engine = None + if url.drivername.startswith("postgresql"): + from sqlalchemy.pool import NullPool + + self.lock_engine = create_engine( + connection_string, + poolclass=NullPool, + connect_args=engine_args.get("connect_args", {}), + ) + except ImportError as e: # pragma: no cover lib = e.message.rsplit(maxsplit=1)[-1] raise CuckooDependencyError(f"Missing database driver, unable to import {lib} (install with `pip install {lib}`)") diff --git a/tests/test_task_visibility.py b/tests/test_task_visibility.py index 57ba9485feb..87a5563a64c 100644 --- a/tests/test_task_visibility.py +++ b/tests/test_task_visibility.py @@ -421,24 +421,26 @@ def test_check_file_uniq_scoped_even_with_hours_zero(db): assert db.check_file_uniq(h, hours=0) is True -def test_advisory_lock_noop_on_sqlite(): - """No-op on sqlite (single-writer, tests) so single-node/legacy behavior is - unchanged: _advisory_lock returns None and issues no SQL.""" +def test_advisory_lock_noop_without_lock_engine(): + """No-op when there is no lock engine (non-Postgres / sqlite is single-writer): + _advisory_lock returns None and issues no SQL.""" import lib.cuckoo.core.data.tasking as tk - class _Sess: - def get_bind(self): - return type("E", (), {"dialect": type("D", (), {"name": "sqlite"})()})() + assert tk._advisory_lock(None, 7) is None - assert tk._advisory_lock(_Sess(), 7) is None + +@pytest.mark.usefixtures("tmp_cuckoo_root") +def test_sqlite_database_has_no_lock_engine(db): + """On sqlite the Database builds NO dedicated lock engine (advisory locks are a + no-op there), so set_task_visibility runs its unserialized-but-single-writer path.""" + assert getattr(db, "lock_engine", "missing") is None -def test_advisory_lock_uses_dedicated_pool_connection_on_postgres(monkeypatch): - """The concurrent-toggle lock must be taken on a DEDICATED connection from a - SEPARATE pool (_lock_engine), NOT the pooled ORM session (re-entrant/leak across - the mid-method commit) and NOT the shared app pool (which the lock, held across the - slow mongo round-trip, could starve). The SAME connection is locked, unlocked, and - closed.""" +def test_advisory_lock_uses_dedicated_engine_connection_on_postgres(): + """The concurrent-toggle lock must be taken on a DEDICATED connection from the + dedicated lock ENGINE (NullPool, off the app pool), NOT the pooled ORM session + (re-entrant/leak across the mid-method commit). The SAME connection is locked, + unlocked, and closed.""" import lib.cuckoo.core.data.tasking as tk events = [] @@ -455,18 +457,12 @@ def invalidate(self): the_conn = _Conn() - class _LockEngine: # the SEPARATE lock-engine (NullPool) source + class _LockEngine: # the dedicated NullPool lock engine (Database.lock_engine) def connect(self): events.append(("connect", None, None)) return the_conn - class _Sess: - def get_bind(self): - return type("E", (), {"dialect": type("D", (), {"name": "postgresql"})()})() - - monkeypatch.setattr(tk, "_lock_engine", lambda app_engine: _LockEngine()) - - conn = tk._advisory_lock(_Sess(), 42) + conn = tk._advisory_lock(_LockEngine(), 42) assert conn is the_conn # dedicated connection from the lock engine tk._advisory_unlock(conn, 42) @@ -475,23 +471,18 @@ def get_bind(self): assert "pg_advisory_unlock" in events[2][1] and events[2][2] == {"k": 42} # unlock on the SAME conn -def test_advisory_lock_fails_closed_on_postgres_acquire_failure(monkeypatch): - """Pool/connection exhaustion on Postgres must FAIL CLOSED (raise), never return - None and proceed unserialized (which would reopen the concurrent-toggle race).""" +def test_advisory_lock_fails_closed_on_acquire_failure(): + """Pool/connection exhaustion must FAIL CLOSED (raise), never return None and + proceed unserialized (which would reopen the concurrent-toggle race).""" import pytest as _pytest import lib.cuckoo.core.data.tasking as tk - class _Sess: - def get_bind(self): - return type("E", (), {"dialect": type("D", (), {"name": "postgresql"})()})() - class _BoomEngine: def connect(self): raise RuntimeError("pool exhausted") - monkeypatch.setattr(tk, "_lock_engine", lambda app_engine: _BoomEngine()) with _pytest.raises(RuntimeError): - tk._advisory_lock(_Sess(), 7) + tk._advisory_lock(_BoomEngine(), 7) @pytest.mark.usefixtures("tmp_cuckoo_root") From b13c9070ea01fc8688d9c4a7707abe5afe33e2e9 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Wed, 15 Jul 2026 08:42:43 -0500 Subject: [PATCH 036/183] mt(tests): make web MT fixtures a named plugin, not web/conftest.py (fix upstream CI) Upstream CI runs `pytest --import-mode=append` with pythonpath=["." ,"web"] and testpaths=["tests","agent"]. The new web/conftest.py sat on the `web` pythonpath root, so it was importable as the top-level module `conftest` and shadowed tests/conftest.py -> ImportPathMismatchError, failing the whole run (our box/deb runs used --import-mode=importlib, which masks it; and web/ isn't even in testpaths). Rename web/conftest.py -> web/mt_test_fixtures.py (a normal module, unique name, no auto-conftest shadowing) and have the 8 web MT test modules load it via `pytest_plugins = ("mt_test_fixtures",)`. Fixtures (mt_enabled, cape_db) are unchanged; web tests still get them under importlib, and the tests/ suite (which CI actually collects) no longer collides. --- web/analysis/test_visibility.py | 4 ++++ web/apiv2/test_hash_isolation.py | 4 ++++ web/apiv2/test_visibility.py | 4 ++++ web/compare/test_visibility.py | 4 ++++ web/dashboard/test_dashboard_scope.py | 4 ++++ web/guac/test_visibility.py | 4 ++++ web/{conftest.py => mt_test_fixtures.py} | 0 web/submission/test_visibility.py | 4 ++++ web/users/test_tenancy.py | 4 ++++ 9 files changed, 32 insertions(+) rename web/{conftest.py => mt_test_fixtures.py} (100%) diff --git a/web/analysis/test_visibility.py b/web/analysis/test_visibility.py index eebea9d7511..b521f67f89e 100644 --- a/web/analysis/test_visibility.py +++ b/web/analysis/test_visibility.py @@ -1,6 +1,10 @@ import pytest from django.contrib.auth.models import User +pytest_plugins = ("mt_test_fixtures",) # fixtures live in web/mt_test_fixtures.py (not a conftest, +# which would shadow tests/conftest.py under pythonpath=web + --import-mode=append) + + class ForeignTask: id = 1 diff --git a/web/apiv2/test_hash_isolation.py b/web/apiv2/test_hash_isolation.py index 3f01e1d18d3..290888d1a24 100644 --- a/web/apiv2/test_hash_isolation.py +++ b/web/apiv2/test_hash_isolation.py @@ -1,6 +1,10 @@ import pytest from django.contrib.auth.models import User +pytest_plugins = ("mt_test_fixtures",) # fixtures live in web/mt_test_fixtures.py (not a conftest, +# which would shadow tests/conftest.py under pythonpath=web + --import-mode=append) + + class _Req: def __init__(self, user): diff --git a/web/apiv2/test_visibility.py b/web/apiv2/test_visibility.py index 4e161d3c2bb..16ed9b64196 100644 --- a/web/apiv2/test_visibility.py +++ b/web/apiv2/test_visibility.py @@ -4,6 +4,10 @@ import pytest from django.contrib.auth.models import User +pytest_plugins = ("mt_test_fixtures",) # fixtures live in web/mt_test_fixtures.py (not a conftest, +# which would shadow tests/conftest.py under pythonpath=web + --import-mode=append) + + # A view "enforces visibility" if its source references any of these — a read # guard, the artifact preamble, the list filter, the web decorator, or a # management guard (for mutation endpoints). diff --git a/web/compare/test_visibility.py b/web/compare/test_visibility.py index 7bf8a42fd2b..c78dee9d207 100644 --- a/web/compare/test_visibility.py +++ b/web/compare/test_visibility.py @@ -1,6 +1,10 @@ import pytest from django.contrib.auth.models import User +pytest_plugins = ("mt_test_fixtures",) # fixtures live in web/mt_test_fixtures.py (not a conftest, +# which would shadow tests/conftest.py under pythonpath=web + --import-mode=append) + + class ForeignTask: id = 1 diff --git a/web/dashboard/test_dashboard_scope.py b/web/dashboard/test_dashboard_scope.py index d20280513b8..9ead28762e3 100644 --- a/web/dashboard/test_dashboard_scope.py +++ b/web/dashboard/test_dashboard_scope.py @@ -1,6 +1,10 @@ import pytest from django.contrib.auth.models import User +pytest_plugins = ("mt_test_fixtures",) # fixtures live in web/mt_test_fixtures.py (not a conftest, +# which would shadow tests/conftest.py under pythonpath=web + --import-mode=append) + + @pytest.mark.django_db def test_dashboard_entitled_scopes(cape_db, mt_enabled, monkeypatch): diff --git a/web/guac/test_visibility.py b/web/guac/test_visibility.py index 036e2a80c1f..fc9748284e9 100644 --- a/web/guac/test_visibility.py +++ b/web/guac/test_visibility.py @@ -1,6 +1,10 @@ import pytest from django.contrib.auth.models import User +pytest_plugins = ("mt_test_fixtures",) # fixtures live in web/mt_test_fixtures.py (not a conftest, +# which would shadow tests/conftest.py under pythonpath=web + --import-mode=append) + + class ForeignTask: id = 1 diff --git a/web/conftest.py b/web/mt_test_fixtures.py similarity index 100% rename from web/conftest.py rename to web/mt_test_fixtures.py diff --git a/web/submission/test_visibility.py b/web/submission/test_visibility.py index c5948444fc3..550a4cedff2 100644 --- a/web/submission/test_visibility.py +++ b/web/submission/test_visibility.py @@ -1,6 +1,10 @@ import pytest from django.contrib.auth.models import User +pytest_plugins = ("mt_test_fixtures",) # fixtures live in web/mt_test_fixtures.py (not a conftest, +# which would shadow tests/conftest.py under pythonpath=web + --import-mode=append) + + @pytest.mark.django_db def test_submit_form_renders_visibility_control(cape_db, client): diff --git a/web/users/test_tenancy.py b/web/users/test_tenancy.py index 6cfb218768e..6ebd83793ca 100644 --- a/web/users/test_tenancy.py +++ b/web/users/test_tenancy.py @@ -1,6 +1,10 @@ import pytest from django.contrib.auth.models import User +pytest_plugins = ("mt_test_fixtures",) # fixtures live in web/mt_test_fixtures.py (not a conftest, +# which would shadow tests/conftest.py under pythonpath=web + --import-mode=append) + + @pytest.mark.django_db def test_tenant_and_profile_fields(): From 1312b0b8de5262bef2c4f656e0ba6e5de0e84a8d Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Wed, 15 Jul 2026 09:48:04 -0500 Subject: [PATCH 037/183] mt(apiv2): defer missing-task 404 to the caller when MT is disabled _deny_if_hidden/_deny_manage returned a 404 for a nonexistent task unconditionally. Under MT that is correct -- a missing task is indistinguishable from a hidden one, so other tenants' task ids cannot be enumerated by status code. But with MT DISABLED it changed the default-install contract: upstream endpoints return their own missing-task response (e.g. reprocess of a nonexistent task -> 200 with an error body). This broke an existing upstream test on CI (which runs with MT off): tests/web/test_apiv2.py::ReprocessTask.test_task_does_not_exist. Only apply the missing-task 404 when multitenancy is enabled; otherwise return None so the caller's own missing-task handling runs. The can_view_task / can_manage_task branch is unchanged (is_local_admin => allowed when MT is off), so an existing task behaves exactly as before. Regression tests cover both modes for both helpers. --- web/apiv2/test_visibility.py | 51 ++++++++++++++++++++++++++++++++++-- web/apiv2/views.py | 15 +++++++++-- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/web/apiv2/test_visibility.py b/web/apiv2/test_visibility.py index 16ed9b64196..07642d52b1d 100644 --- a/web/apiv2/test_visibility.py +++ b/web/apiv2/test_visibility.py @@ -223,11 +223,58 @@ def test_deny_if_hidden_blocks_cross_tenant_private(mt_enabled): assert resp.status_code == 404 +def _force_mt_off(monkeypatch): + """Deterministically disable MT for the apiv2 deny helpers (the facade + delegates to users.tenancy.multitenancy_config at call time).""" + import users.tenancy as ut + from lib.cuckoo.common.tenancy import MTConfig + monkeypatch.setattr(ut, "multitenancy_config", lambda: MTConfig(False, "shared", "", True)) + + +@pytest.mark.django_db +def test_deny_if_hidden_missing_task_mt_on(mt_enabled): + """Under MT a MISSING task returns the SAME generic 404 as a hidden task, so + another tenant's task ids can't be enumerated by status code.""" + import apiv2.views as views + other = User.objects.create_user("b", "b@x.com", "x") + resp = views._deny_if_hidden(FakeReq(other), None) + assert resp is not None and resp.status_code == 404 + + +@pytest.mark.django_db +def test_deny_if_hidden_missing_task_mt_off_defers(monkeypatch): + """With MT DISABLED there is no isolation to enforce, so the gate must NOT turn + a missing task into a 404 — it defers (None) to the caller's own missing-task + handling. This preserves upstream's default-install contract (e.g. reprocess of + a nonexistent task returns 200 with an error body — the CI regression this + fixes: tests/web/test_apiv2.py::ReprocessTask.test_task_does_not_exist).""" + import apiv2.views as views + _force_mt_off(monkeypatch) + other = User.objects.create_user("b", "b@x.com", "x") + assert views._deny_if_hidden(FakeReq(other), None) is None + + +@pytest.mark.django_db +def test_deny_manage_missing_task_mt_on(cape_db, mt_enabled, monkeypatch): + """_deny_manage mirrors _deny_if_hidden for a missing task: generic 404 under MT. + (cape_db initializes the CAPE db singleton that views.db.view_task binds to — + same fixture the other views.db-patching tests in this file use.)""" + import apiv2.views as views + monkeypatch.setattr(views.db, "view_task", lambda *a, **k: None) + other = User.objects.create_user("b", "b@x.com", "x") + resp = views._deny_manage(FakeReq(other), 1) + assert resp is not None and resp.status_code == 404 + + @pytest.mark.django_db -def test_deny_if_hidden_missing_task(): +def test_deny_manage_missing_task_mt_off_defers(cape_db, monkeypatch): + """_deny_manage defers (None) on a missing task when MT is off — same + default-install back-compat as _deny_if_hidden.""" import apiv2.views as views + _force_mt_off(monkeypatch) + monkeypatch.setattr(views.db, "view_task", lambda *a, **k: None) other = User.objects.create_user("b", "b@x.com", "x") - assert views._deny_if_hidden(FakeReq(other), None) is not None # not found + assert views._deny_manage(FakeReq(other), 1) is None @pytest.mark.django_db diff --git a/web/apiv2/views.py b/web/apiv2/views.py index 9fc44dac4f4..764bdfd8c2b 100644 --- a/web/apiv2/views.py +++ b/web/apiv2/views.py @@ -52,7 +52,13 @@ def _deny_if_hidden(request, task): an attacker cannot enumerate which task IDs / states exist in other tenants. Callers must invoke this BEFORE validate_task()/status/TLP checks so those don't leak existence either.""" - if task is None or not can_view_task(request.user, task): + if task is None: + # Missing task: under MT this returns the SAME generic 404 as a hidden + # task so other tenants' task ids can't be enumerated. With MT DISABLED + # there is no isolation to enforce, so defer to the caller's own + # missing-task handling (upstream behavior; default-off changes nothing). + return Response({"error": True, "error_value": "Task not found"}, status=404) if multitenancy_config().enabled else None + if not can_view_task(request.user, task): return Response({"error": True, "error_value": "Task not found"}, status=404) return None @@ -67,7 +73,12 @@ def _deny_manage(request, task_id): """Like _deny_task but for MUTATIONS — requires can_manage (owner/tenant-admin/ break-glass). Returns a generic 404 Response if not allowed, else None.""" task = db.view_task(task_id) - if task is None or not can_manage_task(request.user, task): + if task is None: + # Missing task: generic 404 under MT (no cross-tenant id enumeration); + # with MT disabled, defer to the caller's own missing-task handling so a + # default (non-MT) install keeps its existing responses. + return Response({"error": True, "error_value": "Task not found"}, status=404) if multitenancy_config().enabled else None + if not can_manage_task(request.user, task): return Response({"error": True, "error_value": "Task not found"}, status=404) return None From 8e76e5655d1c1bbd486be65e8bed64b48bf48b5c Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Wed, 15 Jul 2026 10:02:24 -0500 Subject: [PATCH 038/183] mt(perf): batch per-row visibility resolution (N+1 -> one query) Addresses the gemini-code-assist review nits on #3112. Several per-task visibility post-filters called db.view_task() once per row; replace each with a single db.list_tasks(task_ids=..., visible_to=viewer) membership check -- the same batch contract the mongo md5-pivot and the apiv2 ids-term paths already use. Behavior is unchanged (same predicate, batch form); only the query count drops. - analysis.views.index: buf[0].to_dict()["id"] -> buf[0].id for the paging prev-links (avoid serializing the whole Task for one int). - compare.views.left/hash (ES backend): batch the md5-pivot post-filter. - apiv2.ext_tasks_search: batch the perform_search row filter. - apiv2.tasks_rollingsuri: batch the alert-feed filter (keeps the is_local_admin see-all fast-path). Tests: compare ES test now mocks list_tasks; new ext_tasks_search cross-tenant-drop runtime test; REVIEWED_MONGO_PIVOTS reason updated. --- web/analysis/views.py | 8 +++---- web/apiv2/test_visibility.py | 32 +++++++++++++++++++++++++- web/apiv2/views.py | 40 ++++++++++++++++++++------------ web/compare/test_visibility.py | 6 +++++ web/compare/views.py | 42 ++++++++++++++++++++++++---------- 5 files changed, 97 insertions(+), 31 deletions(-) diff --git a/web/analysis/views.py b/web/analysis/views.py index 1fa9dba4292..e63e70cfff3 100644 --- a/web/analysis/views.py +++ b/web/analysis/views.py @@ -596,25 +596,25 @@ def index(request, page=1): # than re-querying for the id (halves the DB round-trips). buf = db.list_tasks(limit=1, category="file", not_status=TASK_PENDING, order_by=Task.added_on.asc(), visible_to=_visible) if buf: - first_file = buf[0].to_dict()["id"] + first_file = buf[0].id paging["show_file_prev"] = "show" else: paging["show_file_prev"] = "hide" buf = db.list_tasks(limit=1, category="static", not_status=TASK_PENDING, order_by=Task.added_on.asc(), visible_to=_visible) if buf: - first_static = buf[0].to_dict()["id"] + first_static = buf[0].id paging["show_static_prev"] = "show" else: paging["show_static_prev"] = "hide" buf = db.list_tasks(limit=1, category="url", not_status=TASK_PENDING, order_by=Task.added_on.asc(), visible_to=_visible) if buf: - first_url = buf[0].to_dict()["id"] + first_url = buf[0].id paging["show_url_prev"] = "show" else: paging["show_url_prev"] = "hide" buf = db.list_tasks(limit=1, category="pcap", not_status=TASK_PENDING, order_by=Task.added_on.asc(), visible_to=_visible) if buf: - first_pcap = buf[0].to_dict()["id"] + first_pcap = buf[0].id paging["show_pcap_prev"] = "show" else: paging["show_pcap_prev"] = "hide" diff --git a/web/apiv2/test_visibility.py b/web/apiv2/test_visibility.py index 07642d52b1d..0eea6fc3cc9 100644 --- a/web/apiv2/test_visibility.py +++ b/web/apiv2/test_visibility.py @@ -465,6 +465,36 @@ def test_tasks_delete_many_skips_unmanageable_cross_tenant(cape_db, mt_enabled, assert resp.data.get(1) == "not exists" # indistinguishable from missing +@pytest.mark.django_db +def test_ext_tasks_search_drops_cross_tenant_rows(cape_db, mt_enabled, monkeypatch): + """ext_tasks_search batch-filters perform_search rows through + list_tasks(visible_to=viewer) in ONE query: a report row for a task the caller + can't see (foreign/private) must be dropped from the response. Locks the N+1 -> + batch rewrite so it can't regress into a cross-tenant leak.""" + import types + from rest_framework.test import APIRequestFactory, force_authenticate + import apiv2.views as views + + class _T: + def __init__(self, i): + self.id = i + + monkeypatch.setattr(views, "apiconf", types.SimpleNamespace(extendedtasksearch={"enabled": True})) + monkeypatch.setattr(views, "repconf", types.SimpleNamespace(mongodb=types.SimpleNamespace(enabled=True)), raising=False) + monkeypatch.setattr(views, "es_as_db", False, raising=False) + monkeypatch.setattr(views, "perform_search", lambda *a, **k: [{"info": {"id": 2}}, {"info": {"id": 3}}]) + # only task 2 is visible to this viewer; task 3 (foreign/private) is not + monkeypatch.setattr(views.db, "list_tasks", lambda *a, **k: [_T(2)]) + + req = APIRequestFactory().post("/apiv2/tasks/extendedsearch/", {"option": "malscore", "argument": "5"}) + u = User.objects.create_user("ext", "ext@x.com", "x") # tenant-less, non-admin + force_authenticate(req, user=u) + req.user = u + resp = views.ext_tasks_search(req) + ids = [r["info"]["id"] for r in resp.data.get("data", [])] + assert ids == [2] # foreign task 3 dropped by the batch visibility filter + + def test_every_perform_search_caller_passes_viewer(): """SECURITY GATE: perform_search() is unscoped by default (no tenant filter at the mongo/ES layer). Every web caller MUST pass viewer= so the query is @@ -553,7 +583,7 @@ def test_viewer_scope_es_filter_locked_vs_disabled(monkeypatch): "analysis.views:search_behavior": "mongo_find('calls', _id $in) — ObjectIds from the gated task's own behavior doc", "analysis.views:report": "mongo_aggregate $match info.id == the can_view_task-gated task_id", "analysis.views:hunt": "mongo_aggregate $facet pinned by entitled_scope_filter()", - "apiv2.views:tasks_rollingsuri": "mongo_find then per-row can_view_task (+ is_local_admin fast-path)", + "apiv2.views:tasks_rollingsuri": "mongo_find then batch list_tasks(visible_to=) membership (+ is_local_admin fast-path)", "compare.views:left": "mongo_find md5-pivot AND-ed with entitled_scope_filter()", "compare.views:hash": "mongo_find md5-pivot AND-ed with entitled_scope_filter()", } diff --git a/web/apiv2/views.py b/web/apiv2/views.py index 764bdfd8c2b..9ae54346af5 100644 --- a/web/apiv2/views.py +++ b/web/apiv2/views.py @@ -950,13 +950,26 @@ def ext_tasks_search(request): resp = {"error": True, "error_value": "No option or argument provided."} if records: + # Visibility filter: the mongo/ES report rows don't carry tenant info, + # so resolve the visible set in ONE query (list_tasks(visible_to=)) + # instead of a view_task() per row, then drop rows the viewer can't see. + _tids = set() for results in records: - # Visibility filter: the mongo/ES report rows don't carry tenant - # info, so resolve each task and drop ones the viewer can't see. _doc = results.get("_source", results) if es_as_db else results _tid = (_doc.get("info") or {}).get("id") if isinstance(_doc, dict) else None - _t = db.view_task(_tid) if _tid is not None else None - if _t is None or not can_view_task(request.user, _t): + if _tid is not None: + try: + _tids.add(int(_tid)) + except (ValueError, TypeError): + pass + _visible = {t.id for t in db.list_tasks(task_ids=list(_tids), visible_to=viewer_for(request.user))} if _tids else set() + for results in records: + _doc = results.get("_source", results) if es_as_db else results + _tid = (_doc.get("info") or {}).get("id") if isinstance(_doc, dict) else None + try: + if _tid is None or int(_tid) not in _visible: + continue + except (ValueError, TypeError): continue if repconf.mongodb.enabled: return_data.append(results) @@ -2525,21 +2538,20 @@ def tasks_rollingsuri(request, window=60): # task_id coverage gate can't catch this endpoint (no task_id in its route). # When multitenancy is disabled, viewer.is_local_admin short-circuits to # see-all, so this is a no-op and behavior is unchanged. + # Break-glass (is_local_admin, incl. MT-disabled) sees all -> no filter. + # Otherwise batch-resolve the visible set in ONE query instead of a + # view_task() per row. viewer = viewer_for(request.user) - _seen = {} - - def _can_see(tid): - if viewer.is_local_admin: - return True - if tid not in _seen: - t = db.view_task(tid) - _seen[tid] = bool(t) and can_view_task(request.user, t) - return _seen[tid] + if viewer.is_local_admin: + _visible = None + else: + _tids = {e["info"]["id"] for e in result if "info" in e and "id" in e.get("info", {})} + _visible = {t.id for t in db.list_tasks(task_ids=list(_tids), visible_to=viewer)} if _tids else set() resp = [] for e in result: tid = e["info"]["id"] - if not _can_see(tid): + if _visible is not None and tid not in _visible: continue for alert in e["suricata"]["alerts"]: alert["id"] = tid diff --git a/web/compare/test_visibility.py b/web/compare/test_visibility.py index c78dee9d207..8e0fd0d1f3e 100644 --- a/web/compare/test_visibility.py +++ b/web/compare/test_visibility.py @@ -109,6 +109,12 @@ def _view(tid): class FakeDB: view_task = staticmethod(_view) + def list_tasks(self, task_ids=None, visible_to=None, **k): + # SoT emulation: the ES path now batches visibility via + # list_tasks(visible_to=). For this tenant-less viewer only PUBLIC + # tasks are visible, so the foreign private pivot (task 2) is dropped. + return [_view(t) for t in (task_ids or []) if getattr(_view(t), "visibility", None) == "public"] + class FakeES: def search(self, index=None, query=None, body=None): if body is not None: # md5 pivot diff --git a/web/compare/views.py b/web/compare/views.py index bdc3e61eb96..8322c68c039 100644 --- a/web/compare/views.py +++ b/web/compare/views.py @@ -121,18 +121,27 @@ def left(request, left_id): # tenant isolation: the mongo path filters via entitled_scope_filter; the # ES backend can't take that $match, so post-filter each hit through # can_view_task (no-op for break-glass / shared / multitenancy disabled). + # Batch-resolve the visible set in ONE query (list_tasks(visible_to=)) + # instead of a view_task() per hit — same contract the mongo md5-pivot + # path above uses. _db = Database() + _tids = set() + for item in results: + _tid = (item["_source"].get("info") or {}).get("id") + if _tid is not None: + try: + _tids.add(int(_tid)) + except (ValueError, TypeError): + pass # malformed id in a corrupt ES doc — skip, don't 500 + _visible = {t.id for t in _db.list_tasks(task_ids=list(_tids), visible_to=viewer_for(request.user))} if _tids else set() for item in results: _source = item["_source"] _tid = (_source.get("info") or {}).get("id") - if _tid is None: - continue try: - _vt = _db.view_task(int(_tid)) + if _tid is not None and int(_tid) in _visible: + records.append(_source) except (ValueError, TypeError): - continue # malformed id in a corrupt ES doc — skip, don't 500 - if _vt is not None and can_view_task(request.user, _vt): - records.append(_source) + continue data = {"title": "Compare", "left": left, "records": records} return render(request, "compare/left.html", data) @@ -208,18 +217,27 @@ def hash(request, left_id, right_hash): # tenant isolation: the mongo path filters via entitled_scope_filter; the # ES backend can't take that $match, so post-filter each hit through # can_view_task (no-op for break-glass / shared / multitenancy disabled). + # Batch-resolve the visible set in ONE query (list_tasks(visible_to=)) + # instead of a view_task() per hit — same contract the mongo md5-pivot + # path above uses. _db = Database() + _tids = set() + for item in results: + _tid = (item["_source"].get("info") or {}).get("id") + if _tid is not None: + try: + _tids.add(int(_tid)) + except (ValueError, TypeError): + pass # malformed id in a corrupt ES doc — skip, don't 500 + _visible = {t.id for t in _db.list_tasks(task_ids=list(_tids), visible_to=viewer_for(request.user))} if _tids else set() for item in results: _source = item["_source"] _tid = (_source.get("info") or {}).get("id") - if _tid is None: - continue try: - _vt = _db.view_task(int(_tid)) + if _tid is not None and int(_tid) in _visible: + records.append(_source) except (ValueError, TypeError): - continue # malformed id in a corrupt ES doc — skip, don't 500 - if _vt is not None and can_view_task(request.user, _vt): - records.append(_source) + continue # Select all analyses with specified file hash. return render(request, "compare/hash.html", {"left": left, "records": records, "hash": right_hash}) From e6d1a95e16a52ab1b7b002be216a527e62ae2c46 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Wed, 15 Jul 2026 10:38:36 -0500 Subject: [PATCH 039/183] mt(apiv2): restore upstream missing-task response (fix MT-off 500 regression) The MT gate replaced the `if not task:` guard in tasks_view, tasks_status and tasks_file_stream with _deny_if_hidden/_deny_manage. Since the helpers DEFER (return None) on a missing task when MT is off, execution fell through to task.to_dict()/task.guest.name -> AttributeError -> HTTP 500 on a default (MT-off) install (upstream returned a 200 error body). Re-add each caller's missing-task guard AFTER the deny check, restoring upstream's exact response. No-op under MT (the helper already returns the generic 404 for a missing/hidden task first), so enumeration-hiding is preserved. Found by an exhaustive MT-off-invariant audit of the diff. Regression tests cover both modes for all three endpoints (MT off -> upstream 200 error body; MT on -> generic 404). --- web/apiv2/test_visibility.py | 92 ++++++++++++++++++++++++++++++++++++ web/apiv2/views.py | 16 +++++++ 2 files changed, 108 insertions(+) diff --git a/web/apiv2/test_visibility.py b/web/apiv2/test_visibility.py index 0eea6fc3cc9..12657359c01 100644 --- a/web/apiv2/test_visibility.py +++ b/web/apiv2/test_visibility.py @@ -495,6 +495,98 @@ def __init__(self, i): assert ids == [2] # foreign task 3 dropped by the batch visibility filter +# --- missing-task regression: the MT gate deleted upstream's `if not task:` guard +# in these 3 endpoints; with MT off _deny_* defers on a missing task, so without +# the restored guard they fall through to task.to_dict()/task.guest -> HTTP 500. +# MT off MUST reproduce upstream's 200 error body; MT on stays the generic 404. +def _apiget(views, u, path): + from rest_framework.test import APIRequestFactory, force_authenticate + req = APIRequestFactory().get(path) + force_authenticate(req, user=u) + req.user = u + return req + + +@pytest.mark.django_db +def test_tasks_view_missing_mt_off_restores_upstream(cape_db, monkeypatch): + import types + import apiv2.views as views + _force_mt_off(monkeypatch) + monkeypatch.setattr(views, "apiconf", types.SimpleNamespace(taskview={"enabled": True})) + monkeypatch.setattr(views.db, "view_task", lambda *a, **k: None) + u = User.objects.create_user("tv_off", "tv_off@x.com", "x") + resp = views.tasks_view(_apiget(views, u, "/apiv2/tasks/view/999/"), 999) + assert resp.status_code == 200 + assert resp.data == {"error": True, "error_value": "Task not found in database"} + + +@pytest.mark.django_db +def test_tasks_view_missing_mt_on_generic_404(cape_db, mt_enabled, monkeypatch): + import types + import apiv2.views as views + monkeypatch.setattr(views, "apiconf", types.SimpleNamespace(taskview={"enabled": True})) + monkeypatch.setattr(views.db, "view_task", lambda *a, **k: None) + u = User.objects.create_user("tv_on", "tv_on@x.com", "x") + resp = views.tasks_view(_apiget(views, u, "/apiv2/tasks/view/999/"), 999) + assert resp.status_code == 404 + + +@pytest.mark.django_db +def test_tasks_status_missing_mt_off_restores_upstream(cape_db, monkeypatch): + import types + import apiv2.views as views + _force_mt_off(monkeypatch) + monkeypatch.setattr(views, "apiconf", types.SimpleNamespace(taskstatus={"enabled": True})) + monkeypatch.setattr(views.db, "view_task", lambda *a, **k: None) + u = User.objects.create_user("ts_off", "ts_off@x.com", "x") + resp = views.tasks_status(_apiget(views, u, "/apiv2/tasks/status/999/"), 999) + assert resp.status_code == 200 + assert resp.data == {"error": True, "error_value": "Task does not exist"} + + +@pytest.mark.django_db +def test_tasks_status_missing_mt_on_generic_404(cape_db, mt_enabled, monkeypatch): + import types + import apiv2.views as views + monkeypatch.setattr(views, "apiconf", types.SimpleNamespace(taskstatus={"enabled": True})) + monkeypatch.setattr(views.db, "view_task", lambda *a, **k: None) + u = User.objects.create_user("ts_on", "ts_on@x.com", "x") + resp = views.tasks_status(_apiget(views, u, "/apiv2/tasks/status/999/"), 999) + assert resp.status_code == 404 + + +def _apipost(views, u, path): + from rest_framework.test import APIRequestFactory, force_authenticate + req = APIRequestFactory().post(path) # tasks_file_stream is @api_view(["POST"]) + force_authenticate(req, user=u) + req.user = u + return req + + +@pytest.mark.django_db +def test_tasks_file_stream_missing_mt_off_restores_upstream(cape_db, monkeypatch): + import types + import apiv2.views as views + _force_mt_off(monkeypatch) + monkeypatch.setattr(views, "apiconf", types.SimpleNamespace(taskstatus={"enabled": True})) + monkeypatch.setattr(views.db, "view_task", lambda *a, **k: None) + u = User.objects.create_user("fs_off", "fs_off@x.com", "x") + resp = views.tasks_file_stream(_apipost(views, u, "/apiv2/tasks/get/stream/999/"), 999) + assert resp.status_code == 200 + assert resp.data == {"error": True, "error_value": "Task does not exist"} + + +@pytest.mark.django_db +def test_tasks_file_stream_missing_mt_on_generic_404(cape_db, mt_enabled, monkeypatch): + import types + import apiv2.views as views + monkeypatch.setattr(views, "apiconf", types.SimpleNamespace(taskstatus={"enabled": True})) + monkeypatch.setattr(views.db, "view_task", lambda *a, **k: None) + u = User.objects.create_user("fs_on", "fs_on@x.com", "x") + resp = views.tasks_file_stream(_apipost(views, u, "/apiv2/tasks/get/stream/999/"), 999) + assert resp.status_code == 404 + + def test_every_perform_search_caller_passes_viewer(): """SECURITY GATE: perform_search() is unscoped by default (no tenant filter at the mongo/ES layer). Every web caller MUST pass viewer= so the query is diff --git a/web/apiv2/views.py b/web/apiv2/views.py index 9ae54346af5..56aa99e427a 100644 --- a/web/apiv2/views.py +++ b/web/apiv2/views.py @@ -1086,6 +1086,12 @@ def tasks_view(request, task_id): _denied = _deny_if_hidden(request, task) if _denied is not None: return _denied + # Missing task: with MT off _deny_if_hidden defers (returns None), so restore + # upstream's explicit missing-task response here. No-op under MT, where the + # helper already returned the generic 404 for a missing/hidden task. + if not task: + resp = {"error": True, "error_value": "Task not found in database"} + return Response(resp) resp = {"error": False} entry = task.to_dict() @@ -1342,6 +1348,11 @@ def tasks_status(request, task_id): _denied = _deny_if_hidden(request, task) if _denied is not None: return _denied + # MT off: _deny_if_hidden defers on a missing task -> restore upstream's + # explicit response (no-op under MT, which already 404'd missing/hidden). + if not task: + resp = {"error": True, "error_value": "Task does not exist"} + return Response(resp) if request.method == "GET": status = task.to_dict()["status"] resp = {"error": False, "data": status} @@ -3288,6 +3299,11 @@ def _stream_iterator(fp, guest_name, chunk_size=1024): if _denied is not None: return _denied task = db.view_task(task_id) + # MT off: _deny_manage defers on a missing task -> restore upstream's explicit + # response (no-op under MT, which already 404'd missing/hidden). + if not task: + resp = {"error": True, "error_value": "Task does not exist"} + return Response(resp) machine = db.view_machine(task.guest.name) if machine.status != "running": resp = {"error": True, "error_value": "Machine is not running", "errors": machine.status} From 8b731a0248b26e626fc7c925819893879aa8d2c6 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Wed, 15 Jul 2026 12:04:05 -0500 Subject: [PATCH 040/183] mt: make multitenancy a true no-op when disabled (default-off == upstream) Follow-up to an adversarial MT-off-invariant audit of the whole diff: with multitenancy disabled a default install must behave byte-for-byte like upstream. Several new MT code paths ran unconditionally; gate each so it is a no-op when off (MT-on isolation unchanged): - apiv2.ext_tasks_search: break-glass/MT-off appends every record (no SQL-existence drop). apiv2.task_x_hours: preserve upstream's reversed-bounds query when off. Task.to_dict() responses strip the new tenant_id/visibility keys when off (via _strip_mt_task_fields). - analysis.report(): gate the SQL-existence pre-check on MT so a mongo-only analysis still renders. require_task_visibility/require_task_manage decorators: pass through to the upstream view when off. - compare left/hash/both: gate the SQL seed check + md5-pivot list_tasks intersection on MT; MT-off passes the raw mongo cursor through unchanged. - submission.index: only offer the visibility select when MT on. submission._scope_existent: no-op when off. - guac.index: only apply the manage gate when MT on (upstream ordering + message otherwise). - dashboard.index + statistics_data (+ templates): single "global" scope reproduces upstream single-panel markup; per-scope chrome only for >1 panel. - cape_utils.static_config_lookup ES branch: upstream query verbatim when no MT scope filter is present. Regression tests cover both modes per fix (167 web MT tests green; upstream apiv2 + cape_utils suites green; ruff clean). --- lib/cuckoo/common/cape_utils.py | 21 +- tests/test_cape_utils.py | 52 ++++- web/analysis/test_visibility.py | 144 +++++++++++- web/analysis/views.py | 44 +++- web/apiv2/test_visibility.py | 307 ++++++++++++++++++++++++++ web/apiv2/views.py | 96 +++++--- web/compare/test_visibility.py | 125 +++++++++++ web/compare/views.py | 261 ++++++++++++---------- web/dashboard/test_dashboard_scope.py | 184 +++++++++++++++ web/dashboard/views.py | 36 ++- web/guac/test_visibility.py | 51 +++++ web/guac/views.py | 14 +- web/mt_test_fixtures.py | 16 ++ web/submission/test_visibility.py | 92 +++++++- web/submission/views.py | 40 ++-- web/templates/dashboard/index.html | 91 +++++++- web/templates/statistics.html | 42 ++-- 17 files changed, 1411 insertions(+), 205 deletions(-) diff --git a/lib/cuckoo/common/cape_utils.py b/lib/cuckoo/common/cape_utils.py index cc82ec8b2dd..d55f2e2f030 100644 --- a/lib/cuckoo/common/cape_utils.py +++ b/lib/cuckoo/common/cape_utils.py @@ -356,13 +356,24 @@ def static_config_lookup(file_path: str, sha256: str = False, viewer=None) -> di "analysis", _q, {"CAPE.configs": 1, "info.id": 1, "_id": 0}, sort=[("_id", -1)] ) elif repconf.elasticsearchdb.enabled: - _esmust = [{"term": {"target.file.sha256": sha256}}] # exact hash -> term, not analyzed match - _esbody = {"query": {"bool": {"must": _esmust}}, "_source": ["CAPE.configs", "info.id"], "sort": {"_id": {"order": "desc"}}} _esf = _config_lookup_es_filter(viewer) if _esf: - _esbody["query"]["bool"]["filter"] = [_esf] - _hits = es.search(index=get_analysis_index(), body=_esbody)["hits"]["hits"] - document_dict = _hits[0]["_source"] if _hits else None + # 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/tests/test_cape_utils.py b/tests/test_cape_utils.py index 5d290e8d081..3cef59dfbd0 100644 --- a/tests/test_cape_utils.py +++ b/tests/test_cape_utils.py @@ -1,7 +1,7 @@ import unittest from unittest.mock import MagicMock, patch -from lib.cuckoo.common.cape_utils import cape_name_from_yara, static_config_parsers +from lib.cuckoo.common.cape_utils import cape_name_from_yara, static_config_lookup, static_config_parsers class TestCapeUtils(unittest.TestCase): @@ -70,5 +70,55 @@ def test_static_config_parsers_no_extractors(self): self.assertEqual(result, {}) +class TestStaticConfigLookupES(unittest.TestCase): + """static_config_lookup ES branch: MT-off must be byte-for-byte upstream; + MT-on must scope + return None on no hits.""" + + def _run(self, es_hits, esf): + # Force the ES branch: mongo disabled, es enabled. + with patch("lib.cuckoo.common.cape_utils.repconf") as mock_repconf, patch( + "lib.cuckoo.common.cape_utils.es", create=True + ) as mock_es, patch( + "lib.cuckoo.common.cape_utils.get_analysis_index", return_value="cuckoo-*", create=True + ), patch( + "lib.cuckoo.common.cape_utils._config_lookup_es_filter", return_value=esf + ): + mock_repconf.mongodb.enabled = False + mock_repconf.elasticsearchdb.enabled = True + mock_es.search.return_value = {"hits": {"hits": es_hits}} + result = static_config_lookup("/path/to/file", sha256="a" * 64, viewer=object()) + return result, mock_es + + def test_es_mt_off_uses_upstream_match_query(self): + # MT off => filter is None => original upstream query (match) + [0]["_source"]. + hit_source = {"CAPE": {"configs": [{"x": 1}]}, "info": {"id": 7}} + result, mock_es = self._run(es_hits=[{"_source": hit_source}], esf=None) + self.assertEqual(result, {"id": 7}) + body = mock_es.search.call_args.kwargs["body"] + # Upstream shape: match query, no bool/filter. + self.assertEqual(body["query"], {"match": {"target.file.sha256": "a" * 64}}) + self.assertNotIn("bool", body["query"]) + + def test_es_mt_off_no_hits_raises_indexerror(self): + # Preserve upstream no-hits behavior (IndexError) when MT off. + with self.assertRaises(IndexError): + self._run(es_hits=[], esf=None) + + def test_es_mt_on_uses_scoped_term_query_and_returns_none_on_empty(self): + esf = {"bool": {"should": [{"term": {"info.visibility": "public"}}], "minimum_should_match": 1}} + result, mock_es = self._run(es_hits=[], esf=esf) + self.assertIsNone(result) + body = mock_es.search.call_args.kwargs["body"] + self.assertIn("bool", body["query"]) + self.assertEqual(body["query"]["bool"]["must"], [{"term": {"target.file.sha256": "a" * 64}}]) + self.assertEqual(body["query"]["bool"]["filter"], [esf]) + + def test_es_mt_on_returns_scoped_hit(self): + esf = {"bool": {"should": [{"term": {"info.visibility": "public"}}], "minimum_should_match": 1}} + hit_source = {"CAPE": {"configs": [{"x": 1}]}, "info": {"id": 9}} + result, _ = self._run(es_hits=[{"_source": hit_source}], esf=esf) + self.assertEqual(result, {"id": 9}) + + if __name__ == "__main__": unittest.main() diff --git a/web/analysis/test_visibility.py b/web/analysis/test_visibility.py index b521f67f89e..ee20bf9c912 100644 --- a/web/analysis/test_visibility.py +++ b/web/analysis/test_visibility.py @@ -38,9 +38,10 @@ def test_report_denies_cross_tenant_private(cape_db, mt_enabled, monkeypatch, cl @pytest.mark.django_db -def test_report_missing_task_renders_error_200(cape_db, monkeypatch, client): - """A missing/deleted task renders the same generic error page at HTTP 200 as - a hidden task (upstream parity + indistinguishability) — not a 403.""" +def test_report_missing_task_renders_error_200_mt_on(cape_db, mt_enabled, monkeypatch, client): + """With MT ON, a missing/deleted task renders the same generic "No analysis + found" error page at HTTP 200 as a hidden task (indistinguishability) — not a + 403 — so cross-tenant task IDs can't be enumerated by status/message.""" import analysis.views as av monkeypatch.setattr(av.db, "view_task", lambda *a, **k: None) u = User.objects.create_user("c", "c@x.com", "x") @@ -50,6 +51,32 @@ def test_report_missing_task_renders_error_200(cape_db, monkeypatch, client): assert b"No analysis found" in r.content +@pytest.mark.django_db +def test_report_mt_off_falls_through_to_upstream_error(cape_db, mt_disabled, monkeypatch, client): + """HARD INVARIANT: with MT OFF the whole SQL-existence pre-check is a NO-OP. + A task with no SQL Task row (e.g. a mongo/ES-only analysis, or a fresh/empty + DB) must NOT hit the new "No analysis found with specified ID" page; it falls + straight through to upstream's original mongo/ES 'if not report:' path with its + unchanged message and HTTP 200. (view_task is made to blow up to prove the + pre-check never touches the DB when MT is disabled.)""" + import analysis.views as av + + def _boom(*a, **k): + raise AssertionError("report() must not run the SQL-existence pre-check when MT is off") + + monkeypatch.setattr(av.db, "view_task", _boom) + u = User.objects.create_user("c", "c@x.com", "x") + client.force_login(u) + r = client.get(_report_url()) + assert r.status_code == 200 + # Upstream message, NOT the MT "No analysis found with specified ID" string. + assert b"No analysis found with specified ID" not in r.content + assert ( + b"The specified analysis does not exist or not finished yet." in r.content + or b"enable Mongodb/ES" in r.content + ) + + @pytest.mark.django_db def test_full_memory_denies_cross_tenant(cape_db, mt_enabled, monkeypatch, client): """full_memory_dump_file routes via the analysis_number group (not task_id); @@ -62,10 +89,13 @@ def test_full_memory_denies_cross_tenant(cape_db, mt_enabled, monkeypatch, clien @pytest.mark.django_db -def test_non_numeric_task_id_denied_before_db(cape_db, monkeypatch, client): - """A non-numeric id on a \\w+ analysis route (full_memory) is coerced-and-denied - (403) BEFORE db.view_task runs — so a bad id can't raise a DB DataError -> 500 - (which would also leak a task-vs-no-task signal). Mode-independent hardening.""" +def test_non_numeric_task_id_denied_before_db(cape_db, mt_enabled, monkeypatch, client): + """With MT ON, a non-numeric id on a \\w+ analysis route (full_memory) is + coerced-and-denied (403) BEFORE db.view_task runs — so a bad id can't raise a + DB DataError -> 500 (which would also leak a task-vs-no-task signal). This + hardening only runs when MT is enabled; with MT off the decorator is a pure + pass-through so the view keeps upstream's behavior (see + test_require_visibility_mt_off_passthrough).""" import analysis.views as av def _boom(*a, **k): @@ -76,6 +106,37 @@ def _boom(*a, **k): assert client.get("/full_memory/abc/").status_code == 403 +@pytest.mark.django_db +def test_require_visibility_mt_off_passthrough(cape_db, mt_disabled, monkeypatch): + """HARD INVARIANT: with MT OFF, require_task_visibility / require_task_manage + are PURE pass-throughs — they must NOT coerce the id, call db.view_task, or + return a 403. The wrapped view runs exactly as upstream (which then renders off + disk/mongo, ~200).""" + from django.test import RequestFactory + import analysis.views as av + + def _boom(*a, **k): + raise AssertionError("decorators must not touch the DB when MT is off") + + monkeypatch.setattr(av.db, "view_task", _boom) + + sentinel = object() + + @av.require_task_visibility + def _vis_view(request, task_id): + return sentinel + + @av.require_task_manage + def _mng_view(request, task_id): + return sentinel + + req = RequestFactory().get("/x/") + req.user = User.objects.create_user("pt", "pt@x.com", "x") + # even a non-numeric id passes straight through untouched when MT is off + assert _vis_view(req, task_id="abc") is sentinel + assert _mng_view(req, task_id="abc") is sentinel + + @pytest.mark.django_db def test_vtupload_denies_cross_tenant(cape_db, mt_enabled, monkeypatch, client): """vtupload reads + exfiltrates a sample to VirusTotal — require_task_manage.""" @@ -163,6 +224,75 @@ def _fake_yara(term, recs): assert "/opt/CAPEv2/storage/binaries/deadbeefdeadbeef" not in paths # foreign content-addressed sample dropped +_STATS = { + "total": 3, + "average": 1, + "tasks": {}, + "detections": {"Emotet": {"family": "Emotet", "total": 2}}, + "asns": [], + "signatures": {}, + "processing": {}, + "reporting": {}, + "custom_statistics": {}, +} + + +@pytest.mark.django_db +def test_statistics_mt_off_renders_upstream_markup(cape_db, mt_disabled, monkeypatch, client): + """HARD INVARIANT: with MT OFF, entitled_scopes()->['global'] and the single + panel must render BYTE-FOR-BYTE upstream markup — bare element ids (no + '-global' suffix), a plain 'All Detections' modal title (no '— Global'), and + no per-scope header.""" + import analysis.views as av + import dashboard.views as dv + + monkeypatch.setattr(av, "statistics", lambda *a, **k: dict(_STATS)) + monkeypatch.setattr(dv, "entitled_scopes", lambda user: ["global"]) + client.force_login(User.objects.create_user("st", "st@x.com", "x")) + + r = client.get("/statistics/7/") + assert r.status_code == 200 + body = r.content + # bare upstream ids, no scope suffix + assert b'id="tasksChart"' in body + assert b'id="allDetectionsModal"' in body + assert b'id="performanceTabs"' in body + assert b'data-bs-target="#processing"' in body + assert b"tasksChart-global" not in body + assert b"allDetectionsModal-global" not in body + # upstream modal title, no label suffix, and no per-scope header + assert b"All Detections" in body + assert b"All Detections \xe2\x80\x94" not in body # no " — " suffix + assert b"fa-layer-group" not in body + + +@pytest.mark.django_db +def test_statistics_mt_on_multiscope_suffixes_ids_and_labels(cape_db, mt_enabled, monkeypatch, client): + """With MT ON and multiple entitled scopes, each panel's ids/targets are + suffixed with '-' and the modal title carries the '—