diff --git a/analyzer/windows/modules/auxiliary/network_etw.py b/analyzer/windows/modules/auxiliary/network_etw.py index 3411914634c..2266f9307ab 100644 --- a/analyzer/windows/modules/auxiliary/network_etw.py +++ b/analyzer/windows/modules/auxiliary/network_etw.py @@ -176,7 +176,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/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/conf/default/web.conf.default b/conf/default/web.conf.default index f5f29af8595..c20e5f2e929 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 @@ -252,3 +258,19 @@ enabled = no [audit_framework] enabled = no + +[display_etw] +# Show ETW Telemetry tab on analysis report (requires network_etw processing module) +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 + +[display_authenticode] +# Show Authenticode certificate chain card on the analysis overview tab +enabled = 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/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/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: diff --git a/modules/processing/behavior.py b/modules/processing/behavior.py index e04558c5d0b..d6d2598b4a3 100644 --- a/modules/processing/behavior.py +++ b/modules/processing/behavior.py @@ -1239,9 +1239,37 @@ 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": "mshta.exe", # HTMLDocument OOP → mshta + } 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) + ctx = _safe_int(args_map.get("clscontext", "0")) + if ctx & 0x4 or clsid in self._OOP_CLSIDS: + 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 +1378,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 +1464,39 @@ 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.""" + # 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.""" @@ -1478,6 +1539,11 @@ def run(self): 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/modules/processing/network_etw.py b/modules/processing/network_etw.py index 4f5e31eafb4..39f557e43a1 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,69 @@ 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 = {} + udp53_by_src_port = {} + 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", "")) + 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. + 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 = udp53_by_src_port.get(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 +792,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 +812,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 +870,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 +924,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/tests/web/test_guac_consumers.py b/tests/web/test_guac_consumers.py new file mode 100644 index 00000000000..78de0ad917d --- /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 "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/analysis/templatetags/analysis_tags.py b/web/analysis/templatetags/analysis_tags.py index cd23811a835..523af0f35c2 100644 --- a/web/analysis/templatetags/analysis_tags.py +++ b/web/analysis/templatetags/analysis_tags.py @@ -252,3 +252,20 @@ 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()] + +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/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/analysis/views.py b/web/analysis/views.py index cda4d5afa9f..29df40d88c5 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: @@ -211,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 @@ -234,7 +249,12 @@ 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()]}) + new["submitter_username"] = _get_username_by_id(new.get("user_id") or 0) if new.get("machine"): machine = new["machine"] @@ -258,6 +278,18 @@ 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.yara.name": 1, + "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,15 +356,49 @@ 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"] + 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] != "/": settings.MOLOCH_BASE += "/" @@ -650,6 +719,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.fromtimestamp(micros / 1_000_000, tz=datetime.timezone.utc).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 +1683,7 @@ def load_files(request, task_id, category): "memory", "tracee", "eventlogs", + "etw", ): data = {} debugger_logs = {} @@ -1149,6 +1850,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 +2720,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 any(os.scandir(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 +2921,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 +3032,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/apiv2/views.py b/web/apiv2/views.py index 7ebf18e941f..6db198f2496 100644 --- a/web/apiv2/views.py +++ b/web/apiv2/views.py @@ -25,6 +25,10 @@ from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_safe from rest_framework.decorators import api_view +try: + from apikey.authentication import ApiKeyAuthentication +except ImportError: + ApiKeyAuthentication = None from rest_framework.response import Response sys.path.append(settings.CUCKOO_PATH) @@ -1126,6 +1130,8 @@ 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"]) def tasks_status(request, task_id): 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/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..41a633d663b 100644 --- a/web/guac/templates/guac/index.html +++ b/web/guac/templates/guac/index.html @@ -1,42 +1,51 @@ {% load static %} - + - - + + + Guacamole Console · CAPE Sandbox + + + + + - + - - Guacamole Console - +
-
- +
+ + 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/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/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..ec708431fd8 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,144 +85,167 @@ 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(); - }; + _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; + } - /* Mouse handling */ - var mouse = new Guacamole.Mouse(terminal_element); + _setupKeyboard() { + this.keyboard = new Guacamole.Keyboard(this.display); - mouse.onmousedown = - mouse.onmouseup = - mouse.onmousemove = function(mouseState) { - terminal_client.sendMouseState(mouseState, true); - }; + this.keyboard.onkeydown = (keysym) => { + if (keysym === KEYSYM.SHIFT) this.shift = true; + else if (keysym === KEYSYM.CTRL) this.ctrl = true; - var keyboard = new Guacamole.Keyboard(terminal_element); - var ctrl, shift = false; - - keyboard.onkeydown = function (keysym) { - var cancel_event = true; - - if (keysym == 0xFFE1 || keysym == 0xFFE3 || keysym == 0xFF63 - || keysym == 0x0056 || keysym == 0x0076) { - cancel_event = false; - } - - if (keysym == 0xFFE1) { shift = true; } - else if (keysym == 0xFFE3) { 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 complete", "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 getCsrfToken() { + var match = document.cookie.match(/csrftoken=([^;]+)/); + return match ? match[1] : ''; +} +function stopTask(taskId, onSuccess, onError) { + var btn = document.getElementById('stopTask'); + if (btn) { btn.disabled = true; btn.innerHTML = 'Stopping...'; } + + const apiUrl = location.origin + "/apiv2/tasks/status/" + taskId + "/"; + + 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(data => { + console.log('Response:', data); + if (onSuccess) onSuccess(data); + location.replace(location.origin + '/submit/status/' + taskId + '/'); + }) + .catch(error => { + console.error('Error:', error); + if (onError) onError(error); + if (btn) { btn.disabled = false; btn.innerHTML = 'End Session'; } + }); } 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 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 %} 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/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/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..8296b99931a 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 %} + + {% 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 %} diff --git a/web/templates/analysis/overview/_authenticode.html b/web/templates/analysis/overview/_authenticode.html new file mode 100644 index 00000000000..beb81bea879 --- /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 %} + {% with cert_items=gs.aux_signers|cert_chain_signers %} + {% if cert_items %} +
+ Code Signing Chain +
+
+ {% 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" %} +
+
+ +
+
+
+ + {% 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 %} + {% 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 and forloop.first and forloop.last %} Self-Signed{% endif %}
Expires{{ signer|get_item:"Expires" }}
SHA1{{ sha1 }}
+
+
+
+ {% endwith %} + {% endfor %} +
+ {% endif %} + {% endwith %} + + {% comment %}--- Timestamp Chain ---{% endcomment %} + {% with ts_items=gs.aux_signers|ts_chain_signers %} + {% if ts_items %} +
+ Timestamp Chain +
+
+ {% for signer in ts_items %} +
+
+ +
+
+
+ + + + + +
Subject{{ signer|get_item:"Issued to" }}
Issuer{{ signer|get_item:"Issued by" }}
Expires{{ signer|get_item:"Expires" }}
SHA1{{ signer|get_item:"SHA1 hash" }}
+
+
+
+ {% endfor %} +
+ {% endif %} + {% endwith %} + + {% if not gs.aux_signers and 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.issuer_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/_info.html b/web/templates/analysis/overview/_info.html index 9b4e4cf1299..969ae5cfd75 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|split_csv %}{{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/overview/_playback.html b/web/templates/analysis/overview/_playback.html index 36933b9ba63..a601c27883f 100644 --- a/web/templates/analysis/overview/_playback.html +++ b/web/templates/analysis/overview/_playback.html @@ -1,7 +1,7 @@ {% if config.guacamole and analysis.info.options.interactive %} - +
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" %} 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" %} diff --git a/web/templates/analysis/search.html b/web/templates/analysis/search.html index 791ad5eb447..b44f186ded8 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 or config.display_browser_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 or config.display_browser_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.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 %} - - {% 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 %} 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 %}