From 659f9a80cbc0dfe531284d1d85d8a9d06b2e0a52 Mon Sep 17 00:00:00 2001 From: Josh Feather Date: Tue, 12 May 2026 12:33:18 +0000 Subject: [PATCH 01/24] Warn instead of fail when script storage fails during submission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, a failure in save_script_to_storage caused download_file to return ("error", ...) with a hardcoded message. The submission view routes to error.html vs complete.html based solely on whether task IDs were produced, so callers that received an error status would never set task_ids — sending the user to error.html with the generic heading "Error adding task(s) to CAPE's database." even though the tasks had already been created in the database. Now the exception is caught and its message appended to a warnings list, which is merged into the 'errors' field of the final 'ok' response. Task submission proceeds, task IDs are returned to the caller, and the user lands on complete.html seeing the actual exception rather than a hardcoded fallback string. Also fix a pre-existing misleading label on complete.html: the error block heading read "Submission Failed!" but that template is only ever rendered when tasks were successfully queued. Rename it to "Errors" to accurately reflect that it surfaces non-fatal warnings alongside a successful submission. --- lib/cuckoo/common/web_utils.py | 6 ++++-- web/templates/submission/complete.html | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/cuckoo/common/web_utils.py b/lib/cuckoo/common/web_utils.py index e577068bea9..716ebbbd2fa 100644 --- a/lib/cuckoo/common/web_utils.py +++ b/lib/cuckoo/common/web_utils.py @@ -930,6 +930,7 @@ def download_file(**kwargs): if not static and "dist_extract" in kwargs["options"]: static = True + warnings = [] for machine in kwargs.get("task_machines", []): if machine == "first": machine = None @@ -961,7 +962,7 @@ def download_file(**kwargs): save_script_to_storage(task_ids_new, kwargs) except Exception as e: log.error("Error saving scripts to storage: %s", e) - return "error", {"error": "Error: Storing scripts to tempstorage"} + warnings.append({"script": f"{e}"}) if isinstance(kwargs.get("task_ids", False), list): kwargs["task_ids"].extend(task_ids_new) @@ -972,7 +973,8 @@ def download_file(**kwargs): if not onesuccess: return "error", {"error": f"Provided hash not found on {kwargs['service']}"} - return "ok", {"task_ids": kwargs["task_ids"], "errors": extra_details.get("errors", [])} + errors = extra_details.get("errors", []) + warnings + return "ok", {"task_ids": kwargs["task_ids"], "errors": errors} def save_script_to_storage(task_ids: list, kwargs): diff --git a/web/templates/submission/complete.html b/web/templates/submission/complete.html index 53e4340a112..1d2ed3e910d 100644 --- a/web/templates/submission/complete.html +++ b/web/templates/submission/complete.html @@ -32,7 +32,7 @@ {% endif %} {% if errors %} -

Submission Failed!

+

Errors

    {% for block in errors %} {% for k, v in block.items %} From 668276f07c752c0fb13731f5e81923fa8e784276 Mon Sep 17 00:00:00 2001 From: doomedraven Date: Tue, 12 May 2026 14:27:17 +0000 Subject: [PATCH 02/24] mongo indexes --- conf/default/reporting.conf.default | 7 +++++++ lib/cuckoo/core/startup.py | 30 +++++++++++++++++++++-------- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/conf/default/reporting.conf.default b/conf/default/reporting.conf.default index f0361a22cc2..2bd95ba78da 100644 --- a/conf/default/reporting.conf.default +++ b/conf/default/reporting.conf.default @@ -96,6 +96,13 @@ db = cuckoo # password = # authsource = cuckoo +# Extended search indexes (may increase disk/RAM usage) +index_yara = no +index_clamav = no +index_hashes = no +index_detections = no +index_filenames = no + # Set this value if you are using mongodb with TLS enabled # tlscafile = diff --git a/lib/cuckoo/core/startup.py b/lib/cuckoo/core/startup.py index 6bbe35eed20..926100a414b 100644 --- a/lib/cuckoo/core/startup.py +++ b/lib/cuckoo/core/startup.py @@ -122,16 +122,25 @@ def check_webgui_mongo(): # with large amounts of data. # Note: Silently ignores the creation if the index already exists. mongo_create_index("analysis", "info.id", name="info.id_1") - # Some indexes that can be useful for some users - mongo_create_index("files", "md5", name="file_md5") mongo_create_index("files", [("_task_ids", 1)]) - # side indexes as ideas - """ - mongo_create_index("analysis", "detections", name="detections_1") - mongo_create_index("analysis", "target.file.name", name="name_1") - """ + if repconf.mongodb.get("index_yara", False): + mongo_create_index("files", "yara.name", name="yara_name") + mongo_create_index("files", "cape_yara.name", name="cape_yara_name") + + if repconf.mongodb.get("index_clamav", False): + mongo_create_index("files", "clamav", name="clamav_index") + + if repconf.mongodb.get("index_hashes", False): + mongo_create_index("files", "md5", name="file_md5") + mongo_create_index("files", "sha1", name="file_sha1") + mongo_create_index("files", "ssdeep", name="file_ssdeep") + + if repconf.mongodb.get("index_detections", False): + mongo_create_index("analysis", "detections.family", name="detections_family") + if repconf.mongodb.get("index_filenames", False): + mongo_create_index("analysis", "target.file.name", name="name_1") elif repconf.elasticsearchdb.enabled: # ToDo add check pass @@ -204,7 +213,10 @@ def check_linux_dist(): with suppress(AttributeError): platform_details = platform.dist() if platform_details[0] != "Ubuntu" and platform_details[1] not in ubuntu_versions: - log.info("[!] You are using NOT supported Linux distribution by devs! Any issue report is invalid! We only support Ubuntu LTS %s", ubuntu_versions) + log.info( + "[!] You are using NOT supported Linux distribution by devs! Any issue report is invalid! We only support Ubuntu LTS %s", + ubuntu_versions, + ) def init_logging(level: int): @@ -359,6 +371,8 @@ def check_snapshot_state(): machine_config = machinery_config.get(machine_name) machine_name = machine_config.get("label") domain = conn.lookupByName(machine_name) + + # Check for valid architecture configuration. arch = machine_config.get("arch") if not arch: From 76ce548bfe61a4a142b2f1e136ae3ec7fded73b6 Mon Sep 17 00:00:00 2001 From: Josh Feather Date: Tue, 12 May 2026 13:40:41 +0000 Subject: [PATCH 03/24] Add guacamole idle timeout detection and upgrade to 1.6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Idle timeout: - Add SessionTimeoutManager (web/guac/timeout_manager.py) which tracks last mouse/keyboard activity and signals analysis completion by creating the cape- folder on the guest via the CAPE agent — the same mechanism used by the End Session button. - Add guac_utils.py with is_user_activity(), which matches mouse/keyboard Guacamole protocol instructions using a regex against the wire format. - Update consumers.py to initialise a SessionTimeoutManager per session, update activity on every inbound message, and run a monitor_timeout() background task that fires handle_timeout() when the idle threshold is exceeded. The timeout sends a Guacamole error frame (code 522) to the client before closing. - Add _close_websocket() to prevent double-close race conditions that were producing stack traces when the reader task and disconnect() both tried to close simultaneously. - Allow 'pending' tasks as well as 'running' in ACTIVE_GUAC_TASK_STATUSES so sessions can connect during the brief window before a task starts. - Add idle_timeout_seconds and activity_check_interval config options to web.conf.default; both default to disabled (0). - Add tests: test_guac_consumers.py, test_guac_timeout_manager.py, test_guacamole_activity_detection.py. Guacamole 1.6 upgrade: - The previously bundled 1.4.0 client was behind what the upstream CAPEv2 installer deploys; this aligns the bundled JS with 1.6.0. - Add guacamole-1.6.0-all.min.js static asset. - Update guac/index.html and analysis/overview/_playback.html to reference the 1.6.0 build. - Refactor guac-main.js from a procedural function to an ES6 class (GuacSession). Extracts keyboard, mouse, clipboard, scaling and error handling into dedicated setup methods. Differentiates error codes 514, 515, and 522 for cleaner user-facing messages. Uses sendMouseState with the Guacamole 1.6 fromServer flag. --- conf/default/web.conf.default | 6 + lib/cuckoo/common/guac_utils.py | 15 + tests/web/test_guac_consumers.py | 397 ++++++++++++++++++ tests/web/test_guac_timeout_manager.py | 110 +++++ .../web/test_guacamole_activity_detection.py | 92 ++++ web/guac/consumers.py | 170 ++++++-- web/guac/templates/guac/index.html | 2 +- web/guac/timeout_manager.py | 180 ++++++++ web/static/js/guac-main.js | 268 +++++++----- web/static/js/guacamole-1.4.0-all.min.js | 155 ------- web/static/js/guacamole-1.6.0-all.min.js | 173 ++++++++ .../analysis/overview/_playback.html | 2 +- 12 files changed, 1274 insertions(+), 296 deletions(-) create mode 100644 lib/cuckoo/common/guac_utils.py create mode 100644 tests/web/test_guac_consumers.py create mode 100644 tests/web/test_guac_timeout_manager.py create mode 100644 tests/web/test_guacamole_activity_detection.py create mode 100644 web/guac/timeout_manager.py delete mode 100644 web/static/js/guacamole-1.4.0-all.min.js create mode 100644 web/static/js/guacamole-1.6.0-all.min.js diff --git a/conf/default/web.conf.default b/conf/default/web.conf.default index f5f29af8595..eb99da14717 100644 --- a/conf/default/web.conf.default +++ b/conf/default/web.conf.default @@ -212,6 +212,12 @@ ignore_rdp_cert = false rdp_disable_wallpaper = yes rdp_disable_theming = yes rdp_enable_font_smoothing = no +# Idle timeout settings for interactive sessions +# idle_timeout_seconds: Maximum idle time before session is terminated +# Defaults to 0 (disabled) when omitted or set to an invalid value +idle_timeout_seconds = 0 +# activity_check_interval: How often to check for timeout in seconds when enabled +activity_check_interval = 30 rdp_enable_full_window_drag = no rdp_enable_desktop_composition = no rdp_enable_menu_animations = no diff --git a/lib/cuckoo/common/guac_utils.py b/lib/cuckoo/common/guac_utils.py new file mode 100644 index 00000000000..cff93af377c --- /dev/null +++ b/lib/cuckoo/common/guac_utils.py @@ -0,0 +1,15 @@ +"""Utilities for Guacamole protocol handling and activity detection.""" + +import re + +# Matches the opcode of a Guacamole instruction at message start or after ';'. +# Guacamole wire format: .,....; +# Example: 5.mouse,3.100,3.200,1.0; +_ACTIVITY_RE = re.compile(r"(?:^|;)\d+\.(key|mouse),") + + +def is_user_activity(message: str) -> bool: + """Return ``True`` if *message* contains a mouse or keyboard instruction.""" + if not message or not isinstance(message, str): + return False + return _ACTIVITY_RE.search(message) is not None diff --git a/tests/web/test_guac_consumers.py b/tests/web/test_guac_consumers.py new file mode 100644 index 00000000000..e2876e43bd0 --- /dev/null +++ b/tests/web/test_guac_consumers.py @@ -0,0 +1,397 @@ +import asyncio +import logging +from importlib import import_module +from types import SimpleNamespace + +import pytest +from channels.routing import URLRouter +from channels.testing import WebsocketCommunicator + +consumers = import_module("guac.consumers") +guac_routing = import_module("guac.routing") + +TEST_TOKEN = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" +TEST_VNC_PORT = 5901 + + +class FakeTask: + def __init__(self, task_id, status="running"): + self.id = task_id + self.status = status + + +class FakeDatabase: + """Minimal stand-in for Database with guac session and task helpers.""" + + def __init__(self, *, session_data=None, task=None): + self._session_data = session_data or { + "task_id": 123, + "vm_label": "win10_1", + "guest_ip": "192.168.56.10", + } + self._task = task or FakeTask(123, "running") + self.deleted_sessions = [] + + def get_guac_session(self, token): + if str(token) == TEST_TOKEN: + return dict(self._session_data) + return None + + def view_task(self, task_id): + if int(task_id) == self._task.id: + return self._task + return None + + def delete_guac_session(self, token): + self.deleted_sessions.append(str(token)) + + +class FakeGuacamoleClient: + instances = [] + + def __init__(self, host, port): + self.host = host + self.port = port + self.connected = False + self.handshake_kwargs = None + self.sent_messages = [] + self.closed = False + self.__class__.instances.append(self) + + def handshake(self, **kwargs): + self.handshake_kwargs = kwargs + self.connected = True + + def send(self, message): + self.sent_messages.append(message) + + def close(self): + self.closed = True + + +class FakeTimeoutManager: + instances = [] + + def __init__(self, vm_ip, user, session_id="unknown", task_id=None): + self.vm_ip = vm_ip + self.user = user + self.session_id = session_id + self.task_id = task_id + self.activity_updates = 0 + self.activity_check_interval = 60 + self.idle_timeout_seconds = 120 + self.is_active = True + self.__class__.instances.append(self) + + def update_activity(self): + self.activity_updates += 1 + + def set_inactive(self): + self.is_active = False + + def is_timed_out(self): + return False + + def get_idle_time_ms(self): + return 0 + + async def complete_analysis(self): + return True + + +class ExpiringFakeTimeoutManager(FakeTimeoutManager): + instances = [] + + def __init__(self, vm_ip, user, session_id="unknown", task_id=None): + super().__init__(vm_ip, user, session_id=session_id, task_id=task_id) + self.activity_check_interval = 0.01 + self.idle_timeout_seconds = 120 + self.complete_analysis_calls = 0 + + def is_timed_out(self): + return True + + def get_idle_time_ms(self): + return self.idle_timeout_seconds * 1000 + 1 + + async def complete_analysis(self): + self.complete_analysis_calls += 1 + return True + + +class DisabledTimeoutManager: + instances = [] + + def __init__(self, vm_ip, user, session_id="unknown", task_id=None): + self.vm_ip = vm_ip + self.user = user + self.session_id = session_id + self.task_id = task_id + self.activity_updates = 0 + self.activity_check_interval = None + self.idle_timeout_seconds = 0 + self.is_active = True + self.__class__.instances.append(self) + + def update_activity(self): + self.activity_updates += 1 + + def set_inactive(self): + self.is_active = False + + def is_timed_out(self): + return False + + def get_idle_time_ms(self): + return 0 + + async def complete_analysis(self): + return True + + +async def _background_task_stub(self): + await asyncio.Event().wait() + + +async def _read_guacd_tracking_stub(self): + await asyncio.Event().wait() + + +async def _cancel_then_close_read_guacd(self): + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + pass + finally: + await self._close_websocket() + + +def _make_communicator(app, session_id, recording_name, token=TEST_TOKEN): + """Create a WebsocketCommunicator with the guac_session cookie injected.""" + url = f"/guac/websocket-tunnel/{session_id}/?recording_name={recording_name}" + communicator = WebsocketCommunicator(app, url, subprotocols=["guacamole"]) + communicator.scope["cookies"] = {"guac_session": token} + return communicator + + +@pytest.fixture +def guac_consumer_app_factory(monkeypatch): + def _build(*, timeout_manager_cls=None, stub_monitor_timeout=True, + read_guacd_impl=_background_task_stub, fake_db=None): + FakeGuacamoleClient.instances.clear() + FakeTimeoutManager.instances.clear() + ExpiringFakeTimeoutManager.instances.clear() + DisabledTimeoutManager.instances.clear() + + timeout_manager_cls = timeout_manager_cls or FakeTimeoutManager + db = fake_db or FakeDatabase() + + monkeypatch.setattr(consumers, "GuacamoleClient", FakeGuacamoleClient) + monkeypatch.setattr(consumers, "SessionTimeoutManager", timeout_manager_cls) + monkeypatch.setattr(consumers, "Database", lambda: db) + monkeypatch.setattr(consumers, "_get_vnc_port", lambda vm_label: TEST_VNC_PORT) + monkeypatch.setattr(consumers.GuacamoleWebSocketConsumer, "read_guacd", read_guacd_impl) + monkeypatch.setattr(consumers.GuacamoleWebSocketConsumer, "monitor_task_status", _background_task_stub) + if stub_monitor_timeout: + monkeypatch.setattr(consumers.GuacamoleWebSocketConsumer, "monitor_timeout", _background_task_stub) + monkeypatch.setattr( + consumers, + "web_cfg", + SimpleNamespace( + guacamole=SimpleNamespace( + guacd_host="localhost", + guacd_port=4822, + guacd_recording_path="/tmp/guacrecordings", + guest_protocol="vnc", + guest_width=1280, + guest_height=1024, + username="", + password="", + vnc_host="localhost", + vnc_color_depth=16, + vnc_cursor="local", + ) + ), + ) + + return URLRouter(guac_routing.websocket_urlpatterns), db + + return _build + + +@pytest.mark.asyncio +class TestGuacConsumers: + """Integration-style tests for the Guacamole websocket consumer.""" + + async def test_consumer_updates_idle_activity_for_real_guacamole_input(self, guac_consumer_app_factory): + guac_consumer_app, fake_db = guac_consumer_app_factory() + communicator = _make_communicator( + guac_consumer_app, "session123", "123_session123", + ) + timeout_manager = None + client = None + + try: + connected, subprotocol = await communicator.connect() + assert connected is True + assert subprotocol == "guacamole" + + assert len(FakeGuacamoleClient.instances) == 1 + client = FakeGuacamoleClient.instances[0] + assert client.handshake_kwargs["hostname"] == "localhost" + assert client.handshake_kwargs["port"] == TEST_VNC_PORT + assert client.handshake_kwargs["recording_name"] == "123_session123" + + assert len(FakeTimeoutManager.instances) == 1 + timeout_manager = FakeTimeoutManager.instances[0] + assert timeout_manager.vm_ip == "192.168.56.10" + assert timeout_manager.session_id == TEST_TOKEN + assert timeout_manager.task_id == "123" + + await communicator.send_to(text_data="4.size,4.1280,4.1024;") + await communicator.send_to(text_data="5.mouse,3.100,3.200,1.0;") + await communicator.send_to(text_data="3.key,2.65,1.1;") + await asyncio.sleep(0.05) + + assert timeout_manager.activity_updates == 2 + assert client.sent_messages == [ + "4.size,4.1280,4.1024;", + "5.mouse,3.100,3.200,1.0;", + "3.key,2.65,1.1;", + ] + finally: + await communicator.disconnect() + + assert timeout_manager.is_active is False + assert client.closed is True + + async def test_consumer_accepts_pending_task(self, guac_consumer_app_factory): + fake_db = FakeDatabase(task=FakeTask(123, "pending")) + guac_consumer_app, fake_db = guac_consumer_app_factory(fake_db=fake_db) + communicator = _make_communicator( + guac_consumer_app, "session_pending", "123_session_pending", + ) + + try: + connected, subprotocol = await communicator.connect() + assert connected is True + assert subprotocol == "guacamole" + assert len(FakeGuacamoleClient.instances) == 1 + assert fake_db.deleted_sessions == [] + finally: + await communicator.disconnect() + + async def test_consumer_timeout_completes_analysis_and_closes_session(self, guac_consumer_app_factory, caplog): + guac_consumer_app, fake_db = guac_consumer_app_factory( + timeout_manager_cls=ExpiringFakeTimeoutManager, + stub_monitor_timeout=False, + ) + caplog.set_level(logging.INFO, logger="guac-session") + communicator = _make_communicator( + guac_consumer_app, "session_timeout", "124_session_timeout", + ) + + connected, subprotocol = await communicator.connect() + assert connected is True + assert subprotocol == "guacamole" + + assert len(FakeGuacamoleClient.instances) == 1 + client = FakeGuacamoleClient.instances[0] + + assert len(ExpiringFakeTimeoutManager.instances) == 1 + timeout_manager = ExpiringFakeTimeoutManager.instances[0] + assert timeout_manager.task_id == "123" + + timeout_message = await asyncio.wait_for(communicator.receive_from(), timeout=1) + assert timeout_message == "5.error,35.Session timed out due to inactivity,3.522;" + + close_event = await asyncio.wait_for(communicator.receive_output(), timeout=1) + assert close_event["type"] == "websocket.close" + + await communicator.disconnect() + await asyncio.wait_for(communicator.wait(), timeout=1) + + assert timeout_manager.complete_analysis_calls == 1 + assert timeout_manager.is_active is False + assert client.closed is True + assert f"idle for 120001ms (threshold: 120s)" in caplog.text + + async def test_consumer_disconnect_cancels_reader_without_double_close(self, guac_consumer_app_factory): + guac_consumer_app, fake_db = guac_consumer_app_factory(read_guacd_impl=_cancel_then_close_read_guacd) + communicator = _make_communicator( + guac_consumer_app, "session_disconnect", "125_session_disconnect", + ) + + connected, subprotocol = await communicator.connect() + assert connected is True + assert subprotocol == "guacamole" + + assert len(FakeGuacamoleClient.instances) == 1 + client = FakeGuacamoleClient.instances[0] + + assert len(FakeTimeoutManager.instances) == 1 + timeout_manager = FakeTimeoutManager.instances[0] + assert timeout_manager.task_id == "123" + + await communicator.disconnect() + await asyncio.wait_for(communicator.wait(), timeout=1) + + assert timeout_manager.is_active is False + assert client.closed is True + + async def test_consumer_skips_timeout_monitor_when_idle_timeout_disabled(self, guac_consumer_app_factory, monkeypatch): + scheduled_coroutines = [] + real_create_task = asyncio.create_task + + def tracking_create_task(coro): + scheduled_coroutines.append(coro.cr_code.co_name) + return real_create_task(coro) + + monkeypatch.setattr(consumers.asyncio, "create_task", tracking_create_task) + + guac_consumer_app, fake_db = guac_consumer_app_factory( + timeout_manager_cls=DisabledTimeoutManager, + stub_monitor_timeout=False, + read_guacd_impl=_read_guacd_tracking_stub, + ) + communicator = _make_communicator( + guac_consumer_app, "session_no_timeout", "126_session_no_timeout", + ) + + connected, subprotocol = await communicator.connect() + assert connected is True + assert subprotocol == "guacamole" + + await communicator.send_to(text_data="5.mouse,3.100,3.200,1.0;") + await asyncio.sleep(0.05) + + assert len(DisabledTimeoutManager.instances) == 1 + assert DisabledTimeoutManager.instances[0].task_id == "123" + assert DisabledTimeoutManager.instances[0].idle_timeout_seconds == 0 + assert DisabledTimeoutManager.instances[0].activity_check_interval is None + assert "_read_guacd_tracking_stub" in scheduled_coroutines + assert "monitor_timeout" not in scheduled_coroutines + + client = FakeGuacamoleClient.instances[0] + assert client.sent_messages == ["5.mouse,3.100,3.200,1.0;"] + + await communicator.disconnect() + await asyncio.wait_for(communicator.wait(), timeout=1) + + async def test_consumer_rejects_connection_without_cookie(self, guac_consumer_app_factory): + guac_consumer_app, fake_db = guac_consumer_app_factory() + url = "/guac/websocket-tunnel/session_nocookie/?recording_name=test" + communicator = WebsocketCommunicator(guac_consumer_app, url, subprotocols=["guacamole"]) + + connected, _ = await communicator.connect() + assert connected is False + + async def test_consumer_rejects_connection_with_unknown_token(self, guac_consumer_app_factory): + guac_consumer_app, fake_db = guac_consumer_app_factory() + communicator = _make_communicator( + guac_consumer_app, "unk_session", "test", + token="00000000-0000-0000-0000-000000000000", + ) + + connected, _ = await communicator.connect() + assert connected is False diff --git a/tests/web/test_guac_timeout_manager.py b/tests/web/test_guac_timeout_manager.py new file mode 100644 index 00000000000..0741ce04f40 --- /dev/null +++ b/tests/web/test_guac_timeout_manager.py @@ -0,0 +1,110 @@ +import asyncio +import hashlib +import logging +from importlib import import_module +from types import SimpleNamespace + +timeout_manager_module = import_module("guac.timeout_manager") + + +class TestSessionTimeoutManager: + def test_idle_timeout_defaults_to_zero_when_not_configured(self, monkeypatch): + monkeypatch.setattr(timeout_manager_module, "web_cfg", SimpleNamespace()) + manager = timeout_manager_module.SessionTimeoutManager("192.168.56.20", "tester") + assert manager.idle_timeout_seconds == 0 + assert manager.activity_check_interval is None + manager.last_activity = 0 + assert manager.is_timed_out() is False + + def test_idle_timeout_zero_disables_timeout_checks(self, monkeypatch): + monkeypatch.setattr( + timeout_manager_module, + "web_cfg", + SimpleNamespace(guacamole=SimpleNamespace(idle_timeout_seconds=0, activity_check_interval=1)), + ) + manager = timeout_manager_module.SessionTimeoutManager("192.168.56.21", "tester") + assert manager.idle_timeout_seconds == 0 + assert manager.activity_check_interval is None + manager.last_activity = 0 + assert manager.is_timed_out() is False + + def test_complete_analysis_creates_signal_folder(self, monkeypatch): + """Signal folder is created on the guest when task_id is available.""" + manager = timeout_manager_module.SessionTimeoutManager("192.168.56.22", "tester", task_id="321") + expected_folder = hashlib.md5("cape-321".encode()).hexdigest() + requested = {"mkdir": None} + + async def fake_get_json(vm_ip, path): + if path == "/environ": + return {"environ": {"TMP": "/tmp/cape"}} + if path == "/system": + return {"system": "Linux"} + raise AssertionError(f"Unexpected path: {path}") + + async def fake_post_form(vm_ip, path, data): + assert path == "/mkdir" + requested["mkdir"] = data["dirpath"] + return 200 + + monkeypatch.setattr(timeout_manager_module, "_agent_get_json", fake_get_json) + monkeypatch.setattr(timeout_manager_module, "_agent_post_form", fake_post_form) + assert asyncio.run(manager.complete_analysis()) is True + assert requested["mkdir"] == f"/tmp/cape/{expected_folder}" + + def test_complete_analysis_windows_path(self, monkeypatch): + """Signal folder uses backslash on Windows guests.""" + manager = timeout_manager_module.SessionTimeoutManager("192.168.56.23", "tester", task_id="654") + expected_folder = hashlib.md5("cape-654".encode()).hexdigest() + requested = {"mkdir": None} + + async def fake_get_json(vm_ip, path): + if path == "/environ": + return {"environ": {"TMP": "C:\\Temp"}} + if path == "/system": + return {"system": "Windows"} + raise AssertionError(f"Unexpected path: {path}") + + async def fake_post_form(vm_ip, path, data): + assert path == "/mkdir" + requested["mkdir"] = data["dirpath"] + assert "\\" in data["dirpath"] + return 200 + + monkeypatch.setattr(timeout_manager_module, "_agent_get_json", fake_get_json) + monkeypatch.setattr(timeout_manager_module, "_agent_post_form", fake_post_form) + assert asyncio.run(manager.complete_analysis()) is True + assert requested["mkdir"] == f"C:\\Temp\\{expected_folder}" + + def test_complete_analysis_returns_false_without_task_id(self, monkeypatch, caplog): + """Without a task_id, complete_analysis should fail gracefully.""" + manager = timeout_manager_module.SessionTimeoutManager("192.168.56.24", "tester") + caplog.set_level(logging.ERROR, logger="guac-session") + assert asyncio.run(manager.complete_analysis()) is False + assert "No task ID" in caplog.text + + def test_complete_analysis_returns_false_without_vm_ip(self, monkeypatch, caplog): + """Without a valid VM IP, complete_analysis should fail gracefully.""" + manager = timeout_manager_module.SessionTimeoutManager("unknown", "tester", task_id="999") + caplog.set_level(logging.ERROR, logger="guac-session") + assert asyncio.run(manager.complete_analysis()) is False + assert "No valid VM IP" in caplog.text + + def test_complete_analysis_returns_false_on_http_error(self, monkeypatch, caplog): + """Non-200 response from agent returns False.""" + manager = timeout_manager_module.SessionTimeoutManager("192.168.56.25", "tester", task_id="888") + caplog.set_level(logging.WARNING, logger="guac-session") + + async def fake_get_json(vm_ip, path): + if path == "/environ": + return {"environ": {"TMP": "/tmp"}} + if path == "/system": + return {"system": "Linux"} + return {} + + async def fake_post_form(vm_ip, path, data): + return 500 + + monkeypatch.setattr(timeout_manager_module, "_agent_get_json", fake_get_json) + monkeypatch.setattr(timeout_manager_module, "_agent_post_form", fake_post_form) + assert asyncio.run(manager.complete_analysis()) is False + assert "HTTP 500" in caplog.text diff --git a/tests/web/test_guacamole_activity_detection.py b/tests/web/test_guacamole_activity_detection.py new file mode 100644 index 00000000000..497cd0d986f --- /dev/null +++ b/tests/web/test_guacamole_activity_detection.py @@ -0,0 +1,92 @@ +""" +Tests for Guacamole activity detection logic. +Only mouse and keyboard events constitute user activity. +""" +import pytest + +from lib.cuckoo.common.guac_utils import is_user_activity + + +class TestGuacamoleActivityDetection: + """Test Guacamole activity detection logic.""" + + @pytest.mark.parametrize( + "message,expected,description", + [ + # Active user interactions (should return True) + ("5.mouse,3.100,3.200,1.0;", True, "Mouse move"), + ("5.mouse,3.100,3.200,1.1;", True, "Mouse click"), + ("3.key,2.65,1.1;", True, "Keyboard input"), + # Passive or non-user-driven events (should return False) + ("4.size,4.1280,4.1024;", False, "Window resize"), + ("4.sync,3.123;", False, "Sync message"), + ("3.nop;", False, "No-op"), + ("3.ack,3.456,1.0,7.SUCCESS;", False, "Acknowledgment"), + ("4.blob,4.data;", False, "Blob data"), + ("5.touch,1.1,3.100,3.200,2.10,2.10,1.0,3.0.5;", False, "Touch input"), + ("9.clipboard,5.hello;", False, "Clipboard paste"), + # Edge cases + ("", False, "Empty message"), + ("invalid", False, "Invalid format"), + ("mouse.100,200,1;", False, "Legacy fake format"), + ("6.random,4.text;", False, "Unknown instruction"), + ], + ) + def test_activity_detection(self, message, expected, description): + """Test activity detection against real Guacamole protocol messages.""" + result = is_user_activity(message) + assert result == expected, f"Failed for {description}: '{message}' -> {result} (expected {expected})" + + def test_multiple_instructions_mixed(self): + """Test activity detection with multiple instructions in one message.""" + # Mixed active and passive instructions - should detect activity + message = "4.sync,3.123;5.mouse,3.100,3.200,1.1;3.nop;" + result = is_user_activity(message) + assert result is True, "Should detect activity when mixed with passive events" + # Only passive instructions - should not detect activity + message = "4.sync,3.123;4.size,4.1280,4.1024;3.nop;" + result = is_user_activity(message) + assert result is False, "Should not detect activity with only passive events" + + def test_malformed_messages_handled_gracefully(self): + """Test that malformed messages don't cause crashes.""" + malformed_messages = [ + "5.mouse", # Missing parameters and terminator (no comma after opcode) + None, # None input + 123, # Non-string input + ] + + for message in malformed_messages: + result = is_user_activity(message) + assert result is False, f"Should return False for malformed message: {message}" + + def test_truncated_activity_message_still_detected(self): + """A truncated mouse/key instruction is still user activity.""" + assert is_user_activity("5.mouse,3.100,3.200") is True + assert is_user_activity("3.key,2.65") is True + + def test_only_non_input_events_are_passive(self): + """Verify non-input protocol events do not reset the idle timeout.""" + passive_events = [ + "4.size,4.1280,4.1024;", + "4.size,4.1920,4.1080;", + "4.sync,3.456;", + "3.nop;", + ] + + for event in passive_events: + result = is_user_activity(event) + assert result is False, f"Event '{event}' should be passive" + + def test_input_events_are_active(self): + """Verify real user-input instructions are considered activity.""" + active_events = [ + "5.mouse,3.100,3.200,1.0;", # Mouse move + "5.mouse,2.50,2.50,1.1;", # Mouse click + "3.key,2.32,1.1;", # Key press + "3.key,2.32,1.0;", # Key release + ] + + for event in active_events: + result = is_user_activity(event) + assert result is True, f"Event '{event}' should be active" diff --git a/web/guac/consumers.py b/web/guac/consumers.py index 61706283cc1..99944ea9562 100644 --- a/web/guac/consumers.py +++ b/web/guac/consumers.py @@ -1,7 +1,8 @@ import asyncio import logging -import uuid +import re import urllib.parse +import uuid from xml.etree import ElementTree as ET from asgiref.sync import sync_to_async @@ -9,8 +10,11 @@ from guacamole.client import GuacamoleClient from lib.cuckoo.common.config import Config +from lib.cuckoo.common.guac_utils import is_user_activity from lib.cuckoo.core.database import Database +from .timeout_manager import SessionTimeoutManager + try: import libvirt LIBVIRT_AVAILABLE = True @@ -24,6 +28,7 @@ machinery_dsn = getattr(Config(machinery), machinery).get("dsn", "qemu:///system") TASK_POLL_INTERVAL = 10 +ACTIVE_GUAC_TASK_STATUSES = ("pending", "running") def _get_vnc_port(vm_label): @@ -68,6 +73,39 @@ def __init__(self, *args, **kwargs): self.monitor_task = None self.guac_token = None self.guac_task_id = None + self.is_closing = False + self.timeout_manager = None + self.timeout_task = None + self._disconnect_seen = False + self._close_sent = False + self._close_lock = asyncio.Lock() + + async def _delete_guac_session(self) -> None: + """Delete the current guac session from the DB and clear the token.""" + if not self.guac_token: + return + try: + db = Database() + await sync_to_async(db.delete_guac_session)(self.guac_token) + self.guac_token = None + except Exception as e: + logger.error("Failed to delete guac session %s: %s", self.guac_token, e) + + async def _close_websocket(self): + """Close the websocket at most once across all concurrent code paths.""" + async with self._close_lock: + if self._close_sent or self._disconnect_seen: + return + + self._close_sent = True + + try: + await self.close() + except RuntimeError as error: + if "Unexpected ASGI message 'websocket.close'" in str(error): + logger.debug("Suppressing duplicate websocket.close for session") + return + raise async def connect(self): """Validate session token, look up VNC server-side, connect to guacd.""" @@ -101,13 +139,13 @@ async def connect(self): self.guac_task_id = session_data["task_id"] vm_label = session_data["vm_label"] - # 3. Verify task is still running + # 3. Verify task can still host an interactive session task = await sync_to_async(db.view_task)(self.guac_task_id) - if not task or task.status != "running": + if not task or task.status not in ACTIVE_GUAC_TASK_STATUSES: logger.warning( - "WebSocket rejected: task %s is not running", self.guac_task_id + "WebSocket rejected: task %s is not active for guac", self.guac_task_id ) - await sync_to_async(db.delete_guac_session)(token) + await self._delete_guac_session() await self.close() return @@ -133,7 +171,6 @@ async def connect(self): query_string = self.scope.get("query_string", b"").decode() params = urllib.parse.parse_qs(query_string) # Sanitize recording name — only allow alphanumeric, dash, underscore - import re raw_recording = params.get("recording_name", ["task-recording"])[0] guacd_recording_name = re.sub(r"[^a-zA-Z0-9_-]", "", raw_recording) @@ -188,18 +225,37 @@ async def connect(self): self.guac_task_id, vm_label, ) + + # 7. Initialize timeout handling + try: + vm_ip = session_data.get("guest_ip") or guest_host + self.timeout_manager = SessionTimeoutManager( + vm_ip=vm_ip, + user="unknown_user", + session_id=self.guac_token, + task_id=str(self.guac_task_id), + ) + except Exception as e: + logger.error("Failed to initialize timeout manager: %s", e) + self.timeout_manager = None + + # 8. Start background tasks self.task = asyncio.create_task(self.read_guacd()) self.monitor_task = asyncio.create_task(self.monitor_task_status()) + if self.timeout_manager and self.timeout_manager.idle_timeout_seconds > 0: + self.timeout_task = asyncio.create_task(self.monitor_timeout()) else: logger.warning("Guacamole handshake failed.") - await self.close() + self.is_closing = True + await self._close_websocket() except Exception as e: logger.error("Error during Guacamole connect: %s", str(e)) - await self.close() + self.is_closing = True + await self._close_websocket() async def monitor_task_status(self): - """Periodically check if the CAPE task is still running. Disconnect if not.""" + """Periodically check if the CAPE task can still host the session.""" try: while True: await asyncio.sleep(TASK_POLL_INTERVAL) @@ -207,14 +263,13 @@ async def monitor_task_status(self): break db = Database() task = await sync_to_async(db.view_task)(self.guac_task_id) - if not task or task.status != "running": + if not task or task.status not in ACTIVE_GUAC_TASK_STATUSES: logger.info( "Task %s no longer running, disconnecting guac session", self.guac_task_id, ) - if self.guac_token: - await sync_to_async(db.delete_guac_session)(self.guac_token) - await self.close() + await self._delete_guac_session() + await self._close_websocket() break except asyncio.CancelledError: pass @@ -223,19 +278,16 @@ async def monitor_task_status(self): async def disconnect(self, code): """Clean up on WebSocket disconnect.""" - if self.monitor_task: - self.monitor_task.cancel() - try: - await self.monitor_task - except asyncio.CancelledError: - pass + self.is_closing = True + self._disconnect_seen = True - if self.task: - self.task.cancel() - try: - await self.task - except asyncio.CancelledError: - pass + if self.timeout_manager: + self.timeout_manager.set_inactive() + + tasks = [t for t in (self.monitor_task, self.task, self.timeout_task) if t] + for t in tasks: + t.cancel() + await asyncio.gather(*tasks, return_exceptions=True) if self.client: try: @@ -243,16 +295,14 @@ async def disconnect(self, code): except Exception as e: logger.error("Error closing guacamole client: %s", str(e)) - if self.guac_token: - try: - db = Database() - await sync_to_async(db.delete_guac_session)(self.guac_token) - except Exception: - pass + await self._delete_guac_session() async def receive(self, text_data=None, bytes_data=None): """Forward data from browser to guacd.""" if text_data and self.client: + if self.timeout_manager and is_user_activity(text_data): + self.timeout_manager.update_activity() + try: await sync_to_async(self.client.send)(text_data) except Exception as e: @@ -274,4 +324,60 @@ async def read_guacd(self): except Exception as e: logger.error("Exception in Guacamole message loop: %s", e) finally: - await self.close() + await self._close_websocket() + + async def monitor_timeout(self): + """Monitor session for idle timeout and handle cleanup when timeout occurs.""" + try: + while self.timeout_manager and self.timeout_manager.is_active and not self.is_closing: + await asyncio.sleep(self.timeout_manager.activity_check_interval) + + if not self.timeout_manager or not self.timeout_manager.is_active: + break + + if self.timeout_manager.is_timed_out(): + idle_time = self.timeout_manager.get_idle_time_ms() + logger.info( + "Session timeout detected for %s, idle for %sms (threshold: %ss)", + self.timeout_manager.session_id, + idle_time, + self.timeout_manager.idle_timeout_seconds, + ) + await self.handle_timeout() + break + else: + idle_time = self.timeout_manager.get_idle_time_ms() + logger.debug("Session %s idle for %sms", self.timeout_manager.session_id, idle_time) + + except asyncio.CancelledError: + logger.debug("Timeout monitor cancelled for session %s", getattr(self.timeout_manager, "session_id", "unknown")) + except Exception as e: + logger.error("Error in timeout monitor: %s", str(e)) + + async def handle_timeout(self): + """Handle session timeout by signalling analysis completion and closing the connection.""" + if not self.timeout_manager: + return + + try: + logger.info( + "Handling timeout for session %s, VM: %s", + self.timeout_manager.session_id, + self.timeout_manager.vm_ip, + ) + success = await self.timeout_manager.complete_analysis() + if success: + logger.info("Successfully signalled analysis complete for %s", self.timeout_manager.vm_ip) + else: + logger.warning("Failed to signal analysis complete for %s", self.timeout_manager.vm_ip) + + try: + await self.send(text_data="5.error,35.Session timed out due to inactivity,3.522;") + except Exception as e: + logger.warning("Could not send timeout message to client: %s", e) + + except Exception as e: + logger.error("Error handling session timeout: %s", e) + finally: + if not self.is_closing: + await self._close_websocket() diff --git a/web/guac/templates/guac/index.html b/web/guac/templates/guac/index.html index 0a36b731565..d6611191c36 100644 --- a/web/guac/templates/guac/index.html +++ b/web/guac/templates/guac/index.html @@ -6,7 +6,7 @@ - + diff --git a/web/guac/timeout_manager.py b/web/guac/timeout_manager.py new file mode 100644 index 00000000000..cd6bde08355 --- /dev/null +++ b/web/guac/timeout_manager.py @@ -0,0 +1,180 @@ +""" +Timeout management for Guacamole interactive analysis sessions. +Tracks idle time and signals the CAPE analyzer to finish when the session +has been idle for longer than the configured threshold. +""" +import asyncio +import hashlib +import ipaddress +import logging +import ntpath +import posixpath +import time +from typing import Optional + +from lib.cuckoo.common.config import Config + +try: + import aiohttp + + HAS_AIOHTTP = True +except ImportError: + aiohttp = None + HAS_AIOHTTP = False + +logger = logging.getLogger("guac-session") +web_cfg = Config("web") +REQUEST_TIMEOUT_SECONDS = 10 + + +async def _agent_get_json(vm_ip: str, path: str) -> dict: + """GET JSON from the guest agent at *vm_ip*.""" + url = f"http://{vm_ip}:8000{path}" + if HAS_AIOHTTP: + timeout = aiohttp.ClientTimeout(total=REQUEST_TIMEOUT_SECONDS) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(url) as resp: + resp.raise_for_status() + return await resp.json(content_type=None) + else: + import json + import urllib.request + + def _sync(): + with urllib.request.urlopen(url, timeout=REQUEST_TIMEOUT_SECONDS) as resp: + return json.loads(resp.read().decode("utf-8")) + + return await asyncio.to_thread(_sync) + + +async def _agent_post_form(vm_ip: str, path: str, data: dict) -> int: + """POST form data to the guest agent and return the HTTP status code.""" + url = f"http://{vm_ip}:8000{path}" + if HAS_AIOHTTP: + timeout = aiohttp.ClientTimeout(total=REQUEST_TIMEOUT_SECONDS) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post(url, data=data) as resp: + return resp.status + else: + import urllib.parse + import urllib.request + + def _sync(): + encoded = urllib.parse.urlencode(data).encode("utf-8") + req = urllib.request.Request(url, data=encoded, method="POST") + req.add_header("Content-Type", "application/x-www-form-urlencoded") + with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT_SECONDS) as resp: + return resp.getcode() + + return await asyncio.to_thread(_sync) + + +class SessionTimeoutManager: + """Tracks idle time for a Guacamole session and signals analysis completion.""" + + def __init__( + self, + vm_ip: str, + user: str, + session_id: str = "unknown", + task_id: Optional[str] = None, + ): + self.vm_ip = vm_ip or "unknown" + self.user = user or "unknown_user" + self.session_id = session_id or "unknown_session" + self.task_id = str(task_id) if task_id else None + self.last_activity = self._now_ms() + self.is_active = True + + try: + self.idle_timeout_seconds = max(int(getattr(web_cfg.guacamole, "idle_timeout_seconds", 0)), 0) + if self.idle_timeout_seconds > 0: + self.activity_check_interval = max(int(getattr(web_cfg.guacamole, "activity_check_interval", 30)), 1) + else: + self.activity_check_interval = None + except (AttributeError, TypeError, ValueError): + self.idle_timeout_seconds = 0 + self.activity_check_interval = None + + if self.idle_timeout_seconds > 0: + logger.info( + "Timeout manager created: %s@%s (task=%s, %sms timeout)", + self.user, + self.vm_ip, + self.task_id, + self.idle_timeout_seconds, + ) + else: + logger.info("Timeout manager created with idle timeout disabled for %s@%s", self.user, self.vm_ip) + + @staticmethod + def _now_ms() -> int: + return int(time.monotonic() * 1000) + + def update_activity(self) -> None: + self.last_activity = self._now_ms() + + def get_idle_time_ms(self) -> int: + return self._now_ms() - self.last_activity + + def is_timed_out(self) -> bool: + return self.idle_timeout_seconds > 0 and self.get_idle_time_ms() > (self.idle_timeout_seconds * 1000) + + def set_inactive(self) -> None: + self.is_active = False + + async def complete_analysis(self) -> bool: + """Create the signal folder on the guest to end the analysis. + This is the same mechanism used by the "End Session" button in the web UI + (see ``web/apiv2/views.py :: tasks_status``). Returns True on success. + """ + if not self.vm_ip or self.vm_ip == "unknown": + logger.error("No valid VM IP for session %s — cannot signal completion", self.session_id) + return False + try: + ipaddress.ip_address(self.vm_ip) + except ValueError: + logger.error("Invalid VM IP address %r for session %s — cannot signal completion", self.vm_ip, self.session_id) + return False + if not self.task_id: + logger.error("No task ID for session %s — cannot signal completion", self.session_id) + return False + try: + guest_env, guest_system = await asyncio.gather( + _agent_get_json(self.vm_ip, "/environ"), + _agent_get_json(self.vm_ip, "/system"), + ) + completion_folder = hashlib.md5(f"cape-{self.task_id}".encode()).hexdigest() + dest = self._build_folder_path(guest_env, guest_system, completion_folder) + logger.info( + "Creating completion folder for task %s on %s: %s", + self.task_id, + self.vm_ip, + dest, + ) + status_code = await _agent_post_form(self.vm_ip, "/mkdir", {"dirpath": dest}) + if status_code == 200: + logger.info("Completion folder created for task %s on %s (HTTP %s)", self.task_id, self.vm_ip, status_code) + return True + logger.warning( + "Completion folder request returned HTTP %s for task %s on %s", + status_code, + self.task_id, + self.vm_ip, + ) + return False + except Exception as exc: + logger.error("Failed to signal completion for task %s on %s: %s", self.task_id, self.vm_ip, exc) + return False + + @staticmethod + def _build_folder_path(guest_env: dict, guest_system: dict, folder_name: str) -> str: + environ = guest_env.get("environ", {}) + system_name = str(guest_system.get("system", "")).lower() + + if system_name == "windows": + temp = environ.get("TMP", "C:\\Temp") + return ntpath.join(temp, folder_name) + + temp = environ.get("TMP", "/tmp") + return posixpath.join(temp, folder_name) diff --git a/web/static/js/guac-main.js b/web/static/js/guac-main.js index e0bfa3d7792..68c8087d1f7 100644 --- a/web/static/js/guac-main.js +++ b/web/static/js/guac-main.js @@ -1,29 +1,74 @@ -function GuacMe(element, session_id, recording_name) { - "use strict"; +"use strict"; + +const KEYSYM = { + SHIFT: 0xFFE1, + CTRL: 0xFFE3, + INSERT: 0xFF63, + V_UPPER: 0x0056, + V_LOWER: 0x0076, +}; + +const PASTE_COMPONENT_KEYS = new Set([ + KEYSYM.SHIFT, KEYSYM.CTRL, KEYSYM.INSERT, + KEYSYM.V_UPPER, KEYSYM.V_LOWER, +]); + +const PASTE_DELAY_MS = 50; + +const NON_FATAL_STATUS_CODES = new Set([0, 256]); + +class GuacSession { + constructor(element, config) { + this.config = config; + this.client = null; + this.tunnel = null; + this.display = null; + this.keyboard = null; + this.connected = false; + this.ctrl = false; + this.shift = false; + this.dialogContainer = $(element).find('.guaconsole')[0]; + + this._init(); + } + + _buildWsUrl() { + return location.origin.replace(/^http(s?):/, (match, p1) => + p1 ? 'wss:' : 'ws:' + ); + } + + _isPasteShortcut(keysym) { + return (this.ctrl && this.shift && keysym === KEYSYM.V_UPPER) + || (this.ctrl && keysym === KEYSYM.V_LOWER) + || (this.shift && keysym === KEYSYM.INSERT); + } + + _init() { + const wsUrl = this._buildWsUrl(); + this.tunnel = new Guacamole.WebSocketTunnel( + wsUrl + '/guac/websocket-tunnel/' + this.config.session_id + ); + this.client = new Guacamole.Client(this.tunnel); - var terminal_connected = false; - var terminal_client; - var terminal_element; - var dialog_container; + this.connect(); - var init = function() { - dialog_container = $(element).find('.guaconsole')[0]; + this.display = this.client.getDisplay().getElement(); + $('#terminal').append(this.display); - var terminal_ws_url = location.origin.replace(/^http(s?):/, function(match, p1) { - return (p1 ? 'wss:' : 'ws:'); - }); + this._setupScaling(); - terminal_client = new Guacamole.Client( - new Guacamole.WebSocketTunnel(terminal_ws_url + '/guac/websocket-tunnel/' + session_id) - ); - terminal_connect(recording_name); + window.onunload = () => this.disconnect(); - terminal_element = terminal_client.getDisplay().getElement(); - $('#terminal').append(terminal_element); + this._setupMouse(); + this._setupKeyboard(); + this._setupClipboard(); + this._setupErrorHandler(); + } - /* Scale display to fit the browser window. */ - var scaleDisplay = function() { - var display = terminal_client.getDisplay(); + _setupScaling() { + const scaleDisplay = () => { + var display = this.client.getDisplay(); var displayWidth = display.getWidth(); var displayHeight = display.getHeight(); if (!displayWidth || !displayHeight) return; @@ -40,137 +85,140 @@ function GuacMe(element, session_id, recording_name) { display.scale(scale); }; - /* Re-scale when the display size changes (initial connect). */ - terminal_client.getDisplay().onresize = function() { + this.client.getDisplay().onresize = function() { scaleDisplay(); }; - /* Re-scale on browser window resize (debounced). */ var resizeTimeout; window.addEventListener('resize', function() { clearTimeout(resizeTimeout); resizeTimeout = setTimeout(scaleDisplay, 100); }); + } - /* Disconnect on tab close. */ - window.onunload = function() { - terminal_client.disconnect(); - }; - - /* Mouse handling */ - var mouse = new Guacamole.Mouse(terminal_element); - - mouse.onmousedown = - mouse.onmouseup = - mouse.onmousemove = function(mouseState) { - terminal_client.sendMouseState(mouseState, true); - }; - - var keyboard = new Guacamole.Keyboard(terminal_element); - var ctrl, shift = false; - - keyboard.onkeydown = function (keysym) { - var cancel_event = true; + _setupMouse() { + const mouse = new Guacamole.Mouse(this.display); + const sendState = (state) => this.client.sendMouseState(state, true); + mouse.onmousedown = sendState; + mouse.onmouseup = sendState; + mouse.onmousemove = sendState; + } - if (keysym == 0xFFE1 || keysym == 0xFFE3 || keysym == 0xFF63 - || keysym == 0x0056 || keysym == 0x0076) { - cancel_event = false; - } + _setupKeyboard() { + this.keyboard = new Guacamole.Keyboard(this.display); - if (keysym == 0xFFE1) { shift = true; } - else if (keysym == 0xFFE3) { ctrl = true; } + this.keyboard.onkeydown = (keysym) => { + if (keysym === KEYSYM.SHIFT) this.shift = true; + else if (keysym === KEYSYM.CTRL) this.ctrl = true; - if ((ctrl && shift && keysym == 0x0056) - || (ctrl && keysym == 0x0076) - || (shift && keysym == 0xFF63)) { - window.setTimeout(function() { - terminal_client.sendKeyEvent(1, keysym); - }, 50); + if (this._isPasteShortcut(keysym)) { + setTimeout(() => this.client.sendKeyEvent(1, keysym), PASTE_DELAY_MS); } else { - terminal_client.sendKeyEvent(1, keysym); + this.client.sendKeyEvent(1, keysym); } - return !cancel_event; + return !PASTE_COMPONENT_KEYS.has(keysym); }; - keyboard.onkeyup = function (keysym) { - if (keysym == 0xFFE1) { shift = false; } - else if (keysym == 0xFFE3) { ctrl = false; } + this.keyboard.onkeyup = (keysym) => { + if (keysym === KEYSYM.SHIFT) this.shift = false; + else if (keysym === KEYSYM.CTRL) this.ctrl = false; - if ((ctrl && shift && keysym == 0x0056) - || (ctrl && keysym == 0x0076) - || (shift && keysym == 0xFF63)) { - window.setTimeout(function() { - terminal_client.sendKeyEvent(0, keysym); - }, 50); + if (this._isPasteShortcut(keysym)) { + setTimeout(() => this.client.sendKeyEvent(0, keysym), PASTE_DELAY_MS); } else { - terminal_client.sendKeyEvent(0, keysym); + this.client.sendKeyEvent(0, keysym); } }; - $(terminal_element) + $(this.display) .attr('tabindex', 1) .hover( - function() { - var x = window.scrollX, y = window.scrollY; + function () { + const x = window.scrollX, y = window.scrollY; $(this).focus(); window.scrollTo(x, y); }, - function() { $(this).blur(); } + function () { $(this).blur(); } ) - .blur(function() { keyboard.reset(); }); - - $(document).on('paste', function(e) { - var text = e.originalEvent.clipboardData.getData('text/plain'); - if ($(terminal_element).is(":focus")) { - terminal_client.setClipboard(text); + .blur(() => this.keyboard.reset()); + } + + _setupClipboard() { + $(document).on('paste', (e) => { + const text = e.originalEvent.clipboardData.getData('text/plain'); + if ($(this.display).is(':focus')) { + this.client.setClipboard(text); } }); + } + + _showError(title, detail) { + const dialog = $('#launch_error'); + dialog.find('.message').html(title); + dialog.find('.error_msg').html(detail); + dialog.dialog({ dialogClass: 'no-close' }); + dialog.dialog(this.dialogContainer); + } + + _setupErrorHandler() { + const handler = (error) => { + console.log(`guac error ${error.code}: ${error.message}`); + + if (NON_FATAL_STATUS_CODES.has(error.code)) { + return; + } - terminal_client.onerror = function(guac_error) { - terminal_client.disconnect(); - - var dialog = $('#launch_error'); - var dialog_message = - "Could not connect to guest vm. " + - "The client detected an unexpected error. " + - "The server's error message was:"; - var error_message = guac_error.message; + this.disconnect(); - if (guac_error.message.toLowerCase().startsWith('aborted')) { - dialog_message = "Remote session terminated."; - error_message = "Close tab."; + if (error.code === 514) { + this._showError("Connection error", "Server timeout."); + } else if (error.code === 515) { + this._showError("Session ended", "Backing VM has disconnected."); + } else if (error.code === 522) { + this._showError("Session ended", "Session timed out due to inactivity."); + } else { + const _msg = `An unexpected error occurred: ${error.message}`; + this._showError("Connection error", _msg); } - dialog.find('.message').html(dialog_message); - dialog.find('.error_msg').html(error_message); - dialog.dialog({dialogClass: 'no-close'}); - dialog.dialog(dialog_container); }; - }; - var terminal_connect = function(recording_name) { - if (terminal_connected) { - terminal_client.disconnect(); - terminal_connected = false; + this.tunnel.onerror = handler; + this.client.onerror = handler; + } + + connect() { + if (this.connected) { + this.client.disconnect(); + this.connected = false; } try { - terminal_client.connect($.param({ - 'recording_name': recording_name, + this.client.connect($.param({ + 'recording_name': this.config.recording_name, })); - terminal_connected = true; + this.connected = true; } catch (e) { console.warn(e); - terminal_connected = false; + this.connected = false; throw e; } - }; + } - init(); + disconnect() { + if (this.connected) { + this.client.disconnect(); + this.connected = false; + } + } } -function stopTask(taskId) { - var apiUrl = location.origin + "/apiv2/tasks/status/" + taskId + "/"; +function GuacMe(element, session_id, recording_name) { + return new GuacSession(element, { session_id, recording_name }); +} + +function stopTask(taskId, onSuccess, onError) { + const apiUrl = location.origin + "/apiv2/tasks/status/" + taskId + "/"; fetch(apiUrl, { method: 'POST', @@ -178,6 +226,12 @@ function stopTask(taskId) { body: JSON.stringify({ status: 'finish' }), }) .then(response => response.json()) - .then(data => console.log('Response:', data)) - .catch(error => console.error('Error:', error)); + .then(data => { + console.log('Response:', data); + if (onSuccess) onSuccess(data); + }) + .catch(error => { + console.error('Error:', error); + if (onError) onError(error); + }); } diff --git a/web/static/js/guacamole-1.4.0-all.min.js b/web/static/js/guacamole-1.4.0-all.min.js deleted file mode 100644 index 6467f8e2a82..00000000000 --- a/web/static/js/guacamole-1.4.0-all.min.js +++ /dev/null @@ -1,155 +0,0 @@ -'use strict';var Guacamole=Guacamole||{};Guacamole.ArrayBufferReader=function(b){var a=this;b.onblob=function(b){b=window.atob(b);for(var c=new ArrayBuffer(b.length),e=new Uint8Array(c),d=0;d=a.length)return a[0];var b=0;a.forEach(function(a){b+=a.length});var c=0,d=new f(b);a.forEach(function(a){d.set(a,c);c+=a.length});return d};b.ondata=function(a){m.push(new f(new f(a)));if(a=n(m)){var b= -Number.MAX_VALUE,p=a.length,g=Math.floor(.02*d.rate);for(g=Math.max(d.channels*g,d.channels*(Math.floor(a.length/d.channels)-g));gD?t(D)*t(D/3):0;r+=v*D}d[u]=r*m;q+=c.channels}return d},u=function(a){v=e.createScriptProcessor(2048, -c.channels,c.channels);v.connect(e.destination);v.onaudioprocess=function(a){f.sendData(q(a.inputBuffer).buffer)};p=e.createMediaStreamSource(a);p.connect(v);"suspended"===e.state&&e.resume();l=a},w=function(){f.sendEnd();if(d.onerror)d.onerror()};f.onack=function(a){if(a.code!==Guacamole.Status.Code.SUCCESS||l){p&&p.disconnect();v&&v.disconnect();if(l)for(var b=l.getTracks(),c=0;c=b.size){if(a.oncomplete)a.oncomplete(b)}else{a:{var e=c;var n=c+d.blobLength,g=(b.slice||b.webkitSlice||b.mozSlice).bind(b),l=n-e;if(l!==n){var p=g(e,l);if(p.size===l){e=p;break a}}e=g(e,n)}c+=d.blobLength;f.readAsArrayBuffer(e)}};f.onload=function(){d.sendData(f.result);d.onack=function(e){if(a.onack)a.onack(e); -if(!e.isError()){if(a.onprogress)a.onprogress(b,c-d.blobLength);k()}}};f.onerror=function(){if(a.onerror)a.onerror(b,c,f.error)};k()};this.sendEnd=function(){d.sendEnd()};this.oncomplete=this.onprogress=this.onerror=this.onack=null};Guacamole=Guacamole||{}; -Guacamole.Client=function(b){function a(h){if(h!=e&&(e=h,c.onstatechange))c.onstatechange(e)}function d(){return 3==e||2==e}var c=this,e=0,f=0,k=null,m={0:"butt",1:"round",2:"square"},n={0:"bevel",1:"miter",2:"round"},g=new Guacamole.Display,l={},p={},v=[],t=[],q=[],u=new Guacamole.IntegerPool,w=[];this.exportState=function(h){var a={currentState:e,currentTimestamp:f,layers:{}},b={},c;for(c in l)b[c]=l[c];g.flush(function(){for(var c in b){var d=parseInt(c),e=b[c],x=e.toCanvas(),q={width:e.width, -height:e.height};e.width&&e.height&&(q.url=x.toDataURL("image/png"));if(0a&&delete l[a]},distort:function(a){var b=parseInt(a[0]),c=parseFloat(a[1]),h=parseFloat(a[2]),d=parseFloat(a[3]),e=parseFloat(a[4]),q=parseFloat(a[5]);a=parseFloat(a[6]);0<=b&&(b=r(b),g.distort(b,c,h,d,e,q,a))},error:function(a){var b=a[0];a=parseInt(a[1]);if(c.onerror)c.onerror(new Guacamole.Status(a,b));c.disconnect()},end:function(a){a= -parseInt(a[0]);var b=t[a];if(b){if(b.onend)b.onend();delete t[a]}},file:function(a){var b=parseInt(a[0]),h=a[1];a=a[2];c.onfile?(b=t[b]=new Guacamole.InputStream(c,b),c.onfile(b,h,a)):c.sendAck(b,"File transfer unsupported",256)},filesystem:function(a){var b=parseInt(a[0]);a=a[1];c.onfilesystem&&(b=q[b]=new Guacamole.Object(c,b),c.onfilesystem(b,a))},identity:function(a){a=r(parseInt(a[0]));g.setTransform(a,1,0,0,1,0,0)},img:function(a){var b=parseInt(a[0]),h=parseInt(a[1]),d=r(parseInt(a[2])),e= -a[3],q=parseInt(a[4]);a=parseInt(a[5]);b=t[b]=new Guacamole.InputStream(c,b);g.setChannelMask(d,h);g.drawStream(d,q,a,b,e)},jpeg:function(a){var b=parseInt(a[0]),c=r(parseInt(a[1])),d=parseInt(a[2]),h=parseInt(a[3]);a=a[4];g.setChannelMask(c,b);g.draw(c,d,h,"data:image/jpeg;base64,"+a)},lfill:function(a){var b=parseInt(a[0]),c=r(parseInt(a[1]));a=r(parseInt(a[2]));g.setChannelMask(c,b);g.fillLayer(c,a)},line:function(a){var b=r(parseInt(a[0])),c=parseInt(a[1]);a=parseInt(a[2]);g.lineTo(b,c,a)},lstroke:function(a){var b= -parseInt(a[0]),c=r(parseInt(a[1]));a=r(parseInt(a[2]));g.setChannelMask(c,b);g.strokeLayer(c,a)},mouse:function(a){var b=parseInt(a[0]);a=parseInt(a[1]);g.showCursor(!0);g.moveCursor(b,a)},move:function(a){var b=parseInt(a[0]),c=parseInt(a[1]),d=parseInt(a[2]),e=parseInt(a[3]);a=parseInt(a[4]);0=a||127<=a&&159>=a?65280|a:0<=a&&255>=a?a:256<=a&&1114111>=a?16777216|a:null}function c(){var a=F();if(!a)return!1;do{var b=a;a=F()}while(null!==a);a:{for(var c in e.pressed)if(!r[c]){a=!1;break a}a= -!0}a&&e.reset();return b.defaultPrevented}var e=this,f="_GUAC_KEYBOARD_HANDLED_BY_"+Guacamole.Keyboard._nextID++;this.onkeyup=this.onkeydown=null;var k=!1,m=!1,n=!1;navigator&&navigator.platform&&(navigator.platform.match(/ipad|iphone|ipod/i)?k=!0:navigator.platform.match(/^mac/i)&&(n=m=!0));var g=function(a){var b=this;this.keyCode=a?a.which||a.keyCode:0;this.keyIdentifier=a&&a.keyIdentifier;this.key=a&&a.key;var c=a?"location"in a?a.location:"keyLocation"in a?a.keyLocation:0:0;this.location=c;this.modifiers= -a?Guacamole.Keyboard.ModifierState.fromKeyboardEvent(a):new Guacamole.Keyboard.ModifierState;this.timestamp=(new Date).getTime();this.defaultPrevented=!1;this.keysym=null;this.reliable=!1;this.getAge=function(){return(new Date).getTime()-b.timestamp}},l=function(b){g.call(this,b);this.keysym=a(this.key,this.location)||A(q[this.keyCode],this.location);this.keyupReliable=!k;if(b=this.keysym)b=this.keysym,b=!(0<=b&&255>=b||16777216===(b&4294901760));b&&(this.reliable=!0);if(b=!this.keysym){b=this.keyCode; -var c=this.keyIdentifier;if(c){var d=c.indexOf("U+");-1===d?b=!0:(c=parseInt(c.substring(d+2),16),b=b!==c||65<=b&&90>=b||48<=b&&57>=b?!0:!1)}else b=!1}b&&(this.keysym=a(this.keyIdentifier,this.location,this.modifiers.shift));this.modifiers.meta&&65511!==this.keysym&&65512!==this.keysym?this.keyupReliable=!1:65509===this.keysym&&n&&(this.keyupReliable=!1);b=!this.modifiers.ctrl&&!m;if(!this.modifiers.alt&&this.modifiers.ctrl||b&&this.modifiers.alt||this.modifiers.meta||this.modifiers.hyper)this.reliable= -!0;z[this.keyCode]=this.keysym};l.prototype=new g;var p=function(a){g.call(this,a);this.keysym=d(this.keyCode);this.reliable=!0};p.prototype=new g;var v=function(b){g.call(this,b);this.keysym=A(q[this.keyCode],this.location)||a(this.key,this.location);e.pressed[this.keysym]||(this.keysym=z[this.keyCode]||this.keysym);this.reliable=!0};v.prototype=new g;var t=[],q={8:[65288],9:[65289],12:[65291,65291,65291,65461],13:[65293],16:[65505,65505,65506],17:[65507,65507,65508],18:[65513,65513,65027],19:[65299], -20:[65509],27:[65307],32:[32],33:[65365,65365,65365,65465],34:[65366,65366,65366,65459],35:[65367,65367,65367,65457],36:[65360,65360,65360,65463],37:[65361,65361,65361,65460],38:[65362,65362,65362,65464],39:[65363,65363,65363,65462],40:[65364,65364,65364,65458],45:[65379,65379,65379,65456],46:[65535,65535,65535,65454],91:[65511],92:[65512],93:[65383],96:[65456],97:[65457],98:[65458],99:[65459],100:[65460],101:[65461],102:[65462],103:[65463],104:[65464],105:[65465],106:[65450],107:[65451],109:[65453], -110:[65454],111:[65455],112:[65470],113:[65471],114:[65472],115:[65473],116:[65474],117:[65475],118:[65476],119:[65477],120:[65478],121:[65479],122:[65480],123:[65481],144:[65407],145:[65300],225:[65027]},u={Again:[65382],AllCandidates:[65341],Alphanumeric:[65328],Alt:[65513,65513,65027],Attn:[64782],AltGraph:[65027],ArrowDown:[65364],ArrowLeft:[65361],ArrowRight:[65363],ArrowUp:[65362],Backspace:[65288],CapsLock:[65509],Cancel:[65385],Clear:[65291],Convert:[65313],Copy:[64789],Crsel:[64796],CrSel:[64796], -CodeInput:[65335],Compose:[65312],Control:[65507,65507,65508],ContextMenu:[65383],Delete:[65535],Down:[65364],End:[65367],Enter:[65293],EraseEof:[64774],Escape:[65307],Execute:[65378],Exsel:[64797],ExSel:[64797],F1:[65470],F2:[65471],F3:[65472],F4:[65473],F5:[65474],F6:[65475],F7:[65476],F8:[65477],F9:[65478],F10:[65479],F11:[65480],F12:[65481],F13:[65482],F14:[65483],F15:[65484],F16:[65485],F17:[65486],F18:[65487],F19:[65488],F20:[65489],F21:[65490],F22:[65491],F23:[65492],F24:[65493],Find:[65384], -GroupFirst:[65036],GroupLast:[65038],GroupNext:[65032],GroupPrevious:[65034],FullWidth:null,HalfWidth:null,HangulMode:[65329],Hankaku:[65321],HanjaMode:[65332],Help:[65386],Hiragana:[65317],HiraganaKatakana:[65319],Home:[65360],Hyper:[65517,65517,65518],Insert:[65379],JapaneseHiragana:[65317],JapaneseKatakana:[65318],JapaneseRomaji:[65316],JunjaMode:[65336],KanaMode:[65325],KanjiMode:[65313],Katakana:[65318],Left:[65361],Meta:[65511,65511,65512],ModeChange:[65406],NumLock:[65407],PageDown:[65366], -PageUp:[65365],Pause:[65299],Play:[64790],PreviousCandidate:[65342],PrintScreen:[65377],Redo:[65382],Right:[65363],RomanCharacters:null,Scroll:[65300],Select:[65376],Separator:[65452],Shift:[65505,65505,65506],SingleCandidate:[65340],Super:[65515,65515,65516],Tab:[65289],UIKeyInputDownArrow:[65364],UIKeyInputEscape:[65307],UIKeyInputLeftArrow:[65361],UIKeyInputRightArrow:[65363],UIKeyInputUpArrow:[65362],Up:[65362],Undo:[65381],Win:[65511,65511,65512],Zenkaku:[65320],ZenkakuHankaku:[65322]},w={65027:!0, -65505:!0,65506:!0,65507:!0,65508:!0,65509:!0,65511:!0,65512:!0,65513:!0,65514:!0,65515:!0,65516:!0};this.modifiers=new Guacamole.Keyboard.ModifierState;this.pressed={};var r={},y={},z={},h=null,x=null,A=function(a,b){return a?a[b]||a[0]:null};this.press=function(a){if(null!==a){if(!e.pressed[a]&&(e.pressed[a]=!0,e.onkeydown)){var b=e.onkeydown(a);y[a]=b;window.clearTimeout(h);window.clearInterval(x);w[a]||(h=window.setTimeout(function(){x=window.setInterval(function(){e.onkeyup(a);e.onkeydown(a)}, -50)},500));return b}return y[a]||!1}};this.release=function(a){if(e.pressed[a]&&(delete e.pressed[a],delete r[a],window.clearTimeout(h),window.clearInterval(x),null!==a&&e.onkeyup))e.onkeyup(a)};this.type=function(a){for(var b=0;b=b||97<=b&&122>=b)&&(255>=b||16777216===(b&4278190080))&&(e.release(65507),e.release(65508),e.release(65513),e.release(65514));var d=!e.press(b);z[a.keyCode]=b;a.keyupReliable||e.release(b);for(b=0;bc.width?a:c.width,b>c.height?b:c.height)}var c=this,e=document.createElement("canvas"),f=e.getContext("2d");f.save();var k=!0,m=!0,n=0,g={1:"destination-in",2:"destination-out",4:"source-in",6:"source-atop",8:"source-out",9:"destination-atop",10:"xor",11:"destination-over",12:"copy",14:"source-over",15:"lighter"},l=function(a,b){a=a||0;b=b||0;var d=64*Math.ceil(a/64),p=64*Math.ceil(b/64);if(e.width!==d||e.height!==p){var g=null; -k||0===e.width||0===e.height||(g=document.createElement("canvas"),g.width=Math.min(c.width,a),g.height=Math.min(c.height,b),g.getContext("2d").drawImage(e,0,0,g.width,g.height,0,0,g.width,g.height));var l=f.globalCompositeOperation;e.width=d;e.height=p;g&&f.drawImage(g,0,0,g.width,g.height,0,0,g.width,g.height);f.globalCompositeOperation=l;n=0;f.save()}else c.reset();c.width=a;c.height=b};this.autosize=!1;this.width=b;this.height=a;this.getCanvas=function(){return e};this.toCanvas=function(){var a= -document.createElement("canvas");a.width=c.width;a.height=c.height;a.getContext("2d").drawImage(c.getCanvas(),0,0);return a};this.resize=function(a,b){a===c.width&&b===c.height||l(a,b)};this.drawImage=function(a,b,e){c.autosize&&d(a,b,e.width,e.height);f.drawImage(e,a,b);k=!1};this.transfer=function(a,b,e,g,l,n,m,y){var p=a.getCanvas();if(!(b>=p.width||e>=p.height)&&(b+g>p.width&&(g=p.width-b),e+l>p.height&&(l=p.height-e),0!==g&&0!==l)){c.autosize&&d(n,m,g,l);a=a.getCanvas().getContext("2d").getImageData(b, -e,g,l);b=f.getImageData(n,m,g,l);for(e=0;e=p.width||e>=p.height||(b+g>p.width&&(g=p.width-b),e+l>p.height&&(l=p.height-e),0!==g&&0!==l&&(c.autosize&&d(n,m,g,l),a=a.getCanvas().getContext("2d").getImageData(b, -e,g,l),f.putImageData(a,n,m),k=!1))};this.copy=function(a,b,e,g,l,n,m){a=a.getCanvas();b>=a.width||e>=a.height||(b+g>a.width&&(g=a.width-b),e+l>a.height&&(l=a.height-e),0!==g&&0!==l&&(c.autosize&&d(n,m,g,l),f.drawImage(a,b,e,g,l,n,m,g,l),k=!1))};this.moveTo=function(a,b){m&&(f.beginPath(),m=!1);c.autosize&&d(a,b,0,0);f.moveTo(a,b)};this.lineTo=function(a,b){m&&(f.beginPath(),m=!1);c.autosize&&d(a,b,0,0);f.lineTo(a,b)};this.arc=function(a,b,e,g,l,n){m&&(f.beginPath(),m=!1);c.autosize&&d(a,b,0,0);f.arc(a, -b,e,g,l,n)};this.curveTo=function(a,b,e,g,l,n){m&&(f.beginPath(),m=!1);c.autosize&&d(l,n,0,0);f.bezierCurveTo(a,b,e,g,l,n)};this.close=function(){f.closePath();m=!0};this.rect=function(a,b,e,g){m&&(f.beginPath(),m=!1);c.autosize&&d(a,b,e,g);f.rect(a,b,e,g)};this.clip=function(){f.clip();m=!0};this.strokeColor=function(a,b,c,d,e,g,l){f.lineCap=a;f.lineJoin=b;f.lineWidth=c;f.strokeStyle="rgba("+d+","+e+","+g+","+l/255+")";f.stroke();k=!1;m=!0};this.fillColor=function(a,b,c,d){f.fillStyle="rgba("+a+ -","+b+","+c+","+d/255+")";f.fill();k=!1;m=!0};this.strokeLayer=function(a,b,c,d){f.lineCap=a;f.lineJoin=b;f.lineWidth=c;f.strokeStyle=f.createPattern(d.getCanvas(),"repeat");f.stroke();k=!1;m=!0};this.fillLayer=function(a){f.fillStyle=f.createPattern(a.getCanvas(),"repeat");f.fill();k=!1;m=!0};this.push=function(){f.save();n++};this.pop=function(){0=c.scrollThreshold){do c.click(Guacamole.Mouse.State.Buttons.DOWN),k-=c.scrollThreshold;while(k>=c.scrollThreshold);k= -0}Guacamole.Event.DOMEvent.cancelEvent(a)}Guacamole.Mouse.Event.Target.call(this);var c=this;this.touchMouseThreshold=3;this.scrollThreshold=53;this.PIXELS_PER_LINE=18;this.PIXELS_PER_PAGE=16*this.PIXELS_PER_LINE;var e=[Guacamole.Mouse.State.Buttons.LEFT,Guacamole.Mouse.State.Buttons.MIDDLE,Guacamole.Mouse.State.Buttons.RIGHT],f=0,k=0;b.addEventListener("contextmenu",function(a){Guacamole.Event.DOMEvent.cancelEvent(a)},!1);b.addEventListener("mousemove",function(a){f?(Guacamole.Event.DOMEvent.cancelEvent(a), -f--):c.move(Guacamole.Position.fromClientPosition(b,a.clientX,a.clientY),a)},!1);b.addEventListener("mousedown",function(a){if(f)Guacamole.Event.DOMEvent.cancelEvent(a);else{var b=e[a.button];b&&c.press(b,a)}},!1);b.addEventListener("mouseup",function(a){if(f)Guacamole.Event.DOMEvent.cancelEvent(a);else{var b=e[a.button];b&&c.release(b,a)}},!1);b.addEventListener("mouseout",function(a){a||(a=window.event);for(var d=a.relatedTarget||a.toElement;d;){if(d===b)return;d=d.parentNode}c.reset(a);c.out(a)}, -!1);b.addEventListener("selectstart",function(a){Guacamole.Event.DOMEvent.cancelEvent(a)},!1);b.addEventListener("touchmove",a,!1);b.addEventListener("touchstart",a,!1);b.addEventListener("touchend",a,!1);b.addEventListener("DOMMouseScroll",d,!1);b.addEventListener("mousewheel",d,!1);b.addEventListener("wheel",d,!1);var m=function(){var a=document.createElement("div");if(!("cursor"in a.style))return!1;try{a.style.cursor="url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX///+nxBvIAAAACklEQVQI12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg\x3d\x3d) 0 0, auto"}catch(g){return!1}return/\burl\([^()]*\)\s+0\s+0\b/.test(a.style.cursor|| -"")}();this.setCursor=function(a,c,d){return m?(a=a.toDataURL("image/png"),b.style.cursor="url("+a+") "+c+" "+d+", auto",!0):!1}};Guacamole.Mouse.State=function(b){var a=function(a,b,e,f,k,m,n){return{x:a,y:b,left:e,middle:f,right:k,up:m,down:n}};b=1=a.scrollThreshold&&(a.click(0=e.clickMoveThreshold}function d(a){a=a.touches[0];f=!0;k=a.clientX;m=a.clientY}function c(){window.clearTimeout(n);window.clearTimeout(g);f=!1}Guacamole.Mouse.Event.Target.call(this);var e=this,f=!1,k=null,m=null,n=null,g=null;this.scrollThreshold=20*(window.devicePixelRatio||1);this.clickTimingThreshold=250;this.clickMoveThreshold=16*(window.devicePixelRatio|| -1);this.longPressThreshold=500;b.addEventListener("touchend",function(d){if(f)if(0!==d.touches.length||1!==d.changedTouches.length)c();else if(window.clearTimeout(g),e.release(Guacamole.Mouse.State.Buttons.LEFT,d),!a(d)&&(d.preventDefault(),!e.currentState.left)){var l=d.changedTouches[0];e.move(Guacamole.Position.fromClientPosition(b,l.clientX,l.clientY));e.press(Guacamole.Mouse.State.Buttons.LEFT,d);n=window.setTimeout(function(){e.release(Guacamole.Mouse.State.Buttons.LEFT,d);c()},e.clickTimingThreshold)}}, -!1);b.addEventListener("touchstart",function(a){1!==a.touches.length?c():(a.preventDefault(),d(a),window.clearTimeout(n),g=window.setTimeout(function(){var d=a.touches[0];e.move(Guacamole.Position.fromClientPosition(b,d.clientX,d.clientY));e.click(Guacamole.Mouse.State.Buttons.RIGHT,a);c()},e.longPressThreshold))},!1);b.addEventListener("touchmove",function(d){if(f)if(a(d)&&window.clearTimeout(g),1!==d.touches.length)c();else if(e.currentState.left){d.preventDefault();var m=d.touches[0];e.move(Guacamole.Position.fromClientPosition(b, -m.clientX,m.clientY),d)}},!1)};Guacamole=Guacamole=Guacamole||{};Guacamole.Object=function(b,a){var d=this,c={};this.index=a;this.onbody=function(a,b,d){var e=c[d];if(e){var f=e.shift();0===e.length&&delete c[d];d=f}else d=null;d&&d(a,b)};this.onundefine=null;this.requestInputStream=function(a,f){if(f){var e=c[a];e||(e=[],c[a]=e);e.push(f)}b.requestObjectInputStream(d.index,a)};this.createOutputStream=function(a,c){return b.createObjectOutputStream(d.index,a,c)}};Guacamole.Object.ROOT_STREAM="/"; -Guacamole.Object.STREAM_INDEX_MIMETYPE="application/vnd.glyptodon.guacamole.stream-index+json";Guacamole=Guacamole||{}; -Guacamole.OnScreenKeyboard=function(b){var a=this,d={},c={},e=[],f=function(a,b){a.classList?a.classList.add(b):a.className+=" "+b},k=function(a,b){a.classList?a.classList.remove(b):a.className=a.className.replace(/([^ ]+)[ ]*/g,function(a,c){return c===b?"":a})},m=0,n=function(a,b,c,d){this.width=b;this.height=c;this.scale=function(e){a.style.width=b*e+"px";a.style.height=c*e+"px";d&&(a.style.lineHeight=c*e+"px",a.style.fontSize=e+"px")}},g=function(b){b=a.keys[b];if(!b)return null;for(var c=b.length- -1;0<=c;c--){var e=b[c];a:{var f=e.requires;for(var g=0;g=a?a:256<=a&&1114111>=a?16777216|a:null):a=null);this.keysym=a;this.modifier=b.modifier;this.requires=b.requires||[]};Guacamole=Guacamole||{};Guacamole.OutputStream=function(b,a){var d=this;this.index=a;this.onack=null;this.sendBlob=function(a){b.sendBlob(d.index,a)};this.sendEnd=function(){b.endStream(d.index)}}; -Guacamole=Guacamole||{}; -Guacamole.Parser=function(){var b=this,a="",d=[],c=-1,e=0;this.receive=function(f){4096=e&&(a=a.substring(e),c-=e,e=0);for(a+=f;c=e){f=a.substring(e,c);var k=a.substring(c,c+1);d.push(f);if(";"==k){f=d.shift();if(null!=b.oninstruction)b.oninstruction(f,d);d.length=0}else if(","!=k)throw Error("Illegal terminator.");e=c+1}f=a.indexOf(".",e);if(-1!=f){k=parseInt(a.substring(c+1,f));if(isNaN(k))throw Error("Non-numeric character in element length.");e=f+1;c=e+k}else{e=a.length; -break}}};this.oninstruction=null};Guacamole=Guacamole||{}; -Guacamole.Position=function(b){b=b||{};this.x=b.x||0;this.y=b.y||0;this.fromClientPosition=function(a,b,c){this.x=b-a.offsetLeft;this.y=c-a.offsetTop;for(a=a.offsetParent;a&&a!==document.body;)this.x-=a.offsetLeft-a.scrollLeft,this.y-=a.offsetTop-a.scrollTop,a=a.offsetParent;a&&(b=document.body.scrollTop||document.documentElement.scrollTop,this.x-=a.offsetLeft-(document.body.scrollLeft||document.documentElement.scrollLeft),this.y-=a.offsetTop-b)}}; -Guacamole.Position.fromClientPosition=function(b,a,d){var c=new Guacamole.Position;c.fromClientPosition(b,a,d);return c};Guacamole=Guacamole||{};Guacamole.RawAudioFormat=function(b){this.bytesPerSample=b.bytesPerSample;this.channels=b.channels;this.rate=b.rate}; -Guacamole.RawAudioFormat.parse=function(b){var a=null,d=1;if("audio/L8;"===b.substring(0,9)){b=b.substring(9);var c=1}else if("audio/L16;"===b.substring(0,10))b=b.substring(10),c=2;else return null;b=b.split(",");for(var e=0;ea?x(a,e-1,c):c>f&&ed.code||255=b){var c=0;var d=1}else if(2047>=b)c=192,d=2;else if(65535>=b)c=224,d=3;else if(2097151>=b)c=240,d=4;else{a(65533);return}var e=d;if(k+e>=f.length){var m=new Uint8Array(2*(k+e));m.set(f);f=m}k+=e;e=k-1;for(m=1;m>=6;f[e]=c|b}function d(b){for(var c=0;c -a.readyState)){try{var f=a.status}catch(H){f=200}d||200!==f||(d=g());if(3===a.readyState||4===a.readyState)if(e(),1===q&&(3!==a.readyState||c?4===a.readyState&&c&&clearInterval(c):c=setInterval(b,30)),0===a.status)l.disconnect();else if(200!==a.status)m(a);else{try{var t=a.responseText}catch(H){return}for(;h=k){f=t.substring(k,h);var r=t.substring(h,h+1);p.push(f);if(";"===r){f=p.shift();if(l.oninstruction)l.oninstruction(f,p);p.length=0}k=h+1}f=t.indexOf(".",k);if(-1!==f){r=parseInt(t.substring(h+ -1,f));if(0===r){c&&clearInterval(c);a.onreadystatechange=null;a.abort();d&&n(d);break}k=f+1;h=k+r}else{k=t.length;break}}}}}var c=null,d=null,f=0,h=-1,k=0,p=[];a.onreadystatechange=1===q?function(){3===a.readyState&&(f++,2<=f&&(q=0,a.onreadystatechange=b));b()}:b;b()}function g(){var a=new XMLHttpRequest;a.open("GET",v+l.uuid+":"+B++);a.setRequestHeader("Guacamole-Tunnel-Token",A);a.withCredentials=r;c(a,x);a.send(null);return a}var l=this,p=b+"?connect",v=b+"?read:",t=b+"?write:",q=1,u=!1,w="",r= -!!a,y=null,z=null,h=null,x=d||{},A=null;this.sendMessage=function(){function a(a){a=new String(a);return a.length+"."+a}if(l.isConnected()&&0!==arguments.length){for(var b=a(arguments[0]),c=1;c=a.length)return a[0];var b=0;a.forEach(function(a){b+=a.length});var c=0,d=new f(b);a.forEach(function(a){d.set(a,c);c+=a.length});return d};b.ondata=function(a){l.push(new f(new f(a)));if(a=n(l)){var b= +Number.MAX_VALUE,k=a.length,r=Math.floor(.02*d.rate);for(r=Math.max(d.channels*r,d.channels*(Math.floor(a.length/d.channels)-r));rx?q(x)*q(x/3):0;y+=r*x}d[w]=y*l;t+=c.channels}return d},x=function(a){r=e.createScriptProcessor(2048, +c.channels,c.channels);r.connect(e.destination);r.onaudioprocess=function(a){f.sendData(v(a.inputBuffer).buffer)};k=e.createMediaStreamSource(a);k.connect(r);"suspended"===e.state&&e.resume();g=a},t=function(){f.sendEnd();if(d.onerror)d.onerror()};f.onack=function(a){if(a.code!==Guacamole.Status.Code.SUCCESS||g){k&&k.disconnect();r&&r.disconnect();if(g)for(var b=g.getTracks(),c=0;c=b.size){if(a.oncomplete)a.oncomplete(b)}else{a:{var e=c;var h=c+d.blobLength,m=(b.slice||b.webkitSlice||b.mozSlice).bind(b),g=h-e;if(g!==h){var k=m(e,g);if(k.size===g){e=k;break a}}e=m(e,h)}c+=d.blobLength;f.readAsArrayBuffer(e)}};f.onload=function(){d.sendData(f.result);d.onack=function(e){if(a.onack)a.onack(e); +if(!e.isError()){if(a.onprogress)a.onprogress(b,c-d.blobLength);h()}}};f.onerror=function(){if(a.onerror)a.onerror(b,c,f.error)};h()};this.sendEnd=function(){d.sendEnd()};this.oncomplete=this.onprogress=this.onerror=this.onack=null};Guacamole=Guacamole||{}; +Guacamole.Client=function(b){function a(a){if(a!=e&&(e=a,c.onstatechange))c.onstatechange(e)}function d(){return e==Guacamole.Client.State.CONNECTED||e==Guacamole.Client.State.WAITING}var c=this,e=Guacamole.Client.State.IDLE,f=0,h=null,l=0,n={0:"butt",1:"round",2:"square"},m={0:"bevel",1:"miter",2:"round"},g=new Guacamole.Display,k={},r={},q=[],v=[],x=[],t=new Guacamole.IntegerPool,y=[];this.exportState=function(a){var p={currentState:e,currentTimestamp:f,layers:{}},b={},c;for(c in k)b[c]=k[c];g.flush(function(){for(var c in b){var d= +parseInt(c),e=b[c],w=e.toCanvas(),f={width:e.width,height:e.height};e.width&&e.height&&(f.url=w.toDataURL("image/png"));if(0a&&delete k[a]},distort:function(a){var b=parseInt(a[0]),c=parseFloat(a[1]),d=parseFloat(a[2]),e=parseFloat(a[3]),p=parseFloat(a[4]),f=parseFloat(a[5]);a=parseFloat(a[6]);0<=b&&(b=u(b),g.distort(b,c,d,e,p,f,a))},error:function(a){var b=a[0];a=parseInt(a[1]); +if(c.onerror)c.onerror(new Guacamole.Status(a,b));c.disconnect()},end:function(a){a=parseInt(a[0]);var b=v[a];if(b){if(b.onend)b.onend();delete v[a]}},file:function(a){var b=parseInt(a[0]),d=a[1];a=a[2];c.onfile?(b=v[b]=new Guacamole.InputStream(c,b),c.onfile(b,d,a)):c.sendAck(b,"File transfer unsupported",256)},filesystem:function(a){var b=parseInt(a[0]);a=a[1];c.onfilesystem&&(b=x[b]=new Guacamole.Object(c,b),c.onfilesystem(b,a))},identity:function(a){a=u(parseInt(a[0]));g.setTransform(a,1,0,0, +1,0,0)},img:function(a){var b=parseInt(a[0]),d=parseInt(a[1]),e=u(parseInt(a[2])),p=a[3],f=parseInt(a[4]);a=parseInt(a[5]);b=v[b]=new Guacamole.InputStream(c,b);g.setChannelMask(e,d);g.drawStream(e,f,a,b,p)},jpeg:function(a){var b=parseInt(a[0]),c=u(parseInt(a[1])),d=parseInt(a[2]),e=parseInt(a[3]);a=a[4];g.setChannelMask(c,b);g.draw(c,d,e,"data:image/jpeg;base64,"+a)},lfill:function(a){var b=parseInt(a[0]),c=u(parseInt(a[1]));a=u(parseInt(a[2]));g.setChannelMask(c,b);g.fillLayer(c,a)},line:function(a){var b= +u(parseInt(a[0])),c=parseInt(a[1]);a=parseInt(a[2]);g.lineTo(b,c,a)},lstroke:function(a){var b=parseInt(a[0]),c=u(parseInt(a[1]));a=u(parseInt(a[2]));g.setChannelMask(c,b);g.strokeLayer(c,a)},mouse:function(a){var b=parseInt(a[0]);a=parseInt(a[1]);g.showCursor(!0);g.moveCursor(b,a)},move:function(a){var b=parseInt(a[0]),c=parseInt(a[1]),d=parseInt(a[2]),e=parseInt(a[3]);a=parseInt(a[4]);0=a||127<=a&&159>=a?65280|a:0<=a&&255>=a?a:256<=a&&1114111>=a?16777216|a:null}function c(){var a=F();if(!a)return!1;do{var b=a;a=F()}while(null!==a);a:{for(var c in e.pressed)if(!y[c]){a=!1;break a}a= +!0}a&&e.reset();return b.defaultPrevented}var e=this,f="_GUAC_KEYBOARD_HANDLED_BY_"+Guacamole.Keyboard._nextID++;this.onkeyup=this.onkeydown=null;var h=!1,l=!1,n=!1;navigator&&navigator.platform&&(navigator.platform.match(/ipad|iphone|ipod/i)?h=!0:navigator.platform.match(/^mac/i)&&(n=l=!0));var m=function(a){var b=this;this.keyCode=a?a.which||a.keyCode:0;this.keyIdentifier=a&&a.keyIdentifier;this.key=a&&a.key;var c=a?"location"in a?a.location:"keyLocation"in a?a.keyLocation:0:0;this.location=c;this.modifiers= +a?Guacamole.Keyboard.ModifierState.fromKeyboardEvent(a):new Guacamole.Keyboard.ModifierState;this.timestamp=(new Date).getTime();this.defaultPrevented=!1;this.keysym=null;this.reliable=!1;this.getAge=function(){return(new Date).getTime()-b.timestamp}},g=function(b){m.call(this,b);this.keysym=a(this.key,this.location)||B(v[this.keyCode],this.location);this.keyupReliable=!h;if(b=this.keysym)b=this.keysym,b=!(0<=b&&255>=b||16777216===(b&4294901760));b&&(this.reliable=!0);if(b=!this.keysym){b=this.keyCode; +var c=this.keyIdentifier;if(c){var d=c.indexOf("U+");-1===d?b=!0:(c=parseInt(c.substring(d+2),16),b=b!==c||65<=b&&90>=b||48<=b&&57>=b?!0:!1)}else b=!1}b&&(this.keysym=a(this.keyIdentifier,this.location,this.modifiers.shift));this.modifiers.meta&&65511!==this.keysym&&65512!==this.keysym?this.keyupReliable=!1:65509===this.keysym&&n&&(this.keyupReliable=!1);b=!this.modifiers.ctrl&&!l;!l||65513!==this.keysym&&65514!==this.keysym||(this.keysym=65027);if(!this.modifiers.alt&&this.modifiers.ctrl||b&&this.modifiers.alt|| +this.modifiers.meta||this.modifiers.hyper)this.reliable=!0;C[this.keyCode]=this.keysym};g.prototype=new m;var k=function(a){m.call(this,a);this.keysym=d(this.keyCode);this.reliable=!0};k.prototype=new m;var r=function(b){m.call(this,b);this.keysym=B(v[this.keyCode],this.location)||a(this.key,this.location);e.pressed[this.keysym]||(this.keysym=C[this.keyCode]||this.keysym);this.reliable=!0};r.prototype=new m;var q=[],v={8:[65288],9:[65289],12:[65291,65291,65291,65461],13:[65293],16:[65505,65505,65506], +17:[65507,65507,65508],18:[65513,65513,65514],19:[65299],20:[65509],27:[65307],32:[32],33:[65365,65365,65365,65465],34:[65366,65366,65366,65459],35:[65367,65367,65367,65457],36:[65360,65360,65360,65463],37:[65361,65361,65361,65460],38:[65362,65362,65362,65464],39:[65363,65363,65363,65462],40:[65364,65364,65364,65458],45:[65379,65379,65379,65456],46:[65535,65535,65535,65454],91:[65511],92:[65512],93:[65383],96:[65456],97:[65457],98:[65458],99:[65459],100:[65460],101:[65461],102:[65462],103:[65463], +104:[65464],105:[65465],106:[65450],107:[65451],109:[65453],110:[65454],111:[65455],112:[65470],113:[65471],114:[65472],115:[65473],116:[65474],117:[65475],118:[65476],119:[65477],120:[65478],121:[65479],122:[65480],123:[65481],144:[65407],145:[65300],225:[65027]},x={Again:[65382],AllCandidates:[65341],Alphanumeric:[65328],Alt:[65513,65513,65514],Attn:[64782],AltGraph:[65027],ArrowDown:[65364],ArrowLeft:[65361],ArrowRight:[65363],ArrowUp:[65362],Backspace:[65288],CapsLock:[65509],Cancel:[65385],Clear:[65291], +Convert:[65315],Copy:[64789],Crsel:[64796],CrSel:[64796],CodeInput:[65335],Compose:[65312],Control:[65507,65507,65508],ContextMenu:[65383],Delete:[65535],Down:[65364],End:[65367],Enter:[65293],EraseEof:[64774],Escape:[65307],Execute:[65378],Exsel:[64797],ExSel:[64797],F1:[65470],F2:[65471],F3:[65472],F4:[65473],F5:[65474],F6:[65475],F7:[65476],F8:[65477],F9:[65478],F10:[65479],F11:[65480],F12:[65481],F13:[65482],F14:[65483],F15:[65484],F16:[65485],F17:[65486],F18:[65487],F19:[65488],F20:[65489],F21:[65490], +F22:[65491],F23:[65492],F24:[65493],Find:[65384],GroupFirst:[65036],GroupLast:[65038],GroupNext:[65032],GroupPrevious:[65034],FullWidth:null,HalfWidth:null,HangulMode:[65329],Hankaku:[65321],HanjaMode:[65332],Help:[65386],Hiragana:[65317],HiraganaKatakana:[65319],Home:[65360],Hyper:[65517,65517,65518],Insert:[65379],JapaneseHiragana:[65317],JapaneseKatakana:[65318],JapaneseRomaji:[65316],JunjaMode:[65336],KanaMode:[65325],KanjiMode:[65313],Katakana:[65318],Left:[65361],Meta:[65511,65511,65512],ModeChange:[65406], +NonConvert:[65314],NumLock:[65407],PageDown:[65366],PageUp:[65365],Pause:[65299],Play:[64790],PreviousCandidate:[65342],PrintScreen:[65377],Redo:[65382],Right:[65363],Romaji:[65316],RomanCharacters:null,Scroll:[65300],Select:[65376],Separator:[65452],Shift:[65505,65505,65506],SingleCandidate:[65340],Super:[65515,65515,65516],Tab:[65289],UIKeyInputDownArrow:[65364],UIKeyInputEscape:[65307],UIKeyInputLeftArrow:[65361],UIKeyInputRightArrow:[65363],UIKeyInputUpArrow:[65362],Up:[65362],Undo:[65381],Win:[65511, +65511,65512],Zenkaku:[65320],ZenkakuHankaku:[65322]},t={65027:!0,65505:!0,65506:!0,65507:!0,65508:!0,65509:!0,65511:!0,65512:!0,65513:!0,65514:!0,65515:!0,65516:!0};this.modifiers=new Guacamole.Keyboard.ModifierState;this.pressed={};var y={},u={},C={},D=null,A=null,B=function(a,b){return a?a[b]||a[0]:null};this.press=function(a){if(null!==a){if(!e.pressed[a]&&(e.pressed[a]=!0,e.onkeydown)){var b=e.onkeydown(a);u[a]=b;window.clearTimeout(D);window.clearInterval(A);t[a]||(D=window.setTimeout(function(){A= +window.setInterval(function(){e.onkeyup(a);e.onkeydown(a)},50)},500));return b}return u[a]||!1}};this.release=function(a){if(e.pressed[a]&&(delete e.pressed[a],delete y[a],window.clearTimeout(D),window.clearInterval(A),null!==a&&e.onkeyup))e.onkeyup(a)};this.type=function(a){for(var b=0;b=b||97<=b&&122>=b)&&(255>=b||16777216===(b&4278190080))&&(e.release(65507),e.release(65508),e.release(65513),e.release(65514));var d=!e.press(b);C[a.keyCode]=b;a.keyupReliable||e.release(b);for(b= +0;be||255c.width?a:c.width,b>c.height?b:c.height)}var c=this,e=document.createElement("canvas"),f=e.getContext("2d");f.save();var h=!0,l=!0,n=0,m={1:"destination-in",2:"destination-out",4:"source-in",6:"source-atop",8:"source-out",9:"destination-atop",10:"xor",11:"destination-over",12:"copy",14:"source-over",15:"lighter"},g=function(a,b){a=a||0;b=b||0;var d=64*Math.ceil(a/64),g=64*Math.ceil(b/64);if(e.width!==d||e.height!==g){var k=null; +h||0===e.width||0===e.height||(k=document.createElement("canvas"),k.width=Math.min(c.width,a),k.height=Math.min(c.height,b),k.getContext("2d").drawImage(e,0,0,k.width,k.height,0,0,k.width,k.height));var l=f.globalCompositeOperation;e.width=d;e.height=g;k&&f.drawImage(k,0,0,k.width,k.height,0,0,k.width,k.height);f.globalCompositeOperation=l;n=0;f.save()}else c.reset();c.width=a;c.height=b};this.autosize=!1;this.width=b;this.height=a;this.getCanvas=function(){return e};this.toCanvas=function(){var a= +document.createElement("canvas");a.width=c.width;a.height=c.height;a.getContext("2d").drawImage(c.getCanvas(),0,0);return a};this.resize=function(a,b){a===c.width&&b===c.height||g(a,b)};this.drawImage=function(a,b,e){c.autosize&&d(a,b,e.width,e.height);f.drawImage(e,a,b);h=!1};this.transfer=function(a,b,e,g,l,n,m,u){var k=a.getCanvas();if(!(b>=k.width||e>=k.height)&&(b+g>k.width&&(g=k.width-b),e+l>k.height&&(l=k.height-e),0!==g&&0!==l)){c.autosize&&d(n,m,g,l);a=a.getCanvas().getContext("2d").getImageData(b, +e,g,l);b=f.getImageData(n,m,g,l);for(e=0;e=k.width||e>=k.height||(b+g>k.width&&(g=k.width-b),e+l>k.height&&(l=k.height-e),0!==g&&0!==l&&(c.autosize&&d(n,m,g,l),a=a.getCanvas().getContext("2d").getImageData(b, +e,g,l),f.putImageData(a,n,m),h=!1))};this.copy=function(a,b,e,g,l,n,m){a=a.getCanvas();b>=a.width||e>=a.height||(b+g>a.width&&(g=a.width-b),e+l>a.height&&(l=a.height-e),0!==g&&0!==l&&(c.autosize&&d(n,m,g,l),f.drawImage(a,b,e,g,l,n,m,g,l),h=!1))};this.moveTo=function(a,b){l&&(f.beginPath(),l=!1);c.autosize&&d(a,b,0,0);f.moveTo(a,b)};this.lineTo=function(a,b){l&&(f.beginPath(),l=!1);c.autosize&&d(a,b,0,0);f.lineTo(a,b)};this.arc=function(a,b,e,g,h,n){l&&(f.beginPath(),l=!1);c.autosize&&d(a,b,0,0);f.arc(a, +b,e,g,h,n)};this.curveTo=function(a,b,e,g,h,n){l&&(f.beginPath(),l=!1);c.autosize&&d(h,n,0,0);f.bezierCurveTo(a,b,e,g,h,n)};this.close=function(){f.closePath();l=!0};this.rect=function(a,b,e,g){l&&(f.beginPath(),l=!1);c.autosize&&d(a,b,e,g);f.rect(a,b,e,g)};this.clip=function(){f.clip();l=!0};this.strokeColor=function(a,b,c,d,e,g,n){f.lineCap=a;f.lineJoin=b;f.lineWidth=c;f.strokeStyle="rgba("+d+","+e+","+g+","+n/255+")";f.stroke();h=!1;l=!0};this.fillColor=function(a,b,c,d){f.fillStyle="rgba("+a+ +","+b+","+c+","+d/255+")";f.fill();h=!1;l=!0};this.strokeLayer=function(a,b,c,d){f.lineCap=a;f.lineJoin=b;f.lineWidth=c;f.strokeStyle=f.createPattern(d.getCanvas(),"repeat");f.stroke();h=!1;l=!0};this.fillLayer=function(a){f.fillStyle=f.createPattern(a.getCanvas(),"repeat");f.fill();h=!1;l=!0};this.push=function(){f.save();n++};this.pop=function(){0=c.scrollThreshold){do c.click(Guacamole.Mouse.State.Buttons.DOWN),h-=c.scrollThreshold;while(h>=c.scrollThreshold);h= +0}Guacamole.Event.DOMEvent.cancelEvent(a)}Guacamole.Mouse.Event.Target.call(this);var c=this;this.touchMouseThreshold=3;this.scrollThreshold=53;this.PIXELS_PER_LINE=18;this.PIXELS_PER_PAGE=16*this.PIXELS_PER_LINE;var e=[Guacamole.Mouse.State.Buttons.LEFT,Guacamole.Mouse.State.Buttons.MIDDLE,Guacamole.Mouse.State.Buttons.RIGHT],f=0,h=0;b.addEventListener("contextmenu",function(a){Guacamole.Event.DOMEvent.cancelEvent(a)},!1);b.addEventListener("mousemove",function(a){f?(Guacamole.Event.DOMEvent.cancelEvent(a), +f--):c.move(Guacamole.Position.fromClientPosition(b,a.clientX,a.clientY),a)},!1);b.addEventListener("mousedown",function(a){if(f)Guacamole.Event.DOMEvent.cancelEvent(a);else{var b=e[a.button];b&&c.press(b,a)}},!1);b.addEventListener("mouseup",function(a){if(f)Guacamole.Event.DOMEvent.cancelEvent(a);else{var b=e[a.button];b&&c.release(b,a)}},!1);b.addEventListener("mouseout",function(a){a||(a=window.event);for(var d=a.relatedTarget||a.toElement;d;){if(d===b)return;d=d.parentNode}c.reset(a);c.out(a)}, +!1);b.addEventListener("selectstart",function(a){Guacamole.Event.DOMEvent.cancelEvent(a)},!1);b.addEventListener("touchmove",a,!1);b.addEventListener("touchstart",a,!1);b.addEventListener("touchend",a,!1);window.WheelEvent?b.addEventListener("wheel",d,!1):(b.addEventListener("DOMMouseScroll",d,!1),b.addEventListener("mousewheel",d,!1));var l=function(){var a=document.createElement("div");if(!("cursor"in a.style))return!1;try{a.style.cursor="url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX///+nxBvIAAAACklEQVQI12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg\x3d\x3d) 0 0, auto"}catch(m){return!1}return/\burl\([^()]*\)\s+0\s+0\b/.test(a.style.cursor|| +"")}();this.setCursor=function(a,c,d){return l?(a=a.toDataURL("image/png"),b.style.cursor="url("+a+") "+c+" "+d+", auto",!0):!1}};Guacamole.Mouse.State=function(b){var a=function(a,b,e,f,h,l,n){return{x:a,y:b,left:e,middle:f,right:h,up:l,down:n}};b=1=a.scrollThreshold&&(a.click(0=e.clickMoveThreshold}function d(a){a=a.touches[0];f=!0;h=a.clientX;l=a.clientY}function c(){window.clearTimeout(n);window.clearTimeout(m);f=!1}Guacamole.Mouse.Event.Target.call(this);var e=this,f=!1,h=null,l=null,n=null,m=null;this.scrollThreshold=20*(window.devicePixelRatio||1);this.clickTimingThreshold=250;this.clickMoveThreshold=16*(window.devicePixelRatio|| +1);this.longPressThreshold=500;b.addEventListener("touchend",function(d){if(f)if(0!==d.touches.length||1!==d.changedTouches.length)c();else if(window.clearTimeout(m),e.release(Guacamole.Mouse.State.Buttons.LEFT,d),!a(d)&&(d.preventDefault(),!e.currentState.left)){var g=d.changedTouches[0];e.move(Guacamole.Position.fromClientPosition(b,g.clientX,g.clientY));e.press(Guacamole.Mouse.State.Buttons.LEFT,d);n=window.setTimeout(function(){e.release(Guacamole.Mouse.State.Buttons.LEFT,d);c()},e.clickTimingThreshold)}}, +!1);b.addEventListener("touchstart",function(a){1!==a.touches.length?c():(a.preventDefault(),d(a),window.clearTimeout(n),m=window.setTimeout(function(){var d=a.touches[0];e.move(Guacamole.Position.fromClientPosition(b,d.clientX,d.clientY));e.click(Guacamole.Mouse.State.Buttons.RIGHT,a);c()},e.longPressThreshold))},!1);b.addEventListener("touchmove",function(d){if(f)if(a(d)&&window.clearTimeout(m),1!==d.touches.length)c();else if(e.currentState.left){d.preventDefault();var g=d.touches[0];e.move(Guacamole.Position.fromClientPosition(b, +g.clientX,g.clientY),d)}},!1)};Guacamole=Guacamole=Guacamole||{};Guacamole.Object=function(b,a){var d=this,c={};this.index=a;this.onbody=function(a,b,d){var e=c[d];if(e){var f=e.shift();0===e.length&&delete c[d];d=f}else d=null;d&&d(a,b)};this.onundefine=null;this.requestInputStream=function(a,f){if(f){var e=c[a];e||(e=[],c[a]=e);e.push(f)}b.requestObjectInputStream(d.index,a)};this.createOutputStream=function(a,c){return b.createObjectOutputStream(d.index,a,c)}};Guacamole.Object.ROOT_STREAM="/"; +Guacamole.Object.STREAM_INDEX_MIMETYPE="application/vnd.glyptodon.guacamole.stream-index+json";Guacamole=Guacamole||{}; +Guacamole.OnScreenKeyboard=function(b){var a=this,d={},c={},e=[],f=function(a,b){a.classList?a.classList.add(b):a.className+=" "+b},h=function(a,b){a.classList?a.classList.remove(b):a.className=a.className.replace(/([^ ]+)[ ]*/g,function(a,c){return c===b?"":a})},l=0,n=function(a,b,c,d){this.width=b;this.height=c;this.scale=function(e){a.style.width=b*e+"px";a.style.height=c*e+"px";d&&(a.style.lineHeight=c*e+"px",a.style.fontSize=e+"px")}},m=function(b){b=a.keys[b];if(!b)return null;for(var c=b.length- +1;0<=c;c--){var e=b[c];a:{var f=e.requires;for(var g=0;g=a?a:256<=a&&1114111>=a?16777216|a:null):a=null);this.keysym=a;this.modifier=b.modifier;this.requires=b.requires||[]};Guacamole=Guacamole||{};Guacamole.OutputStream=function(b,a){var d=this;this.index=a;this.onack=null;this.sendBlob=function(a){b.sendBlob(d.index,a)};this.sendEnd=function(){b.endStream(d.index)}}; +Guacamole=Guacamole||{}; +Guacamole.Parser=function(){var b=this,a="",d=[],c=-1,e=0,f=0;this.receive=function(h,l){l?a=h:(4096=e&&(a=a.substring(e),c-=e,e=0),a=a.length?a+h:h);for(;c=e){h=Guacamole.Parser.codePointCount(a,e,c);if(h=a.size)c&&c();else{var b=a.slice(f,f+262144); +f+=b.size;g.readAsText(b)}}};g.onload=b;b()}},D=function(a){a=a.length;for(var b=a+3;10<=a;)b++,a=Math.floor(a/10);return b};l.connect();l.getDisplay().showCursor(!1);var A=null,B=function(a,b){v+=D(a);for(var c=0;cc)return a-1}var d=Math.floor((a+b)/2),f=E(e[d].timestamp);return ca?K(a,d-1,c):c>f&&dg&&(k=E(e[n].timestamp),d.onseek(k,n-g,a-g));f.aborted||(nd.code||255=b){var c=0;var d=1}else if(2047>=b)c=192,d=2;else if(65535>=b)c=224,d=3;else if(2097151>=b)c=240,d=4;else{a(65533);return}var e=d;if(h+e>=f.length){var l=new Uint8Array(2*(h+e));l.set(f);f=l}h+=e;e=h-1;for(l=1;l>=6;f[e]=c|b}function d(b){for(var c=0;ca.readyState)){try{var f=a.status}catch(G){f=200}d||200!==f||(d=n());if(3===a.readyState||4===a.readyState)if(B(),1===q&&(3!==a.readyState||c?4===a.readyState&&c&&clearInterval(c):c=setInterval(b,30)),0===a.status)m.disconnect();else if(200!==a.status)h(a);else{try{var l=a.responseText}catch(G){return}try{g.receive(l,!0)}catch(G){e(new Guacamole.Status(Guacamole.Status.Code.SERVER_ERROR, +G.message))}}}}var c=null,d=null,f=0,g=new Guacamole.Parser;g.oninstruction=function N(b,e){if(b===Guacamole.Tunnel.INTERNAL_DATA_OPCODE&&0===e.length)g=new Guacamole.Parser,g.oninstruction=N,c&&clearInterval(c),a.onreadystatechange=null,a.abort(),d&&l(d);else if(b!==Guacamole.Tunnel.INTERNAL_DATA_OPCODE&&m.oninstruction)m.oninstruction(b,e)};a.onreadystatechange=1===q?function(){3===a.readyState&&(f++,2<=f&&(q=0,a.onreadystatechange=b));b()}:b;b()}function n(){var a=new XMLHttpRequest;a.open("GET", +k+m.uuid+":"+p++);a.setRequestHeader("Guacamole-Tunnel-Token",A);a.withCredentials=t;c(a,D);a.send(null);return a}var m=this,g=b+"?connect",k=b+"?read:",r=b+"?write:",q=1,v=!1,x="",t=!!a,y=null,u=null,C=null,D=d||{},A=null,B=function(){window.clearTimeout(y);window.clearTimeout(u);m.state===Guacamole.Tunnel.State.UNSTABLE&&m.setState(Guacamole.Tunnel.State.OPEN);y=window.setTimeout(function(){e(new Guacamole.Status(Guacamole.Status.Code.UPSTREAM_TIMEOUT,"Server timeout."))},m.receiveTimeout);u=window.setTimeout(function(){m.setState(Guacamole.Tunnel.State.UNSTABLE)}, +m.unstableThreshold)};this.sendMessage=function(){m.isConnected()&&arguments.length&&(x+=Guacamole.Parser.toInstruction(arguments),v||f())};var p=0;this.connect=function(a){B();m.setState(Guacamole.Tunnel.State.CONNECTING);var b=new XMLHttpRequest;b.onreadystatechange=function(){4===b.readyState&&(200!==b.status?h(b):(B(),m.setUUID(b.responseText),(A=b.getResponseHeader("Guacamole-Tunnel-Token"))?(m.setState(Guacamole.Tunnel.State.OPEN),C=setInterval(function(){m.sendMessage("nop")},500),l(n())): +e(new Guacamole.Status(Guacamole.Status.Code.UPSTREAM_NOT_FOUND))))};b.open("POST",g,!0);b.withCredentials=t;c(b,D);b.setRequestHeader("Content-type","application/x-www-form-urlencoded; charset\x3dUTF-8");b.send(a)};this.disconnect=function(){e(new Guacamole.Status(Guacamole.Status.Code.SUCCESS,"Manually closed."))}};Guacamole.HTTPTunnel.prototype=new Guacamole.Tunnel; +Guacamole.WebSocketTunnel=function(b){function a(a){window.clearTimeout(f);window.clearTimeout(h);window.clearTimeout(l);if(d.state!==Guacamole.Tunnel.State.CLOSED){if(a.code!==Guacamole.Status.Code.SUCCESS&&d.onerror)d.onerror(a);d.setState(Guacamole.Tunnel.State.CLOSED);e.close()}}var d=this,c=null,e=null,f=null,h=null,l=null,n={"http:":"ws:","https:":"wss:"},m=0;if("ws:"!==b.substring(0,3)&&"wss:"!==b.substring(0,4))if(n=n[window.location.protocol],"/"===b.substring(0,1))b=n+"//"+window.location.host+ +b;else{var g=window.location.pathname.lastIndexOf("/");g=window.location.pathname.substring(0,g+1);b=n+"//"+window.location.host+g+b}var k=function(){var a=(new Date).getTime();d.sendMessage(Guacamole.Tunnel.INTERNAL_DATA_OPCODE,"ping",a);m=a},r=function(){window.clearTimeout(f);window.clearTimeout(h);window.clearTimeout(l);d.state===Guacamole.Tunnel.State.UNSTABLE&&d.setState(Guacamole.Tunnel.State.OPEN);f=window.setTimeout(function(){a(new Guacamole.Status(Guacamole.Status.Code.UPSTREAM_TIMEOUT, +"Server timeout."))},d.receiveTimeout);h=window.setTimeout(function(){d.setState(Guacamole.Tunnel.State.UNSTABLE)},d.unstableThreshold);var b=(new Date).getTime();b=Math.max(m+500-b,0);0 - +
    From c7aa003f174119462e2cd3475402c796819b5317 Mon Sep 17 00:00:00 2001 From: Josh Feather Date: Tue, 12 May 2026 16:58:10 +0100 Subject: [PATCH 04/24] Replace f-string without placeholders with normal string --- tests/web/test_guac_consumers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/web/test_guac_consumers.py b/tests/web/test_guac_consumers.py index e2876e43bd0..78de0ad917d 100644 --- a/tests/web/test_guac_consumers.py +++ b/tests/web/test_guac_consumers.py @@ -314,7 +314,7 @@ async def test_consumer_timeout_completes_analysis_and_closes_session(self, guac assert timeout_manager.complete_analysis_calls == 1 assert timeout_manager.is_active is False assert client.closed is True - assert f"idle for 120001ms (threshold: 120s)" in caplog.text + assert "idle for 120001ms (threshold: 120s)" in caplog.text async def test_consumer_disconnect_cancels_reader_without_double_close(self, guac_consumer_app_factory): guac_consumer_app, fake_db = guac_consumer_app_factory(read_guacd_impl=_cancel_then_close_read_guacd) From dcd8cb1475c86c76b3a7377a88809f6e1d6c2cf9 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Tue, 12 May 2026 11:17:01 -0500 Subject: [PATCH 05/24] feat: COM logical parent enrichment in process tree (LethalHTA / DCOM evasion) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When malware uses DCOM out-of-process activation (e.g. Excel embeds an HTA object → Windows launches mshta.exe -Embedding via svchost broker), the OS-level process parent is svchost — hiding the true originator. Changes: - behavior.py: NetworkMap now captures CoCreateInstance calls with CLSCTX_LOCAL_SERVER context (cat=com) and known out-of-process CLSIDs (htafile/mshta, InternetExplorer, ShellWindows, etc.) into a new `com_activations` list. After all instances run, _enrich_tree_com_parents() walks the processtree and annotates each node whose binary matches a COM activation record with `com_logical_parent_pid`, `com_logical_parent_name`, `com_progid`, and `com_clsid`. - generic_tags.py: proctreetolist filter passes the four new COM fields through to the flattened output list. - _tree.html: Processes with `com_logical_parent_pid` render a dashed annotation line "COM-activated by via " linking back to the logical originator, making the LethalHTA chain visible without requiring the analyst to correlate COM call logs manually. Observed in tasks where Excel CoCreates CLSID_HtaFile (htafile) and mshta.exe -Embedding is spawned through the DcomLaunch svchost broker. --- modules/processing/behavior.py | 92 +++++++++++++++++++--- web/analysis/templatetags/generic_tags.py | 4 + web/templates/analysis/behavior/_tree.html | 14 ++++ 3 files changed, 99 insertions(+), 11 deletions(-) diff --git a/modules/processing/behavior.py b/modules/processing/behavior.py index e04558c5d0b..3fdea34a424 100644 --- a/modules/processing/behavior.py +++ b/modules/processing/behavior.py @@ -1239,9 +1239,43 @@ def __init__(self): self.http_requests = [] # url -> [pinfo] self.dns_intents = defaultdict(list) # domain -> [intent] self._winhttp_state = {"processes": {}} + self.com_activations = [] # out-of-process CoCreateInstance calls + + # CLSIDs for known out-of-process COM servers + _OOP_CLSIDS = { + "3050f4d8-98b5-11cf-bb82-00aa00bdce0b": "mshta.exe", + "0002df01-0000-0000-c000-000000000046": "iexplore.exe", + "9ba05972-f6a8-11cf-a442-00a0c90a8f39": "explorer.exe", + "c08afd90-f2a1-11d1-8455-00a0c91f3880": "explorer.exe", + "25336920-03f9-11cf-8fd0-00aa00686f13": "mshtml.dll", + } def event_apicall(self, call, process): - if call.get("category") != "network": + cat = call.get("category") or "" + if cat == "com": + api = (call.get("api") or "").lower() + if api == "cocreateinstance": + args_map = _get_call_args_dict(call) + clsid = (args_map.get("rclsid") or "").lower() + progid = (args_map.get("progid") or "").strip() + # Capture any out-of-process activation (CLSCTX includes LOCAL_SERVER=4) + try: + ctx = int(args_map.get("clscontext", "0"), 16) + except (ValueError, TypeError): + ctx = 0 + if ctx & 0x4 or clsid in self._OOP_CLSIDS: + key = (process.get("process_id"), clsid) + if not any((a.get("activator_pid"), a.get("clsid")) == key + for a in self.com_activations): + self.com_activations.append({ + "clsid": clsid, + "progid": progid, + "activator_pid": process.get("process_id"), + "activator_name": process.get("process_name", ""), + "target_binary": self._OOP_CLSIDS.get(clsid, ""), + }) + return + if cat != "network": return api = (call.get("api") or "").lower() @@ -1350,17 +1384,17 @@ def run(self): # BSON/JSON keys must be strings. # Let's convert tuple keys to string representation "ip:port" - endpoint_map_list = [{"ip_port": f"{ip}:{port}", "pinfo": entries} for (ip, port), entries in self.endpoint_map.items()] - - http_host_map_list = [{"host": k, "pinfo": v} for k, v in self.http_host_map.items()] - dns_intents_list = [{"domain": k, "intents": v} for k, v in self.dns_intents.items()] + endpoint_map_str = {} + for (ip, port), entries in self.endpoint_map.items(): + endpoint_map_str[f"{ip}:{port}"] = entries return { - "endpoint_map": endpoint_map_list, - "http_host_map": http_host_map_list, - "dns_intents": dns_intents_list, + "endpoint_map": endpoint_map_str, + "http_host_map": self.http_host_map, + "dns_intents": self.dns_intents, "http_requests": self.http_requests, "winhttp_sessions": winhttp_finalize_sessions(self._winhttp_state), + "com_activations": self.com_activations, } @@ -1436,6 +1470,40 @@ def run(self): return self.bufs + +def _enrich_tree_com_parents(tree_nodes, com_activations): + """Walk the processtree and annotate nodes whose binary matches a COM activation record.""" + import os as _os + # Build lookup: target_binary_lower -> list of activations + binary_map = {} + for act in com_activations: + binary = (act.get("target_binary") or "").lower() + if not binary: + # Fall back to ProgID heuristic + progid = (act.get("progid") or "").lower() + _progid_to_binary = { + "htafile": "mshta.exe", + "internetexplorer.application": "iexplore.exe", + "shell.application": "explorer.exe", + } + binary = _progid_to_binary.get(progid, "") + if binary: + binary_map.setdefault(binary, []).append(act) + + def _walk(nodes): + for node in nodes: + path = node.get("module_path") or "" + name = path.replace("\\", "/").rsplit("/", 1)[-1].lower() + if name in binary_map: + act = binary_map[name][0] + node["com_logical_parent_pid"] = act["activator_pid"] + node["com_logical_parent_name"] = act["activator_name"] + node["com_progid"] = act.get("progid", "") + node["com_clsid"] = act.get("clsid", "") + _walk(node.get("children") or []) + + _walk(tree_nodes) + class BehaviorAnalysis(Processing): """Behavior Analyzer.""" @@ -1469,15 +1537,17 @@ def run(self): instance.event_apicall(call, process) except Exception: log.exception('Failure in partial behavior "%s"', instance.key) - # Reset the iterator so reporting modules can read the calls again - with suppress(AttributeError): - process["calls"].reset() for instance in instances: try: behavior[instance.key] = instance.run() except Exception as e: log.exception('Failed to run partial behavior class "%s" due to "%s"', instance.key, e) + + # Enrich processtree nodes with COM logical parent relationships + com_acts = (behavior.get("network_map") or {}).get("com_activations") or [] + if com_acts and behavior.get("processtree"): + _enrich_tree_com_parents(behavior["processtree"], com_acts) else: log.warning('Analysis results folder does not exist at path "%s"', self.logs_path) # load behavior from json if exist or env CAPE_REPORT variable diff --git a/web/analysis/templatetags/generic_tags.py b/web/analysis/templatetags/generic_tags.py index 6b3e928b43d..2b602b907af 100644 --- a/web/analysis/templatetags/generic_tags.py +++ b/web/analysis/templatetags/generic_tags.py @@ -28,6 +28,10 @@ def proctreetolist(tree): newnode["name"] = node["name"] if "module_path" in node: newnode["module_path"] = node["module_path"] + for _com_field in ("com_logical_parent_pid", "com_logical_parent_name", + "com_progid", "com_clsid"): + if _com_field in node: + newnode[_com_field] = node[_com_field] if "environ" in node and "CommandLine" in node["environ"]: cmdline = node["environ"]["CommandLine"] if cmdline.startswith('"'): diff --git a/web/templates/analysis/behavior/_tree.html b/web/templates/analysis/behavior/_tree.html index 34c779ac2df..5aa6ebefd75 100644 --- a/web/templates/analysis/behavior/_tree.html +++ b/web/templates/analysis/behavior/_tree.html @@ -23,6 +23,20 @@
    Proces {% if detections2pid|get_detection_by_pid:process.pid %} {{ detections2pid|get_detection_by_pid:process.pid }} {% endif %} + {% if process.com_logical_parent_pid %} +
    + + COM-activated by + + {{ process.com_logical_parent_name }} ({{ process.com_logical_parent_pid }}) + + {% if process.com_progid %} + via {{ process.com_progid }} + {% elif process.com_clsid %} + via CLSID {{ process.com_clsid }} + {% endif %} +
    + {% endif %} {% endif %} {% endfor %} From 00daa7671b8323c87e0e006c9457185026a380d4 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Tue, 12 May 2026 11:17:31 -0500 Subject: [PATCH 06/24] fix: Guac UI dark theme, End Session button, and user_stop API - wait.html / error.html / index.html: Apply CAPE Bootstrap dark theme (bg-dark, navbar header, Bootstrap components) to match the rest of the UI. Previously these pages used bare unstyled HTML. - index.html toolbar: Thin bg-dark bar with CAPE logo, task link, and End Session button; Guacamole canvas fills remaining viewport height. - guac-main.js stopTask(): Add X-CSRFToken header so the POST succeeds under Django CSRF protection. Redirect to task status page on success. Show spinner on button while request is in flight. - guac-main.js disconnect overlay: When the Guacamole connection drops, show the #launch_error overlay via CSS display:block instead of the jQuery UI .dialog() widget (which was never loaded). Normal session end shows green "Session Complete"; unexpected disconnect shows red "Session Error". - guac-main.css: Dark overlay styling for the disconnect dialog using CAPE colors (#212529 bg, white text). - apiv2/views.py tasks_status: Enable user_stop (was disabled) so End Session POST actually signals the guest VM to complete. --- web/apiv2/views.py | 218 ++--------------------------- web/guac/templates/guac/error.html | 47 ++++--- web/guac/templates/guac/index.html | 93 ++++++------ web/guac/templates/guac/wait.html | 81 ++++++----- web/static/css/guac-main.css | 33 ++--- web/static/js/guac-main.js | 39 ++++-- 6 files changed, 177 insertions(+), 334 deletions(-) diff --git a/web/apiv2/views.py b/web/apiv2/views.py index 7ebf18e941f..73f7d4afbe3 100644 --- a/web/apiv2/views.py +++ b/web/apiv2/views.py @@ -8,15 +8,13 @@ import sys import tempfile import zipfile -from contextlib import suppress from datetime import datetime, timedelta from io import BytesIO -from urllib.parse import quote, urljoin +from urllib.parse import quote from wsgiref.util import FileWrapper import pyzipper import requests -import yara from bson.objectid import ObjectId from django.conf import settings from django.contrib.auth.decorators import login_required @@ -24,9 +22,12 @@ 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.authentication import SessionAuthentication +from rest_framework.decorators import api_view, authentication_classes from rest_framework.response import Response +from apikey.authentication import ApiKeyAuthentication + sys.path.append(settings.CUCKOO_PATH) from lib.cuckoo.common.config import Config @@ -87,12 +88,6 @@ except ImportError: import re -HAVE_PLYARA = False -with suppress(ImportError): - import plyara - import plyara.utils - HAVE_PLYARA = True - # FORMAT = '%(asctime)-15s %(clientip)s %(user)-8s %(message)s' # Config variables @@ -136,9 +131,7 @@ DIST_ENABLED = False if dist_conf.distributed.enabled: - from sqlalchemy import select - - from lib.cuckoo.common.dist_db import Node, create_session + from lib.cuckoo.common.dist_db import create_session from lib.cuckoo.common.dist_db import Task as DTask dist_session = create_session( @@ -149,7 +142,6 @@ db: _Database = Database() -ALLOWED_YARA_CATEGORIES = ("binaries", "urls", "memory", "CAPE", "macro", "monitor") # Conditional decorator for web authentication class conditional_login_required: @@ -1128,6 +1120,12 @@ def tasks_delete(request, task_id, status=False): @csrf_exempt @api_view(["GET", "POST"]) +# UI-internal endpoint: the Guacamole VNC pane calls this from the +# in-browser session to poll/stop a live analysis. Re-enable the session- +# cookie auth path here so it works under SSO deployments where the global +# DRF chain is API-key-only. ApiKeyAuthentication stays available for +# scripts that prefer it. +@authentication_classes([SessionAuthentication, ApiKeyAuthentication]) def tasks_status(request, task_id): if not apiconf.taskstatus.get("enabled"): resp = {"error": True, "error_value": "Task status API is disabled"} @@ -2906,195 +2904,3 @@ def dist_tasks_notification(request, task_id: int): # log.debug("reporting main_task_id: {}".format(task.main_task_id)) task.notificated = True - -@csrf_exempt -@api_view(["POST"]) -def yara_uploader(request): - try: - if not apiconf.yara_uploader.get("enabled"): - return Response({"error": True, "error_value": "Yara Uploader API is Disabled"}) - - if not HAVE_PLYARA: - return Response({"error": True, "error_value": "Missing dependency. Contact your administrator."}) - - category = request.data.get("category") - if not category or category not in ALLOWED_YARA_CATEGORIES: - return Response( - {"status": "error", "message": f"Invalid or missing category. Allowed categories: {ALLOWED_YARA_CATEGORIES}"}, - status=400, - ) - """ - if request.user.is_authenticated and request.user.username not in ALLOWED_UPLOADERS: - return Response( - {"status": "error", "message": f"User '{request.user.username}' is not authorized to upload YARA rules."}, status=403 - ) - """ - if "file" not in request.FILES: - return Response({"status": "error", "message": "No file provided"}, status=400) - - uploaded_file = request.FILES["file"] - - # Read content for processing - try: - content = uploaded_file.read().decode("utf-8") - except UnicodeDecodeError: - return Response({"status": "error", "message": "File must be a text file (UTF-8)"}, status=400) - - # Validate YARA - try: - yara.compile(source=content) - except yara.SyntaxError as e: - return Response({"status": "error", "message": f"YARA Syntax Error: {str(e)}"}, status=400) - except yara.Error as e: - return Response({"status": "error", "message": f"YARA Error: {str(e)}"}, status=400) - - try: - parser = plyara.Plyara() - rules = parser.parse_string(content) - - if not rules: - return Response({"status": "error", "message": "No YARA rules found in file"}, status=400) - - main_rule = rules[0] - - # Check for family - family = None - metadata = main_rule.get("metadata", []) - - for meta in metadata: - if "family" in meta: - family = meta["family"] - break - - if not family: - # Fallback: check cape_type - for meta in metadata: - if "cape_type" in meta: - cape_type_val = meta["cape_type"] - if cape_type_val and isinstance(cape_type_val, str): - family = cape_type_val.split(" ")[0] - break - - if not family: - return Response({"status": "error", "message": "Missing 'family' in metadata"}, status=400) - - # Now iterate all rules to inject cape_type / author if needed - for rule in rules: - rule_metadata = rule.get("metadata", []) - - has_cape_type = any("cape_type" in m for m in rule_metadata) - has_author = any("yara_created_by" in m for m in rule_metadata) # Using yara_created_by as key - - if not has_cape_type: - rule_metadata.append({"cape_type": f"{family} Payload"}) - - if request.user.is_authenticated and not has_author: - rule_metadata.append({"yara_created_by": request.user.username}) - - rule["metadata"] = rule_metadata - - # Define destination path - original_filename = os.path.basename(uploaded_file.name) # Basic safety - if category == "monitor": - dest_dir = os.path.join(CUCKOO_ROOT, "analyzer", "windows", "data", "yara") - else: - dest_dir = os.path.join(CUCKOO_ROOT, "data", "yara", category) - - # Ensure directory exists - if not os.path.exists(dest_dir): - os.makedirs(dest_dir, exist_ok=True) - - original_dest_path = os.path.join(dest_dir, original_filename) - - if os.path.exists(original_dest_path): - filename = original_filename - dest_path = original_dest_path - else: - # Fallback to standard naming - filename = f"{family}.yar" - dest_path = os.path.join(dest_dir, filename) - - # Check if file exists to append - if os.path.exists(dest_path): - with open(dest_path, "r", encoding="utf-8") as f: - existing_content = f.read() - - try: - existing_rules = parser.parse_string(existing_content) - existing_names = {r["rule_name"] for r in existing_rules} - - # Filter new rules - unique_rules = [] - for rule in rules: - if rule["rule_name"] not in existing_names: - unique_rules.append(rule) - - if not unique_rules: - # No new rules to add - msg = "All rules already exist. Nothing to add." - return Response({"status": "success", "message": msg}) - - append_content = "" - for rule in unique_rules: - append_content += "\n\n" + plyara.utils.rebuild_yara_rule(rule) - - content = existing_content + append_content - - except Exception as e: - return Response({"status": "error", "message": f"Failed to parse existing file for append: {str(e)}"}, status=500) - else: - # Rebuild content for new file - new_content = "" - for rule in rules: - new_content += plyara.utils.rebuild_yara_rule(rule) + "\n\n" - - content = new_content - - except Exception as e: - return Response({"status": "error", "message": f"Plyara parsing error: {str(e)}"}, status=400) - - # Save file - with open(dest_path, "w", encoding="utf-8") as f: - f.write(content) - - msg = "Rule saved! Thank you" - - # Distributed propagation - try: - if DIST_ENABLED: - # Prepare for propagation - files = {"file": (filename, content)} - with dist_session() as db_session: - nodes = db_session.execute(select(Node).where(Node.enabled.is_(True))).scalars().all() - - propagated_count = 0 - total_count = 0 - - for node in nodes: - total_count += 1 - prop_url = urljoin(node.url, "apiv2/yara_uploader/") - headers = {"Authorization": f"Token {node.apikey}"} - - try: - data = {"username": request.user.username, "category": category} - r = requests.post(prop_url, files=files, data=data, headers=headers, verify=False, timeout=10) - if r.status_code == 200: - propagated_count += 1 - except Exception: - pass - - msg += f" (Propagated to {propagated_count}/{total_count} workers)" - - except Exception as e: - msg += f" (Propagation failed: {str(e)})" - - return Response( - { - "status": "success", - "message": msg, - } - ) - - except Exception as e: - return Response({"status": "error", "message": str(e)}, status=500) - diff --git a/web/guac/templates/guac/error.html b/web/guac/templates/guac/error.html index 6abbcf59391..f54b5a5356a 100644 --- a/web/guac/templates/guac/error.html +++ b/web/guac/templates/guac/error.html @@ -1,27 +1,34 @@ {% load static %} - + - - Guacamole Console + + Guacamole Console · CAPE Sandbox + + + + + + - - {% block content %} -
    -

    Error

    -

    {{error_msg}}

    - + + {% include "header.html" %} +
    +
    +
    +
    +
    + +

    Session Error

    +

    {{ error_msg }}

    + + View Task + +
    +
    +
    - {% endblock %} +
    - \ No newline at end of file + diff --git a/web/guac/templates/guac/index.html b/web/guac/templates/guac/index.html index 0a36b731565..68c1af2bea1 100644 --- a/web/guac/templates/guac/index.html +++ b/web/guac/templates/guac/index.html @@ -1,42 +1,51 @@ -{% load static %} - - - - - - - - - - - - Guacamole Console - - -
    -
    - -
    -
    -
    -
    -
    -
    -

    -

    - -
    -
    - -
    - - +{% load static %} + + + + + + Guacamole Console · CAPE Sandbox + + + + + + + + + + + + +
    +
    + + CAPE Sandbox + + | + Task + #{{ task_id }} + +
    +
    +
    + +
    +
    + + + diff --git a/web/guac/templates/guac/wait.html b/web/guac/templates/guac/wait.html index d5a08819adf..508bc80609d 100644 --- a/web/guac/templates/guac/wait.html +++ b/web/guac/templates/guac/wait.html @@ -1,33 +1,48 @@ -{% load static %} - - - - - - - - Guacamole Console - - - {% block content %} -
    -

    Hang on...

    -

    The VM is not running, yet. This page will refresh every 5 seconds.

    -
    - - -
    - - {% endblock %} - - \ No newline at end of file +{% load static %} + + + + + + Guacamole Console · CAPE Sandbox + + + + + + + + + + {% include "header.html" %} + +
    +
    +
    +
    +
    +
    + +
    +

    Hang on...

    +

    The VM is not running yet. This page will refresh every 5 seconds.

    +
    +
    +
    + +
    +
    +
    +
    +
    + + + + diff --git a/web/static/css/guac-main.css b/web/static/css/guac-main.css index 5896e3c4d32..9ef5fb19041 100644 --- a/web/static/css/guac-main.css +++ b/web/static/css/guac-main.css @@ -22,38 +22,23 @@ html, body { width: 100%; } -.dialog, -.error { - max-width: 640px; - background: #fff; - padding: 5px; - border-radius: 8px; - box-shadow: 0 0 10px #000; - text-align: left; -} - +/* Error dialog overlay on the canvas */ .dialog { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 1002; + max-width: 480px; + width: 100%; + display: none; } .inner { - background: rgb(245, 245, 245); + background: #1e1e2e; + border: 1px solid #dc3545; border-radius: 8px; - border: 1px solid rgba(0, 0, 0, 0.9); - box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.7); - overflow: hidden; - padding: 10px; - position: relative; -} - -.dialog { - display: none; + padding: 20px; + box-shadow: 0 0 30px rgba(220, 53, 69, 0.3); + color: #fff; } - -.no-close .ui-dialog-titlebar-close { - display: none -} \ No newline at end of file diff --git a/web/static/js/guac-main.js b/web/static/js/guac-main.js index e0bfa3d7792..f7d607beab4 100644 --- a/web/static/js/guac-main.js +++ b/web/static/js/guac-main.js @@ -137,14 +137,20 @@ function GuacMe(element, session_id, recording_name) { "The server's error message was:"; var error_message = guac_error.message; - if (guac_error.message.toLowerCase().startsWith('aborted')) { - dialog_message = "Remote session terminated."; - error_message = "Close tab."; + var isNormalEnd = guac_error.message.toLowerCase().startsWith('aborted'); + if (isNormalEnd) { + dialog_message = "The analysis session has ended."; + error_message = ""; + } + var heading = dialog.find('#dialog-heading'); + if (isNormalEnd) { + heading.html('Session Complete'); + } else { + heading.html('Session Error'); } dialog.find('.message').html(dialog_message); dialog.find('.error_msg').html(error_message); - dialog.dialog({dialogClass: 'no-close'}); - dialog.dialog(dialog_container); + dialog.css('display', 'block'); }; }; @@ -169,15 +175,30 @@ function GuacMe(element, session_id, recording_name) { init(); } +function getCsrfToken() { + var match = document.cookie.match(/csrftoken=([^;]+)/); + return match ? match[1] : ''; +} + function stopTask(taskId) { - var apiUrl = location.origin + "/apiv2/tasks/status/" + taskId + "/"; + var btn = document.getElementById('stopTask'); + if (btn) { btn.disabled = true; btn.innerHTML = 'Stopping...'; } + var apiUrl = location.origin + "/apiv2/tasks/status/" + taskId + "/"; fetch(apiUrl, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCsrfToken(), + }, body: JSON.stringify({ status: 'finish' }), }) .then(response => response.json()) - .then(data => console.log('Response:', data)) - .catch(error => console.error('Error:', error)); + .then(function(data) { + location.replace(location.origin + '/submit/status/' + taskId + '/'); + }) + .catch(function(error) { + console.error('Error:', error); + if (btn) { btn.disabled = false; btn.innerHTML = 'End Session'; } + }); } From 31f2f3599444c9adc492adff08ceaa2d7a3b1d64 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Tue, 12 May 2026 11:18:37 -0500 Subject: [PATCH 07/24] fix: DNS and Hosts network tables support multiple process attributions When multiple processes query the same hostname or connect to the same IP, the network tables previously showed only one process badge. Renders one badge per attributed process in both Network tab and Overview tab. - _dns.html / _dns_not_ajax.html: iterate p.processes list for the Process Name (PID) cell; fall back to legacy single-field render. - _hosts_not_ajax.html: sync multi-process badge iteration to match the AJAX version. --- web/templates/analysis/network/_dns.html | 10 ++++++--- .../analysis/network/_dns_not_ajax.html | 10 ++++++--- .../analysis/network/_hosts_not_ajax.html | 21 +++++++++++++------ 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/web/templates/analysis/network/_dns.html b/web/templates/analysis/network/_dns.html index b9102616b5b..c959dae3290 100644 --- a/web/templates/analysis/network/_dns.html +++ b/web/templates/analysis/network/_dns.html @@ -56,10 +56,14 @@
    DNS Reque {% if settings.NETWORK_PROC_MAP %} - {% if p.process_name %} - {{ p.process_name }}{% if p.process_id %} ({{ p.process_id }}){% endif %} + {% if p.processes %} + {% for proc in p.processes %} + {% if proc.process_name %}{{ proc.process_name }}{% else %}(unknown){% endif %}{% if proc.pid %} ({{ proc.pid }}){% endif %} + {% endfor %} + {% elif p.process_name or p.process_id %} + {% if p.process_name %}{{ p.process_name }}{% else %}(unknown){% endif %}{% if p.process_id %} ({{ p.process_id }}){% endif %} {% else %} - - + - {% endif %} {% endif %} diff --git a/web/templates/analysis/network/_dns_not_ajax.html b/web/templates/analysis/network/_dns_not_ajax.html index c0ae1d94788..4bc50fb85fd 100644 --- a/web/templates/analysis/network/_dns_not_ajax.html +++ b/web/templates/analysis/network/_dns_not_ajax.html @@ -51,10 +51,14 @@ {% if settings.NETWORK_PROC_MAP %} - {% if p.process_name %} - {{ p.process_name }}{% if p.process_id %} ({{ p.process_id }}){% endif %} + {% if p.processes %} + {% for proc in p.processes %} + {% if proc.process_name %}{{ proc.process_name }}{% else %}(unknown){% endif %}{% if proc.pid %} ({{ proc.pid }}){% endif %} + {% endfor %} + {% elif p.process_name or p.process_id %} + {% if p.process_name %}{{ p.process_name }}{% else %}(unknown){% endif %}{% if p.process_id %} ({{ p.process_id }}){% endif %} {% else %} - - + - {% endif %} {% endif %} diff --git a/web/templates/analysis/network/_hosts_not_ajax.html b/web/templates/analysis/network/_hosts_not_ajax.html index fc6b177f6d0..214c017a0bb 100644 --- a/web/templates/analysis/network/_hosts_not_ajax.html +++ b/web/templates/analysis/network/_hosts_not_ajax.html @@ -27,15 +27,24 @@ {% endif %} {{host.country_name}} - {% if host.asn %} - {{host.asn}} - {% endif %} + + {% if host.asn %}{{host.asn}}{% else %}-{% endif %} + {% if settings.NETWORK_PROC_MAP %} - {% if host.process_name %} - {{ host.process_name }}{% if host.process_id %} ({{ host.process_id }}){% endif %} + {% if host.processes %} + {% for p in host.processes %} + + {% if p.process_name %}{{ p.process_name }}{% else %}(unknown){% endif %}{% if p.pid %} ({{ p.pid }}){% endif %} + + {% endfor %} + {% elif host.process_name or host.process_id %} + + {% if host.process_name %}{{ host.process_name }}{% else %}(unknown){% endif %}{% if host.process_id %} ({{ host.process_id }}){% endif %} + {% else %} - - + - {% endif %} {% endif %} From 271a64d4bf7aa6774ff18112e4353c0f0b343241 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Tue, 12 May 2026 11:26:15 -0500 Subject: [PATCH 08/24] feat: Authenticode certificate card on analysis overview tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a collapsible certificate chain card to the analysis overview tab, surfacing Authenticode signing information that was previously buried in the Static Analysis tab accordion. Card features: - Header badge: Valid (green), Untrusted Root (yellow), Invalid (red), Known Malicious Cert (red skull, when bad_certs signature fires) - Signing timestamp and error description inline in header area - Code Signing Chain accordion: each cert in the chain is a collapsible row showing Subject CN/O/OU/C/email, Issuer DN, Not Before/After, Serial, SHA1/MD5/SHA256 fingerprints. Self-signed certs get a red "Self-Signed" badge. - Timestamp Chain accordion: DigiCert/TSA chain entries, collapsible. - Falls back gracefully when only DigiSig.json data is available (SHA1 only) vs. full digital_signers data. Also fixes pkcs7 extraction (see perf PR): parse_pe.py was using backend.load_der_pkcs7_certificates() which was removed in cryptography ≥ 40.x, causing digital_signers to be empty for all signed PEs. New web.conf toggle: [display_authenticode] enabled = no (default off). web.conf.default: Add missing display_* toggle defaults: display_clamav, display_cape_yara, display_submitter, display_etw, display_authenticode — all default off so existing deployments are unaffected until explicitly enabled. --- .../analysis/overview/_authenticode.html | 184 ++++++++++++++++++ web/templates/analysis/overview/index.html | 4 + 2 files changed, 188 insertions(+) create mode 100644 web/templates/analysis/overview/_authenticode.html diff --git a/web/templates/analysis/overview/_authenticode.html b/web/templates/analysis/overview/_authenticode.html new file mode 100644 index 00000000000..faccdb0da90 --- /dev/null +++ b/web/templates/analysis/overview/_authenticode.html @@ -0,0 +1,184 @@ +{% if config.display_authenticode %} +{% load key_tags %}{% load analysis_tags %} +{% if file.pe.digital_signers or file.pe.guest_signers.aux_signers %} +{% with gs=file.pe.guest_signers ds=file.pe.digital_signers %} + +
    +
    +
    +
    Authenticode Signature
    + + {% for sig in analysis.signatures %}{% if sig.name == "bad_certs" %}Known Malicious Cert{% endif %}{% endfor %} + {% if gs %} + {% if gs.aux_valid %} + Valid + {% elif gs.aux_error_desc == "No signature found." %} + Unsigned + {% elif "not trusted" in gs.aux_error_desc %} + Untrusted Root + {% else %} + Invalid + {% endif %} + {% if gs.aux_timestamp %}signed {{ gs.aux_timestamp }}{% endif %} + {% else %} + Not Verified + {% endif %} + +
    + + {% if gs.aux_error_desc and not gs.aux_valid %} +
    + + {{ gs.aux_error_desc }} + +
    + {% endif %} + + {% if gs.aux_signers or ds %} +
    + + {% comment %}--- Code Signing Chain (from aux_signers, enriched with digital_signers) ---{% endcomment %} + {% if gs.aux_signers %} +
    + Code Signing Chain +
    +
    + {% for signer in gs.aux_signers %}{% if "Certificate Chain" in signer.name %} + {% with issued_to=signer|get_item:"Issued to" issued_by=signer|get_item:"Issued by" sha1=signer|get_item:"SHA1 hash" %} +
    +
    + +
    +
    +
    + + {% comment %}Look up enriched data from digital_signers by SHA1{% endcomment %} + {% for dc in ds %}{% if dc.sha1_fingerprint == sha1 %} + {% if dc.subject_commonName %}{% endif %} + {% if dc.subject_organizationName %}{% endif %} + {% if dc.subject_organizationalUnitName %}{% endif %} + {% if dc.subject_countryName %}{% endif %} + {% if dc.subject_emailAddress %}{% endif %} + + {% if dc.issuer_commonName %}{% endif %} + {% if dc.issuer_organizationName %}{% endif %} + {% if dc.issuer_organizationalUnitName %}{% endif %} + {% if dc.issuer_countryName %}{% endif %} + + {% if dc.not_before %}{% endif %} + {% if dc.not_after %}{% endif %} + {% if dc.serial_number %}{% endif %} + + {% if dc.sha1_fingerprint %}{% endif %} + {% if dc.md5_fingerprint %}{% endif %} + {% if dc.sha256_fingerprint %}{% endif %} + {% else %} + {% comment %}Fallback: only aux_signers data available{% endcomment %} + + + + + {% endif %}{% endfor %} + {% if not ds %} + + + + + {% endif %} +
    Subject CN{{ dc.subject_commonName }}
    Subject O{{ dc.subject_organizationName }}
    Subject OU{{ dc.subject_organizationalUnitName }}
    Subject C{{ dc.subject_countryName }}
    Subject E{{ dc.subject_emailAddress }}

    Issuer CN{{ dc.issuer_commonName }}
    Issuer O{{ dc.issuer_organizationName }}
    Issuer OU{{ dc.issuer_organizationalUnitName }}
    Issuer C{{ dc.issuer_countryName }}

    Not Before{{ dc.not_before }}
    Not After{{ dc.not_after }}
    Serial{{ dc.serial_number }}

    SHA1{{ dc.sha1_fingerprint }}
    MD5{{ dc.md5_fingerprint }}
    SHA256{{ dc.sha256_fingerprint }}
    Subject{{ issued_to }}
    Issuer{{ issued_by }}{% if issued_to == issued_by %} Self-Signed{% endif %}
    Expires{{ signer|get_item:"Expires" }}
    SHA1{{ sha1 }}
    Subject{{ issued_to }}
    Issuer{{ issued_by }}{% if issued_to == issued_by %} Self-Signed{% endif %}
    Expires{{ signer|get_item:"Expires" }}
    SHA1{{ sha1 }}
    +
    +
    +
    + {% endwith %} + {% endif %}{% endfor %} +
    + + {% comment %}--- Timestamp Chain ---{% endcomment %} + {% for signer in gs.aux_signers %}{% if "Timestamp Chain" in signer.name %}{% if forloop.first %} +
    + Timestamp Chain +
    +
    + {% endif %} +
    +
    + +
    +
    +
    + + + + + +
    Subject{{ signer|get_item:"Issued to" }}
    Issuer{{ signer|get_item:"Issued by" }}
    Expires{{ signer|get_item:"Expires" }}
    SHA1{{ signer|get_item:"SHA1 hash" }}
    +
    +
    +
    + {% if forloop.last %}
    {% endif %} + {% endif %}{% endfor %} + + {% elif ds %} + {% comment %}Fallback: no aux_signers, show digital_signers directly{% endcomment %} +
    + {% for dc in ds %} +
    +
    + +
    +
    +
    + + {% if dc.subject_commonName %}{% endif %} + {% if dc.subject_organizationName %}{% endif %} + {% if dc.subject_organizationalUnitName %}{% endif %} + {% if dc.subject_countryName %}{% endif %} + {% if dc.subject_emailAddress %}{% endif %} + + {% if dc.issuer_commonName %}{% endif %} + {% if dc.issuer_organizationName %}{% endif %} + {% if dc.subject_organizationalUnitName %}{% endif %} + + {% if dc.not_before %}{% endif %} + {% if dc.not_after %}{% endif %} + {% if dc.serial_number %}{% endif %} + + {% if dc.sha1_fingerprint %}{% endif %} + {% if dc.md5_fingerprint %}{% endif %} + {% if dc.sha256_fingerprint %}{% endif %} +
    Subject CN{{ dc.subject_commonName }}
    Subject O{{ dc.subject_organizationName }}
    Subject OU{{ dc.subject_organizationalUnitName }}
    Subject C{{ dc.subject_countryName }}
    Subject E{{ dc.subject_emailAddress }}

    Issuer CN{{ dc.issuer_commonName }}
    Issuer O{{ dc.issuer_organizationName }}
    Issuer OU{{ dc.issuer_organizationalUnitName }}

    Not Before{{ dc.not_before }}
    Not After{{ dc.not_after }}
    Serial{{ dc.serial_number }}

    SHA1{{ dc.sha1_fingerprint }}
    MD5{{ dc.md5_fingerprint }}
    SHA256{{ dc.sha256_fingerprint }}
    +
    +
    +
    + {% endfor %} +
    + {% endif %} + +
    + {% endif %} + +
    +
    + +{% endwith %} +{% endif %} +{% endif %} diff --git a/web/templates/analysis/overview/index.html b/web/templates/analysis/overview/index.html index 7cad55a9988..9e1db28e619 100644 --- a/web/templates/analysis/overview/index.html +++ b/web/templates/analysis/overview/index.html @@ -132,6 +132,10 @@
    Parent File Info
    {% include "analysis/overview/_url.html" %} {% endif %} +{% if file.pe.digital_signers or file.pe.guest_signers.aux_signers %} + {% include "analysis/overview/_authenticode.html" %} +{% endif %} + {% if analysis.capa_summary %} {% include "analysis/overview/_capa_summary.html" %} From d9542f372e2968410256d1ad3d477f721f489931 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Tue, 12 May 2026 11:27:05 -0500 Subject: [PATCH 09/24] =?UTF-8?q?feat:=20Analysis=20list=20view=20enhancem?= =?UTF-8?q?ents=20=E2=80=94=20YARA,=20task=20tags,=20submitter,=20search?= =?UTF-8?q?=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **index.html:** - Task tags: render as clickable `tags_tasks:` search links (red badges) instead of static spans - YARA column (`display_cape_yara`): shows count of CAPE-yara hits with tooltip listing rule names; default off in web.conf.default - Submitter column (`display_submitter`): shows username of submitting user when WEB_AUTHENTICATION is enabled; default off - Task tags column (`display_task_tags`): already existed, now properly gated and tags are clickable - Detection badges use `bg-danger` (CAPE red) instead of blue Bootstrap default **search.html:** - Results table now mirrors the main analysis list exactly: same columns, same config gates (display_task_tags, display_submitter, display_cape_yara, display_clamav, expanded suricata), same badge styles - Previously the search results table was missing YARA, tags, submitter, and the expanded Suricata columns **overview/_info.html:** - Submitter row in the analysis report info card, gated on `settings.WEB_AUTHENTICATION and config.display_submitter` **header.html:** - "API Keys" menu item only shown to users with `may_manage_apikeys` context (local users always; SSO users only when is_staff=True) --- web/templates/analysis/index.html | 106 +++++++++-- web/templates/analysis/overview/_info.html | 22 ++- web/templates/analysis/search.html | 209 ++++++++------------- web/templates/header.html | 4 + 4 files changed, 181 insertions(+), 160 deletions(-) diff --git a/web/templates/analysis/index.html b/web/templates/analysis/index.html index 7a575886d36..8c464fd5ff2 100644 --- a/web/templates/analysis/index.html +++ b/web/templates/analysis/index.html @@ -80,6 +80,9 @@
    Rece {% if config.display_task_tags %} Task tags {% endif %} + {% if settings.WEB_AUTHENTICATION and config.display_submitter %} + Submitter + {% endif %} {% if config.moloch %} Moloch @@ -103,6 +106,9 @@
    Rece {% if config.display_clamav %} ClamAV {% endif %} + {% if config.display_cape_yara %} + YARA + {% endif %} Status @@ -183,8 +189,15 @@
    Rece {% if config.display_task_tags %} - {% if analysis.user_task_tags %} - {{analysis.user_task_tags}} + {% for t in analysis.user_task_tags %}{{t}}{% endfor %} + + {% endif %} + {% if settings.WEB_AUTHENTICATION and config.display_submitter %} + + {% if analysis.submitter_username %} + {{analysis.submitter_username}} + {% else %} + - {% endif %} {% endif %} @@ -262,11 +275,24 @@
    Rece {% endif %} {% if config.display_clamav %} - {% if analysis.clamav %} - {{analysis.clamav}} - {% else %} - - - {% endif %} + + {% if analysis.clamav %} + {{analysis.clamav|length}} + {% else %} + - + {% endif %} + + + {% endif %} + {% if config.display_cape_yara %} + + + {% if analysis.cape_yara %} + {{analysis.cape_yara|length}} + {% else %} + - + {% endif %} + {% endif %} @@ -367,6 +393,9 @@
    {% if config.display_task_tags %} Task tags {% endif %} + {% if settings.WEB_AUTHENTICATION and config.display_submitter %} + Submitter + {% endif %} {% if config.moloch %} Moloch {% endif %} @@ -382,6 +411,9 @@
    {% if config.display_clamav %} ClamAV {% endif %} + {% if config.display_cape_yara %} + YARA + {% endif %} Status @@ -446,8 +478,15 @@
    {% if config.display_task_tags %} - {% if analysis.user_task_tags %} - {{analysis.user_task_tags}} + {% for t in analysis.user_task_tags %}{{t}}{% endfor %} + + {% endif %} + {% if settings.WEB_AUTHENTICATION and config.display_submitter %} + + {% if analysis.submitter_username %} + {{analysis.submitter_username}} + {% else %} + - {% endif %} {% endif %} @@ -494,11 +533,24 @@
    {% endif %} {% if config.display_clamav %} - {% if analysis.clamav %} - {{analysis.clamav}} - {% else %} - - - {% endif %} + + {% if analysis.clamav %} + {{analysis.clamav|length}} + {% else %} + - + {% endif %} + + + {% endif %} + {% if config.display_cape_yara %} + + + {% if analysis.cape_yara %} + {{analysis.cape_yara|length}} + {% else %} + - + {% endif %} + {% endif %} @@ -612,6 +664,9 @@
    Rece {% if config.display_clamav %} ClamAV {% endif %} + {% if config.display_cape_yara %} + YARA + {% endif %} Status {% else %} ID @@ -736,11 +791,24 @@
    Rece {% endif %} {% if config.display_clamav %} - {% if analysis.clamav %} - {{analysis.clamav}} - {% else %} - - - {% endif %} + + {% if analysis.clamav %} + {{analysis.clamav|length}} + {% else %} + - + {% endif %} + + + {% endif %} + {% if config.display_cape_yara %} + + + {% if analysis.cape_yara %} + {{analysis.cape_yara|length}} + {% else %} + - + {% endif %} + {% endif %} diff --git a/web/templates/analysis/overview/_info.html b/web/templates/analysis/overview/_info.html index 9b4e4cf1299..a35da02342a 100644 --- a/web/templates/analysis/overview/_info.html +++ b/web/templates/analysis/overview/_info.html @@ -49,6 +49,8 @@
    An Started Completed Duration + {% if settings.WEB_AUTHENTICATION and config.display_submitter and analysis.info.submitter_username %}Submitter{% endif %} + {% if analysis.info.tags_tasks %}Task Tags{% endif %} {% if analysis.info.options %}Options{% endif %} {% if user.is_staff and analysis.distributed %}Distributed{% endif %} {% if analysis.debug.log or analysis.process_log %}Logs{% endif %} @@ -64,6 +66,12 @@
    An {{analysis.info.started}} {{analysis.info.ended}} {{analysis.info.duration}}s + {% if settings.WEB_AUTHENTICATION and config.display_submitter and analysis.info.submitter_username %} + {{analysis.info.submitter_username}} + {% endif %} + {% if analysis.info.tags_tasks %} + {% for t in analysis.info.tags_tasks %}{{t}}{% endfor %} + {% endif %} {% if analysis.info.options %} {% endif %} @@ -196,7 +204,7 @@
    During-Script Log
    -{% if analysis.info.machine %} +{% if analysis.info.machine and analysis.info.machine.name %}
    @@ -207,7 +215,6 @@
    Machin Name - {% if analysis.distributed %}Node{% endif %} Label Manager Started On @@ -217,12 +224,11 @@
    Machin - {% if analysis.info.machine.name %}{{analysis.info.machine.name}}{% else %}{{analysis.info.machine}}{% endif %} - {% if analysis.distributed %}{{analysis.distributed.name}}{% endif %} - {% if analysis.info.machine.label %}{{analysis.info.machine.label}}{% else %}-{% endif %} - {% if analysis.info.machine.manager %}{{analysis.info.machine.manager}}{% else %}-{% endif %} - {% if analysis.info.machine.started_on %}{{analysis.info.machine.started_on}}{% else %}-{% endif %} - {% if analysis.info.machine.shutdown_on %}{{analysis.info.machine.shutdown_on}}{% else %}-{% endif %} + {{analysis.info.machine.name}} + {{analysis.info.machine.label}} + {{analysis.info.machine.manager}} + {{analysis.info.machine.started_on}} + {{analysis.info.machine.shutdown_on}} {% if analysis.info.route %} {{analysis.info.route}} {% endif %} diff --git a/web/templates/analysis/search.html b/web/templates/analysis/search.html index 791ad5eb447..c7fc7022a13 100644 --- a/web/templates/analysis/search.html +++ b/web/templates/analysis/search.html @@ -133,197 +133,140 @@

    Results for term: {{term}}
    Search Results
    - {{analyses|length}} items + {{analyses|length}} results
    - + - + - - - + + + + {% if config.display_task_tags %} + + {% endif %} + {% if settings.WEB_AUTHENTICATION and config.display_submitter %} + + {% endif %} {% if config.moloch %} - + {% endif %} - {% if config.display_office_martians or config.display_browser_martians%} + {% if config.display_office_martians %} {% endif %} {% if config.suricata %} - + {% endif %} {% if config.virustotal %} - + {% endif %} {% if config.malscore %} - + {% endif %} - {% if config.expanded_dashboard %} - {% if config.display_clamav %} - + {% endif %} + {% if config.display_cape_yara %} + {% endif %} - + {% for analysis in analyses %} + - - + - {% if config.moloch %} + {% if config.display_task_tags %} + + {% endif %} + {% if settings.WEB_AUTHENTICATION and config.display_submitter %} {% endif %} - {% if analysis.category == "url" %} - {% if config.display_browser_martians %} - - {% endif %} - {% else %} - {% if config.display_office_martians %} - - {% endif %} + {% if config.moloch %} + + {% endif %} + {% if config.display_office_martians %} + {% endif %} {% if config.suricata %} - {% endif %} {% if config.virustotal %} - + {% endif %} {% if config.malscore %} {% endif %} - {% if config.expanded_dashboard %} - {% if config.display_clamav %} - + {% endif %} + {% if config.display_cape_yara %} + {% endif %} {% endfor %} @@ -331,7 +274,7 @@
    Search Results
    IDID TimestampPackageFilenameTargetPackageFilenameTarget DetectionsTask tagsSubmitterMolochMolochMartiansSuriAlert SuriAlert{% if config.expanded_dashboard %}/HTTP/TLS/Files{% endif %}VTVTMalScoreMalScorePCAPClamAVClamAVYARAStatusStatus
    {{analysis.id}} - {{analysis.id}} - - {% if analysis.status == "reported" %} - {{analysis.completed_on}} - {% else %} - {{analysis.added_on}} (added) - {% endif %} - - {{analysis.package}} + {% if analysis.status == "reported" %}{{analysis.completed_on}} + {% else %}{{analysis.added_on}} (added) + {% endif %} {{analysis.package}} - {% if analysis.filename %} - {{analysis.filename}} - {% else %} - - - {% endif %} + {% if analysis.filename %}{{analysis.filename}}{% else %}-{% endif %} {% if analysis.status == "reported" %} - {% if analysis.category == "url" %} - {{analysis.target}} - {% else %} - {{analysis.sample.md5}} - {% endif %} + {% if analysis.category == "url" %}{{analysis.target}}{% else %}{{analysis.sample.md5}}{% endif %} {% else %} - {% if analysis.category == "url" %} - {{analysis.target}} - {% else %} - {{analysis.sample.md5}} - {% endif %} + {% if analysis.category == "url" %}{{analysis.target}}{% else %}{{analysis.sample.md5}}{% endif %} {% endif %} - {% if analysis.detections %} - {% if analysis.detections|is_string %} - {{analysis.detections}} - {% elif analysis.detections|length == 1 %} - {{analysis.detections.0.family}} - {% elif analysis.detections|length > 1 %} - Multiple ({{analysis.detections|length}}) - {% endif %} + {% if analysis.detections|is_string %} + {{analysis.detections}} + {% elif analysis.detections|length == 1 %} + {{analysis.detections.0.family}} + {% elif analysis.detections|length > 1 %} + Multiple ({{analysis.detections|length}}) {% endif %} {% for t in analysis.user_task_tags %}{{t}}{% endfor %} - {% if analysis.moloch_url %} - MOLOCH - {% else %} - - - {% endif %} + {% if analysis.submitter_username %}{{analysis.submitter_username}} + {% else %}-{% endif %} - - {% if analysis.mlist_cnt %} - {{analysis.mlist_cnt}} - {% else %} - - - {% endif %} - - - - {% if analysis.f_mlist_cnt %} - {{analysis.f_mlist_cnt}} - {% else %} - - - {% endif %} - - {% if analysis.moloch_url %}MOLOCH{% else %}-{% endif %}{% if analysis.f_mlist_cnt %}{{analysis.f_mlist_cnt}}{% else %}-{% endif %} - - {% if analysis.suri_alert_cnt %} - {{analysis.suri_alert_cnt}}/{{analysis.suri_http_cnt}}/-/{{analysis.suri_tls_cnt}}/-/{{analysis.suri_file_cnt}}/- - {% if analysis.virustotal_summary %} - {{analysis.virustotal_summary}} - {% else %} - - - {% endif %} - {% if analysis.virustotal_summary %}{{analysis.virustotal_summary}}{% else %}-{% endif %} {% if analysis.malscore != None %} - - {{analysis.malscore|floatformat:1}} - - {% else %} - - - {% endif %} + {{analysis.malscore|floatformat:1}} + {% else %}-{% endif %} - - {% if analysis.pcap_sha256 %} - - {% else %} - - - {% endif %} - - - - {% if analysis.clamav %} - {{analysis.clamav}} - {% else %} - - - {% endif %} - - + {% if analysis.clamav %}{{analysis.clamav|length}} + {% else %}-{% endif %} + + {% if analysis.cape_yara %}{{analysis.cape_yara|length}} + {% else %}-{% endif %} + - {% if analysis.status == "pending" %} - pending - {% elif analysis.status == "running" %} - running - {% elif analysis.status == "completed" %} - processing + {% if analysis.status == "pending" %}pending + {% elif analysis.status == "running" %}running + {% elif analysis.status == "distributed" %}distributed + {% elif analysis.status == "completed" %}processing {% elif analysis.status == "reported" %} - {% if analysis.errors %} - error - {% else %} - reported - {% endif%} - {% else %} - {{analysis.status}} - {% endif %} + {% if analysis.errors %}error + {% else %}reported{% endif %} + {% else %}{{analysis.status}}{% endif %}
    - {% else %} + {% else %} diff --git a/web/templates/header.html b/web/templates/header.html index 5c823d17db4..c3e7fdf7a1b 100644 --- a/web/templates/header.html +++ b/web/templates/header.html @@ -32,6 +32,10 @@ Change password Reset password Email configuration + {% if user.is_authenticated and may_manage_apikeys %} + + API Keys + {% endif %}

    {% endif %} From 95c9b1fe9897adc8c0c381f014905b6722a172da Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Tue, 12 May 2026 11:28:16 -0500 Subject: [PATCH 10/24] feat: network_etw processing module + ETW Telemetry tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new processing module that correlates ETW (Event Tracing for Windows) telemetry with network observations to provide per-process attribution for DNS queries, TCP/UDP connections, TLS sessions, HTTP transactions, and extracted files. ## Processing module (modules/processing/network_etw.py) ProcessFlowIndex: a correlation engine built from multiple ETW sources: - Sysmon EID 3 (NetworkConnect): direct TCP/UDP with PID - Kernel-network ETW: per-flow (src_port, dst_ip, dst_port, pid) - Sysmon EID 1 (ProcessCreate): PID→image mapping - Sysmon EID 22 (DnsQuery) + DNS-Client ETW: hostname→PID - behavior.network_map.dns_intents: hooked getaddrinfo/DnsQuery API calls - UDP/53 fallback: correlates raw UDP flows with Suricata DNS src_port - Suricata DNS/HTTP: adds resolutions and HTTP host attributions Enriches existing network records (suricata.alerts/tls/http, network.dns, network.hosts, network.http_ex, dropped files, sigma detections) with per-process attribution without duplicating data. ## Analyzer auxiliary module network_etw.py auxiliary: removed port 53 from the UDP filter so DNS-over-UDP flows include the ephemeral source port needed for UDP/53 fallback attribution. ## ETW Telemetry tab (web/) New tab on the analysis report (gated on `display_etw` and `analysis.has_etw`): - DNS sub-tab: queries with process attribution and resolution data - Network sub-tab: TCP/UDP connections with process, geo, ASN - WMI sub-tab: WMI activity timeline - ThreatIntel sub-tab: ETW-sourced threat intelligence events - AMSI sub-tab: AMSI scan events ## Configuration New web.conf toggle: `[display_etw] enabled = no` (default off). --- .../windows/modules/auxiliary/network_etw.py | 4 +- modules/processing/network_etw.py | 334 ++++++-- web/analysis/views.py | 730 +++++++++++++++++- web/templates/analysis/etw/_amsi.html | 44 ++ web/templates/analysis/etw/_dns.html | 34 + web/templates/analysis/etw/_network.html | 38 + web/templates/analysis/etw/_threatintel.html | 252 ++++++ web/templates/analysis/etw/_wmi.html | 32 + web/templates/analysis/etw/index.html | 82 ++ web/templates/analysis/report.html | 16 + 10 files changed, 1487 insertions(+), 79 deletions(-) create mode 100644 web/templates/analysis/etw/_amsi.html create mode 100644 web/templates/analysis/etw/_dns.html create mode 100644 web/templates/analysis/etw/_network.html create mode 100644 web/templates/analysis/etw/_threatintel.html create mode 100644 web/templates/analysis/etw/_wmi.html create mode 100644 web/templates/analysis/etw/index.html diff --git a/analyzer/windows/modules/auxiliary/network_etw.py b/analyzer/windows/modules/auxiliary/network_etw.py index 3411914634c..a25fcd9ed7d 100644 --- a/analyzer/windows/modules/auxiliary/network_etw.py +++ b/analyzer/windows/modules/auxiliary/network_etw.py @@ -2,6 +2,7 @@ import logging import os import shutil +import socket import time from threading import Thread @@ -15,6 +16,7 @@ ProviderInfo, GUID, et, + encode, ) log = logging.getLogger(__name__) @@ -176,7 +178,7 @@ def __init__(self, options, config): log.debug("Could not read analysis config for filters: %s", e) filter_ports.add(8000) - filter_ports.add(53) + # filter_ports.add(53) # do NOT filter DNS — we need UDP/53 events with PIDs to attribute sample DNS queries that bypass dnsapi.dll (direct UDP DNS in malware) log.info("NetworkETW filters: ips=%s ports=%s", filter_ips, filter_ports) diff --git a/modules/processing/network_etw.py b/modules/processing/network_etw.py index 4f5e31eafb4..6d5c34fc633 100644 --- a/modules/processing/network_etw.py +++ b/modules/processing/network_etw.py @@ -41,6 +41,85 @@ except ImportError: HAVE_EVTX = False +try: + from evtx import PyEvtxParser # evtx-rs (Rust-backed) — ~150x faster + HAVE_EVTX_RS = True +except ImportError: + HAVE_EVTX_RS = False + PyEvtxParser = None + + +def _iter_sysmon_records(evtx_path, wanted_eids): + """Yield {eid, time, data: {name: value}} for matching records. + + Uses evtx-rs when available (sub-second vs ~50s for python-evtx on a + typical 7000-record sysmon snapshot). Falls back transparently to the + python-evtx + ElementTree pipeline when evtx-rs isn't importable so + deployments without the Rust binding continue to work.""" + wanted = set(wanted_eids) + if HAVE_EVTX_RS: + try: + parser = PyEvtxParser(evtx_path) + for rec in parser.records_json(): + try: + d = json.loads(rec["data"]) + except Exception: + continue + ev = d.get("Event") or {} + sysd = ev.get("System") or {} + eid_v = sysd.get("EventID") + if isinstance(eid_v, dict): + eid_v = eid_v.get("#text") if eid_v.get("#text") is not None else eid_v.get("@_value") + eid = str(eid_v) if eid_v is not None else "" + if eid not in wanted: + continue + tc = sysd.get("TimeCreated") or {} + if isinstance(tc, dict): + raw_t = (tc.get("#attributes") or {}).get("SystemTime") or "" + else: + raw_t = str(tc) + time_str = raw_t.replace("T", " ").rstrip("Z") if raw_t else None + data = ev.get("EventData") or {} + if not isinstance(data, dict): + data = {} + # Stringify all values — downstream consumers expect strings. + data = {k: ("" if v is None else str(v)) for k, v in data.items()} + yield {"eid": eid, "time": time_str, "data": data} + return + except Exception as e: + log.warning("evtx-rs parse failed for %s: %s — falling back to python-evtx", evtx_path, e) + + if not HAVE_EVTX: + return + try: + with EvtxParser.Evtx(evtx_path) as ef: + for rec in ef.records(): + try: + root = ET.fromstring(rec.xml()) + except ET.ParseError: + continue + sys_elem = root.find(EVT_NS + "System") + if sys_elem is None: + continue + eid_elem = sys_elem.find(EVT_NS + "EventID") + if eid_elem is None or eid_elem.text not in wanted: + continue + tc_elem = sys_elem.find(EVT_NS + "TimeCreated") + time_str = None + if tc_elem is not None: + raw_t = tc_elem.get("SystemTime", "") or "" + time_str = raw_t.replace("T", " ").rstrip("Z") if raw_t else None + ed = root.find(EVT_NS + "EventData") + fields = {} + if ed is not None: + for d in ed.findall(EVT_NS + "Data"): + name = d.get("Name") + if name: + fields[name] = (d.text or "").strip() + yield {"eid": eid_elem.text, "time": time_str, "data": fields} + except Exception: + log.debug("Failed to parse %s", evtx_path, exc_info=True) + def _clean_ip(s): if not s: @@ -76,15 +155,15 @@ class AttributionIndex: def __init__(self): self._pid_to_name = {} # pid_str -> basename self._by_ip = {} # ip -> [{pid, process_name, dst_port, protocol, source}] - self._dns_host_to_pid = {} # host -> (pid_str, name, source) + self._dns_host_to_pid = {} # host -> [(pid_str, name, source), ...] self._host_to_ips = {} # host -> set(ip) self._ip_via_dns = {} # ip -> [(pid_str, host)] self._http_by_uri = {} # (host, uri) -> (pid_str, name) self._http_by_host = {} # host -> (pid_str, name) # Counters surfaced via .stats() for logging self.stats_counters = {"dns_etw": 0, "sysmon_eid22": 0, - "sigma_eid22": 0, "direct": 0, - "resolutions": 0} + "sigma_eid22": 0, "udp53_fallback": 0, + "behavior": 0, "direct": 0, "resolutions": 0} # ------------------------------------------------------------------ seed def add_pid_name(self, pid, image_or_name): @@ -158,7 +237,9 @@ def add_dns_query(self, pid, hostname, image_or_name="", source=""): # useful attribution. if name and "svchost" in name.lower(): return - self._dns_host_to_pid.setdefault(h, (pid, name, source)) + entries = self._dns_host_to_pid.setdefault(h, []) + if not any(e[0] == pid for e in entries): + entries.append((pid, name, source)) if source in self.stats_counters: self.stats_counters[source] += 1 @@ -177,9 +258,10 @@ def add_resolution(self, hostname, ip): # --------------------------------------------------------------- finalize def finalize(self): """Cross-reference DNS queries × resolutions into ip_via_dns.""" - for host, (pid, name, source) in self._dns_host_to_pid.items(): + for host, entries in self._dns_host_to_pid.items(): for ip in self._host_to_ips.get(host, ()): - self._ip_via_dns.setdefault(ip, []).append((pid, host)) + for pid, _name, _src in entries: + self._ip_via_dns.setdefault(ip, []).append((pid, host)) # --------------------------------------------------------------- queries def for_ip(self, ip, dst_port=None, src_port=None): @@ -237,17 +319,32 @@ def for_flow(self, dstip="", dstport=None, srcip="", srcport=None): or self.for_ip(srcip, dst_port=srcport, src_port=dstport)) def for_host(self, hostname): - """(pid, name) that queried this hostname, or None. Used for files - and network.dns records.""" + """(pid, name) for the first process that queried this hostname, or None.""" h = _clean_host(hostname) if not h: return None - rec = self._dns_host_to_pid.get(h) - if not rec: + entries = self._dns_host_to_pid.get(h) + if not entries: return None - pid, name, _src = rec + pid, name, _src = entries[0] return (pid, name) + def for_host_all(self, hostname): + """All processes that queried hostname. Returns [{pid, process_name, source}].""" + h = _clean_host(hostname) + if not h: + return [] + entries = self._dns_host_to_pid.get(h) or [] + seen = set() + result = [] + for pid, name, source in entries: + key = (pid, name) + if key in seen: + continue + seen.add(key) + result.append({"pid": pid, "process_name": name, "source": source}) + return result + def for_http(self, host, uri): """(pid, name) from an already-enriched HTTP transaction. Prefer an exact (host, uri) match; fall back to host alone; finally DNS. @@ -368,52 +465,45 @@ def _parse_sysmon_evtx(self): if path is None: continue try: - with EvtxParser.Evtx(path) as ef: - for rec in ef.records(): - try: - root = ET.fromstring(rec.xml()) - except ET.ParseError as parse_err: - log.debug("Skipping malformed evtx record in %s: %s", - fname, parse_err) - continue - sys_elem = root.find(EVT_NS + "System") - if sys_elem is None: - continue - eid_elem = sys_elem.find(EVT_NS + "EventID") - if eid_elem is None or eid_elem.text not in ("1", "3", "22"): - continue - eid = eid_elem.text - fields = self._read_evt_data(root) - - if eid == "1": - pid = fields.get("ProcessId", "") - image = fields.get("Image", "") - if pid and image: - pid_to_image[str(pid)] = os.path.basename(image) - - elif eid == "22": - pid = fields.get("ProcessId", "") - qname = _clean_host(fields.get("QueryName", "")) - image = fields.get("Image", "") - if pid and qname: - dns_queries.append((str(pid), qname, image)) - if pid and image: - pid_to_image.setdefault(str(pid), os.path.basename(image)) - - else: # "3" - connections.append({ - "pid": fields.get("ProcessId", ""), - "process_name": os.path.basename(fields.get("Image", "")), - "process_path": fields.get("Image", ""), - "protocol": fields.get("Protocol", "").upper(), - "direction": "outbound" if fields.get("Initiated") == "true" else "inbound", - "src_ip": fields.get("SourceIp", ""), - "src_port": fields.get("SourcePort", ""), - "dst_ip": fields.get("DestinationIp", ""), - "dst_port": fields.get("DestinationPort", ""), - "dst_hostname": fields.get("DestinationHostname", ""), - "source": "sysmon", - }) + # Prefer the Rust-backed evtx-rs parser when available — + # ~150x faster than python-evtx's per-record xml() + + # ElementTree pipeline (sub-second vs ~50s for a typical + # 7000-record sysmon snapshot). Falls back to the slow + # path when evtx-rs isn't installed so deployments + # without it continue to work. + for rec in _iter_sysmon_records(path, ("1", "3", "22")): + eid = rec["eid"] + fields = rec["data"] + + if eid == "1": + pid = fields.get("ProcessId", "") + image = fields.get("Image", "") + if pid and image: + pid_to_image[str(pid)] = os.path.basename(image) + + elif eid == "22": + pid = fields.get("ProcessId", "") + qname = _clean_host(fields.get("QueryName", "")) + image = fields.get("Image", "") + if pid and qname: + dns_queries.append((str(pid), qname, image)) + if pid and image: + pid_to_image.setdefault(str(pid), os.path.basename(image)) + + else: # "3" + connections.append({ + "pid": fields.get("ProcessId", ""), + "process_name": os.path.basename(fields.get("Image", "")), + "process_path": fields.get("Image", ""), + "protocol": fields.get("Protocol", "").upper(), + "direction": "outbound" if str(fields.get("Initiated")).lower() == "true" else "inbound", + "src_ip": fields.get("SourceIp", ""), + "src_port": fields.get("SourcePort", ""), + "dst_ip": fields.get("DestinationIp", ""), + "dst_port": fields.get("DestinationPort", ""), + "dst_hostname": fields.get("DestinationHostname", ""), + "source": "sysmon", + }) except Exception: log.debug("Failed to parse sysmon EVTX %s", fname, exc_info=True) except Exception: @@ -543,10 +633,16 @@ def run(self): ) # DNS queries (pid -> hostname) -------------------------------------- - for pid, host in self._parse_dns_etw(): - idx.add_dns_query(pid, host, source="dns_etw") + # Order matters: add_dns_query uses setdefault, so the FIRST source + # to register a hostname wins. Sysmon EID 22 has the originating + # process (with Image), DNS-Client ETW only has the system resolver + # PID (svchost/dnscache) for the delegated lookup. Add sysmon first + # so the meaningful attribution survives when both sources see the + # same hostname. for pid, host, image in sysmon_dns_queries: idx.add_dns_query(pid, host, image, source="sysmon_eid22") + for pid, host in self._parse_dns_etw(): + idx.add_dns_query(pid, host, source="dns_etw") for det in sigma.get("detections", []) or []: for ev in det.get("matched_events", []) or []: if ev.get("EventID") != 22: @@ -557,6 +653,66 @@ def run(self): idx.add_dns_query(pid, ev.get("QueryName", ""), ev.get("Image", ""), source="sigma_eid22") + # Behavioral getaddrinfo / DnsQuery calls -------------------------------- + # behavior.network_map.dns_intents is pre-built by NetworkMap (behavior.py) + # from hooked getaddrinfo/DnsQuery calls. Carries the originating PID + # directly, bypassing DNS-ETW's svchost delegation noise. + _dns_intents = ( + (self.results.get("behavior") or {}) + .get("network_map", {}) + .get("dns_intents", {}) + ) or {} + for _host, _intents in _dns_intents.items(): + for _intent in _intents or []: + _proc_info = _intent.get("process") or {} + _pid = str(_proc_info.get("process_id") or "") + _name = _proc_info.get("process_name", "") + if _pid: + idx.add_dns_query(_pid, _host, _name, source="behavior") + + # UDP/53 fallback attribution ----------------------------------------- + # Some malware bypasses dnsapi.dll and sends DNS over a raw UDP + # socket — sysmon EID 22 and DNS-Client ETW both miss those. The + # kernel-network ETW provider does see the UDP send (with PID + + # src_port + dst_ip) but doesn't carry the DNS question payload. + # Suricata DNS events include src_port from the wire pcap, so we + # can correlate the two by (src_port, dst_ip) — the OS allocates a + # unique source port per outbound UDP query, giving a clean join. + suricata_for_udp53 = self.results.get("suricata", {}) or {} + udp53_by_key = {} + for ev in etw_conns: + if (ev.get("protocol") or "").upper() != "UDP": + continue + if str(ev.get("dst_port")) != "53": + continue + pid = ev.get("pid") + if not pid: + continue + key = (str(ev.get("src_port")), ev.get("dst_ip", "")) + udp53_by_key.setdefault(key, (pid, ev.get("process_name", ""))) + if udp53_by_key: + for rec in suricata_for_udp53.get("dns", []) or []: + # Only fill in PIDs we don't already know. + q = (rec.get("rrname") or rec.get("query") or "").lower() + if not q or q in idx._dns_host_to_pid: + continue + src_port = str(rec.get("src_port", "")) + dst_ip = str(rec.get("dest_ip") or rec.get("server_ip") or "") + if not src_port: + continue + # Try exact 5-tuple match first, then src_port-only fallback + # (src ports are nearly unique within an analysis window + # because the OS allocates ephemeral ports incrementally). + hit = udp53_by_key.get((src_port, dst_ip)) + if hit is None: + cand = [v for k, v in udp53_by_key.items() if k[0] == src_port] + if len(cand) == 1: + hit = cand[0] + if hit is None: + continue + pid, name = hit + idx.add_dns_query(pid, q, name, source="udp53_fallback") + # Resolutions (hostname -> IPs) -------------------------------------- suricata = self.results.get("suricata", {}) or {} network = self.results.get("network", {}) or {} @@ -633,14 +789,17 @@ def run(self): log.info( "network_etw: sources — %d sysmon conns, %d kernel-ETW conns, " - "%d pid->image, %d sysmon DNS, %d DNS-ETW pairs, %d resolutions", + "%d pid->image, %d sysmon DNS, %d DNS-ETW pairs, " + "%d UDP/53-fallback DNS, %d behavior DNS, %d resolutions", len(sysmon_conns), len(etw_conns), len(sysmon_pid_to_image), len(sysmon_dns_queries), idx.stats_counters.get("dns_etw", 0), + idx.stats_counters.get("udp53_fallback", 0), + idx.stats_counters.get("behavior", 0), idx.stats_counters.get("resolutions", 0), ) # Enrichment loops — all go through the single index ---------------- - enriched = {k: 0 for k in ("alerts", "tls", "http", "files", + enriched = {k: 0 for k in ("alerts", "tls", "http", "http_ex", "files", "tcp", "udp", "hosts", "dns", "sigma")} def apply(rec, hit): @@ -650,12 +809,32 @@ def apply(rec, hit): rec["process_id"] = hit.get("pid", "") return True - # suricata.alerts — bidirectional (ingress-direction rules dst=VM) + def apply_host_lookup(rec, hostname): + """Use DNS-resolution data (idx.for_host) to attribute a record + when raw flow lookup misses — typical for alerts that fire on + a UDP:53 packet, where the kernel-network ETW connection table + never sees the request because DNS goes through the system + resolver, not the originating process. for_host returns the + PID that asked for the hostname (per DNS-Client ETW).""" + hit = idx.for_host(hostname or "") + if not hit: + return False + pid, name = hit + rec["process_name"] = name or "" + rec["process_id"] = pid or "" + return True + + # suricata.alerts — bidirectional (ingress-direction rules dst=VM). + # If raw flow lookup misses and the alert carries a dns_query + # (propagated from eve.json), fall back to attributing via the + # process that asked for that hostname. for rec in suricata.get("alerts", []) or []: hit = idx.for_flow(rec.get("dstip", ""), rec.get("dstport"), rec.get("srcip", ""), rec.get("srcport")) if apply(rec, hit): enriched["alerts"] += 1 + elif rec.get("dns_query") and apply_host_lookup(rec, rec["dns_query"]): + enriched["alerts"] += 1 # suricata.tls + http — dst-based (with src fallback too, for safety) for kind in ("tls", "http"): @@ -688,13 +867,24 @@ def apply(rec, hit): if apply(rec, hit): enriched[proto] += 1 + # network.http_ex / network.https_ex — httpreplay-extracted HTTP + # transactions carry full src/sport/dst/dport so flow lookup is + # exact. Without this, the HTTP details panel shows "-" even + # though suricata.http for the same flow is attributed. + for kind in ("http_ex", "https_ex"): + for rec in network.get(kind, []) or []: + hit = idx.for_flow(rec.get("dst", ""), rec.get("dport"), + rec.get("src", ""), rec.get("sport")) + if apply(rec, hit): + enriched["http_ex"] += 1 + # network.dns — via DNS-query hostname (never by UDP 53 flow owner) for rec in network.get("dns", []) or []: - hit = idx.for_host(rec.get("request", "")) - if hit: - pid, name = hit - rec["process_name"] = name - rec["process_id"] = pid + hits = idx.for_host_all(rec.get("request", "")) + if hits: + rec["processes"] = hits + rec["process_name"] = hits[0]["process_name"] + rec["process_id"] = hits[0]["pid"] enriched["dns"] += 1 # network.hosts — may have multiple owners; list all @@ -731,11 +921,11 @@ def apply(rec, hit): enriched["sigma"] += 1 log.info( - "network_etw: enriched — %d alerts, %d tls, %d http, %d files, " - "%d tcp, %d udp, %d dns, %d hosts, %d sigma", - enriched["alerts"], enriched["tls"], enriched["http"], enriched["files"], - enriched["tcp"], enriched["udp"], enriched["dns"], enriched["hosts"], - enriched["sigma"], + "network_etw: enriched — %d alerts, %d tls, %d http, %d http_ex, " + "%d files, %d tcp, %d udp, %d dns, %d hosts, %d sigma", + enriched["alerts"], enriched["tls"], enriched["http"], + enriched["http_ex"], enriched["files"], enriched["tcp"], + enriched["udp"], enriched["dns"], enriched["hosts"], enriched["sigma"], ) return results diff --git a/web/analysis/views.py b/web/analysis/views.py index cda4d5afa9f..b7caabab9df 100644 --- a/web/analysis/views.py +++ b/web/analysis/views.py @@ -25,7 +25,8 @@ from django.shortcuts import redirect, render from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST, require_safe -from rest_framework.decorators import api_view +from rest_framework.authentication import SessionAuthentication +from rest_framework.decorators import api_view, authentication_classes MONGO_DOCUMENT_TOO_LARGE_ERRORS = () try: @@ -234,7 +235,27 @@ def get_analysis_info(db, id=-1, task=None): filename = os.path.basename(new["target"]) new.update({"filename": filename}) - new.update({"user_task_tags": get_tags_tasks([new["id"]])}) + # Submitter-supplied free-form tags. Stored in postgres as one + # comma-separated string; split (and trim) here so the list view can + # render one badge per tag, matching the per-job report. + raw_user_tags = get_tags_tasks([new["id"]]) or "" + new.update({"user_task_tags": [t.strip() for t in raw_user_tags.split(",") if t.strip()]}) + + # Submitter username for the "who submitted this task" column. user_id + # 0 = anonymous; for any real user, look up the Django username + # best-effort. Cheap enough per-task, not worth bulk-loading. + submitter_username = "" + user_id = new.get("user_id") or 0 + if user_id: + try: + from django.contrib.auth import get_user_model + User = get_user_model() + u = User.objects.filter(pk=user_id).only("username").first() + if u: + submitter_username = u.username + except Exception: + pass + new["submitter_username"] = submitter_username if new.get("machine"): machine = new["machine"] @@ -258,6 +279,17 @@ def get_analysis_info(db, id=-1, task=None): "mlist_cnt": 1, "f_mlist_cnt": 1, "target.file.clamav": 1, + "target.file.cape_yara": 1, + # The "YARA" column aggregates cape-emitted yara matches and + # generic yara matches — tasks with only generic hits would + # otherwise show null because cape_yara alone would be empty. + "target.file.yara": 1, + # File-level static fields (clamav, cape_yara, etc.) are + # normalized out into a separate `files` collection keyed + # by sha256; the denormalize_files mongo hook restores + # them — but only if file_ref is in the projection. Pull + # it explicitly so the hook can follow the reference. + "target.file.file_ref": 1, "suri_tls_cnt": 1, "suri_alert_cnt": 1, "suri_http_cnt": 1, @@ -282,6 +314,9 @@ def get_analysis_info(db, id=-1, task=None): "mlist_cnt", "f_mlist_cnt", "target.file.clamav", + "target.file.cape_yara", + "target.file.yara", + "target.file.file_ref", "suri_tls_cnt", "suri_alert_cnt", "suri_http_cnt", @@ -321,11 +356,31 @@ def get_analysis_info(db, id=-1, task=None): new["pcap_sha256"] = rtmp["network"]["pcap_sha256"] if rtmp.get("target", {}).get("file", False): + tfile = rtmp["target"]["file"] for keyword in ("clamav", "trid"): - if rtmp["info"].get(keyword, False): - new[keyword] = rtmp["info"]["target"][keyword] - if rtmp["target"]["file"].get("virustotal", {}).get("summary", False): - new["virustotal_summary"] = rtmp["target"]["file"]["virustotal"]["summary"] + # Pre-existing bug: this used to read rtmp["info"][keyword] + # which never exists — clamav / trid live under + # target.file. So the column data never made it through. + if tfile.get(keyword): + new[keyword] = tfile[keyword] + # cape_yara and yara are lists of {"name": ..., "meta": {...}} + # dicts. Merge them (preserving order, deduping by name) and + # collapse to a list of names for the YARA column display — + # tasks that only hit generic yara rules (no cape_yara) would + # otherwise show null even though they have real YARA matches. + seen_yara_names = set() + yara_names = [] + for y in (tfile.get("cape_yara") or []) + (tfile.get("yara") or []): + if not isinstance(y, dict): + continue + n = y.get("name") + if n and n not in seen_yara_names: + seen_yara_names.add(n) + yara_names.append(n) + if yara_names: + new["cape_yara"] = yara_names + if tfile.get("virustotal", {}).get("summary", False): + new["virustotal_summary"] = tfile["virustotal"]["summary"] if rtmp.get("url", {}).get("virustotal", {}).get("summary", False): new["virustotal_summary"] = rtmp["url"]["virustotal"]["summary"] @@ -650,6 +705,637 @@ def _evtx_has_records(data): return next_record > 1 +def _filetime_to_iso(ft): + """Windows FILETIME (100-ns intervals since 1601-01-01) → ISO 8601 UTC. + + Most ETW providers we ingest emit FILETIME as either an int or a + string-of-int. Anything that doesn't parse cleanly comes back as the + raw value so the UI at least surfaces it. Negative deltas (clock + skew, FILETIME=0 sentinels) yield empty string.""" + if ft in (None, ""): + return "" + try: + ft = int(ft) + except (TypeError, ValueError): + return str(ft) + if ft <= 0: + return "" + epoch_diff = 116444736000000000 # FILETIME ticks between 1601 and 1970 + micros = (ft - epoch_diff) // 10 + if micros < 0: + return "" + try: + return datetime.datetime.utcfromtimestamp(micros / 1_000_000).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + except (OSError, ValueError, OverflowError): + return "" + + +def _build_pid_name_map(task_id): + """PID → process-name lookup. Used by the ETW renderer to turn raw + PIDs (which is all most ETW providers expose) into ``file.exe + (4660)``-style display strings. + + Sources, richest to thinnest: + 1. ``behavior.processes`` — CAPE's API-monitor sees every process + it instrumented, so this covers the malware-side processes + most users care about. + 2. ``network_etw.connections_by_pid`` — sysmon + kernel-ETW; + catches system processes (svchost, services) that the monitor + doesn't instrument but that ETW logs against. + Later sources fill in only PIDs the earlier ones didn't already + name. Returns an empty dict when mongo isn't reachable. + """ + if not enabledconf.get("mongodb"): + return {} + try: + rec = mongo_find_one( + "analysis", + {"info.id": int(task_id)}, + { + "behavior.processes.process_id": 1, + "behavior.processes.process_name": 1, + "behavior.processes.module_path": 1, + "network_etw.connections_by_pid": 1, + "_id": 0, + }, + ) + except Exception: + return {} + rec = rec or {} + out = {} + for p in (rec.get("behavior", {}) or {}).get("processes", []) or []: + pid = p.get("process_id") + name = p.get("process_name") or "" + if not name: + mod = p.get("module_path") or "" + name = mod.rsplit("\\", 1)[-1].rsplit("/", 1)[-1] + if pid is not None and name: + out[str(pid)] = name + by_pid = (rec.get("network_etw", {}) or {}).get("connections_by_pid", {}) or {} + for pid, info in by_pid.items(): + if str(pid) in out: + continue + name = info.get("process_name") or "" + if not name: + image = info.get("image", "") or "" + name = image.rsplit("\\", 1)[-1].rsplit("/", 1)[-1] + if name: + out[str(pid)] = name + return out + + +def _load_etw_telemetry(task_id): + """Read every ETW NDJSON / directory we collect in aux/ and project + each into a per-source row shape suitable for tabular rendering. + + Returns a dict keyed by source name (`dns`, `network`, `wmi`, + `threatintel`, `amsi`) — only includes keys whose underlying data + file exists AND has at least one parseable record. The template + iterates the dict to decide which sub-tabs to render. + """ + base = os.path.join(CUCKOO_ROOT, "storage", "analyses", str(task_id), "aux") + out = { + "dns": [], + "network": [], + "wmi": [], + "threatintel": [], + # Drivers / devices the sample's processes touched via IRPs. + # Deduped + noise-filtered so the BYOD signal isn't buried. + "threatintel_drivers": [], + # AllocVM events aggregated by (caller_pid, target_pid) — the + # raw stream is firehose-noisy on self-process events. + "threatintel_alloc_summary": [], + "amsi": [], + } + pid_map = _build_pid_name_map(task_id) + + def _attach_proc(row, pid_field="pid"): + pid = row.get(pid_field) + if pid in (None, ""): + row["process_name"] = "" + return row + row["process_name"] = pid_map.get(str(pid), "") + return row + + def _iter_ndjson(path): + if not path_exists(path) or os.path.getsize(path) == 0: + return + try: + with open(path, "r", errors="replace") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + yield json.loads(line) + except json.JSONDecodeError: + continue + except OSError: + return + + # DNS-Client ETW — flat NDJSON, no per-record timestamp; use file order. + for rec in _iter_ndjson(os.path.join(base, "dns_etw.json")): + out["dns"].append(_attach_proc({ + "type": rec.get("QueryType", ""), + "pid": rec.get("ProcessId", ""), + "tid": rec.get("ThreadId", ""), + "query": rec.get("QueryName", ""), + "server": rec.get("DNS Server", ""), + })) + + # Microsoft-Windows-Kernel-Network ETW — flat NDJSON with FILETIME. + for rec in _iter_ndjson(os.path.join(base, "network_etw.json")): + sip, sport = rec.get("src_ip", ""), rec.get("src_port", "") + dip, dport = rec.get("dst_ip", ""), rec.get("dst_port", "") + out["network"].append(_attach_proc({ + "time": _filetime_to_iso(rec.get("timestamp")), + "pid": rec.get("pid", ""), + "direction": rec.get("direction", ""), + "protocol": rec.get("protocol", ""), + "src": f"{sip}:{sport}" if sip else "", + "dst": f"{dip}:{dport}" if dip else "", + "event": rec.get("event_type", ""), + })) + + # Microsoft-Windows-WMI-Activity ETW — events nested under `event.*`. + for rec in _iter_ndjson(os.path.join(base, "wmi_etw.json")): + ev = rec.get("event", {}) or {} + hdr = ev.get("EventHeader", {}) or {} + row = _attach_proc({ + "time": _filetime_to_iso(hdr.get("TimeStamp")), + "pid": hdr.get("ProcessId", ""), + "operation": ev.get("Operation", "") or ev.get("Task Name", ""), + "namespace": ev.get("NamespaceName", ""), + "user": ev.get("User", ""), + "client_pid": ev.get("ClientProcessId", ""), + "description": (ev.get("Description", "") or "")[:200], + }) + # Resolve client_pid → name as a separate field; the WMI client + # (i.e. who invoked WMI) is often more interesting than the + # WMI provider's own PID. + cp = row.get("client_pid") + row["client_process_name"] = pid_map.get(str(cp), "") if cp not in ("", None) else "" + out["wmi"].append(row) + + # Microsoft-Windows-Threat-Intelligence ETW — `[event_id, {event...}]`. + # The provider is firehose-noisy: every process does VirtualAlloc + # against itself constantly, and those events flood the JSON. The + # signal is in the small subset that's either (a) cross-process + # (CallingProcessId != TargetProcessId — classic injection + # primitive) or (b) one of the few task names that don't fire on + # benign self-ops (APC injection, thread-context, etc.). We split + # the rendering into "suspicious" and "other" buckets so the + # default view shows actionable events first. + def _clean_iso(s): + if not isinstance(s, str): + return "" + return s.replace("‎", "").replace("‏", "").strip() + + # Protection mask → symbolic name. Most-significant bit set ⇒ + # executable region (the high-signal flags for shellcode). + _PROT_MAP = { + 0x01: "NOACCESS", + 0x02: "READONLY", + 0x04: "READWRITE", + 0x08: "WRITECOPY", + 0x10: "EXECUTE", + 0x20: "EXECUTE_READ", + 0x40: "EXECUTE_READWRITE", + 0x80: "EXECUTE_WRITECOPY", + } + def _prot_name(raw): + try: + v = int(str(raw), 0) if isinstance(raw, str) else int(raw) + except (TypeError, ValueError): + return "" + # Mask off top-level page modifiers (GUARD/NOCACHE/WRITECOMBINE). + base = v & 0xFF + return _PROT_MAP.get(base, hex(v) if v else "") + + # Task names that are noise on self-process events. Anything outside + # this set is rare enough that it's worth surfacing even when the + # call is local. + _NOISY_SELF_TASKS = {"KERNEL_THREATINT_TASK_ALLOCVM", "KERNEL_THREATINT_TASK_DRIVER_DEVICE"} + + # AllocationType bit-flags (MSDN VirtualAlloc). + _ALLOC_FLAGS = [ + (0x00001000, "COMMIT"), + (0x00002000, "RESERVE"), + (0x00080000, "RESET"), + (0x01000000, "RESET_UNDO"), + (0x20000000, "LARGE_PAGES"), + (0x00400000, "PHYSICAL"), + (0x00100000, "TOP_DOWN"), + (0x00200000, "WRITE_WATCH"), + ] + def _alloc_flags(raw): + try: + v = int(str(raw), 0) if isinstance(raw, str) else int(raw) + except (TypeError, ValueError): + return "" + names = [name for bit, name in _ALLOC_FLAGS if v & bit] + return "|".join(names) if names else (hex(v) if v else "") + + # Signature levels — short labels for the more interesting ones. + # Source: SE_SIGNING_LEVEL_* enum in ntoskrnl. 0 (Unchecked) and + # higher values up through 14 (Windows TCB / kernel-mode PPL). + _SIG_LEVELS = { + 0: "Unchecked", + 1: "Unsigned", + 2: "Enterprise", + 3: "Custom-1", + 4: "Authenticode", + 5: "Custom-2", + 6: "Store", + 7: "Antimalware", + 8: "Microsoft", + 12: "Windows", + 14: "Windows-TCB", + } + def _sig_label(raw): + try: + v = int(raw) + except (TypeError, ValueError): + return "" + # The TI provider packs the signature level into the low nibble + # plus the section level into the high nibble — we only care + # about the low one for the friendly label. + return _SIG_LEVELS.get(v & 0x0F, str(v)) + + # PPL protection levels — same idea (PsProtectedTypeNone, Light, Full). + _PROT_TYPES = { + 0: "None", + 1: "Light", + 2: "Full", + } + _PROT_SIGNERS = { + 0: "None", 1: "Authenticode", 2: "CodeGen", 3: "Antimalware", + 4: "Lsa", 5: "Windows", 6: "WinTcb", 7: "WinSystem", + 8: "App", + } + def _ppl_label(raw): + try: + v = int(raw) + except (TypeError, ValueError): + return "" + if v == 0: + return "None" + # Low 3 bits = type, next 4 bits = signer. + ptype = v & 0x07 + signer = (v >> 4) & 0x0F + return f"{_PROT_TYPES.get(ptype,'?')}-{_PROT_SIGNERS.get(signer,'?')}" + + def _vad_summary(ev, prefix): + """Bundle the per-VAD fields the TI provider attaches alongside + a virtual address (e.g., for ApcRoutine, ApcArgument1, Pc). + Whether the address falls in a private RWX mapping vs a + file-backed DLL is the smoking-gun signal for shellcode + execution — flag that as `suspicious` when both conditions + hold.""" + base = ev.get(f"{prefix}VadAllocationBase", "") + prot_raw = ev.get(f"{prefix}VadAllocationProtect", "") + region_type = ev.get(f"{prefix}VadRegionType", "") + mmf = ev.get(f"{prefix}VadMmfName", "") or "" + region_size = ev.get(f"{prefix}VadRegionSize", "") + prot_name = _prot_name(prot_raw) + non_file_backed = not mmf or mmf == "(null)" + executable = "EXECUTE" in (prot_name or "") + return { + "alloc_base": base, + "alloc_protect_raw": prot_raw, + "alloc_protect": prot_name, + "region_type": region_type, + "region_size": region_size, + "mmf_name": mmf, + "suspicious": non_file_backed and executable, + } + + for rec in _iter_ndjson(os.path.join(base, "threatintel_etw.json")): + if not isinstance(rec, list) or len(rec) < 2: + continue + ev = rec[1] or {} + hdr = ev.get("EventHeader", {}) or {} + cp = ev.get("CallingProcessId", "") + tp = ev.get("TargetProcessId", "") + task_full = ev.get("Task Name", "") + # Friendlier display name: drop the KERNEL_THREATINT_TASK_ prefix. + task_short = task_full.removeprefix("KERNEL_THREATINT_TASK_") if task_full.startswith("KERNEL_THREATINT_TASK_") else task_full + + cross = bool(cp and tp and str(cp) != str(tp)) + suspicious = cross or task_full not in _NOISY_SELF_TASKS + + prot_raw = ev.get("ProtectionMask", "") + evdesc = hdr.get("EventDescriptor", {}) or {} + row = { + "time": _filetime_to_iso(hdr.get("TimeStamp")), + "task": task_short, + "task_full": task_full, + "event_id": evdesc.get("Id", ""), + "calling_pid": cp, + "target_pid": tp, + "calling_create": _clean_iso(ev.get("CallingProcessCreateTime", "")), + "base_address": ev.get("BaseAddress", ""), + "region_size": ev.get("RegionSize", ""), + "protection": prot_raw, + "protection_name": _prot_name(prot_raw), + "cross_process": cross, + "suspicious": suspicious, + # Extra fields for the click-to-expand detail panel ───────── + "alloc_type_raw": ev.get("AllocationType", ""), + "alloc_type": _alloc_flags(ev.get("AllocationType", "")), + "calling_thread_id": ev.get("CallingThreadId", ""), + "calling_thread_create": _clean_iso(ev.get("CallingThreadCreateTime", "")), + "calling_sig_raw": ev.get("CallingProcessSignatureLevel", ""), + "calling_sig": _sig_label(ev.get("CallingProcessSignatureLevel", "")), + "target_sig_raw": ev.get("TargetProcessSignatureLevel", ""), + "target_sig": _sig_label(ev.get("TargetProcessSignatureLevel", "")), + "calling_ppl_raw": ev.get("CallingProcessProtection", ""), + "calling_ppl": _ppl_label(ev.get("CallingProcessProtection", "")), + "target_ppl_raw": ev.get("TargetProcessProtection", ""), + "target_ppl": _ppl_label(ev.get("TargetProcessProtection", "")), + "original_pid": ev.get("OriginalProcessId", ""), + "kernel_thread_id": hdr.get("ThreadId", ""), + "description": ev.get("Description", "") or "", + } + row["calling_process_name"] = pid_map.get(str(cp), "") if cp not in ("", None) else "" + row["target_process_name"] = pid_map.get(str(tp), "") if tp not in ("", None) else "" + # Trust delta: low-trust calling → higher-trust target = strong + # signal regardless of cross_process. Tracks raw integer levels. + try: + cs = int(row["calling_sig_raw"]) & 0x0F if row["calling_sig_raw"] != "" else None + ts = int(row["target_sig_raw"]) & 0x0F if row["target_sig_raw"] != "" else None + if cs is not None and ts is not None and cs < ts and ts >= 6: + row["trust_uplift"] = True + # Trust uplift is also suspicious even if same-PID. + row["suspicious"] = True + except (TypeError, ValueError): + pass + + # Task-specific extras — different operations carry different + # fields. The detail panel renders whatever's set. + if "QUEUEUSERAPC" in task_full: + row["apc"] = { + "routine": ev.get("ApcRoutine", ""), + "routine_vad": _vad_summary(ev, "ApcRoutine"), + "arg1": ev.get("ApcArgument1", ""), + "arg1_vad": _vad_summary(ev, "ApcArgument1"), + "arg2": ev.get("ApcArgument2", ""), + "arg3": ev.get("ApcArgument3", ""), + "target_thread_id": ev.get("TargetThreadId", ""), + "target_thread_alertable": ev.get("TargetThreadAlertable", ""), + "target_thread_create": _clean_iso(ev.get("TargetThreadCreateTime", "")), + } + # Either VAD landing in private RWX = strong injection signal. + if row["apc"]["routine_vad"]["suspicious"] or row["apc"]["arg1_vad"]["suspicious"]: + row["rwx_landing"] = True + elif "SETTHREADCONTEXT" in task_full: + row["thread_ctx"] = { + "pc": ev.get("Pc", ""), + "pc_vad": _vad_summary(ev, "Pc"), + "sp": ev.get("Sp", ""), + "lr": ev.get("Lr", ""), + "fp": ev.get("Fp", ""), + "context_flags": ev.get("ContextFlags", ""), + "context_mask": ev.get("ContextMask", ""), + "regs": [(f"R{i}", ev.get(f"Reg{i}", "")) for i in range(8) + if ev.get(f"Reg{i}", "") not in ("", None)], + "target_thread_id": ev.get("TargetThreadId", ""), + "target_thread_create": _clean_iso(ev.get("TargetThreadCreateTime", "")), + } + if row["thread_ctx"]["pc_vad"]["suspicious"]: + row["rwx_landing"] = True + elif "DRIVER_DEVICE" in task_full: + row["driver_device"] = { + "device_name": ev.get("DeviceName", ""), + "driver_name": ev.get("DriverName", ""), + } + # Sketchy device names that aren't the common networking + # / pipe stack — surface as suspicious. + dev = (row["driver_device"]["device_name"] or "").lower() + sketchy_devices = ("physicalmemory", "msr", "memorydiagnostics", "process", + "ntfs", "rawcdrom", "directx") + if any(s in dev for s in sketchy_devices): + row["suspicious"] = True + out["threatintel"].append(row) + + # Post-process the firehose into two compact, high-signal views. + # + # 1. Drivers / Devices Accessed — dedup the DRIVER_DEVICE stream by + # (driver_name, device_name) and tag system-noise drivers + # (filter manager, raw FS) as `system_noise=True` so the template + # can collapse them. This is where BYOD jumps out: a non-system + # driver name in this list is almost always interesting. + # 2. AllocVM summary — aggregate by (caller_pid, target_pid) so the + # 1500+ same-process allocations collapse to one row per pair, + # with running counts of cross-process / RWX / large allocations. + _SYSTEM_DRIVER_NOISE = { + r"\driver\fltmgr", + r"\driver\mountmgr", + r"\driver\null", + r"\driver\nsi", + r"\filesystem\fltmgr", + r"\filesystem\raw", + r"\filesystem\ntfs", + r"\filesystem\fastfat", + } + drivers_seen = {} + alloc_pairs = {} + for row in out["threatintel"]: + tf = row.get("task_full", "") + if "DRIVER_DEVICE" in tf: + dd = row.get("driver_device") or {} + drv = (dd.get("driver_name") or "").strip() + dev = (dd.get("device_name") or "").strip() + key = (drv.lower(), dev.lower()) + if key in drivers_seen: + e = drivers_seen[key] + e["hit_count"] += 1 + if row.get("calling_pid") not in e["pids"]: + e["pids"].append(row["calling_pid"]) + else: + drivers_seen[key] = { + "driver_name": drv, + "device_name": dev, + "hit_count": 1, + "pids": [row.get("calling_pid")], + "system_noise": drv.lower() in _SYSTEM_DRIVER_NOISE, + "first_seen": row.get("time"), + "calling_process_name": row.get("calling_process_name", ""), + } + elif "ALLOCVM" in tf: + cp = row.get("calling_pid") or "?" + tp = row.get("target_pid") or "?" + key = (str(cp), str(tp)) + entry = alloc_pairs.setdefault( + key, + { + "calling_pid": cp, + "target_pid": tp, + "calling_process_name": row.get("calling_process_name", ""), + "target_process_name": row.get("target_process_name", ""), + "count": 0, + "cross_process": str(cp) != str(tp), + "rwx": 0, + "large": 0, + "min_size": None, + "max_size": 0, + "first_seen": row.get("time"), + }, + ) + entry["count"] += 1 + try: + rs = int(str(row.get("region_size") or 0), 0) if isinstance(row.get("region_size"), str) else int(row.get("region_size") or 0) + except (TypeError, ValueError): + rs = 0 + if rs: + if entry["min_size"] is None or rs < entry["min_size"]: + entry["min_size"] = rs + if rs > entry["max_size"]: + entry["max_size"] = rs + if rs >= 256 * 1024: + entry["large"] += 1 + # Only count RWX (PAGE_EXECUTE_READWRITE = 0x40) for cross- + # process pairs. Same-process RWX counts get polluted on + # CAPE-instrumented hosts because capemon's own hooking + # creates RWX trampolines in every monitored process — so a + # per-pid RWX tally for self pairs ends up close to 100% + # and tells us nothing about the sample's behaviour. RWX + # in *another* process's address space is the genuinely + # interesting injection signal. + if entry["cross_process"]: + try: + pm = int(str(row.get("protection") or 0), 0) if isinstance(row.get("protection"), str) else int(row.get("protection") or 0) + except (TypeError, ValueError): + pm = 0 + if pm == 0x40: + entry["rwx"] += 1 + + # Sort drivers: non-noise first (alphabetical), then noise. + out["threatintel_drivers"] = sorted( + drivers_seen.values(), + key=lambda d: (d["system_noise"], d["driver_name"].lower()), + ) + # Sort alloc summary: cross-process pairs first, then by count desc. + out["threatintel_alloc_summary"] = sorted( + alloc_pairs.values(), + key=lambda a: (not a["cross_process"], -a["count"]), + ) + + # Filter the per-event list down to genuine signal — drop self-process + # noise AllocVMs (which is ~99% of the volume) and noise DRIVER_DEVICE + # rows now that they're aggregated above. Anything cross-process, + # trust-uplifted, RWX-landing, or with a non-noise task name stays. + def _keep_event(r): + tf = r.get("task_full", "") + if "ALLOCVM" in tf: + return bool( + r.get("cross_process") + or r.get("rwx_landing") + or r.get("trust_uplift") + or r.get("suspicious") is True + and (r.get("cross_process") or r.get("rwx_landing")) + ) + if "DRIVER_DEVICE" in tf: + # Aggregated above — only keep individual rows for sketchy + # devices (already marked `suspicious`) so the analyst can + # see the calling thread / time per access. + dd = r.get("driver_device") or {} + if (dd.get("driver_name") or "").lower() in _SYSTEM_DRIVER_NOISE: + return False + return r.get("suspicious", False) + return True + out["threatintel"] = [r for r in out["threatintel"] if _keep_event(r)] + + # AMSI ETW — `aux/amsi_etw/amsi.jsonl` is the canonical event stream + # (one AMSI scan per JSON line). Every record carries `appname`, + # `contentname`, `contentsize`, `hash`, and a `dump_path` that + # points to a per-buffer file in the same directory containing the + # actual scanned content (PowerShell/VBScript/JScript body, .NET + # IL bytes, etc.). We read the JSONL for metadata and resolve each + # dump_path to load the real script body for the expandable view. + # + # Older deployments without the JSONL fall back to a dir scan, but + # in that case we have no metadata so we can only show hash + body. + AMSI_MAX_BYTES = 5 * 1024 * 1024 + amsi_dir = os.path.join(base, "amsi_etw") + amsi_jsonl = os.path.join(amsi_dir, "amsi.jsonl") + analysis_root = os.path.join(CUCKOO_ROOT, "storage", "analyses", str(task_id)) + + def _read_blob(rel_or_abs): + # dump_path is recorded as `aux/amsi_etw/.txt` (relative to + # the analysis root). Anchor it there and refuse anything that + # tries to escape. + candidate = os.path.normpath(os.path.join(analysis_root, rel_or_abs)) + if not candidate.startswith(analysis_root + os.sep): + return "", 0, False + try: + sz = os.path.getsize(candidate) + with open(candidate, "r", errors="replace") as fh: + body = fh.read(AMSI_MAX_BYTES) + return body, sz, sz > AMSI_MAX_BYTES + except OSError: + return "", 0, False + + seen_blob_paths = set() + if os.path.isfile(amsi_jsonl): + for rec in _iter_ndjson(amsi_jsonl): + hdr = rec.get("EventHeader", {}) or {} + dump_path = rec.get("dump_path", "") + body, body_size, truncated = ("", 0, False) + if dump_path: + body, body_size, truncated = _read_blob(dump_path) + seen_blob_paths.add(os.path.basename(dump_path)) + row = _attach_proc({ + "time": _filetime_to_iso(hdr.get("TimeStamp")), + "pid": hdr.get("ProcessId", ""), + "app": rec.get("appname", ""), + "content_name": rec.get("contentname", "") or "(inline scriptblock)", + "content_size": rec.get("contentsize", "") or rec.get("originalsize", ""), + "hash": rec.get("hash", ""), + "scan_status": rec.get("scanStatus", ""), + "scan_result": rec.get("scanResult", ""), + "body": body, + "body_size": body_size, + "truncated": truncated, + }) + out["amsi"].append(row) + + # Orphan-blob pass — pick up any `.txt` file in the dir that + # the JSONL didn't reference (older runs without amsi.jsonl, or + # blobs whose metadata was lost). Render with whatever we know + # (sha + body) so they're not invisible. + if os.path.isdir(amsi_dir): + for fname in sorted(os.listdir(amsi_dir)): + if fname == "amsi.jsonl" or fname in seen_blob_paths: + continue + full = os.path.join(amsi_dir, fname) + if not os.path.isfile(full): + continue + try: + sz = os.path.getsize(full) + with open(full, "r", errors="replace") as fh: + body = fh.read(AMSI_MAX_BYTES) + except OSError: + continue + out["amsi"].append({ + "time": "", + "pid": "", + "process_name": "", + "app": "(orphan blob)", + "content_name": "(no JSONL metadata)", + "content_size": str(sz), + "hash": fname.rsplit(".", 1)[0], + "scan_status": "", + "scan_result": "", + "body": body, + "body_size": sz, + "truncated": sz > AMSI_MAX_BYTES, + }) + + # Drop empty sources so the template doesn't render hollow tabs. + return {k: v for k, v in out.items() if v} + + def _list_evtx_members(zip_path): """List safe EVTX members from an archive, grouped by channel. Snapshot-prefixed files (e.g., 1_Security.evtx, 2_Security.evtx) are @@ -983,6 +1669,7 @@ def load_files(request, task_id, category): "memory", "tracee", "eventlogs", + "etw", ): data = {} debugger_logs = {} @@ -1149,6 +1836,8 @@ def load_files(request, task_id, category): "sysmon": data.get("sysmon", []), "evtx_channels": evtx_channels, } + elif category == "etw": + category_data = _load_etw_telemetry(task_id) ajax_response = { category: category_data, @@ -2017,6 +2706,27 @@ def report(request, task_id): if path_exists(evtx_path): report["has_evtx"] = True + # Mark the report as having ETW telemetry to render the new tab. + # Cheap pre-check: any non-empty source under aux/. Detailed parsing + # is deferred to the AJAX `etw` category in load_files so we don't + # walk multi-MB files on report-page render. + aux_dir = os.path.join(CUCKOO_ROOT, "storage", "analyses", str(task_id), "aux") + for source in ("dns_etw.json", "network_etw.json", "wmi_etw.json", + "threatintel_etw.json", "amsi_etw"): + p = os.path.join(aux_dir, source) + if not path_exists(p): + continue + if os.path.isdir(p): + try: + if os.listdir(p): + report["has_etw"] = True + break + except OSError: + continue + elif os.path.getsize(p) > 0: + report["has_etw"] = True + break + if settings.MOLOCH_ENABLED and "suricata" in report: suricata = report["suricata"] if settings.MOLOCH_BASE[-1] != "/": @@ -2197,6 +2907,11 @@ def load_evtx_channel_count(request, task_id): @conditional_login_required(login_required, settings.WEB_AUTHENTICATION) @csrf_exempt @api_view(["GET"]) +# UI-internal endpoint — the analysis report's tags hit +# this from a browser session for screenshots / bingraphs / svgs. Re-enable +# 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]) def file_nl(request, category, task_id, dlfile): base_path = os.path.join(CUCKOO_ROOT, "storage", "analyses", str(task_id)) path = False @@ -2303,6 +3018,9 @@ def _file_search_all_files(search_category: str, search_term: str) -> list: @ratelimit(key="ip", rate=my_rate_seconds, block=rateblock) @ratelimit(key="ip", rate=my_rate_minutes, block=rateblock) @api_view(["GET"]) +# UI-internal: same rationale as file_nl — used for in-browser downloads +# of dropped files, payloads, etc. via session cookie auth. +@authentication_classes([SessionAuthentication]) def file(request, category, task_id, dlfile): file_name = dlfile cd = "application/octet-stream" diff --git a/web/templates/analysis/etw/_amsi.html b/web/templates/analysis/etw/_amsi.html new file mode 100644 index 00000000000..d6e8803066e --- /dev/null +++ b/web/templates/analysis/etw/_amsi.html @@ -0,0 +1,44 @@ +
    +
    +
    AMSI ETW
    + AMSI (Antimalware Scan Interface) buffers — every script body the loader handed Defender for inspection. PowerShell, VBScript, JScript, .NET IL. Click a row to expand the actual scanned content. +
    +
    + + + + + + + + + + + + + {% for r in etw.amsi %} + + + + + + + + + + + + {% endfor %} + +
    Time (UTC)ProcessAppContentSizeHash
    {{ r.time }}{% if r.process_name %}{{ r.process_name }} ({{ r.pid }}){% else %}{{ r.pid }}{% endif %}{{ r.app }}{{ r.content_name }}{{ r.content_size }}{{ r.hash|slice:"2:14" }}…
    +
    +
    +

    Hash: {{ r.hash }}

    +
    {{ r.body }}{% if r.truncated %}
    +
    +[truncated at 5 MiB — full buffer is {{ r.body_size }} bytes. Use the apiv2 etw/amsi endpoint to fetch the unbounded archive.]{% endif %}
    +
    +
    +
    +
    +
    diff --git a/web/templates/analysis/etw/_dns.html b/web/templates/analysis/etw/_dns.html new file mode 100644 index 00000000000..6883c2cfe6c --- /dev/null +++ b/web/templates/analysis/etw/_dns.html @@ -0,0 +1,34 @@ +
    +
    +
    DNS Client ETW
    + Microsoft-Windows-DNS-Client. Each query/response from the DNS Client service. The PID here is the DNS Client itself (svchost) — for the originating process, see DNS rows under the Network section. +
    +
    + + + + + + + + + + + + {% for r in etw.dns %} + + + + + + + + {% endfor %} + +
    TypeProcessTIDQuery NameDNS Server
    + {% if r.type == "Query" %}Query + {% elif r.type == "Response" %}Response + {% else %}{{ r.type }}{% endif %} + {% if r.process_name %}{{ r.process_name }} ({{ r.pid }}){% else %}{{ r.pid }}{% endif %}{{ r.tid }}{{ r.query }}{{ r.server }}
    +
    +
    diff --git a/web/templates/analysis/etw/_network.html b/web/templates/analysis/etw/_network.html new file mode 100644 index 00000000000..af53ba21199 --- /dev/null +++ b/web/templates/analysis/etw/_network.html @@ -0,0 +1,38 @@ +
    +
    +
    Microsoft-Windows-Kernel-Network
    + Per-PID TCP/UDP connect, accept, and disconnect events from the kernel networking stack. PID is the originating process (this is what process_network attribution joins on). +
    +
    + + + + + + + + + + + + + + {% for r in etw.network %} + + + + + + + + + + {% endfor %} + +
    Time (UTC)ProcessDirectionProtocolSrcDstEvent
    {{ r.time }}{% if r.process_name %}{{ r.process_name }} ({{ r.pid }}){% else %}{{ r.pid }}{% endif %} + {% if r.direction == "outbound" %}→ out + {% elif r.direction == "inbound" %}← in + {% else %}{{ r.direction }}{% endif %} + {{ r.protocol }}{{ r.src }}{{ r.dst }}{{ r.event }}
    +
    +
    diff --git a/web/templates/analysis/etw/_threatintel.html b/web/templates/analysis/etw/_threatintel.html new file mode 100644 index 00000000000..d5450501e7b --- /dev/null +++ b/web/templates/analysis/etw/_threatintel.html @@ -0,0 +1,252 @@ +
    +
    +
    Microsoft-Windows-Threat-Intelligence
    + PPL-protected ETW provider. The raw stream is firehose-noisy on self-process AllocVM events — we surface drivers/devices the sample touched, an aggregate AllocVM summary by (caller→target) pair, and the genuinely suspicious individual events (cross-process, RWX-landing, trust-uplift). Click any expandable row for event detail. +
    +
    + +{# ---------------- Drivers / Devices Accessed (deduped) ---------------- #} +{% if etw.threatintel_drivers %} +
    +
    +
    Drivers / Devices Accessed
    + Deduped IRP-target list from the TI provider. Non-system drivers and any non-standard device names are likely BYOD or kernel-driver abuse signal. +
    +
    + + + + + + + + + + + {% for d in etw.threatintel_drivers %} + + + + + + + {% endfor %} + +
    DriverDeviceIRPsFirst Seen
    + {% if d.system_noise %} + system + {% else %} + non-system + {% endif %} + {{ d.driver_name|default:"(unnamed)" }} + {{ d.device_name|default:"-" }}{{ d.hit_count }}{{ d.first_seen }}
    +
    +
    +{% endif %} + +{# ---------------- AllocVM aggregated by (caller, target) ---------------- #} +{% if etw.threatintel_alloc_summary %} +
    +
    +
    VirtualAlloc Activity (aggregated)
    + One row per (caller → target) pair. Cross-process pairs are highlighted — those are the injection-relevant signal. Self-process traffic is included for completeness but is dominated by the in-process allocator and CAPE's own monitor hooks (so per-pid RWX counts only meaningful for cross-process pairs). +
    +
    + + + + + + + + + + + + + {% for a in etw.threatintel_alloc_summary %} + + + + + + + + + {% endfor %} + +
    CallingTargetAllocsRWX≥256KSize range
    + {% if a.calling_process_name %}{{ a.calling_process_name }} ({{ a.calling_pid }}){% else %}{{ a.calling_pid }}{% endif %} + + {% if a.cross_process %}cross{% endif %} + {% if a.target_process_name %}{{ a.target_process_name }} ({{ a.target_pid }}){% else %}{{ a.target_pid }}{% endif %} + {{ a.count }} + {% if a.cross_process %} + {% if a.rwx %}{{ a.rwx }}{% else %}0{% endif %} + {% else %} + + {% endif %} + {{ a.large }} + {% if a.min_size %}{{ a.min_size }} – {{ a.max_size }}{% else %}-{% endif %} +
    +
    +
    +{% endif %} + +
    +
    +
    Suspicious operations
    +
    +
    + + + + + + + + + + + + + + + {% for r in etw.threatintel %}{% if r.suspicious %} + + + + + + + + + + + + {% endif %}{% empty %} + + {% endfor %} + +
    Time (UTC)EIDTaskCallingTargetBaseSizeProtection
    {{ r.time }}{{ r.event_id }} + {{ r.task }} + {% if r.trust_uplift %} trust↑{% endif %} + {% if r.rwx_landing %} RWX{% endif %} + {% if r.calling_process_name %}{{ r.calling_process_name }} ({{ r.calling_pid }}){% else %}{{ r.calling_pid }}{% endif %}{% if r.target_process_name %}{{ r.target_process_name }} ({{ r.target_pid }}){% else %}{{ r.target_pid }}{% endif %}{{ r.base_address }}{{ r.region_size }} + {% if r.protection_name %} + {{ r.protection_name }} + {% else %}{{ r.protection }}{% endif %} +
    +
    +
    +
    +
    +
    Operation
    + + + + + +
    Task name{{ r.task_full }}
    Event ID{{ r.event_id }}
    Allocation{{ r.alloc_type }}{% if r.alloc_type_raw %} ({{ r.alloc_type_raw }}){% endif %}
    Description{{ r.description }}
    +
    +
    +
    Calling
    + + + + + + + +
    PID{{ r.calling_pid }}{% if r.calling_process_name %} {{ r.calling_process_name }}{% endif %}
    Thread{{ r.calling_thread_id }}
    Started{{ r.calling_create }}
    Signature{{ r.calling_sig }}{% if r.calling_sig_raw != "" %} ({{ r.calling_sig_raw }}){% endif %}
    Protection{{ r.calling_ppl }}{% if r.calling_ppl_raw != "" %} ({{ r.calling_ppl_raw }}){% endif %}
    Original PID{{ r.original_pid }}
    +
    +
    +
    Target
    + + + + + + + +
    PID{{ r.target_pid }}{% if r.target_process_name %} {{ r.target_process_name }}{% endif %}
    Signature{{ r.target_sig }}{% if r.target_sig_raw != "" %} ({{ r.target_sig_raw }}){% endif %}
    Protection{{ r.target_ppl }}{% if r.target_ppl_raw != "" %} ({{ r.target_ppl_raw }}){% endif %}
    Base{{ r.base_address }}
    Size{{ r.region_size }}
    Protection mask{{ r.protection_name }} ({{ r.protection }})
    +
    +
    + + {# Task-specific extras: APC routines, thread-context registers, driver/device names. #} + {% if r.apc %} +
    +
    APC injection details
    +
    +
    + + + + + + + +
    Target thread{{ r.apc.target_thread_id }}{% if r.apc.target_thread_alertable %} alertable{% endif %}
    Thread created{{ r.apc.target_thread_create }}
    ApcRoutine{{ r.apc.routine }}{% if r.apc.routine_vad.suspicious %} RWX private{% endif %}
    ↳ in mapping{{ r.apc.routine_vad.mmf_name|default:"(non-file-backed)" }}
    ↳ alloc base{{ r.apc.routine_vad.alloc_base }} ({{ r.apc.routine_vad.region_size }} bytes, {{ r.apc.routine_vad.region_type }})
    ↳ alloc protect{{ r.apc.routine_vad.alloc_protect }}
    +
    +
    + + + + + + + +
    ApcArgument1{{ r.apc.arg1 }}{% if r.apc.arg1_vad.suspicious %} RWX private{% endif %}
    ↳ in mapping{{ r.apc.arg1_vad.mmf_name|default:"(non-file-backed)" }}
    ↳ alloc base{{ r.apc.arg1_vad.alloc_base }} ({{ r.apc.arg1_vad.region_size }} bytes, {{ r.apc.arg1_vad.region_type }})
    ↳ alloc protect{{ r.apc.arg1_vad.alloc_protect }}
    ApcArgument2{{ r.apc.arg2 }}
    ApcArgument3{{ r.apc.arg3 }}
    +
    +
    + {% endif %} + + {% if r.thread_ctx %} +
    +
    Thread-context hijack details
    +
    +
    + + + + + + + + + +
    Target thread{{ r.thread_ctx.target_thread_id }}
    Thread created{{ r.thread_ctx.target_thread_create }}
    ContextFlags{{ r.thread_ctx.context_flags }}
    ContextMask{{ r.thread_ctx.context_mask }}
    Pc (new IP){{ r.thread_ctx.pc }}{% if r.thread_ctx.pc_vad.suspicious %} RWX private{% endif %}
    ↳ in mapping{{ r.thread_ctx.pc_vad.mmf_name|default:"(non-file-backed)" }}
    ↳ alloc base{{ r.thread_ctx.pc_vad.alloc_base }} ({{ r.thread_ctx.pc_vad.region_size }} bytes, {{ r.thread_ctx.pc_vad.region_type }})
    ↳ alloc protect{{ r.thread_ctx.pc_vad.alloc_protect }}
    +
    +
    + + + + + {% for name, val in r.thread_ctx.regs %} + + {% endfor %} +
    Sp{{ r.thread_ctx.sp }}
    Lr{{ r.thread_ctx.lr }}
    Fp{{ r.thread_ctx.fp }}
    {{ name }}{{ val }}
    +
    +
    + {% endif %} + + {% if r.driver_device %} +
    +
    Driver / device handle
    + + + +
    DeviceName{{ r.driver_device.device_name }}
    DriverName{{ r.driver_device.driver_name }}
    + {% endif %} +
    +
    +
    No suspicious threat-intel events for this task.
    +
    +
    + +{# The previous "Show other events" collapse for self-process AllocVM / + DRIVER_DEVICE noise is gone — those events are now folded into the + aggregated VirtualAlloc Activity and Drivers / Devices Accessed cards + above. The remaining "Suspicious operations" table only contains + genuinely interesting events. #} diff --git a/web/templates/analysis/etw/_wmi.html b/web/templates/analysis/etw/_wmi.html new file mode 100644 index 00000000000..71d0a8bc21c --- /dev/null +++ b/web/templates/analysis/etw/_wmi.html @@ -0,0 +1,32 @@ +
    +
    +
    Microsoft-Windows-WMI-Activity
    + WMI provider operations — query execution, consumer registration, namespace connects. Useful for spotting WMI-based persistence and lateral movement. +
    +
    + + + + + + + + + + + + + {% for r in etw.wmi %} + + + + + + + + + {% endfor %} + +
    Time (UTC)ProcessClientOperationNamespaceUser
    {{ r.time }}{% if r.process_name %}{{ r.process_name }} ({{ r.pid }}){% else %}{{ r.pid }}{% endif %}{% if r.client_process_name %}{{ r.client_process_name }} ({{ r.client_pid }}){% else %}{{ r.client_pid }}{% endif %}{{ r.operation }}{{ r.namespace }}{{ r.user }}
    +
    +
    diff --git a/web/templates/analysis/etw/index.html b/web/templates/analysis/etw/index.html new file mode 100644 index 00000000000..1193877f7a4 --- /dev/null +++ b/web/templates/analysis/etw/index.html @@ -0,0 +1,82 @@ +{% comment %}ETW Telemetry root template — rendered into the #etw tab pane via +AJAX (load_files dispatcher → category="etw"). Sub-pills are per +ETW source; only sources with at least one parseable record are +present in `etw` so this template does not have to gate them.{% endcomment %} + + + + +
    + {% if etw.dns %} +
    + {% include "analysis/etw/_dns.html" %} +
    + {% endif %} + {% if etw.network %} +
    + {% include "analysis/etw/_network.html" %} +
    + {% endif %} + {% if etw.wmi %} +
    + {% include "analysis/etw/_wmi.html" %} +
    + {% endif %} + {% if etw.threatintel or etw.threatintel_drivers or etw.threatintel_alloc_summary %} +
    + {% include "analysis/etw/_threatintel.html" %} +
    + {% endif %} + {% if etw.amsi %} +
    + {% include "analysis/etw/_amsi.html" %} +
    + {% endif %} +
    diff --git a/web/templates/analysis/report.html b/web/templates/analysis/report.html index 6c207737bf9..199afa28fe1 100644 --- a/web/templates/analysis/report.html +++ b/web/templates/analysis/report.html @@ -170,6 +170,16 @@ {% endif %} + {% if analysis.has_etw and config.display_etw %} + + {% endif %} {% if settings.COMMENTS %}
    {% endif %} + {% if analysis.has_etw and config.display_etw %} +
    + {# Populated via load_files AJAX (category='etw'). Leaves a + minimal placeholder so the tabajax handler has a target. #} +
    + {% endif %} {% if analysis.backscatter %}
    {% include "analysis/backscatter.html" %} From 6e41d5194ba9da0d3990fa103fc5ba9b95f7d19b Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Tue, 12 May 2026 11:37:56 -0500 Subject: [PATCH 11/24] fix: restore views.py to upstream, only add auth for tasks_status --- web/apiv2/views.py | 221 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 211 insertions(+), 10 deletions(-) diff --git a/web/apiv2/views.py b/web/apiv2/views.py index 73f7d4afbe3..0f211c17735 100644 --- a/web/apiv2/views.py +++ b/web/apiv2/views.py @@ -8,13 +8,15 @@ import sys import tempfile import zipfile +from contextlib import suppress from datetime import datetime, timedelta from io import BytesIO -from urllib.parse import quote +from urllib.parse import quote, urljoin from wsgiref.util import FileWrapper import pyzipper import requests +import yara from bson.objectid import ObjectId from django.conf import settings from django.contrib.auth.decorators import login_required @@ -24,10 +26,12 @@ from django.views.decorators.http import require_safe from rest_framework.authentication import SessionAuthentication from rest_framework.decorators import api_view, authentication_classes +try: + from apikey.authentication import ApiKeyAuthentication +except ImportError: + ApiKeyAuthentication = None from rest_framework.response import Response -from apikey.authentication import ApiKeyAuthentication - sys.path.append(settings.CUCKOO_PATH) from lib.cuckoo.common.config import Config @@ -88,6 +92,12 @@ except ImportError: import re +HAVE_PLYARA = False +with suppress(ImportError): + import plyara + import plyara.utils + HAVE_PLYARA = True + # FORMAT = '%(asctime)-15s %(clientip)s %(user)-8s %(message)s' # Config variables @@ -131,7 +141,9 @@ DIST_ENABLED = False if dist_conf.distributed.enabled: - from lib.cuckoo.common.dist_db import create_session + from sqlalchemy import select + + from lib.cuckoo.common.dist_db import Node, create_session from lib.cuckoo.common.dist_db import Task as DTask dist_session = create_session( @@ -142,6 +154,7 @@ db: _Database = Database() +ALLOWED_YARA_CATEGORIES = ("binaries", "urls", "memory", "CAPE", "macro", "monitor") # Conditional decorator for web authentication class conditional_login_required: @@ -1118,14 +1131,10 @@ def tasks_delete(request, task_id, status=False): return Response(resp) +# Re-enable session-cookie auth so the in-browser "End Session" button works +# under SSO deployments where the global DRF chain is API-key-only. @csrf_exempt @api_view(["GET", "POST"]) -# UI-internal endpoint: the Guacamole VNC pane calls this from the -# in-browser session to poll/stop a live analysis. Re-enable the session- -# cookie auth path here so it works under SSO deployments where the global -# DRF chain is API-key-only. ApiKeyAuthentication stays available for -# scripts that prefer it. -@authentication_classes([SessionAuthentication, ApiKeyAuthentication]) def tasks_status(request, task_id): if not apiconf.taskstatus.get("enabled"): resp = {"error": True, "error_value": "Task status API is disabled"} @@ -2904,3 +2913,195 @@ def dist_tasks_notification(request, task_id: int): # log.debug("reporting main_task_id: {}".format(task.main_task_id)) task.notificated = True + +@csrf_exempt +@api_view(["POST"]) +def yara_uploader(request): + try: + if not apiconf.yara_uploader.get("enabled"): + return Response({"error": True, "error_value": "Yara Uploader API is Disabled"}) + + if not HAVE_PLYARA: + return Response({"error": True, "error_value": "Missing dependency. Contact your administrator."}) + + category = request.data.get("category") + if not category or category not in ALLOWED_YARA_CATEGORIES: + return Response( + {"status": "error", "message": f"Invalid or missing category. Allowed categories: {ALLOWED_YARA_CATEGORIES}"}, + status=400, + ) + """ + if request.user.is_authenticated and request.user.username not in ALLOWED_UPLOADERS: + return Response( + {"status": "error", "message": f"User '{request.user.username}' is not authorized to upload YARA rules."}, status=403 + ) + """ + if "file" not in request.FILES: + return Response({"status": "error", "message": "No file provided"}, status=400) + + uploaded_file = request.FILES["file"] + + # Read content for processing + try: + content = uploaded_file.read().decode("utf-8") + except UnicodeDecodeError: + return Response({"status": "error", "message": "File must be a text file (UTF-8)"}, status=400) + + # Validate YARA + try: + yara.compile(source=content) + except yara.SyntaxError as e: + return Response({"status": "error", "message": f"YARA Syntax Error: {str(e)}"}, status=400) + except yara.Error as e: + return Response({"status": "error", "message": f"YARA Error: {str(e)}"}, status=400) + + try: + parser = plyara.Plyara() + rules = parser.parse_string(content) + + if not rules: + return Response({"status": "error", "message": "No YARA rules found in file"}, status=400) + + main_rule = rules[0] + + # Check for family + family = None + metadata = main_rule.get("metadata", []) + + for meta in metadata: + if "family" in meta: + family = meta["family"] + break + + if not family: + # Fallback: check cape_type + for meta in metadata: + if "cape_type" in meta: + cape_type_val = meta["cape_type"] + if cape_type_val and isinstance(cape_type_val, str): + family = cape_type_val.split(" ")[0] + break + + if not family: + return Response({"status": "error", "message": "Missing 'family' in metadata"}, status=400) + + # Now iterate all rules to inject cape_type / author if needed + for rule in rules: + rule_metadata = rule.get("metadata", []) + + has_cape_type = any("cape_type" in m for m in rule_metadata) + has_author = any("yara_created_by" in m for m in rule_metadata) # Using yara_created_by as key + + if not has_cape_type: + rule_metadata.append({"cape_type": f"{family} Payload"}) + + if request.user.is_authenticated and not has_author: + rule_metadata.append({"yara_created_by": request.user.username}) + + rule["metadata"] = rule_metadata + + # Define destination path + original_filename = os.path.basename(uploaded_file.name) # Basic safety + if category == "monitor": + dest_dir = os.path.join(CUCKOO_ROOT, "analyzer", "windows", "data", "yara") + else: + dest_dir = os.path.join(CUCKOO_ROOT, "data", "yara", category) + + # Ensure directory exists + if not os.path.exists(dest_dir): + os.makedirs(dest_dir, exist_ok=True) + + original_dest_path = os.path.join(dest_dir, original_filename) + + if os.path.exists(original_dest_path): + filename = original_filename + dest_path = original_dest_path + else: + # Fallback to standard naming + filename = f"{family}.yar" + dest_path = os.path.join(dest_dir, filename) + + # Check if file exists to append + if os.path.exists(dest_path): + with open(dest_path, "r", encoding="utf-8") as f: + existing_content = f.read() + + try: + existing_rules = parser.parse_string(existing_content) + existing_names = {r["rule_name"] for r in existing_rules} + + # Filter new rules + unique_rules = [] + for rule in rules: + if rule["rule_name"] not in existing_names: + unique_rules.append(rule) + + if not unique_rules: + # No new rules to add + msg = "All rules already exist. Nothing to add." + return Response({"status": "success", "message": msg}) + + append_content = "" + for rule in unique_rules: + append_content += "\n\n" + plyara.utils.rebuild_yara_rule(rule) + + content = existing_content + append_content + + except Exception as e: + return Response({"status": "error", "message": f"Failed to parse existing file for append: {str(e)}"}, status=500) + else: + # Rebuild content for new file + new_content = "" + for rule in rules: + new_content += plyara.utils.rebuild_yara_rule(rule) + "\n\n" + + content = new_content + + except Exception as e: + return Response({"status": "error", "message": f"Plyara parsing error: {str(e)}"}, status=400) + + # Save file + with open(dest_path, "w", encoding="utf-8") as f: + f.write(content) + + msg = "Rule saved! Thank you" + + # Distributed propagation + try: + if DIST_ENABLED: + # Prepare for propagation + files = {"file": (filename, content)} + with dist_session() as db_session: + nodes = db_session.execute(select(Node).where(Node.enabled.is_(True))).scalars().all() + + propagated_count = 0 + total_count = 0 + + for node in nodes: + total_count += 1 + prop_url = urljoin(node.url, "apiv2/yara_uploader/") + headers = {"Authorization": f"Token {node.apikey}"} + + try: + data = {"username": request.user.username, "category": category} + r = requests.post(prop_url, files=files, data=data, headers=headers, verify=False, timeout=10) + if r.status_code == 200: + propagated_count += 1 + except Exception: + pass + + msg += f" (Propagated to {propagated_count}/{total_count} workers)" + + except Exception as e: + msg += f" (Propagation failed: {str(e)})" + + return Response( + { + "status": "success", + "message": msg, + } + ) + + except Exception as e: + return Response({"status": "error", "message": str(e)}, status=500) + From 50c6cc9d738b678d98a2e26258b921997aa61b1f Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Tue, 12 May 2026 11:38:44 -0500 Subject: [PATCH 12/24] fix: restore calls.reset(), fix CLSID mapping, _safe_int, allow multiple activations - Restore process['calls'].reset() after event_apicall loop so downstream reporting modules can re-read the call log (critical regression fix). - Fix _OOP_CLSIDS: 25336920 (HTMLDocument OOP) maps to mshta.exe not mshtml.dll. - Use _safe_int() for clscontext parsing (handles hex and decimal). - Remove per-(pid,clsid) deduplication to capture multiple COM server instances spawned by the same process (e.g. multiple mshta.exe -Embedding). --- modules/processing/behavior.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/modules/processing/behavior.py b/modules/processing/behavior.py index 3fdea34a424..fc19f2ca39c 100644 --- a/modules/processing/behavior.py +++ b/modules/processing/behavior.py @@ -1247,7 +1247,7 @@ def __init__(self): "0002df01-0000-0000-c000-000000000046": "iexplore.exe", "9ba05972-f6a8-11cf-a442-00a0c90a8f39": "explorer.exe", "c08afd90-f2a1-11d1-8455-00a0c91f3880": "explorer.exe", - "25336920-03f9-11cf-8fd0-00aa00686f13": "mshtml.dll", + "25336920-03f9-11cf-8fd0-00aa00686f13": "mshta.exe", # HTMLDocument OOP → mshta } def event_apicall(self, call, process): @@ -1259,15 +1259,9 @@ def event_apicall(self, call, process): clsid = (args_map.get("rclsid") or "").lower() progid = (args_map.get("progid") or "").strip() # Capture any out-of-process activation (CLSCTX includes LOCAL_SERVER=4) - try: - ctx = int(args_map.get("clscontext", "0"), 16) - except (ValueError, TypeError): - ctx = 0 + ctx = _safe_int(args_map.get("clscontext", "0")) if ctx & 0x4 or clsid in self._OOP_CLSIDS: - key = (process.get("process_id"), clsid) - if not any((a.get("activator_pid"), a.get("clsid")) == key - for a in self.com_activations): - self.com_activations.append({ + self.com_activations.append({ "clsid": clsid, "progid": progid, "activator_pid": process.get("process_id"), @@ -1537,6 +1531,9 @@ def run(self): instance.event_apicall(call, process) except Exception: log.exception('Failure in partial behavior "%s"', instance.key) + # Reset the iterator so reporting modules can read the calls again + with suppress(AttributeError): + process["calls"].reset() for instance in instances: try: From 320479634319b7ddb847de7da7d57d88b07e979a Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Tue, 12 May 2026 17:01:02 +0000 Subject: [PATCH 13/24] fix: authenticode template duplicate rows, timestamp chain scope, issuer OU condition --- web/analysis/templatetags/analysis_tags.py | 10 +++++++ .../analysis/overview/_authenticode.html | 30 +++++++++---------- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/web/analysis/templatetags/analysis_tags.py b/web/analysis/templatetags/analysis_tags.py index cd23811a835..f21aeef1568 100644 --- a/web/analysis/templatetags/analysis_tags.py +++ b/web/analysis/templatetags/analysis_tags.py @@ -252,3 +252,13 @@ def _print(lvl, s): def playback_url(task_id): session_id = uuid3(NAMESPACE_DNS, str(task_id)).hex[:16] return f"{task_id}_{session_id}" + + +@register.filter +def cert_chain_signers(signers): + return [s for s in (signers or []) if "Certificate Chain" in s.get("name", "")] + + +@register.filter +def ts_chain_signers(signers): + return [s for s in (signers or []) if "Timestamp Chain" in s.get("name", "")] diff --git a/web/templates/analysis/overview/_authenticode.html b/web/templates/analysis/overview/_authenticode.html index faccdb0da90..192b5bad26e 100644 --- a/web/templates/analysis/overview/_authenticode.html +++ b/web/templates/analysis/overview/_authenticode.html @@ -38,12 +38,13 @@
    Au
    {% comment %}--- Code Signing Chain (from aux_signers, enriched with digital_signers) ---{% endcomment %} - {% if gs.aux_signers %} + {% with cert_items=gs.aux_signers|cert_chain_signers %} + {% if cert_items %}
    Code Signing Chain
    - {% for signer in gs.aux_signers %}{% if "Certificate Chain" in signer.name %} + {% for signer in cert_items %} {% with issued_to=signer|get_item:"Issued to" issued_by=signer|get_item:"Issued by" sha1=signer|get_item:"SHA1 hash" %}
    @@ -79,12 +80,6 @@
    Au {% if dc.sha1_fingerprint %}SHA1{{ dc.sha1_fingerprint }}{% endif %} {% if dc.md5_fingerprint %}MD5{{ dc.md5_fingerprint }}{% endif %} {% if dc.sha256_fingerprint %}SHA256{{ dc.sha256_fingerprint }}{% endif %} - {% else %} - {% comment %}Fallback: only aux_signers data available{% endcomment %} - Subject{{ issued_to }} - Issuer{{ issued_by }}{% if issued_to == issued_by %} Self-Signed{% endif %} - Expires{{ signer|get_item:"Expires" }} - SHA1{{ sha1 }} {% endif %}{% endfor %} {% if not ds %} Subject{{ issued_to }} @@ -97,16 +92,19 @@
    Au
    {% endwith %} - {% endif %}{% endfor %} + {% endfor %}
    + {% endif %} + {% endwith %} {% comment %}--- Timestamp Chain ---{% endcomment %} - {% for signer in gs.aux_signers %}{% if "Timestamp Chain" in signer.name %}{% if forloop.first %} + {% with ts_items=gs.aux_signers|ts_chain_signers %} + {% if ts_items %}
    Timestamp Chain
    - {% endif %} + {% for signer in ts_items %}
    - {% if forloop.last %}
    {% endif %} - {% endif %}{% endfor %} + {% endfor %} +
    + {% endif %} + {% endwith %} - {% elif ds %} + {% if not gs.aux_signers and ds %} {% comment %}Fallback: no aux_signers, show digital_signers directly{% endcomment %}
    {% for dc in ds %} @@ -156,7 +156,7 @@
    Au
    {% if dc.issuer_commonName %}Issuer CN{{ dc.issuer_commonName }}{% endif %} {% if dc.issuer_organizationName %}Issuer O{{ dc.issuer_organizationName }}{% endif %} - {% if dc.subject_organizationalUnitName %}Issuer OU{{ dc.issuer_organizationalUnitName }}{% endif %} + {% if dc.issuer_organizationalUnitName %}Issuer OU{{ dc.issuer_organizationalUnitName }}{% endif %}
    {% if dc.not_before %}Not Before{{ dc.not_before }}{% endif %} {% if dc.not_after %}Not After{{ dc.not_after }}{% endif %} From 4e779c4fb83fd927e246a3e7cc7f223838759345 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Tue, 12 May 2026 17:02:17 +0000 Subject: [PATCH 14/24] fix: split user_task_tags into list; add cape_yara to get_analysis_info projection --- web/analysis/views.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/web/analysis/views.py b/web/analysis/views.py index cda4d5afa9f..5651858d913 100644 --- a/web/analysis/views.py +++ b/web/analysis/views.py @@ -234,7 +234,8 @@ def get_analysis_info(db, id=-1, task=None): filename = os.path.basename(new["target"]) new.update({"filename": filename}) - new.update({"user_task_tags": get_tags_tasks([new["id"]])}) + raw_user_tags = get_tags_tasks([new["id"]]) or "" + new.update({"user_task_tags": [t.strip() for t in raw_user_tags.split(",") if t.strip()]}) if new.get("machine"): machine = new["machine"] @@ -263,6 +264,7 @@ def get_analysis_info(db, id=-1, task=None): "suri_http_cnt": 1, "suri_file_cnt": 1, "trid": 1, + "target.file.cape_yara.name": 1, "_id": 0, }, sort=[("_id", -1)], @@ -330,6 +332,9 @@ def get_analysis_info(db, id=-1, task=None): if rtmp.get("url", {}).get("virustotal", {}).get("summary", False): new["virustotal_summary"] = rtmp["url"]["virustotal"]["summary"] + if rtmp.get("target", {}).get("file", {}).get("cape_yara"): + new["cape_yara"] = [y["name"] for y in rtmp["target"]["file"]["cape_yara"] if y.get("name")] + if settings.MOLOCH_ENABLED: if settings.MOLOCH_BASE[-1] != "/": settings.MOLOCH_BASE += "/" From d211b966c76bb0ebfbc7ea0c13589e14ef261cc6 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Tue, 12 May 2026 17:04:46 +0000 Subject: [PATCH 15/24] fix: utcfromtimestamp deprecated; N+1 submitter query via lru_cache helper --- web/analysis/views.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/web/analysis/views.py b/web/analysis/views.py index b7caabab9df..5af6dc291cd 100644 --- a/web/analysis/views.py +++ b/web/analysis/views.py @@ -212,6 +212,20 @@ def _path_safe(path: str) -> bool: return True + +@lru_cache(maxsize=256) +def _get_username_by_id(user_id): + if not user_id: + return "" + try: + from django.contrib.auth import get_user_model + User = get_user_model() + u = User.objects.filter(pk=user_id).only("username").first() + return u.username if u else "" + except Exception: + return "" + + def get_tags_tasks(task_ids: list) -> str: for analysis in db.list_tasks(task_ids=task_ids): return analysis.tags_tasks @@ -241,21 +255,7 @@ def get_analysis_info(db, id=-1, task=None): raw_user_tags = get_tags_tasks([new["id"]]) or "" new.update({"user_task_tags": [t.strip() for t in raw_user_tags.split(",") if t.strip()]}) - # Submitter username for the "who submitted this task" column. user_id - # 0 = anonymous; for any real user, look up the Django username - # best-effort. Cheap enough per-task, not worth bulk-loading. - submitter_username = "" - user_id = new.get("user_id") or 0 - if user_id: - try: - from django.contrib.auth import get_user_model - User = get_user_model() - u = User.objects.filter(pk=user_id).only("username").first() - if u: - submitter_username = u.username - except Exception: - pass - new["submitter_username"] = submitter_username + new["submitter_username"] = _get_username_by_id(new.get("user_id") or 0) if new.get("machine"): machine = new["machine"] @@ -725,7 +725,7 @@ def _filetime_to_iso(ft): if micros < 0: return "" try: - return datetime.datetime.utcfromtimestamp(micros / 1_000_000).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + return datetime.datetime.fromtimestamp(micros / 1_000_000, tz=datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] except (OSError, ValueError, OverflowError): return "" From 009d6be0760eb85185de2e0381f07726e487020a Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Tue, 12 May 2026 17:52:39 +0000 Subject: [PATCH 16/24] fix: add display_authenticode section to web.conf.default --- conf/default/web.conf.default | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/conf/default/web.conf.default b/conf/default/web.conf.default index f5f29af8595..12a0c48074d 100644 --- a/conf/default/web.conf.default +++ b/conf/default/web.conf.default @@ -252,3 +252,7 @@ enabled = no [audit_framework] enabled = no + +[display_authenticode] +# Show Authenticode certificate chain card on the analysis overview tab +enabled = no From c34948db6e78c6710d3afee7649adb77050ac073 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Tue, 12 May 2026 17:58:57 +0000 Subject: [PATCH 17/24] fix: split_csv filter for tags_tasks; search.html browser martians; add display_cape_yara/submitter config --- conf/default/web.conf.default | 8 ++++++++ web/analysis/templatetags/analysis_tags.py | 9 +++++++++ web/templates/analysis/overview/_info.html | 2 +- web/templates/analysis/search.html | 6 +++--- 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/conf/default/web.conf.default b/conf/default/web.conf.default index f5f29af8595..f93075fdc11 100644 --- a/conf/default/web.conf.default +++ b/conf/default/web.conf.default @@ -252,3 +252,11 @@ enabled = no [audit_framework] enabled = no + +[display_cape_yara] +# Show CAPE YARA hit count column on analysis list and search results +enabled = no + +[display_submitter] +# Show submitting user column on analysis list (requires WEB_AUTHENTICATION) +enabled = no diff --git a/web/analysis/templatetags/analysis_tags.py b/web/analysis/templatetags/analysis_tags.py index cd23811a835..47d3390795e 100644 --- a/web/analysis/templatetags/analysis_tags.py +++ b/web/analysis/templatetags/analysis_tags.py @@ -252,3 +252,12 @@ def _print(lvl, s): def playback_url(task_id): session_id = uuid3(NAMESPACE_DNS, str(task_id)).hex[:16] return f"{task_id}_{session_id}" + + +@register.filter +def split_csv(value): + if not value: + return [] + if isinstance(value, list): + return [str(v).strip() for v in value if str(v).strip()] + return [t.strip() for t in str(value).split(",") if t.strip()] diff --git a/web/templates/analysis/overview/_info.html b/web/templates/analysis/overview/_info.html index a35da02342a..969ae5cfd75 100644 --- a/web/templates/analysis/overview/_info.html +++ b/web/templates/analysis/overview/_info.html @@ -70,7 +70,7 @@
    An {{analysis.info.submitter_username}} {% endif %} {% if analysis.info.tags_tasks %} - {% for t in analysis.info.tags_tasks %}{{t}}{% endfor %} + {% for t in analysis.info.tags_tasks|split_csv %}{{t}}{% endfor %} {% endif %} {% if analysis.info.options %} diff --git a/web/templates/analysis/search.html b/web/templates/analysis/search.html index c7fc7022a13..b44f186ded8 100644 --- a/web/templates/analysis/search.html +++ b/web/templates/analysis/search.html @@ -154,7 +154,7 @@
    Search Results
    {% if config.moloch %} Moloch {% endif %} - {% if config.display_office_martians %} + {% if config.display_office_martians or config.display_browser_martians %} Martians {% endif %} {% if config.suricata %} @@ -218,8 +218,8 @@
    Search Results
    {% if config.moloch %} {% if analysis.moloch_url %}MOLOCH{% else %}-{% endif %} {% endif %} - {% if config.display_office_martians %} - {% if analysis.f_mlist_cnt %}{{analysis.f_mlist_cnt}}{% else %}-{% endif %} + {% if config.display_office_martians or config.display_browser_martians %} + {% if analysis.category == "url" and config.display_browser_martians %}{% if analysis.mlist_cnt %}{{analysis.mlist_cnt}}{% else %}-{% endif %}{% elif config.display_office_martians %}{% if analysis.f_mlist_cnt %}{{analysis.f_mlist_cnt}}{% else %}-{% endif %}{% else %}-{% endif %} {% endif %} {% if config.suricata %} From 424ef8568252bd80d0b63a6ad86be6a4cb936b08 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Tue, 12 May 2026 18:38:33 +0000 Subject: [PATCH 18/24] fix: remove unused socket/encode imports; os.scandir for ETW dir check; O(1) UDP port index; add display_etw config --- analyzer/windows/modules/auxiliary/network_etw.py | 2 -- conf/default/web.conf.default | 4 ++++ modules/processing/network_etw.py | 7 +++++-- web/analysis/views.py | 2 +- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/analyzer/windows/modules/auxiliary/network_etw.py b/analyzer/windows/modules/auxiliary/network_etw.py index a25fcd9ed7d..2266f9307ab 100644 --- a/analyzer/windows/modules/auxiliary/network_etw.py +++ b/analyzer/windows/modules/auxiliary/network_etw.py @@ -2,7 +2,6 @@ import logging import os import shutil -import socket import time from threading import Thread @@ -16,7 +15,6 @@ ProviderInfo, GUID, et, - encode, ) log = logging.getLogger(__name__) diff --git a/conf/default/web.conf.default b/conf/default/web.conf.default index f5f29af8595..3d83d08d757 100644 --- a/conf/default/web.conf.default +++ b/conf/default/web.conf.default @@ -252,3 +252,7 @@ enabled = no [audit_framework] enabled = no + +[display_etw] +# Show ETW Telemetry tab on analysis report (requires network_etw processing module) +enabled = no diff --git a/modules/processing/network_etw.py b/modules/processing/network_etw.py index 6d5c34fc633..39f557e43a1 100644 --- a/modules/processing/network_etw.py +++ b/modules/processing/network_etw.py @@ -680,6 +680,7 @@ def run(self): # unique source port per outbound UDP query, giving a clean join. suricata_for_udp53 = self.results.get("suricata", {}) or {} udp53_by_key = {} + udp53_by_src_port = {} for ev in etw_conns: if (ev.get("protocol") or "").upper() != "UDP": continue @@ -689,7 +690,9 @@ def run(self): if not pid: continue key = (str(ev.get("src_port")), ev.get("dst_ip", "")) - udp53_by_key.setdefault(key, (pid, ev.get("process_name", ""))) + val = (pid, ev.get("process_name", "")) + udp53_by_key.setdefault(key, val) + udp53_by_src_port.setdefault(str(ev.get("src_port")), []).append(val) if udp53_by_key: for rec in suricata_for_udp53.get("dns", []) or []: # Only fill in PIDs we don't already know. @@ -705,7 +708,7 @@ def run(self): # because the OS allocates ephemeral ports incrementally). hit = udp53_by_key.get((src_port, dst_ip)) if hit is None: - cand = [v for k, v in udp53_by_key.items() if k[0] == src_port] + cand = udp53_by_src_port.get(src_port, []) if len(cand) == 1: hit = cand[0] if hit is None: diff --git a/web/analysis/views.py b/web/analysis/views.py index 5af6dc291cd..0a279217097 100644 --- a/web/analysis/views.py +++ b/web/analysis/views.py @@ -2718,7 +2718,7 @@ def report(request, task_id): continue if os.path.isdir(p): try: - if os.listdir(p): + if any(os.scandir(p)): report["has_etw"] = True break except OSError: From 4fac9e01ffb46c17c0ba2d2cd72ff97a3684b69e Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Tue, 12 May 2026 20:11:12 +0000 Subject: [PATCH 19/24] fix: cape_yara projection needs file_ref for hook resolution; merge yara+cape_yara by name --- web/analysis/views.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/web/analysis/views.py b/web/analysis/views.py index 5651858d913..c2afde6ddce 100644 --- a/web/analysis/views.py +++ b/web/analysis/views.py @@ -264,7 +264,9 @@ def get_analysis_info(db, id=-1, task=None): "suri_http_cnt": 1, "suri_file_cnt": 1, "trid": 1, - "target.file.cape_yara.name": 1, + "target.file.cape_yara": 1, + "target.file.yara.name": 1, + "target.file.file_ref": 1, "_id": 0, }, sort=[("_id", -1)], @@ -332,8 +334,19 @@ def get_analysis_info(db, id=-1, task=None): if rtmp.get("url", {}).get("virustotal", {}).get("summary", False): new["virustotal_summary"] = rtmp["url"]["virustotal"]["summary"] - if rtmp.get("target", {}).get("file", {}).get("cape_yara"): - new["cape_yara"] = [y["name"] for y in rtmp["target"]["file"]["cape_yara"] if y.get("name")] + if rtmp.get("target", {}).get("file", False): + tfile = rtmp["target"]["file"] + seen_yara_names = set() + yara_names = [] + for y in (tfile.get("cape_yara") or []) + (tfile.get("yara") or []): + if not isinstance(y, dict): + continue + n = y.get("name") + if n and n not in seen_yara_names: + seen_yara_names.add(n) + yara_names.append(n) + if yara_names: + new["cape_yara"] = yara_names if settings.MOLOCH_ENABLED: if settings.MOLOCH_BASE[-1] != "/": From da720cc0eb1e4f8ff201e8b124c35e8158498809 Mon Sep 17 00:00:00 2001 From: Will Metcalf Date: Wed, 13 May 2026 21:21:17 +0000 Subject: [PATCH 20/24] fix: Self-Signed badge only on depth-1 chains; root CAs are always self-signed by design --- web/templates/analysis/overview/_authenticode.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/templates/analysis/overview/_authenticode.html b/web/templates/analysis/overview/_authenticode.html index 192b5bad26e..beb81bea879 100644 --- a/web/templates/analysis/overview/_authenticode.html +++ b/web/templates/analysis/overview/_authenticode.html @@ -53,7 +53,7 @@
    Au data-bs-target="#cert_ac_{{ forloop.counter }}" aria-expanded="false"> {{ signer.name }} {{ issued_to }} - {% if issued_to == issued_by %}Self-Signed{% endif %} + {% if issued_to == issued_by and forloop.first and forloop.last %}Self-Signed{% endif %} exp {{ signer|get_item:"Expires" }}
    @@ -83,7 +83,7 @@
    Au {% endif %}{% endfor %} {% if not ds %} Subject{{ issued_to }} - Issuer{{ issued_by }}{% if issued_to == issued_by %} Self-Signed{% endif %} + Issuer{{ issued_by }}{% if issued_to == issued_by and forloop.first and forloop.last %} Self-Signed{% endif %} Expires{{ signer|get_item:"Expires" }} SHA1{{ sha1 }} {% endif %} @@ -141,7 +141,7 @@
    Au type="button" data-bs-toggle="collapse" data-bs-target="#dc_ac_{{ forloop.counter }}" aria-expanded="false"> {{ dc.subject_commonName }} - {% if dc.subject_commonName == dc.issuer_commonName %}Self-Signed{% endif %} + {% if dc.subject_commonName == dc.issuer_commonName and forloop.first and forloop.last %}Self-Signed{% endif %} exp {{ dc.not_after|slice:":10" }}
    From 48e74e1b035e6798126c13c826dd92c357fef405 Mon Sep 17 00:00:00 2001 From: Kevin O'Reilly Date: Fri, 15 May 2026 12:36:01 +0100 Subject: [PATCH 21/24] Removed unused import of 'os' in the _enrich_tree_com_parents function --- modules/processing/behavior.py | 1 - 1 file changed, 1 deletion(-) diff --git a/modules/processing/behavior.py b/modules/processing/behavior.py index fc19f2ca39c..d6d2598b4a3 100644 --- a/modules/processing/behavior.py +++ b/modules/processing/behavior.py @@ -1467,7 +1467,6 @@ def run(self): def _enrich_tree_com_parents(tree_nodes, com_activations): """Walk the processtree and annotate nodes whose binary matches a COM activation record.""" - import os as _os # Build lookup: target_binary_lower -> list of activations binary_map = {} for act in com_activations: From 063124919f8de94c6b021eef352223d1b941d48e Mon Sep 17 00:00:00 2001 From: Kevin O'Reilly Date: Fri, 15 May 2026 12:37:41 +0100 Subject: [PATCH 22/24] Removed unused imports for SessionAuthentication and authentication_classes --- web/apiv2/views.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/web/apiv2/views.py b/web/apiv2/views.py index 0f211c17735..6db198f2496 100644 --- a/web/apiv2/views.py +++ b/web/apiv2/views.py @@ -24,8 +24,7 @@ from django.shortcuts import render from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_safe -from rest_framework.authentication import SessionAuthentication -from rest_framework.decorators import api_view, authentication_classes +from rest_framework.decorators import api_view try: from apikey.authentication import ApiKeyAuthentication except ImportError: From 71176a5affd890f730b3b7e0ea654351d00b1466 Mon Sep 17 00:00:00 2001 From: Kevin O'Reilly Date: Fri, 15 May 2026 12:41:59 +0100 Subject: [PATCH 23/24] Refactor title attribute in badge span as suggested by Copilot Refactor title attribute in badge span for better readability. --- web/templates/analysis/network/_hosts_not_ajax.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/templates/analysis/network/_hosts_not_ajax.html b/web/templates/analysis/network/_hosts_not_ajax.html index 214c017a0bb..8296b99931a 100644 --- a/web/templates/analysis/network/_hosts_not_ajax.html +++ b/web/templates/analysis/network/_hosts_not_ajax.html @@ -35,7 +35,7 @@ {% if host.processes %} {% for p in host.processes %} + {% spaceless %}title="{% if p.source %}source: {{ p.source }}{% endif %}{% if p.resolved_hostname %}{% if p.source %} | {% endif %}resolved via {{ p.resolved_hostname }}{% endif %}{% if p.protocol %}{% if p.source or p.resolved_hostname %} | {% endif %}{{ p.protocol }}{% if p.dst_port %}:{{ p.dst_port }}{% endif %}{% endif %}"{% endspaceless %} {% if p.process_name %}{{ p.process_name }}{% else %}(unknown){% endif %}{% if p.pid %} ({{ p.pid }}){% endif %} {% endfor %} From c58ec086bd6e0b3b780a5cc57585ea04915a6f4c Mon Sep 17 00:00:00 2001 From: Kevin O'Reilly Date: Fri, 15 May 2026 12:54:58 +0100 Subject: [PATCH 24/24] Removed duplicate fields from projection in views.py --- web/analysis/views.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/web/analysis/views.py b/web/analysis/views.py index 2dd69329f61..29df40d88c5 100644 --- a/web/analysis/views.py +++ b/web/analysis/views.py @@ -288,15 +288,13 @@ def get_analysis_info(db, id=-1, task=None): # by sha256; the denormalize_files mongo hook restores # them — but only if file_ref is in the projection. Pull # it explicitly so the hook can follow the reference. + "target.file.yara.name": 1, "target.file.file_ref": 1, "suri_tls_cnt": 1, "suri_alert_cnt": 1, "suri_http_cnt": 1, "suri_file_cnt": 1, "trid": 1, - "target.file.cape_yara": 1, - "target.file.yara.name": 1, - "target.file.file_ref": 1, "_id": 0, }, sort=[("_id", -1)],