Files
PYAS/PYAS_Scanner.py
2026-10-09 12:12:42 +08:00

765 lines
29 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from PYAS_Diagnostics import log_exception
from PYAS_Diagnostics import observe_future, exception_sequence
import os
import json
import time
import stat
import threading
from itertools import islice
import ctypes
import ctypes.wintypes
from PYAS_Tools import MEMORY_BASIC_INFORMATION
from PYAS_Cloud import CloudQueueMixin
class ScannerMixin(CloudQueueMixin):
def init_engine_thread(self):
try:
self.start_daemon_thread(self.backup_mbr)
self.start_daemon_thread(self.relock_file)
self.start_daemon_thread(self.init_whitelist)
self.start_daemon_thread(self.popup_intercept_thread)
def log_callback(x):
self.write_log("INFO", "Load Engine", source=os.path.basename(x))
self.heuristic.load_path(self.path_heuristic, callback=log_callback)
self.properties.load_path(self.path_properties, callback=log_callback)
self.write_log("INFO", "System", detail="Engine Initialization Complete")
if "-scan" in self.args_pyas:
try:
idx = self.args_pyas.index("-scan")
self.trigger_context_scan(self.args_pyas[idx + 1])
except Exception:
log_exception("PYAS_Scanner.ScannerMixin.init_engine_thread:31")
pass
with self.lock_config:
first_launch = self.pyas_config.get("first_launch", True)
driver_enabled = self.pyas_config.get("driver_switch", False)
context_enabled = self.pyas_config.get("context_switch", True)
autostart_enabled = self.pyas_config.get("autostart_switch", True)
self.register_context_menu(context_enabled)
with self.lock_update:
with self.lock_config:
autostart_enabled = self.pyas_config.get("autostart_switch", True)
if not self.manage_autostart(autostart_enabled) and autostart_enabled:
with self.lock_config:
self.pyas_config["autostart_switch"] = False
self.save_config()
self.ui_queue.put(
"(()=>{const ats=document.querySelector('[data-config-key=autostart_switch]');if(ats)ats.checked=false;})();"
)
if not first_launch:
with self.lock_config:
if self.pyas_config.get("process_switch"):
self.start_feature_thread(self.protect_proc_thread, "process_switch")
if self.pyas_config.get("document_switch"):
self.start_feature_thread(self.protect_file_thread, "document_switch")
if self.pyas_config.get("system_switch"):
self.start_feature_thread(self.protect_system_thread, "system_switch")
if self.pyas_config.get("network_switch"):
self.start_feature_thread(self.protect_net_thread, "network_switch")
if driver_enabled:
if self.install_system_driver() and self.start_driver_listener(wait_ready=True):
pass
else:
self.stop_system_driver()
with self.lock_config:
self.pyas_config["driver_switch"] = False
self.save_config()
self.write_log(
"WARN",
"System",
detail="Driver Protection Failed to Start",
success=False,
)
except Exception as e:
log_exception("PYAS_Scanner.ScannerMixin.init_engine_thread:72")
self.write_log("WARN", "init_engine_thread", detail=str(e), success=False)
def yield_files(self, targets):
if isinstance(targets, str):
if os.path.isdir(targets):
for root, dirs, files in os.walk(targets):
dirs[:] = [d for d in dirs if not self._is_reparse_point(os.path.join(root, d))]
for f in files:
yield self.norm_path(os.path.join(root, f)), False
elif os.path.isfile(targets):
yield self.norm_path(targets), True
elif isinstance(targets, (list, tuple, set)):
for t in targets:
yield from self.yield_files(t)
def _is_reparse_point(self, path):
try:
if os.path.islink(path):
return True
if hasattr(os.path, "isjunction") and os.path.isjunction(path):
return True
st = os.lstat(path)
return bool(getattr(st, "st_file_attributes", 0) & 0x400)
except OSError:
log_exception("PYAS_Scanner.ScannerMixin._is_reparse_point:101")
return False
def scan_engine(self, file_path):
with self.lock_config:
ext_switch = self.pyas_config.get("extension_switch", False)
sen_switch = self.pyas_config.get("sensitive_switch", False)
try:
pe_label, _ = self.properties.pe_scan(file_path, enhanced_mode=sen_switch)
if pe_label:
return pe_label
except Exception:
log_exception("PYAS_Scanner.ScannerMixin.scan_engine:115")
pass
try:
if ext_switch:
yara_label, _ = self.heuristic.yara_scan(file_path)
if yara_label:
return yara_label
except Exception:
log_exception("PYAS_Scanner.ScannerMixin.scan_engine:123")
pass
return False
def safe_scan_engine(self, file_path):
norm_path = self.norm_path(file_path)
if not norm_path:
return False
gate, error = self._acquire_file_scan_gate(norm_path)
if not gate:
self.write_log("WARN", "Scan Deferred", source=norm_path, code=error, success=False)
self._schedule_file_task("verify", norm_path, self._retry_verified_scan, 0.5)
return self.scan_engine(norm_path)
try:
file_hash = self.calc_file_hash(norm_path)
if not file_hash:
return self.scan_engine(norm_path)
with self.lock_config:
policy = (
bool(self.pyas_config.get("extension_switch", False)),
bool(self.pyas_config.get("sensitive_switch", False)),
)
cache_key = (file_hash, policy)
with self.lock_file_ops:
if cache_key in self.hash_cache:
return self.hash_cache[cache_key]
event = self.scan_events.get(cache_key)
needs_scan = event is None
if needs_scan:
event = threading.Event()
self.scan_events[cache_key] = event
if not needs_scan:
event.wait(timeout=60)
with self.lock_file_ops:
if cache_key in self.hash_cache:
return self.hash_cache[cache_key]
return self.scan_engine(norm_path)
try:
before_exceptions = exception_sequence()
result = self.scan_engine(norm_path)
scan_succeeded = exception_sequence() == before_exceptions
with self.lock_config:
current_policy = (
bool(self.pyas_config.get("extension_switch", False)),
bool(self.pyas_config.get("sensitive_switch", False)),
)
if scan_succeeded and current_policy == policy:
with self.lock_file_ops:
if len(self.hash_cache) >= 100000:
for key in list(islice(self.hash_cache, 10000)):
del self.hash_cache[key]
self.hash_cache[cache_key] = result
return result
finally:
with self.lock_file_ops:
self.scan_events.pop(cache_key, None)
event.set()
finally:
self.kernel32.CloseHandle(gate)
def _retry_verified_scan(self, file_path):
if self.closing or not os.path.isfile(file_path):
return
result = self.safe_scan_engine(file_path)
if result:
self.manage_named_list(
"quarantine", [file_path], action="add", lock_func=self.lock_file
)
self.write_log(
"BLOCK",
"Deferred Scan Block",
source=file_path,
file_hash=self.calc_file_hash(file_path),
)
def _scan_result_messages(self, count=None, scanned=None, elapsed=None):
if count is None or scanned is None or elapsed is None:
with self.lock_virus:
count = len(getattr(self, "virus_results", [])) if count is None else count
scanned = getattr(self, "scan_count", 0) if scanned is None else scanned
scan_start = getattr(self, "scan_start", time.time())
elapsed = int(time.time() - scan_start) if elapsed is None else elapsed
return {
"traditional_switch": f"發現 {count} 個病毒,掃描 {scanned} 個檔案,耗時 {elapsed} 秒",
"simplified_switch": f"发现 {count} 个病毒,扫描 {scanned} 个文件,耗时 {elapsed} 秒",
"english_switch": f"Found {count} viruses, scanned {scanned} files, time {elapsed}s",
"japanese_switch": f"{count} 個のウイルスを発見し、{scanned} 個のファイルをスキャンしました(所要時間 {elapsed} 秒)",
"korean_switch": f"바이러스 {count}개 발견, 파일 {scanned}개 검사, 소요 시간 {elapsed}초",
"french_switch": f"{count} virus trouvés, {scanned} fichiers analysés, temps {elapsed}s",
"spanish_switch": f"Se encontraron {count} virus, {scanned} archivos escaneados, tiempo {elapsed}s",
"hindi_switch": f"{count} वायरस मिले, {scanned} फ़ाइलें स्कैन की गईं, समय {elapsed}s",
"arabic_switch": f"تم العثور على {count} فيروسات، تم فحص {scanned} ملفات، الوقت {elapsed} ثانية",
"russian_switch": f"Найдено {count} вирусов, проверено {scanned} файлов, время {elapsed}с",
"slovenian_switch": f"Najdenih {count} virusov, skeniranih {scanned} datotek, čas {elapsed}s",
}
def _is_scan_cancel_requested(self):
with self.lock_virus:
return getattr(self, "scan_stop_requested", False)
def _finish_scan_cancelled(self, messages=None):
with self.lock_virus:
count = len(getattr(self, "virus_results", []))
scanned = getattr(self, "scan_count", 0)
scan_start = getattr(self, "scan_start", time.time())
elapsed = int(time.time() - scan_start)
self.scan_running = False
self.scan_preparing = False
self.scan_finished = True
self.scan_stop_requested = False
messages = messages or self._scan_result_messages(count, scanned, elapsed)
if self._window:
js_cmd = f"if(window.finishScan) window.finishScan({json.dumps(messages)}, {count});"
self.ui_queue.put(js_cmd)
return False
def _begin_scan_preparation(self):
with self.lock_virus:
if getattr(self, "scan_running", False) or getattr(self, "scan_preparing", False):
return False
self.scan_preparing = True
self.scan_stop_requested = False
self.scan_finished = False
self.virus_results = []
self.scan_count = 0
self.scan_start = time.time()
return True
def start_scan(self, targets, from_preparing=False):
with self.lock_virus:
if getattr(self, "scan_running", False) or (
getattr(self, "scan_preparing", False) and not from_preparing
):
return False
if getattr(self, "scan_stop_requested", False):
self.scan_running = False
self.scan_preparing = False
self.scan_finished = True
return False
self.scan_preparing = False
self.scan_running = True
self.scan_finished = False
self.scan_stop_requested = False
self.virus_results = []
self.scan_count = 0
self.scan_start = time.time()
try:
observe_future(
self.scan_pool.submit(self.scan_worker, targets), "ScannerMixin.scan_worker"
)
return True
except Exception:
log_exception("PYAS_Scanner.ScannerMixin.start_scan:253")
with self.lock_virus:
self.scan_running = False
self.scan_preparing = False
self.scan_finished = True
return False
def scan_worker(self, targets):
last_update = 0.0
try:
for file_path, is_explicit in self.yield_files(targets):
with self.lock_virus:
if not self.scan_running or getattr(self, "scan_stop_requested", False):
break
norm_path = self.norm_path(file_path)
if not norm_path:
continue
current_time = time.time()
if current_time - last_update >= 0.05:
if self._window:
js_cmd = f"if(window.updateScanProgress) window.updateScanProgress({json.dumps(norm_path.replace(os.sep, '/'))});"
self.ui_queue.put(js_cmd)
last_update = current_time
was_locked = False
try:
with self.lock_file_ops:
if norm_path in self.virus_lock:
self.lock_file(norm_path, False)
was_locked = True
if self.is_in_whitelist(norm_path):
continue
with self.lock_virus:
if not self.scan_running or getattr(self, "scan_stop_requested", False):
break
self.scan_count += 1
with self.lock_config:
ext_filter = self.pyas_config.get("suffix_switch", True)
suffix = self.pyas_config.get("suffix", [])
if (
not is_explicit
and ext_filter
and os.path.splitext(norm_path)[-1].lower() not in suffix
):
continue
result = self.safe_scan_engine(norm_path)
with self.lock_virus:
if not self.scan_running or getattr(self, "scan_stop_requested", False):
break
if result:
with self.lock_virus:
self.virus_results.append(norm_path)
if self._window:
js_cmd = f"if(window.addVirusResult) window.addVirusResult({json.dumps(result)}, {json.dumps(norm_path.replace(os.sep, '/'))});"
self.ui_queue.put(js_cmd)
self.write_log(
"SCAN",
"Virus Detected",
source=norm_path,
file_hash=self.calc_file_hash(norm_path),
)
self.cloud_check(norm_path)
except Exception as e:
log_exception("PYAS_Scanner.ScannerMixin.scan_worker:318")
self.write_log(
"WARN", "Scan Engine", source=norm_path, detail=str(e), success=False
)
finally:
if was_locked:
self.lock_file(norm_path, True)
finally:
with self.lock_virus:
cancelled = getattr(self, "scan_stop_requested", False)
self.scan_running = False
self.scan_preparing = False
self.scan_finished = True
self.scan_stop_requested = False
count, scanned, elapsed = (
len(self.virus_results),
self.scan_count,
int(time.time() - self.scan_start),
)
messages = self._scan_result_messages(count, scanned, elapsed)
log_detail = f"Found {count} viruses, scanned {scanned} files, time {elapsed}s"
if self._window:
js_cmd = (
f"if(window.finishScan) window.finishScan({json.dumps(messages)}, {count});"
)
self.ui_queue.put(js_cmd)
self.write_log("INFO", "Scan Completed", detail=log_detail)
def stop_scan(self):
with self.lock_virus:
active = getattr(self, "scan_running", False) or getattr(self, "scan_preparing", False)
if active:
self.scan_stop_requested = True
self.scan_running = False
return active
def trigger_scan(self, method):
targets = []
if not self._begin_scan_preparation():
return False
try:
if method == "smart":
for folder in ["Desktop", "Downloads", "AppData"]:
if self._is_scan_cancel_requested():
return self._finish_scan_cancelled()
fp = os.path.join(self.path_user, folder)
if os.path.exists(fp):
targets.append(fp)
if self._is_scan_cancel_requested():
return self._finish_scan_cancelled()
if os.path.exists(self.path_config):
targets.append(self.path_config)
buf = ctypes.create_unicode_buffer(1024)
max_address = 0x7FFFFFFFFFFF if ctypes.sizeof(ctypes.c_void_p) == 8 else 0x7FFFFFFF
system_dir = self.path_system.lower()
for proc in self.get_process_list():
if self._is_scan_cancel_requested():
return self._finish_scan_cancelled()
if proc["path"] and proc["path"] != "None":
targets.append(proc["path"])
pid = proc["pid"]
if pid <= 4:
continue
h_process = self.kernel32.OpenProcess(0x1000, False, pid)
if h_process:
try:
address = 0
mbi = MEMORY_BASIC_INFORMATION()
while (
address < max_address
and not self._is_scan_cancel_requested()
and self.kernel32.VirtualQueryEx(
h_process,
ctypes.c_void_p(address),
ctypes.byref(mbi),
ctypes.sizeof(mbi),
)
):
if mbi.State == 0x1000 and mbi.Type == 0x1000000:
if self.psapi.GetMappedFileNameW(
h_process, ctypes.c_void_p(address), buf, 1024
):
raw_path = buf.value
if raw_path.startswith("\\"):
raw_path = self.device_path_to_drive(raw_path)
file_path = self.norm_path(raw_path)
if file_path and not file_path.lower().startswith(
system_dir
):
targets.append(file_path)
if mbi.RegionSize == 0:
break
address += mbi.RegionSize
finally:
self.kernel32.CloseHandle(h_process)
if self._is_scan_cancel_requested():
return self._finish_scan_cancelled()
if not self.start_scan(list(set(targets)), from_preparing=True):
return self._finish_scan_cancelled()
return True
elif method == "file":
targets = self.select_files()
if self._is_scan_cancel_requested():
return self._finish_scan_cancelled()
if targets:
if not self.start_scan(targets, from_preparing=True):
return self._finish_scan_cancelled()
return True
return self._finish_scan_cancelled()
elif method == "path":
targets = self.select_folder()
if self._is_scan_cancel_requested():
return self._finish_scan_cancelled()
if targets:
if not self.start_scan(targets, from_preparing=True):
return self._finish_scan_cancelled()
return True
return self._finish_scan_cancelled()
elif method == "full":
targets = [f"{chr(d)}:/" for d in range(65, 91) if os.path.exists(f"{chr(d)}:/")]
if self._is_scan_cancel_requested() or not self.start_scan(
targets, from_preparing=True
):
return self._finish_scan_cancelled()
return True
return self._finish_scan_cancelled()
except Exception as e:
log_exception("PYAS_Scanner.ScannerMixin.trigger_scan:453")
self.write_log("WARN", "trigger_scan", detail=str(e), success=False)
return self._finish_scan_cancelled()
def _queue_virus_delete(self, file_path):
self._schedule_file_task("delete", file_path, self._retry_virus_delete, 0.5)
def _retry_virus_delete(self, file_path):
file_hash = self.calc_file_hash(file_path)
try:
with self.lock_file_ops:
if file_path in self.virus_lock:
self.lock_file(file_path, False)
target_key = os.path.normcase(self.norm_path(file_path, must_exist=False) or file_path)
for proc in self.get_process_list():
proc_path = proc.get("path")
if not proc_path or proc_path == "None":
continue
proc_key = os.path.normcase(
self.norm_path(proc_path, must_exist=False) or proc_path
)
if proc_key == target_key:
self.kill_process(proc["pid"], expected_path=file_path)
try:
os.chmod(file_path, stat.S_IWRITE)
except Exception:
log_exception("PYAS_Scanner.ScannerMixin._retry_virus_delete:480")
pass
os.remove(file_path)
except FileNotFoundError:
pass
except Exception as e:
log_exception("PYAS_Scanner.ScannerMixin._retry_virus_delete:486")
self.lock_file(file_path, True, quiet=True)
self._queue_virus_delete(file_path)
self.write_log("INFO", "Virus Delete Deferred", source=file_path, detail=str(e))
return
self.remove_list_items("quarantine", [file_path])
with self.lock_virus:
target_key = os.path.normcase(self.norm_path(file_path, must_exist=False) or file_path)
self.virus_results = [
result
for result in self.virus_results
if os.path.normcase(self.norm_path(result, must_exist=False) or result)
!= target_key
]
self.write_log(
"INFO",
"Virus Delete",
source=file_path,
file_hash=file_hash,
operate=True,
success=True,
)
def solve_scan(self, file_paths):
deleted_paths = []
proc_map = {}
for proc in self.get_process_list():
if proc["path"] and proc["path"] != "None":
norm_p = self.norm_path(proc["path"], must_exist=False)
if norm_p:
proc_map.setdefault(os.path.normcase(norm_p), []).append(proc["pid"])
last_update = 0.0
with self.lock_virus:
deleted_set = set()
for raw_path in file_paths:
path = self.norm_path(raw_path, must_exist=False)
if not path:
continue
current_time = time.time()
if current_time - last_update >= 0.05:
if self._window:
self._window.evaluate_js(
f"if(window.updateDeleteProgress) window.updateDeleteProgress({json.dumps(path.replace(os.sep, '/'))});"
)
last_update = current_time
try:
if path in self.virus_lock:
self.lock_file(path, False)
path_key = os.path.normcase(path)
if path_key in proc_map:
for pid in proc_map[path_key]:
self.kill_process(pid, expected_path=path)
try:
os.chmod(path, stat.S_IWRITE)
except Exception:
log_exception("PYAS_Scanner.ScannerMixin.solve_scan:538")
pass
file_hash = self.calc_file_hash(path)
try:
os.remove(path)
except Exception as e:
log_exception("PYAS_Scanner.ScannerMixin.solve_scan:544")
self.lock_file(path, True, quiet=True)
self._queue_virus_delete(path)
self.write_log("INFO", "Virus Delete Deferred", source=path, detail=str(e))
continue
self.remove_list_items("quarantine", [path])
deleted_paths.append(raw_path)
deleted_set.add(path)
self.write_log(
"INFO",
"Virus Delete",
source=path,
file_hash=file_hash,
operate=True,
success=True,
)
except Exception as e:
log_exception("PYAS_Scanner.ScannerMixin.solve_scan:555")
self.lock_file(path, True)
self.write_log(
"SCAN",
"Virus Delete",
source=path,
file_hash=self.calc_file_hash(path),
detail=str(e),
operate=True,
success=False,
)
if deleted_set:
self.virus_results = [p for p in self.virus_results if p not in deleted_set]
remaining = len(self.virus_results)
messages = {
"traditional_switch": f"剩餘 {remaining} 個病毒,已刪除 {len(deleted_paths)} 個檔案。",
"simplified_switch": f"剩余 {remaining} 个病毒,已删除 {len(deleted_paths)} 个文件。",
"english_switch": f"Remaining {remaining} viruses, deleted {len(deleted_paths)} files.",
"japanese_switch": f"残りのウイルス {remaining} 個、削除されたファイル {len(deleted_paths)} 個。",
"korean_switch": f"남은 바이러스 {remaining}개, 삭제된 파일 {len(deleted_paths)}개.",
"french_switch": f"Virus restants : {remaining}, fichiers supprimés : {len(deleted_paths)}.",
"spanish_switch": f"Virus restantes: {remaining}, archivos eliminados: {len(deleted_paths)}.",
"hindi_switch": f"शेष वायरस {remaining}, हटाए गए फ़ाइलें {len(deleted_paths)}.",
"arabic_switch": f"الفيروسات المتبقية {remaining}، تم حذف {len(deleted_paths)} ملفات.",
"russian_switch": f"Осталось вирусов: {remaining}, удалено файлов: {len(deleted_paths)}.",
"slovenian_switch": f"Preostalih virusov: {remaining}, izbrisanih datotek: {len(deleted_paths)}.",
}
if self._window:
self._window.evaluate_js(
f"if(window.finishScan) window.finishScan({json.dumps(messages)}, {remaining});"
)
return deleted_paths
def remove_virus_result(self, paths):
with self.lock_virus:
norm_paths = set(self.norm_path(p, must_exist=False) for p in paths)
self.virus_results = [p for p in self.virus_results if p not in norm_paths]
return len(self.virus_results)