Add files via upload
This commit is contained in:
1 parent
1be8c10062
commit
11e5fd7bdc
28 files changed
+10377
-6434
No files matched your search
@@ -0,0 +1,589 @@
|
||||
from PYAS_Diagnostics import log_exception
|
||||
import os
|
||||
import io
|
||||
import csv
|
||||
import json
|
||||
import sys
|
||||
import winreg
|
||||
import subprocess
|
||||
import ctypes
|
||||
import ctypes.wintypes
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
|
||||
class AutostartMixin:
|
||||
def _set_registry_autostart(self, enable):
|
||||
run_key = r"Software\Microsoft\Windows\CurrentVersion\Run"
|
||||
value_name = "PYAS_Security"
|
||||
|
||||
if enable:
|
||||
command = f'"{self.file_pyas}" -hide'
|
||||
return self._reg_write(
|
||||
winreg.HKEY_CURRENT_USER, run_key, value_name, winreg.REG_SZ, command
|
||||
)
|
||||
|
||||
try:
|
||||
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, run_key, 0, winreg.KEY_SET_VALUE) as reg:
|
||||
try:
|
||||
winreg.DeleteValue(reg, value_name)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return True
|
||||
except Exception:
|
||||
log_exception("PYAS_Autostart.AutostartMixin._set_registry_autostart:25")
|
||||
return False
|
||||
|
||||
def _get_autostart_user_sid(self):
|
||||
user32 = ctypes.WinDLL("user32", use_last_error=True)
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
advapi32 = ctypes.WinDLL("advapi32", use_last_error=True)
|
||||
user32.GetShellWindow.restype = ctypes.wintypes.HWND
|
||||
user32.GetWindowThreadProcessId.argtypes = [
|
||||
ctypes.wintypes.HWND,
|
||||
ctypes.POINTER(ctypes.wintypes.DWORD),
|
||||
]
|
||||
user32.GetWindowThreadProcessId.restype = ctypes.wintypes.DWORD
|
||||
kernel32.OpenProcess.argtypes = [
|
||||
ctypes.wintypes.DWORD,
|
||||
ctypes.wintypes.BOOL,
|
||||
ctypes.wintypes.DWORD,
|
||||
]
|
||||
kernel32.OpenProcess.restype = ctypes.wintypes.HANDLE
|
||||
kernel32.CloseHandle.argtypes = [ctypes.wintypes.HANDLE]
|
||||
kernel32.LocalFree.argtypes = [ctypes.c_void_p]
|
||||
kernel32.LocalFree.restype = ctypes.c_void_p
|
||||
advapi32.OpenProcessToken.argtypes = [
|
||||
ctypes.wintypes.HANDLE,
|
||||
ctypes.wintypes.DWORD,
|
||||
ctypes.POINTER(ctypes.wintypes.HANDLE),
|
||||
]
|
||||
advapi32.GetTokenInformation.argtypes = [
|
||||
ctypes.wintypes.HANDLE,
|
||||
ctypes.c_int,
|
||||
ctypes.c_void_p,
|
||||
ctypes.wintypes.DWORD,
|
||||
ctypes.POINTER(ctypes.wintypes.DWORD),
|
||||
]
|
||||
advapi32.ConvertSidToStringSidW.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.POINTER(ctypes.c_void_p),
|
||||
]
|
||||
hwnd = user32.GetShellWindow()
|
||||
|
||||
if not hwnd:
|
||||
raise OSError("No interactive Windows desktop is available for autostart")
|
||||
|
||||
pid = ctypes.wintypes.DWORD()
|
||||
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
||||
process = kernel32.OpenProcess(0x1000, False, pid.value)
|
||||
|
||||
if not process:
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
|
||||
token = ctypes.wintypes.HANDLE()
|
||||
sid_string = ctypes.c_void_p()
|
||||
|
||||
try:
|
||||
if not advapi32.OpenProcessToken(process, 0x0008, ctypes.byref(token)):
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
|
||||
size = ctypes.wintypes.DWORD()
|
||||
advapi32.GetTokenInformation(token, 1, None, 0, ctypes.byref(size))
|
||||
|
||||
if not size.value:
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
|
||||
buffer = ctypes.create_string_buffer(size.value)
|
||||
|
||||
if not advapi32.GetTokenInformation(token, 1, buffer, size, ctypes.byref(size)):
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
|
||||
sid = ctypes.cast(buffer, ctypes.POINTER(ctypes.c_void_p)).contents
|
||||
|
||||
if not advapi32.ConvertSidToStringSidW(sid, ctypes.byref(sid_string)):
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
|
||||
return ctypes.wstring_at(sid_string)
|
||||
finally:
|
||||
if sid_string.value:
|
||||
kernel32.LocalFree(sid_string)
|
||||
|
||||
if token.value:
|
||||
kernel32.CloseHandle(token)
|
||||
|
||||
kernel32.CloseHandle(process)
|
||||
|
||||
def _autostart_task_script(self, enable, sid):
|
||||
executable = self.file_pyas if getattr(sys, "frozen", False) else sys.executable
|
||||
arguments = (
|
||||
"-hide"
|
||||
if getattr(sys, "frozen", False)
|
||||
else subprocess.list2cmdline([self.file_pyas, "-hide"])
|
||||
)
|
||||
quote = lambda value: "'" + value.replace("'", "''") + "'"
|
||||
values = (
|
||||
"$ErrorActionPreference = 'Stop'; "
|
||||
"[Console]::OutputEncoding = [Text.UTF8Encoding]::new(); "
|
||||
f"$Sid = {quote(sid)}; $Executable = {quote(os.path.normpath(executable))}; "
|
||||
f"$Arguments = {quote(arguments)}; $WorkingDirectory = {quote(os.path.dirname(self.file_pyas))}; "
|
||||
f"$Enable = {'$true' if enable else '$false'}; "
|
||||
)
|
||||
return (
|
||||
values
|
||||
+ r"""
|
||||
function Resolve-Sid($UserId) {
|
||||
if ($UserId -like 'S-1-*') { return $UserId }
|
||||
return ([Security.Principal.NTAccount]::new($UserId)).Translate([Security.Principal.SecurityIdentifier]).Value
|
||||
}
|
||||
$Identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
if ($Identity.User.Value -ne $Sid) {
|
||||
throw 'The elevated account differs from the signed-in desktop user. Configure autostart while signed in as the target administrator.'
|
||||
}
|
||||
if (-not ([Security.Principal.WindowsPrincipal]::new($Identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
throw 'Administrator privileges are required to configure elevated autostart.'
|
||||
}
|
||||
$Service = New-Object -ComObject 'Schedule.Service'
|
||||
$Service.Connect()
|
||||
$Folder = $Service.GetFolder('\')
|
||||
$TaskName = 'PYAS_Security_ATS_' + $Sid
|
||||
function Find-Task($Name) {
|
||||
foreach ($Task in $Folder.GetTasks(1)) {
|
||||
if ($Task.Name -eq $Name) { return $Task }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
function Save-Task($Task) {
|
||||
$BackupDirectory = Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) 'PYAS\StartupTasks'
|
||||
[IO.Directory]::CreateDirectory($BackupDirectory) | Out-Null
|
||||
$BackupPath = Join-Path $BackupDirectory ($Task.Name + '-' + [Guid]::NewGuid().ToString('N') + '.xml')
|
||||
[IO.File]::WriteAllText($BackupPath, $Task.Xml, [Text.Encoding]::UTF8)
|
||||
}
|
||||
function Test-Task($Task) {
|
||||
if (-not $Task -or -not $Task.Enabled) { return $false }
|
||||
$Definition = $Task.Definition
|
||||
if ($Definition.Actions.Count -ne 1 -or $Definition.Triggers.Count -ne 1) { return $false }
|
||||
$Action = $Definition.Actions.Item(1)
|
||||
$Trigger = $Definition.Triggers.Item(1)
|
||||
$Settings = $Definition.Settings
|
||||
return ((Resolve-Sid $Definition.Principal.UserId) -eq $Sid -and
|
||||
$Definition.Principal.LogonType -eq 3 -and $Definition.Principal.RunLevel -eq 1 -and
|
||||
$Action.Type -eq 0 -and $Action.Path -eq $Executable -and
|
||||
$Action.Arguments -eq $Arguments -and $Action.WorkingDirectory -eq $WorkingDirectory -and
|
||||
$Trigger.Type -eq 9 -and $Trigger.Enabled -and (Resolve-Sid $Trigger.UserId) -eq $Sid -and
|
||||
$Trigger.Delay -eq 'PT10S' -and -not $Settings.DisallowStartIfOnBatteries -and
|
||||
-not $Settings.StopIfGoingOnBatteries -and $Settings.ExecutionTimeLimit -eq 'PT0S' -and
|
||||
$Settings.MultipleInstances -eq 2 -and $Settings.RestartCount -eq 3 -and
|
||||
$Settings.RestartInterval -eq 'PT1M')
|
||||
}
|
||||
$Existing = Find-Task $TaskName
|
||||
$ExistingXml = if ($Existing) { $Existing.Xml } else { $null }
|
||||
if ($Enable) {
|
||||
if (-not (Test-Task $Existing)) {
|
||||
if ($Existing) { Save-Task $Existing }
|
||||
$Definition = $Service.NewTask(0)
|
||||
$Definition.RegistrationInfo.Description = 'PYAS elevated autostart for the signed-in user'
|
||||
$Definition.Principal.UserId = $Sid
|
||||
$Definition.Principal.LogonType = 3
|
||||
$Definition.Principal.RunLevel = 1
|
||||
$Trigger = $Definition.Triggers.Create(9)
|
||||
$Trigger.UserId = $Sid
|
||||
$Trigger.Delay = 'PT10S'
|
||||
$Trigger.Enabled = $true
|
||||
$Action = $Definition.Actions.Create(0)
|
||||
$Action.Path = $Executable
|
||||
$Action.Arguments = $Arguments
|
||||
$Action.WorkingDirectory = $WorkingDirectory
|
||||
$Definition.Settings.Enabled = $true
|
||||
$Definition.Settings.DisallowStartIfOnBatteries = $false
|
||||
$Definition.Settings.StopIfGoingOnBatteries = $false
|
||||
$Definition.Settings.ExecutionTimeLimit = 'PT0S'
|
||||
$Definition.Settings.MultipleInstances = 2
|
||||
$Definition.Settings.RestartCount = 3
|
||||
$Definition.Settings.RestartInterval = 'PT1M'
|
||||
try {
|
||||
$Folder.RegisterTaskDefinition($TaskName, $Definition, 6, $Sid, $null, 3) | Out-Null
|
||||
if (-not (Test-Task (Find-Task $TaskName))) { throw 'Autostart task verification failed' }
|
||||
} catch {
|
||||
$Failure = $_
|
||||
if ($Existing) {
|
||||
$Folder.RegisterTask($TaskName, $ExistingXml, 6, $Sid, $null, 3) | Out-Null
|
||||
} else {
|
||||
$Created = Find-Task $TaskName
|
||||
if ($Created) { $Folder.DeleteTask($TaskName, 0) }
|
||||
}
|
||||
throw $Failure
|
||||
}
|
||||
}
|
||||
} elseif ($Existing) {
|
||||
Save-Task $Existing
|
||||
$Folder.DeleteTask($TaskName, 0)
|
||||
if (Find-Task $TaskName) { throw 'Autostart task removal verification failed' }
|
||||
}
|
||||
$Legacy = Find-Task 'PYAS_Security_ATS'
|
||||
if ($Legacy -and (Resolve-Sid $Legacy.Definition.Principal.UserId) -eq $Sid -and
|
||||
$Legacy.Definition.Actions.Count -eq 1 -and $Legacy.Definition.Actions.Item(1).Path -eq $Executable) {
|
||||
Save-Task $Legacy
|
||||
$Folder.DeleteTask($Legacy.Name, 0)
|
||||
}
|
||||
@{verified=$true; taskName=$TaskName; userSid=$Sid; enabled=$Enable} | ConvertTo-Json -Compress
|
||||
"""
|
||||
)
|
||||
|
||||
def manage_autostart(self, enable):
|
||||
try:
|
||||
sid = self._get_autostart_user_sid()
|
||||
command = self._autostart_task_script(enable, sid)
|
||||
result = self._run_powershell(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if result is None:
|
||||
raise OSError("PowerShell is unavailable; elevated autostart cannot be configured")
|
||||
|
||||
if result.returncode != 0:
|
||||
raise OSError(
|
||||
(
|
||||
result.stderr or result.stdout or f"PowerShell exit {result.returncode}"
|
||||
).strip()
|
||||
)
|
||||
|
||||
state = json.loads(result.stdout)
|
||||
|
||||
if (
|
||||
state.get("verified") is not True
|
||||
or state.get("userSid") != sid
|
||||
or state.get("taskName") != "PYAS_Security_ATS_" + sid
|
||||
or state.get("enabled") is not bool(enable)
|
||||
):
|
||||
raise OSError("Autostart task readback did not match the requested state")
|
||||
|
||||
if not self._set_registry_autostart(False):
|
||||
raise OSError("The legacy HKCU Run entry could not be removed")
|
||||
|
||||
self.autostart_mode = "task" if enable else "disabled"
|
||||
self.write_log(
|
||||
"INFO",
|
||||
"manage_autostart",
|
||||
detail=f"Verified {self.autostart_mode}: {state['taskName']}",
|
||||
success=True,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
log_exception("PYAS_Autostart.AutostartMixin.manage_autostart:201")
|
||||
self.autostart_mode = "unavailable"
|
||||
self.write_log("WARN", "manage_autostart", detail=str(e), success=False)
|
||||
return False
|
||||
|
||||
def get_startup_list(self):
|
||||
items = []
|
||||
|
||||
def get_reg_startup():
|
||||
res = []
|
||||
paths = [
|
||||
(
|
||||
winreg.HKEY_LOCAL_MACHINE,
|
||||
r"SOFTWARE\Microsoft\Windows\CurrentVersion\Run",
|
||||
"enabled",
|
||||
),
|
||||
(
|
||||
winreg.HKEY_CURRENT_USER,
|
||||
r"SOFTWARE\Microsoft\Windows\CurrentVersion\Run",
|
||||
"enabled",
|
||||
),
|
||||
(
|
||||
winreg.HKEY_LOCAL_MACHINE,
|
||||
r"SOFTWARE\Microsoft\Windows\CurrentVersion\Run_Disabled",
|
||||
"disabled",
|
||||
),
|
||||
(
|
||||
winreg.HKEY_CURRENT_USER,
|
||||
r"SOFTWARE\Microsoft\Windows\CurrentVersion\Run_Disabled",
|
||||
"disabled",
|
||||
),
|
||||
]
|
||||
|
||||
for root, path, status in paths:
|
||||
try:
|
||||
with winreg.OpenKey(root, path, 0, winreg.KEY_READ) as reg:
|
||||
i = 0
|
||||
|
||||
while True:
|
||||
try:
|
||||
name, val, _ = winreg.EnumValue(reg, i)
|
||||
file_path = self.extract_paths_from_cmdline(val)
|
||||
fp = file_path[0] if file_path else val
|
||||
res.append(
|
||||
{
|
||||
"id": f"reg|{root}|{path}|{name}",
|
||||
"type": "type_reg",
|
||||
"name": name,
|
||||
"status": status,
|
||||
"path": fp,
|
||||
}
|
||||
)
|
||||
i += 1
|
||||
|
||||
except OSError:
|
||||
log_exception(
|
||||
"PYAS_Autostart.AutostartMixin.get_startup_list.get_reg_startup:230"
|
||||
)
|
||||
break
|
||||
except Exception:
|
||||
log_exception(
|
||||
"PYAS_Autostart.AutostartMixin.get_startup_list.get_reg_startup:232"
|
||||
)
|
||||
pass
|
||||
|
||||
return res
|
||||
|
||||
def get_services():
|
||||
res = []
|
||||
|
||||
try:
|
||||
with winreg.OpenKey(
|
||||
winreg.HKEY_LOCAL_MACHINE,
|
||||
r"SYSTEM\CurrentControlSet\Services",
|
||||
0,
|
||||
winreg.KEY_READ,
|
||||
) as reg:
|
||||
i = 0
|
||||
|
||||
while True:
|
||||
try:
|
||||
name = winreg.EnumKey(reg, i)
|
||||
i += 1
|
||||
|
||||
try:
|
||||
with winreg.OpenKey(reg, name, 0, winreg.KEY_READ) as sub:
|
||||
stype, _ = winreg.QueryValueEx(sub, "Type")
|
||||
|
||||
if stype not in (16, 32, 256):
|
||||
continue
|
||||
|
||||
start, _ = winreg.QueryValueEx(sub, "Start")
|
||||
|
||||
if start not in (2, 3, 4):
|
||||
continue
|
||||
|
||||
path, _ = winreg.QueryValueEx(sub, "ImagePath")
|
||||
|
||||
if (
|
||||
path
|
||||
and "svchost" not in path.lower()
|
||||
and "windows" not in path.lower()
|
||||
):
|
||||
fp = self.extract_paths_from_cmdline(path)
|
||||
fpp = fp[0] if fp else path
|
||||
status = "enabled" if start == 2 else "disabled"
|
||||
res.append(
|
||||
{
|
||||
"id": f"srv|{name}",
|
||||
"type": "type_srv",
|
||||
"name": name,
|
||||
"status": status,
|
||||
"path": fpp,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception:
|
||||
log_exception(
|
||||
"PYAS_Autostart.AutostartMixin.get_startup_list.get_services:262"
|
||||
)
|
||||
pass
|
||||
except OSError:
|
||||
log_exception(
|
||||
"PYAS_Autostart.AutostartMixin.get_startup_list.get_services:264"
|
||||
)
|
||||
break
|
||||
|
||||
except Exception:
|
||||
log_exception("PYAS_Autostart.AutostartMixin.get_startup_list.get_services:267")
|
||||
pass
|
||||
|
||||
return res
|
||||
|
||||
def get_tasks():
|
||||
res = []
|
||||
|
||||
try:
|
||||
proc = self._run_windows_tool(
|
||||
("schtasks.exe",),
|
||||
["/query", "/fo", "csv", "/v"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if proc is not None and proc.returncode == 0:
|
||||
reader = csv.reader(io.StringIO(proc.stdout))
|
||||
next(reader, None)
|
||||
|
||||
for row in reader:
|
||||
if len(row) > 8 and row[1].strip() and row[8].strip() and row[8] != "N/A":
|
||||
name = row[1].split("\\")[-1]
|
||||
path = row[8]
|
||||
status = row[3]
|
||||
|
||||
if path and "windows" not in path.lower():
|
||||
fp = self.extract_paths_from_cmdline(path)
|
||||
fpp = fp[0] if fp else path
|
||||
res.append(
|
||||
{
|
||||
"id": f"tsk|{row[1]}",
|
||||
"type": "type_tsk",
|
||||
"name": name,
|
||||
"status": (
|
||||
"disabled"
|
||||
if status.lower() == "disabled"
|
||||
else "enabled"
|
||||
),
|
||||
"path": fpp,
|
||||
}
|
||||
)
|
||||
|
||||
return res
|
||||
|
||||
command = (
|
||||
"[Console]::OutputEncoding = [Text.UTF8Encoding]::new(); "
|
||||
"Get-ScheduledTask -ErrorAction SilentlyContinue | ForEach-Object { "
|
||||
"$Action = @($_.Actions)[0]; "
|
||||
"[pscustomobject]@{Name=$_.TaskName; FullName=($_.TaskPath + $_.TaskName); "
|
||||
"State=[string]$_.State; Execute=$Action.Execute; Arguments=$Action.Arguments} "
|
||||
"} | ConvertTo-Json -Compress"
|
||||
)
|
||||
proc = self._run_powershell(
|
||||
command, capture_output=True, text=True, encoding="utf-8", errors="replace"
|
||||
)
|
||||
|
||||
if not proc or proc.returncode != 0 or not proc.stdout.strip():
|
||||
return res
|
||||
|
||||
task_data = json.loads(proc.stdout)
|
||||
|
||||
if isinstance(task_data, dict):
|
||||
task_data = [task_data]
|
||||
|
||||
for task in task_data:
|
||||
name = str(task.get("Name") or "").strip()
|
||||
full_name = str(task.get("FullName") or name).strip()
|
||||
execute = str(task.get("Execute") or "").strip()
|
||||
arguments = str(task.get("Arguments") or "").strip()
|
||||
command_line = " ".join(part for part in (execute, arguments) if part)
|
||||
|
||||
if not name or not command_line or "windows" in command_line.lower():
|
||||
continue
|
||||
|
||||
paths = self.extract_paths_from_cmdline(command_line)
|
||||
path = paths[0] if paths else execute or command_line
|
||||
status = (
|
||||
"disabled"
|
||||
if str(task.get("State") or "").lower() == "disabled"
|
||||
else "enabled"
|
||||
)
|
||||
res.append(
|
||||
{
|
||||
"id": f"tsk|{full_name}",
|
||||
"type": "type_tsk",
|
||||
"name": name,
|
||||
"status": status,
|
||||
"path": path,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception:
|
||||
log_exception("PYAS_Autostart.AutostartMixin.get_startup_list.get_tasks:326")
|
||||
pass
|
||||
|
||||
return res
|
||||
|
||||
with ThreadPoolExecutor(max_workers=3) as executor:
|
||||
f1 = executor.submit(get_reg_startup)
|
||||
f2 = executor.submit(get_services)
|
||||
f3 = executor.submit(get_tasks)
|
||||
|
||||
items.extend(f1.result())
|
||||
items.extend(f2.result())
|
||||
items.extend(f3.result())
|
||||
|
||||
return items
|
||||
|
||||
def manage_startup(self, items, action):
|
||||
def process_item(item_id):
|
||||
try:
|
||||
parts = item_id.split("|", 3)
|
||||
stype = parts[0]
|
||||
|
||||
if stype == "reg":
|
||||
root, old_path, name = int(parts[1]), parts[2], parts[3]
|
||||
|
||||
if action == "delete":
|
||||
return self._reg_delete(root, old_path, name)
|
||||
|
||||
else:
|
||||
new_path = (
|
||||
old_path.replace("_Disabled", "")
|
||||
if action == "enable"
|
||||
else (old_path if "_Disabled" in old_path else old_path + "_Disabled")
|
||||
)
|
||||
|
||||
if old_path != new_path:
|
||||
with winreg.OpenKey(root, old_path, 0, winreg.KEY_READ) as reg:
|
||||
val, value_type = winreg.QueryValueEx(reg, name)
|
||||
|
||||
return self._reg_write(
|
||||
root, new_path, name, value_type, val
|
||||
) and self._reg_delete(root, old_path, name)
|
||||
|
||||
return True
|
||||
|
||||
elif stype == "srv":
|
||||
name = parts[1]
|
||||
|
||||
if action == "delete":
|
||||
result = self._run_windows_tool(
|
||||
("sc.exe",), ["delete", name], capture_output=True
|
||||
)
|
||||
|
||||
else:
|
||||
mode = "auto" if action == "enable" else "disabled"
|
||||
result = self._run_windows_tool(
|
||||
("sc.exe",), ["config", name, "start=", mode], capture_output=True
|
||||
)
|
||||
|
||||
if result is not None and result.returncode == 0:
|
||||
return True
|
||||
|
||||
return self._manage_service_native(name, action)
|
||||
|
||||
elif stype == "tsk":
|
||||
name = item_id.partition("|")[2]
|
||||
return self._manage_scheduled_task(name, action)
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
log_exception("PYAS_Autostart.AutostartMixin.manage_startup.process_item:379")
|
||||
self.write_log("WARN", "manage_startup", detail=str(e), success=False)
|
||||
return False
|
||||
|
||||
with ThreadPoolExecutor(max_workers=5) as executor:
|
||||
results = list(executor.map(process_item, items))
|
||||
|
||||
success = all(results) if results else True
|
||||
|
||||
if not success:
|
||||
self.write_log(
|
||||
"WARN",
|
||||
"manage_startup",
|
||||
detail="One or more startup items could not be updated",
|
||||
success=False,
|
||||
)
|
||||
|
||||
return success
|
||||
+309
-248
@@ -1,248 +1,309 @@
|
||||
import os, time, uuid, logging, requests, hashlib
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict, Any, Union
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
####################################################################################################
|
||||
|
||||
class PYAS_Client:
|
||||
def __init__(self, api_key: str, hosts: Union[str, List[str]], timeout: int = 30):
|
||||
self.timeout = timeout
|
||||
self.logger = logging.getLogger("PYAS_API")
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({"X-API-Key": api_key, "User-Agent": "PYAS-API/1.1"})
|
||||
|
||||
if isinstance(hosts, str):
|
||||
self.base_url = hosts.rstrip('/')
|
||||
else:
|
||||
self.base_url = self._get_fastest_host(hosts)
|
||||
|
||||
self.logger.info(f"Initialized PYAS API Client targeting {self.base_url}")
|
||||
|
||||
def _get_fastest_host(self, hosts: List[str]) -> str:
|
||||
def ping(host: str):
|
||||
url = host.rstrip('/')
|
||||
try:
|
||||
start = time.perf_counter()
|
||||
r = self.session.head(url, timeout=3)
|
||||
if r.status_code < 500:
|
||||
return url, time.perf_counter() - start
|
||||
except Exception:
|
||||
pass
|
||||
return url, float('inf')
|
||||
|
||||
self.logger.info("Testing host latencies...")
|
||||
with ThreadPoolExecutor(max_workers=min(len(hosts), 10)) as executor:
|
||||
results = list(executor.map(ping, hosts))
|
||||
|
||||
valid_results = []
|
||||
for url, latency in results:
|
||||
if latency != float('inf'):
|
||||
self.logger.info(f"Host: {url} -> {latency:.4f}s")
|
||||
valid_results.append((url, latency))
|
||||
else:
|
||||
self.logger.warning(f"Host: {url} -> Timeout/Error")
|
||||
|
||||
if not valid_results:
|
||||
self.logger.warning("All host latency tests failed. Falling back to the first host.")
|
||||
return hosts[0].rstrip('/')
|
||||
|
||||
best_host, best_time = min(valid_results, key=lambda x: x[1])
|
||||
self.logger.info(f"Selected fastest host: {best_host} ({best_time:.4f}s)")
|
||||
return best_host
|
||||
|
||||
def _request(self, method: str, endpoint: str, **kwargs) -> Optional[requests.Response]:
|
||||
self.logger.debug(f"Request: {method} {endpoint}")
|
||||
try:
|
||||
r = self.session.request(method, f"{self.base_url}{endpoint}", timeout=self.timeout, **kwargs)
|
||||
if r.status_code == 200:
|
||||
return r
|
||||
|
||||
error_map = {401: "API Key invalid", 402: "Insufficient points", 404: "Not found", 413: "Payload too large", 500: "Server Error"}
|
||||
self.logger.error(f"[{endpoint}] Failed: {error_map.get(r.status_code, f'HTTP {r.status_code}')}")
|
||||
|
||||
if 400 <= r.status_code < 500:
|
||||
setattr(r, 'is_fatal', True)
|
||||
return r
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Connection error: {e}")
|
||||
return None
|
||||
|
||||
####################################################################################################
|
||||
|
||||
def _calculate_sha256(self, path: Path) -> str:
|
||||
self.logger.debug(f"Calculating SHA256 for {path}")
|
||||
sha256_hash = hashlib.sha256()
|
||||
|
||||
with open(path, "rb") as f:
|
||||
for byte_block in iter(lambda: f.read(1048576), b""):
|
||||
sha256_hash.update(byte_block)
|
||||
|
||||
return sha256_hash.hexdigest()
|
||||
|
||||
def upload_file(self, file_path: str, chunk_size: int = 4194304, max_retries: int = 3) -> Optional[str]:
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
self.logger.error(f"File not found: {file_path}")
|
||||
return None
|
||||
|
||||
file_size = path.stat().st_size
|
||||
self.logger.info(f"Initiating unified chunked upload for {path.name} ({file_size} bytes)")
|
||||
|
||||
if file_size > 268435456:
|
||||
self.logger.error(f"File size exceeds 256MB limit: {file_size} bytes")
|
||||
return None
|
||||
|
||||
local_sha256 = self._calculate_sha256(path)
|
||||
status_req = self._request("GET", f"/api/processing_status/{local_sha256}")
|
||||
|
||||
if status_req and status_req.status_code == 200:
|
||||
if status_req.json().get('status') in ['done', 'processing', 'queued']:
|
||||
self.logger.info(f"File already exists or processing on server: {local_sha256}")
|
||||
return local_sha256
|
||||
|
||||
return self._upload_chunked(path, chunk_size, file_size, max_retries)
|
||||
|
||||
####################################################################################################
|
||||
|
||||
def _upload_chunked(self, path: Path, chunk_size: int, file_size: int, max_retries: int = 3) -> Optional[str]:
|
||||
total_chunks = max(1, (file_size + chunk_size - 1) // chunk_size)
|
||||
upload_id = uuid.uuid4().hex
|
||||
self.logger.info(f"Starting chunked upload for {path.name} (Chunks: {total_chunks}, ID: {upload_id})")
|
||||
|
||||
with open(path, 'rb') as f:
|
||||
for i in range(total_chunks):
|
||||
chunk_data = f.read(chunk_size)
|
||||
headers = {
|
||||
"X-Chunk-Index": str(i),
|
||||
"X-Total-Chunks": str(total_chunks),
|
||||
"X-Upload-ID": upload_id
|
||||
}
|
||||
|
||||
chunk_success = False
|
||||
for attempt in range(max_retries):
|
||||
r = self._request("POST", "/api/upload", files={'file': (path.name, chunk_data)}, headers=headers)
|
||||
|
||||
if r and r.status_code == 200:
|
||||
chunk_success = True
|
||||
self.logger.debug(f"Uploaded chunk {i + 1}/{total_chunks}")
|
||||
|
||||
if i == total_chunks - 1:
|
||||
try:
|
||||
return r.json().get('url', '').split('/')[-1] or None
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to parse final chunk response: {e}")
|
||||
return None
|
||||
break
|
||||
|
||||
if hasattr(r, 'is_fatal'):
|
||||
self.logger.error(f"Fatal upload error encountered at chunk {i}. Aborting.")
|
||||
return None
|
||||
|
||||
wait_time = 2 ** attempt
|
||||
self.logger.warning(f"Chunk upload retry {attempt + 1}/{max_retries} for chunk {i}. Waiting {wait_time}s")
|
||||
time.sleep(wait_time)
|
||||
|
||||
if not chunk_success:
|
||||
self.logger.error(f"Failed to upload chunk {i + 1}/{total_chunks} after {max_retries} attempts")
|
||||
return None
|
||||
return None
|
||||
|
||||
####################################################################################################
|
||||
|
||||
def wait_for_analysis(self, sha256: str, interval: int = 5, max_retries: int = 60) -> bool:
|
||||
self.logger.info(f"Waiting for analysis completion: {sha256}")
|
||||
for attempt in range(max_retries):
|
||||
r = self._request("GET", f"/api/processing_status/{sha256}")
|
||||
if r and r.status_code == 200:
|
||||
status = r.json().get('status')
|
||||
self.logger.debug(f"Analysis status: {status}")
|
||||
|
||||
if status == 'done':
|
||||
self.logger.info(f"Analysis completed: {sha256}")
|
||||
return True
|
||||
|
||||
if status in ['missing', 'error', 'failed']:
|
||||
self.logger.error(f"Analysis failed with status: {status}")
|
||||
return False
|
||||
|
||||
time.sleep(interval)
|
||||
|
||||
self.logger.error(f"Analysis polling timed out for {sha256}")
|
||||
return False
|
||||
|
||||
def get_report(self, sha256: str) -> Optional[Dict[str, Any]]:
|
||||
self.logger.info(f"Fetching report for {sha256}")
|
||||
r = self._request("GET", f"/api/report/{sha256}")
|
||||
if r:
|
||||
return r.json()
|
||||
|
||||
self.logger.error(f"Failed to fetch report for {sha256}")
|
||||
return None
|
||||
|
||||
####################################################################################################
|
||||
|
||||
def rescan(self, sha256: str) -> bool:
|
||||
self.logger.info(f"Triggering rescan for {sha256}")
|
||||
r = self._request("POST", f"/api/rescan/{sha256}")
|
||||
|
||||
success = bool(r and r.json().get('status') == 'success')
|
||||
if success:
|
||||
self.logger.info(f"Rescan triggered successfully for {sha256}")
|
||||
else:
|
||||
self.logger.error(f"Rescan failed for {sha256}")
|
||||
return success
|
||||
|
||||
def search_files(self, query: str, limit: int = 50, page: int = 0, snapshot: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
self.logger.info(f"Searching files (Query: '{query}', Limit: {limit}, Page: {page})")
|
||||
payload = {'query': query, 'page': page}
|
||||
if snapshot:
|
||||
payload['snapshot'] = snapshot
|
||||
|
||||
r = self._request("POST", "/api/search_more", data=payload)
|
||||
results = r.json().get('results', [])[:limit] if r else []
|
||||
self.logger.info(f"Search returned {len(results)} results")
|
||||
return results
|
||||
|
||||
def download_sample(self, sha256: str, save_dir: str = "downloads") -> Optional[str]:
|
||||
self.logger.info(f"Downloading sample {sha256}")
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
path = os.path.join(save_dir, f"{sha256}.zip")
|
||||
|
||||
r = self._request("GET", f"/api/download/{sha256}", stream=True)
|
||||
if r:
|
||||
with open(path, 'wb') as f:
|
||||
for chunk in r.iter_content(8192):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
self.logger.info(f"Sample downloaded successfully to {path}")
|
||||
return path
|
||||
|
||||
self.logger.error(f"Failed to download sample {sha256}")
|
||||
return None
|
||||
|
||||
####################################################################################################
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.DEBUG, format='[%(asctime)s] %(levelname)s [%(name)s] - %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
|
||||
|
||||
API_KEY = "" # https://pyas-security.com/setting?tab=application
|
||||
HOSTS = [
|
||||
"https://pyas-security.com/",
|
||||
"https://cn.pyas-security.com/"
|
||||
]
|
||||
|
||||
client = PYAS_Client(API_KEY, HOSTS)
|
||||
target_file = r"C:/Windows/explorer.exe"
|
||||
|
||||
sha = client.upload_file(target_file)
|
||||
if sha and client.wait_for_analysis(sha):
|
||||
report = client.get_report(sha)
|
||||
if report:
|
||||
meta = report.get('data', {}).get('metadata', {})
|
||||
logging.info(f"Result: {meta.get('label')} ({meta.get('score')}%)")
|
||||
|
||||
for item in client.search_files('date>=2020; ext=exe | dll', limit=3):
|
||||
client.download_sample(item['sha256'])
|
||||
from PYAS_Diagnostics import log_exception
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
import requests
|
||||
|
||||
|
||||
class CloudScanner:
|
||||
def __init__(self):
|
||||
self.api_host = None
|
||||
self.api_key = None
|
||||
self.timeout = 30
|
||||
|
||||
self.lock = threading.RLock()
|
||||
self.local = threading.local()
|
||||
|
||||
def _get_session(self, api_host, api_key):
|
||||
if (
|
||||
not hasattr(self.local, "session")
|
||||
or getattr(self.local, "host", None) != api_host
|
||||
or getattr(self.local, "key", None) != api_key
|
||||
):
|
||||
previous = getattr(self.local, "session", None)
|
||||
|
||||
if previous is not None:
|
||||
previous.close()
|
||||
|
||||
session = requests.Session()
|
||||
session.headers.update({"X-API-Key": api_key, "User-Agent": "PYAS-Engine/1.1"})
|
||||
self.local.session = session
|
||||
self.local.host = api_host
|
||||
self.local.key = api_key
|
||||
|
||||
return self.local.session
|
||||
|
||||
def _request(self, method, endpoint, api_host, api_key, **kwargs):
|
||||
session = self._get_session(api_host, api_key)
|
||||
|
||||
try:
|
||||
r = session.request(method, f"{api_host}{endpoint}", timeout=self.timeout, **kwargs)
|
||||
|
||||
if r.status_code == 200:
|
||||
return r
|
||||
|
||||
except Exception:
|
||||
log_exception("PYAS_Cloud.CloudScanner._request:31")
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def rescan(self, sha256, api_host, api_key):
|
||||
r = self._request("POST", f"/api/rescan/{sha256}", api_host, api_key)
|
||||
return r is not None and r.json().get("status") == "success"
|
||||
|
||||
def upload_file(
|
||||
self,
|
||||
file_path,
|
||||
api_host,
|
||||
api_key,
|
||||
chunk_size=4194304,
|
||||
need_rescan=False,
|
||||
max_retries=3,
|
||||
file_hash=None,
|
||||
cancel_event=None,
|
||||
):
|
||||
try:
|
||||
sha256 = file_hash
|
||||
cancel_event = cancel_event or threading.Event()
|
||||
|
||||
if cancel_event.is_set() or not sha256:
|
||||
return False, None
|
||||
|
||||
status_req = self._request("GET", f"/api/processing_status/{sha256}", api_host, api_key)
|
||||
|
||||
if cancel_event.is_set():
|
||||
return False, sha256
|
||||
|
||||
if status_req:
|
||||
current_status = status_req.json().get("status")
|
||||
|
||||
if current_status == "done":
|
||||
if need_rescan:
|
||||
self.rescan(sha256, api_host, api_key)
|
||||
|
||||
return True, sha256
|
||||
|
||||
elif current_status in ("queued", "processing"):
|
||||
return True, sha256
|
||||
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
if file_size > 104857600:
|
||||
return False, sha256
|
||||
|
||||
total_chunks = max(1, (file_size + chunk_size - 1) // chunk_size)
|
||||
upload_id = os.urandom(16).hex()
|
||||
|
||||
with open(file_path, "rb") as f:
|
||||
for i in range(total_chunks):
|
||||
if cancel_event.is_set():
|
||||
return False, sha256
|
||||
|
||||
chunk_data = f.read(chunk_size)
|
||||
headers = {
|
||||
"X-Chunk-Index": str(i),
|
||||
"X-Total-Chunks": str(total_chunks),
|
||||
"X-Upload-ID": upload_id,
|
||||
}
|
||||
|
||||
chunk_success = False
|
||||
|
||||
for attempt in range(max_retries):
|
||||
if cancel_event.is_set():
|
||||
return False, sha256
|
||||
|
||||
r = self._request(
|
||||
"POST",
|
||||
"/api/upload",
|
||||
api_host,
|
||||
api_key,
|
||||
files={"file": (os.path.basename(file_path), chunk_data)},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
if cancel_event.is_set():
|
||||
return False, sha256
|
||||
|
||||
if r:
|
||||
if i == total_chunks - 1:
|
||||
try:
|
||||
resp = r.json()
|
||||
|
||||
if "url" in resp:
|
||||
sha256 = resp.get("url", "").split("/")[-1]
|
||||
except Exception:
|
||||
log_exception("PYAS_Cloud.CloudScanner.upload_file:89")
|
||||
pass
|
||||
|
||||
chunk_success = True
|
||||
break
|
||||
|
||||
if cancel_event.wait(2**attempt):
|
||||
return False, sha256
|
||||
|
||||
if not chunk_success:
|
||||
return False, sha256
|
||||
|
||||
return True, sha256
|
||||
except Exception:
|
||||
log_exception("PYAS_Cloud.CloudScanner.upload_file:101")
|
||||
return False, None
|
||||
|
||||
def get_result(self, sha256, api_host, api_key, max_retries=6, interval=10):
|
||||
try:
|
||||
if not sha256:
|
||||
return False
|
||||
|
||||
is_done = False
|
||||
|
||||
for _ in range(max_retries):
|
||||
r = self._request("GET", f"/api/processing_status/{sha256}", api_host, api_key)
|
||||
|
||||
if r:
|
||||
st = r.json().get("status", "error")
|
||||
|
||||
if st == "done":
|
||||
is_done = True
|
||||
break
|
||||
|
||||
if st in ["error", "failed"]:
|
||||
return False
|
||||
|
||||
time.sleep(interval)
|
||||
|
||||
if not is_done:
|
||||
return False
|
||||
|
||||
r = self._request("GET", f"/api/report/{sha256}", api_host, api_key)
|
||||
|
||||
if r:
|
||||
data = r.json().get("data", {})
|
||||
metadata = data.get("metadata", {})
|
||||
label = metadata.get("label", "Unsupport")
|
||||
score = metadata.get("score", 0)
|
||||
sims = data.get("similar", [])
|
||||
|
||||
is_malicious = "General" in label
|
||||
sim_malicious_count = 0
|
||||
valid_sim_count = 0
|
||||
|
||||
for s in sims:
|
||||
if s.get("similarity", 0) > 80:
|
||||
valid_sim_count += 1
|
||||
|
||||
if "General" in s.get("label", ""):
|
||||
sim_malicious_count += 1
|
||||
|
||||
if is_malicious and (
|
||||
valid_sim_count == 0 or sim_malicious_count == valid_sim_count
|
||||
):
|
||||
return f"Malware:WinPE/General.{score}!cl"
|
||||
|
||||
except Exception:
|
||||
log_exception("PYAS_Cloud.CloudScanner.get_result:149")
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class CloudQueueMixin:
|
||||
def cloud_check(self, file_path):
|
||||
with self.lock_config:
|
||||
if not self.pyas_config.get("cloud_switch", False):
|
||||
return
|
||||
|
||||
cancel_event = self.cloud_cancel_event
|
||||
|
||||
norm_path = self.norm_path(file_path)
|
||||
|
||||
if not norm_path or cancel_event.is_set():
|
||||
return
|
||||
|
||||
cache_key = (os.path.normcase(norm_path), cancel_event)
|
||||
|
||||
with self.lock_file_ops:
|
||||
if cache_key in self.cloud_pending:
|
||||
return
|
||||
|
||||
self.cloud_pending.add(cache_key)
|
||||
|
||||
self.cloud_queue.put((norm_path, cancel_event))
|
||||
|
||||
def cloud_worker(self):
|
||||
while True:
|
||||
try:
|
||||
file_path, cancel_event = self.cloud_queue.get()
|
||||
cache_key = (os.path.normcase(file_path), cancel_event)
|
||||
|
||||
try:
|
||||
self.perform_cloud_scan(file_path, cancel_event)
|
||||
finally:
|
||||
with self.lock_file_ops:
|
||||
self.cloud_pending.discard(cache_key)
|
||||
|
||||
self.cloud_queue.task_done()
|
||||
except Exception:
|
||||
log_exception("PYAS_Cloud.CloudQueueMixin.cloud_worker:31")
|
||||
pass
|
||||
|
||||
def perform_cloud_scan(self, file_path, cancel_event=None):
|
||||
was_locked = False
|
||||
|
||||
try:
|
||||
with self.lock_config:
|
||||
if not self.pyas_config.get("cloud_switch", False):
|
||||
return False
|
||||
|
||||
cancel_event = cancel_event or self.cloud_cancel_event
|
||||
api_host, api_key, max_size = (
|
||||
self.pyas_config.get("api_host"),
|
||||
self.pyas_config.get("api_key"),
|
||||
self.pyas_config.get("size", 256 * 1024 * 1024),
|
||||
)
|
||||
|
||||
if (
|
||||
cancel_event.is_set()
|
||||
or not os.path.exists(file_path)
|
||||
or not os.path.isfile(file_path)
|
||||
):
|
||||
return False
|
||||
|
||||
with self.lock_file_ops:
|
||||
if file_path in self.virus_lock:
|
||||
self.lock_file(file_path, False)
|
||||
was_locked = True
|
||||
|
||||
if os.path.getsize(file_path) > max_size:
|
||||
if was_locked:
|
||||
self.lock_file(file_path, True)
|
||||
|
||||
return False
|
||||
|
||||
file_hash = self.calc_file_hash(file_path)
|
||||
success, sha256 = self.cloud.upload_file(
|
||||
file_path, api_host, api_key, file_hash=file_hash, cancel_event=cancel_event
|
||||
)
|
||||
|
||||
if was_locked:
|
||||
self.lock_file(file_path, True)
|
||||
was_locked = False
|
||||
|
||||
if not success and not cancel_event.is_set():
|
||||
self.write_log(
|
||||
"WARN", "Cloud API", source=file_path, detail="Failed", success=False
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
log_exception("PYAS_Cloud.CloudQueueMixin.perform_cloud_scan:66")
|
||||
self.write_log("WARN", "perform_cloud_scan", detail=str(e), success=False)
|
||||
|
||||
finally:
|
||||
if was_locked:
|
||||
try:
|
||||
self.lock_file(file_path, True)
|
||||
except Exception:
|
||||
log_exception("PYAS_Cloud.CloudQueueMixin.perform_cloud_scan:73")
|
||||
pass
|
||||
|
||||
return False
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
from PYAS_Diagnostics import log_exception
|
||||
import os
|
||||
import copy
|
||||
import json
|
||||
import threading
|
||||
from PYAS_Storage import atomic_write_json
|
||||
|
||||
|
||||
class ConfigMixin:
|
||||
def _loc(self, text_dict):
|
||||
with self.lock_config:
|
||||
lang = (
|
||||
self.pyas_config.get("language", "traditional_switch")
|
||||
if hasattr(self, "pyas_config")
|
||||
else "traditional_switch"
|
||||
)
|
||||
|
||||
return text_dict.get(lang, text_dict.get("traditional_switch", ""))
|
||||
|
||||
def load_config(self):
|
||||
with self.lock_config:
|
||||
|
||||
if not os.path.exists(self.file_config):
|
||||
self.pyas_config = copy.deepcopy(self.pyas_default)
|
||||
self.write_log("INFO", "Config Update", detail="Create default config")
|
||||
self.save_config()
|
||||
|
||||
else:
|
||||
try:
|
||||
with open(self.file_config, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
self.pyas_config = copy.deepcopy(self.pyas_default)
|
||||
self.pyas_config.update(data)
|
||||
|
||||
self.pyas_config["version"] = self.pyas_default["version"]
|
||||
whitelist_metadata_updated = False
|
||||
|
||||
for item in self.pyas_config.get("white_list", []):
|
||||
if not isinstance(item, dict) or not item.get("file") or "is_dir" in item:
|
||||
continue
|
||||
|
||||
item_path = self.norm_path(item["file"], must_exist=False)
|
||||
|
||||
if item_path and os.path.exists(item_path):
|
||||
item["is_dir"] = os.path.isdir(item_path)
|
||||
whitelist_metadata_updated = True
|
||||
|
||||
if whitelist_metadata_updated:
|
||||
self.save_config()
|
||||
except Exception as e:
|
||||
log_exception("PYAS_Config.ConfigMixin.load_config:40")
|
||||
self.pyas_config = copy.deepcopy(self.pyas_default)
|
||||
self.write_log("WARN", "load_config", detail=str(e), success=False)
|
||||
|
||||
def save_config(self):
|
||||
with self.lock_config:
|
||||
try:
|
||||
atomic_write_json(self.file_config, self.pyas_config)
|
||||
return True
|
||||
except Exception as error:
|
||||
log_exception("PYAS_Config.ConfigMixin.save_config:49")
|
||||
self.write_log("WARN", "save_config", detail=str(error), success=False)
|
||||
return False
|
||||
|
||||
def update_config(self, key, value):
|
||||
with self.lock_update:
|
||||
with self.lock_config:
|
||||
old_value = self.pyas_config.get(key)
|
||||
|
||||
if old_value == value:
|
||||
return value
|
||||
|
||||
defer_driver_disable = key == "driver_switch" and not value
|
||||
|
||||
if not defer_driver_disable:
|
||||
self.pyas_config[key] = value
|
||||
|
||||
def apply_switch(state):
|
||||
if key == "cloud_switch":
|
||||
with self.lock_config:
|
||||
if state:
|
||||
self.cloud_cancel_event = threading.Event()
|
||||
else:
|
||||
self.cloud_cancel_event.set()
|
||||
elif (
|
||||
key in ("process_switch", "document_switch", "system_switch", "network_switch")
|
||||
and state
|
||||
):
|
||||
target = {
|
||||
"process_switch": self.protect_proc_thread,
|
||||
"document_switch": self.protect_file_thread,
|
||||
"system_switch": self.protect_system_thread,
|
||||
"network_switch": self.protect_net_thread,
|
||||
}[key]
|
||||
self.start_feature_thread(target, key)
|
||||
elif key == "driver_switch":
|
||||
if state:
|
||||
if self.install_system_driver() and self.start_driver_listener(
|
||||
wait_ready=True
|
||||
):
|
||||
return True
|
||||
|
||||
self.stop_system_driver()
|
||||
return False
|
||||
|
||||
return bool(self.stop_system_driver())
|
||||
elif key == "context_switch":
|
||||
return self.register_context_menu(state) is not False
|
||||
elif key == "autostart_switch":
|
||||
return bool(self.manage_autostart(state))
|
||||
elif key == "document_switch" and not state:
|
||||
self._cancel_pending_file_scans()
|
||||
|
||||
with self.lock_file_ops:
|
||||
if getattr(self, "h_dir_file", None):
|
||||
if not self.kernel32.CloseHandle(self.h_dir_file):
|
||||
raise OSError("Could not close directory watcher")
|
||||
|
||||
self.h_dir_file = None
|
||||
elif key == "suspend_switch" and not state:
|
||||
with self.lock_proc:
|
||||
for handle in list(getattr(self, "suspended_procs", ())):
|
||||
if self.ntdll.NtResumeProcess(handle) != 0:
|
||||
raise OSError("Could not resume suspended process")
|
||||
|
||||
self.suspended_procs.discard(handle)
|
||||
|
||||
return True
|
||||
|
||||
action_succeeded = False
|
||||
|
||||
try:
|
||||
if key in ("extension_switch", "sensitive_switch"):
|
||||
with self.lock_file_ops:
|
||||
self.hash_cache.clear()
|
||||
|
||||
action_succeeded = apply_switch(value)
|
||||
|
||||
if not action_succeeded:
|
||||
raise RuntimeError(f"Could not apply {key}")
|
||||
|
||||
with self.lock_config:
|
||||
self.pyas_config[key] = value
|
||||
|
||||
if self.save_config() is False:
|
||||
raise OSError("Could not persist configuration")
|
||||
|
||||
self.write_log("INFO", "Config Update", detail=f"[{key}] {old_value} -> {value}")
|
||||
|
||||
if key == "language" and self.tray_icon:
|
||||
try:
|
||||
self.tray_icon.update_menu()
|
||||
except Exception:
|
||||
log_exception("PYAS_Config.ConfigMixin.update_config:116")
|
||||
pass
|
||||
|
||||
return value
|
||||
except Exception as error:
|
||||
log_exception("PYAS_Config.ConfigMixin.update_config:119")
|
||||
self.write_log("WARN", "Config Update", detail=f"[{key}] {error}", success=False)
|
||||
|
||||
with self.lock_config:
|
||||
self.pyas_config[key] = old_value
|
||||
|
||||
if action_succeeded:
|
||||
try:
|
||||
if apply_switch(old_value) is False:
|
||||
raise RuntimeError(f"Could not roll back {key}")
|
||||
except Exception as rollback_error:
|
||||
log_exception("PYAS_Config.ConfigMixin.update_config:127")
|
||||
self.write_log(
|
||||
"WARN",
|
||||
"Config Rollback",
|
||||
detail=f"[{key}] {rollback_error}",
|
||||
success=False,
|
||||
)
|
||||
|
||||
if key == "driver_switch":
|
||||
with self.lock_config:
|
||||
self.pyas_config[key] = bool(self.check_system_driver())
|
||||
|
||||
if self._window:
|
||||
try:
|
||||
self._window.evaluate_js(
|
||||
f"if(window.revertSwitch) window.revertSwitch({json.dumps(key)});"
|
||||
)
|
||||
except Exception:
|
||||
log_exception("PYAS_Config.ConfigMixin.update_config:135")
|
||||
pass
|
||||
|
||||
return self.pyas_config.get(key, old_value)
|
||||
|
||||
def get_config(self):
|
||||
with self.lock_config:
|
||||
cfg = self.pyas_config.copy()
|
||||
cfg["autostart_mode"] = self.autostart_mode
|
||||
|
||||
rules = []
|
||||
|
||||
if os.path.exists(self.path_rules):
|
||||
for f in os.listdir(self.path_rules):
|
||||
if f.lower().endswith(".json"):
|
||||
fp = os.path.join(self.path_rules, f)
|
||||
rules.append({"file": fp, "time": os.path.getmtime(fp)})
|
||||
|
||||
cfg["custom_rule"] = rules
|
||||
return cfg
|
||||
|
||||
def reset_config(self):
|
||||
with self.lock_update:
|
||||
with self.lock_config:
|
||||
previous = self.pyas_config
|
||||
self.pyas_config = copy.deepcopy(self.pyas_default)
|
||||
|
||||
if self.save_config() is False:
|
||||
self.pyas_config = previous
|
||||
return False
|
||||
|
||||
self.write_log("INFO", "Config Update", detail="Reset to default")
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,260 @@
|
||||
import logging
|
||||
import atexit
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
import weakref
|
||||
from collections import OrderedDict
|
||||
from PYAS_Storage import atomic_write_json
|
||||
from PYAS_Version import VERSION
|
||||
|
||||
LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s %(message)s"
|
||||
MAX_LOG_ENTRIES = 10000
|
||||
|
||||
_lock = threading.Lock()
|
||||
_recent = OrderedDict()
|
||||
_local = threading.local()
|
||||
|
||||
|
||||
def create_log_entry(
|
||||
level,
|
||||
action,
|
||||
detail=None,
|
||||
code=None,
|
||||
pid=None,
|
||||
file_hash=None,
|
||||
source=None,
|
||||
target=None,
|
||||
operate=None,
|
||||
success=True,
|
||||
timestamp=None,
|
||||
):
|
||||
timestamp = time.time() if timestamp is None else timestamp
|
||||
|
||||
return {
|
||||
"id": str(uuid.uuid4()),
|
||||
"timestamp": timestamp,
|
||||
"time_str": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(timestamp)),
|
||||
"version": VERSION,
|
||||
"level": level,
|
||||
"action": action,
|
||||
"detail": detail,
|
||||
"code": code,
|
||||
"pid": pid,
|
||||
"hash": file_hash,
|
||||
"source": source,
|
||||
"target": target,
|
||||
"operate": operate,
|
||||
"success": success,
|
||||
}
|
||||
|
||||
|
||||
def report_log_path():
|
||||
for handler in logging.getLogger("PYAS").handlers:
|
||||
if isinstance(handler, ReportHandler):
|
||||
return handler.baseFilename
|
||||
|
||||
return os.path.join(os.environ.get("ALLUSERSPROFILE", "C:\\ProgramData"), "PYAS", "Report.json")
|
||||
|
||||
|
||||
class ReportHandler(logging.Handler):
|
||||
def __init__(self, path):
|
||||
super().__init__()
|
||||
self.baseFilename = os.path.abspath(path)
|
||||
self.pending = []
|
||||
self.pending_lock = threading.RLock()
|
||||
self.application = None
|
||||
self.local = threading.local()
|
||||
self.setFormatter(logging.Formatter("%(message)s"))
|
||||
|
||||
def handle(self, record):
|
||||
accepted = self.filter(record)
|
||||
|
||||
if accepted:
|
||||
self.emit(record)
|
||||
|
||||
return accepted
|
||||
|
||||
def emit(self, record):
|
||||
if self._closed or getattr(self.local, "busy", False):
|
||||
return
|
||||
|
||||
self.local.busy = True
|
||||
|
||||
try:
|
||||
entry = create_log_entry(
|
||||
"WARN" if record.levelno == logging.WARNING else record.levelname,
|
||||
"Exception" if record.exc_info else "Diagnostic",
|
||||
detail=self.format(record),
|
||||
pid=record.process,
|
||||
source=record.name,
|
||||
success=record.levelno < logging.WARNING,
|
||||
timestamp=record.created,
|
||||
)
|
||||
application = self.application() if self.application else None
|
||||
|
||||
if application is not None:
|
||||
application._append_log_entry(entry)
|
||||
|
||||
if record.levelno >= logging.ERROR:
|
||||
application.flush_logs_now()
|
||||
else:
|
||||
with self.pending_lock:
|
||||
application = self.application() if self.application else None
|
||||
|
||||
if application is None:
|
||||
self.pending.append(entry)
|
||||
del self.pending[:-MAX_LOG_ENTRIES]
|
||||
|
||||
if record.levelno >= logging.ERROR:
|
||||
self._flush_pending()
|
||||
|
||||
if application is not None:
|
||||
application._append_log_entry(entry)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self.local.busy = False
|
||||
|
||||
def _flush_pending(self):
|
||||
if not self.pending:
|
||||
return True
|
||||
|
||||
try:
|
||||
existing = []
|
||||
|
||||
if os.path.exists(self.baseFilename):
|
||||
with open(self.baseFilename, "r", encoding="utf-8") as stream:
|
||||
existing = json.load(stream)
|
||||
|
||||
if not isinstance(existing, list):
|
||||
return False
|
||||
|
||||
identifiers = {entry.get("id") for entry in existing if isinstance(entry, dict)}
|
||||
merged = existing + [entry for entry in self.pending if entry["id"] not in identifiers]
|
||||
atomic_write_json(self.baseFilename, merged[-MAX_LOG_ENTRIES:])
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def flush(self):
|
||||
if self._closed or getattr(self.local, "busy", False):
|
||||
return
|
||||
|
||||
self.local.busy = True
|
||||
|
||||
try:
|
||||
application = self.application() if self.application else None
|
||||
|
||||
if application is not None:
|
||||
application.flush_logs_now()
|
||||
else:
|
||||
with self.pending_lock:
|
||||
self._flush_pending()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self.local.busy = False
|
||||
|
||||
def bind(self, application):
|
||||
with application.lock_logs:
|
||||
with self.pending_lock:
|
||||
identifiers = {entry.get("id") for entry in application.logs_data}
|
||||
|
||||
for entry in self.pending:
|
||||
if entry["id"] not in identifiers:
|
||||
application._append_log_entry(entry)
|
||||
|
||||
self.pending.clear()
|
||||
self.application = weakref.ref(application)
|
||||
|
||||
|
||||
def configure_report_logging():
|
||||
logger = logging.getLogger("PYAS")
|
||||
|
||||
for handler in logger.handlers:
|
||||
if isinstance(handler, ReportHandler):
|
||||
return handler.baseFilename
|
||||
|
||||
directories = [os.environ.get("ALLUSERSPROFILE", "C:\\ProgramData"), tempfile.gettempdir()]
|
||||
|
||||
for directory in directories:
|
||||
try:
|
||||
path = os.path.join(directory, "PYAS", "Report.json")
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
descriptor, probe = tempfile.mkstemp(prefix=".pyas-log-", dir=os.path.dirname(path))
|
||||
os.close(descriptor)
|
||||
os.unlink(probe)
|
||||
handler = ReportHandler(path)
|
||||
|
||||
for name in ("PYAS", "pywebview"):
|
||||
target = logging.getLogger(name)
|
||||
target.setLevel(logging.DEBUG)
|
||||
target.addHandler(handler)
|
||||
target.propagate = False
|
||||
|
||||
atexit.register(handler.flush)
|
||||
return handler.baseFilename
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
logger.addHandler(logging.NullHandler())
|
||||
return None
|
||||
|
||||
|
||||
def bind_report_logging(application):
|
||||
for handler in logging.getLogger("PYAS").handlers:
|
||||
if isinstance(handler, ReportHandler):
|
||||
handler.bind(application)
|
||||
|
||||
|
||||
def log_exception(context, level=logging.WARNING):
|
||||
exc_info = sys.exc_info()
|
||||
|
||||
if exc_info[0] is None:
|
||||
return
|
||||
|
||||
_local.exceptions = getattr(_local, "exceptions", 0) + 1
|
||||
key = (context, exc_info[0])
|
||||
now = time.monotonic()
|
||||
|
||||
try:
|
||||
with _lock:
|
||||
previous, suppressed = _recent.pop(key, (float("-inf"), 0))
|
||||
|
||||
if now - previous < 30:
|
||||
_recent[key] = (previous, suppressed + 1)
|
||||
return
|
||||
|
||||
_recent[key] = (now, 0)
|
||||
|
||||
while len(_recent) > 128:
|
||||
_recent.popitem(last=False)
|
||||
|
||||
logging.getLogger("PYAS.Exception").log(
|
||||
level, "context=%s; suppressed=%d", context, suppressed, exc_info=exc_info
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
def observe_future(future, context):
|
||||
def completed(result):
|
||||
if result.cancelled():
|
||||
return
|
||||
|
||||
try:
|
||||
result.result()
|
||||
except Exception:
|
||||
log_exception(context)
|
||||
|
||||
future.add_done_callback(completed)
|
||||
return future
|
||||
|
||||
|
||||
def exception_sequence():
|
||||
return getattr(_local, "exceptions", 0)
|
||||
+1180
File diff suppressed because it is too large.
Load diff
+15
-1322
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,949 @@
|
||||
from PYAS_Diagnostics import log_exception
|
||||
import os
|
||||
import math
|
||||
import zlib
|
||||
import numpy
|
||||
import pefile
|
||||
import datetime
|
||||
|
||||
|
||||
def _entropy_from_counts(counts, size):
|
||||
if size <= 0:
|
||||
return 0.0
|
||||
|
||||
nonzero = counts[counts > 0]
|
||||
return float(numpy.log2(size) - numpy.sum(nonzero * numpy.log2(nonzero)) / size)
|
||||
|
||||
|
||||
def _bounded_entropy(data, batch_size=1048576):
|
||||
size = len(data)
|
||||
|
||||
if size == 0:
|
||||
return 0.0
|
||||
|
||||
counts = numpy.zeros(256, dtype=numpy.int64)
|
||||
view = memoryview(data)
|
||||
|
||||
try:
|
||||
for start in range(0, size, batch_size):
|
||||
chunk = numpy.frombuffer(view[start : min(start + batch_size, size)], dtype=numpy.uint8)
|
||||
counts += numpy.bincount(chunk, minlength=256)
|
||||
finally:
|
||||
view.release()
|
||||
|
||||
return _entropy_from_counts(counts, size)
|
||||
|
||||
|
||||
def _accumulate_string_chunk(chunk, array, state, pattern):
|
||||
separators = (array < 0x20) | (array > 0x7E)
|
||||
|
||||
if not separators.any():
|
||||
state["pending_counts"] += numpy.bincount(array, minlength=256)
|
||||
state["pending_length"] += len(array)
|
||||
return
|
||||
|
||||
first_separator = int(numpy.argmax(separators))
|
||||
last_separator = len(separators) - 1 - int(numpy.argmax(separators[::-1]))
|
||||
|
||||
if first_separator:
|
||||
state["pending_counts"] += numpy.bincount(array[:first_separator], minlength=256)
|
||||
state["pending_length"] += first_separator
|
||||
|
||||
if state["pending_length"] >= 5:
|
||||
state["count"] += 1
|
||||
state["total_length"] += state["pending_length"]
|
||||
state["counts"] += state["pending_counts"]
|
||||
|
||||
state["pending_length"] = 0
|
||||
state["pending_counts"].fill(0)
|
||||
|
||||
middle_start = first_separator + 1
|
||||
middle_end = last_separator
|
||||
|
||||
if middle_end > middle_start:
|
||||
matches = pattern.findall(chunk[middle_start:middle_end])
|
||||
|
||||
if matches:
|
||||
combined = b"".join(matches)
|
||||
state["count"] += len(matches)
|
||||
state["total_length"] += len(combined)
|
||||
state["counts"] += numpy.bincount(
|
||||
numpy.frombuffer(combined, dtype=numpy.uint8), minlength=256
|
||||
)
|
||||
|
||||
trailing_start = last_separator + 1
|
||||
|
||||
if trailing_start < len(array):
|
||||
trailing = array[trailing_start:]
|
||||
state["pending_counts"] += numpy.bincount(trailing, minlength=256)
|
||||
state["pending_length"] = len(trailing)
|
||||
|
||||
|
||||
def _accumulate_entropy_windows(data, entropy_histogram, c_log_c_table):
|
||||
block_count = len(data) // 1024
|
||||
|
||||
if block_count < 2:
|
||||
return data
|
||||
|
||||
complete_size = block_count * 1024
|
||||
blocks = numpy.frombuffer(data, dtype=numpy.uint8, count=complete_size).reshape(
|
||||
block_count, 1024
|
||||
)
|
||||
encoded = blocks.astype(numpy.int32)
|
||||
encoded += numpy.arange(block_count, dtype=numpy.int32)[:, None] * 256
|
||||
block_counts = numpy.bincount(encoded.ravel(), minlength=block_count * 256).reshape(
|
||||
block_count, 256
|
||||
)
|
||||
window_counts = block_counts[:-1] + block_counts[1:]
|
||||
sum_c_log_c = numpy.sum(c_log_c_table[window_counts], axis=1)
|
||||
entropies = 11.0 - sum_c_log_c / 2048.0
|
||||
entropy_bins = (entropies * 2.0).astype(numpy.int32)
|
||||
numpy.clip(entropy_bins, 0, 15, out=entropy_bins)
|
||||
byte_bins = window_counts.reshape(block_count - 1, 16, 16).sum(axis=2)
|
||||
flat_indices = (entropy_bins[:, None] * 16 + numpy.arange(16)).ravel()
|
||||
numpy.add.at(entropy_histogram, flat_indices, byte_bins.ravel())
|
||||
return data[(block_count - 1) * 1024 :]
|
||||
|
||||
|
||||
def _collect_resource_ranges(pe, file_size):
|
||||
ranges = []
|
||||
|
||||
if not hasattr(pe, "DIRECTORY_ENTRY_RESOURCE"):
|
||||
return ranges
|
||||
|
||||
for entry_type in pe.DIRECTORY_ENTRY_RESOURCE.entries:
|
||||
for entry_id in getattr(getattr(entry_type, "directory", None), "entries", []):
|
||||
for entry_lang in getattr(getattr(entry_id, "directory", None), "entries", []):
|
||||
if not hasattr(entry_lang, "data"):
|
||||
continue
|
||||
|
||||
try:
|
||||
struct = entry_lang.data.struct
|
||||
offset = int(pe.get_offset_from_rva(int(struct.OffsetToData)))
|
||||
size = int(struct.Size)
|
||||
start = max(0, offset)
|
||||
end = min(file_size, start + max(0, size))
|
||||
ranges.append((start, max(start, end)))
|
||||
except Exception:
|
||||
log_exception("PYAS_Features._collect_resource_ranges:109")
|
||||
ranges.append((0, 0))
|
||||
|
||||
return ranges
|
||||
|
||||
|
||||
def _extract_stream_features(file_path, file_size, ranges, extractor):
|
||||
normalized = []
|
||||
|
||||
for start, end in ranges:
|
||||
start = max(0, min(file_size, int(start)))
|
||||
end = max(start, min(file_size, int(end)))
|
||||
normalized.append((start, end))
|
||||
|
||||
byte_counts = numpy.zeros(256, dtype=numpy.int64)
|
||||
range_counts = [numpy.zeros(256, dtype=numpy.int64) for _ in normalized]
|
||||
entropy_histogram = numpy.zeros(256, dtype=numpy.float64)
|
||||
string_state = {
|
||||
"count": 0,
|
||||
"total_length": 0,
|
||||
"counts": numpy.zeros(256, dtype=numpy.int64),
|
||||
"pending_length": 0,
|
||||
"pending_counts": numpy.zeros(256, dtype=numpy.int64),
|
||||
}
|
||||
histogram_tail = b""
|
||||
position = 0
|
||||
|
||||
with open(file_path, "rb") as source:
|
||||
while True:
|
||||
chunk = source.read(4194304)
|
||||
|
||||
if not chunk:
|
||||
break
|
||||
|
||||
array = numpy.frombuffer(chunk, dtype=numpy.uint8)
|
||||
_accumulate_string_chunk(chunk, array, string_state, extractor._STRING_PATTERN)
|
||||
histogram_tail = _accumulate_entropy_windows(
|
||||
histogram_tail + chunk,
|
||||
entropy_histogram,
|
||||
extractor._C_LOG_C_TABLE,
|
||||
)
|
||||
|
||||
chunk_end = position + len(chunk)
|
||||
boundaries = {0, len(chunk)}
|
||||
|
||||
for start, end in normalized:
|
||||
if position < start < chunk_end:
|
||||
boundaries.add(start - position)
|
||||
|
||||
if position < end < chunk_end:
|
||||
boundaries.add(end - position)
|
||||
|
||||
ordered = sorted(boundaries)
|
||||
|
||||
for index in range(len(ordered) - 1):
|
||||
local_start = ordered[index]
|
||||
local_end = ordered[index + 1]
|
||||
|
||||
if local_end <= local_start:
|
||||
continue
|
||||
|
||||
counts = numpy.bincount(array[local_start:local_end], minlength=256)
|
||||
byte_counts += counts
|
||||
absolute_start = position + local_start
|
||||
absolute_end = position + local_end
|
||||
|
||||
for range_index, (start, end) in enumerate(normalized):
|
||||
if start <= absolute_start and absolute_end <= end:
|
||||
range_counts[range_index] += counts
|
||||
|
||||
position = chunk_end
|
||||
|
||||
if string_state["pending_length"] >= 5:
|
||||
string_state["count"] += 1
|
||||
string_state["total_length"] += string_state["pending_length"]
|
||||
string_state["counts"] += string_state["pending_counts"]
|
||||
|
||||
string_count = string_state["count"]
|
||||
total_string_length = string_state["total_length"]
|
||||
result = {
|
||||
"StringCount": float(string_count),
|
||||
"StringMeanLength": (float(total_string_length) / string_count if string_count else 0.0),
|
||||
"StringEntropy": _entropy_from_counts(string_state["counts"], total_string_length),
|
||||
}
|
||||
result.update(dict.fromkeys(extractor._STRING_KEYS, 0.0))
|
||||
|
||||
if total_string_length:
|
||||
valid_counts = string_state["counts"][0x20:0x7F]
|
||||
result.update(
|
||||
dict(
|
||||
zip(
|
||||
extractor._STRING_KEYS,
|
||||
valid_counts.astype(numpy.float64) / total_string_length,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
result["FileEntropy"] = _entropy_from_counts(byte_counts, file_size)
|
||||
result.update(
|
||||
dict(
|
||||
zip(
|
||||
extractor._BYTE_HIST_KEYS,
|
||||
byte_counts.astype(numpy.float64) / file_size,
|
||||
)
|
||||
)
|
||||
)
|
||||
entropy_sum = numpy.sum(entropy_histogram)
|
||||
|
||||
if entropy_sum:
|
||||
entropy_histogram /= entropy_sum
|
||||
|
||||
result.update(dict(zip(extractor._BYTE_ENT_KEYS, entropy_histogram)))
|
||||
|
||||
range_entropies = [
|
||||
_entropy_from_counts(counts, end - start)
|
||||
for counts, (start, end) in zip(range_counts, normalized)
|
||||
]
|
||||
return result, range_entropies
|
||||
|
||||
|
||||
class PEFeatureMixin:
|
||||
def _safe_float(self, val):
|
||||
try:
|
||||
f = float(val)
|
||||
|
||||
if math.isinf(f) or math.isnan(f):
|
||||
return 0.0
|
||||
|
||||
return f
|
||||
except Exception:
|
||||
log_exception("PYAS_Features.PEFeatureMixin._safe_float:221")
|
||||
return 0.0
|
||||
|
||||
def _base_defaults(self):
|
||||
base = dict.fromkeys(self._OPTIONAL_HEADER_FIELDS, 0.0)
|
||||
base.update(self._SECTION_DEFAULTS)
|
||||
base.update(self._CONDITIONAL_DEFAULTS)
|
||||
|
||||
for i in range(16):
|
||||
base[f"DataDirectory_{i}_Size"] = 0.0
|
||||
base[f"DataDirectory_{i}_VA"] = 0.0
|
||||
|
||||
return base
|
||||
|
||||
def _calc_entropy(self, data):
|
||||
return _bounded_entropy(data)
|
||||
|
||||
def _extract_strings(self, file_bytes):
|
||||
res = {"StringCount": 0.0, "StringMeanLength": 0.0, "StringEntropy": 0.0}
|
||||
res.update(dict.fromkeys(self._STRING_KEYS, 0.0))
|
||||
|
||||
if len(file_bytes) <= self._STRING_FAST_PATH_BYTES:
|
||||
matches = self._STRING_PATTERN.findall(file_bytes)
|
||||
|
||||
if not matches:
|
||||
return res
|
||||
|
||||
combined_data = b"".join(matches)
|
||||
count = len(matches)
|
||||
total_len = len(combined_data)
|
||||
global_counts = numpy.bincount(
|
||||
numpy.frombuffer(combined_data, dtype=numpy.uint8), minlength=256
|
||||
)
|
||||
else:
|
||||
count = 0
|
||||
total_len = 0
|
||||
global_counts = numpy.zeros(256, dtype=numpy.int64)
|
||||
mv = memoryview(file_bytes)
|
||||
pos = 0
|
||||
size = len(file_bytes)
|
||||
|
||||
while pos < size:
|
||||
chunk_end = min(pos + self._STRING_BATCH_BYTES, size)
|
||||
chunk_arr = numpy.frombuffer(mv[pos:chunk_end], dtype=numpy.uint8)
|
||||
separators = (chunk_arr < 0x20) | (chunk_arr > 0x7E)
|
||||
|
||||
if separators.any():
|
||||
last_separator = len(separators) - 1 - int(numpy.argmax(separators[::-1]))
|
||||
segment_end = pos + last_separator + 1
|
||||
matches = self._STRING_PATTERN.findall(mv[pos:segment_end])
|
||||
|
||||
if matches:
|
||||
combined_data = b"".join(matches)
|
||||
count += len(matches)
|
||||
total_len += len(combined_data)
|
||||
global_counts += numpy.bincount(
|
||||
numpy.frombuffer(combined_data, dtype=numpy.uint8), minlength=256
|
||||
)
|
||||
|
||||
pos = segment_end
|
||||
continue
|
||||
|
||||
pending_len = 0
|
||||
pending_counts = numpy.zeros(256, dtype=numpy.int64)
|
||||
|
||||
while pos < size:
|
||||
chunk_end = min(pos + self._STRING_BATCH_BYTES, size)
|
||||
chunk_arr = numpy.frombuffer(mv[pos:chunk_end], dtype=numpy.uint8)
|
||||
separators = (chunk_arr < 0x20) | (chunk_arr > 0x7E)
|
||||
|
||||
if separators.any():
|
||||
first_separator = int(numpy.argmax(separators))
|
||||
|
||||
if first_separator:
|
||||
pending_counts += numpy.bincount(
|
||||
chunk_arr[:first_separator], minlength=256
|
||||
)
|
||||
pending_len += first_separator
|
||||
|
||||
pos += first_separator + 1
|
||||
break
|
||||
|
||||
pending_counts += numpy.bincount(chunk_arr, minlength=256)
|
||||
pending_len += len(chunk_arr)
|
||||
pos = chunk_end
|
||||
|
||||
if pending_len >= 5:
|
||||
count += 1
|
||||
total_len += pending_len
|
||||
global_counts += pending_counts
|
||||
|
||||
if count == 0 or total_len == 0:
|
||||
return res
|
||||
|
||||
res["StringCount"] = float(count)
|
||||
res["StringMeanLength"] = float(total_len) / count
|
||||
|
||||
counts_nonzero = global_counts[global_counts > 0]
|
||||
|
||||
if len(counts_nonzero) > 0:
|
||||
res["StringEntropy"] = float(
|
||||
numpy.log2(total_len)
|
||||
- numpy.sum(counts_nonzero * numpy.log2(counts_nonzero)) / total_len
|
||||
)
|
||||
|
||||
valid_chars = global_counts[0x20:0x7F]
|
||||
res.update(dict(zip(self._STRING_KEYS, valid_chars.astype(numpy.float64) / total_len)))
|
||||
|
||||
return res
|
||||
|
||||
def _extract_histograms(self, file_bytes):
|
||||
arr = numpy.frombuffer(file_bytes, dtype=numpy.uint8)
|
||||
sz = len(arr)
|
||||
res = {}
|
||||
|
||||
if sz == 0:
|
||||
res["FileEntropy"] = 0.0
|
||||
res.update(dict.fromkeys(self._BYTE_HIST_KEYS, 0.0))
|
||||
res.update(dict.fromkeys(self._BYTE_ENT_KEYS, 0.0))
|
||||
return res
|
||||
|
||||
byte_counts = numpy.zeros(256, dtype=numpy.int64)
|
||||
ent_hist = numpy.zeros(256, dtype=numpy.float64)
|
||||
window = 2048
|
||||
step = 1024
|
||||
|
||||
if sz < window:
|
||||
byte_counts += numpy.bincount(arr, minlength=256)
|
||||
p = byte_counts[byte_counts > 0] / float(sz)
|
||||
entropy = float(-numpy.sum(p * numpy.log2(p)))
|
||||
ent_bin = min(int(entropy * 2.0), 15)
|
||||
byte_bins = byte_counts.reshape(16, 16).sum(axis=1)
|
||||
idx_start = ent_bin * 16
|
||||
ent_hist[idx_start : idx_start + 16] += byte_bins
|
||||
else:
|
||||
num_windows = (sz - window) // step + 1
|
||||
|
||||
for w_start in range(0, num_windows, self._HISTOGRAM_BATCH_WINDOWS):
|
||||
w_end = min(w_start + self._HISTOGRAM_BATCH_WINDOWS, num_windows)
|
||||
b_num = w_end - w_start
|
||||
|
||||
start_byte = w_start * step
|
||||
end_byte = start_byte + b_num * step + step
|
||||
encoded = arr[start_byte:end_byte].astype(numpy.int32).reshape(b_num + 1, step)
|
||||
encoded += numpy.arange(b_num + 1, dtype=numpy.int32)[:, None] * 256
|
||||
|
||||
chunk_counts = numpy.bincount(encoded.ravel(), minlength=(b_num + 1) * 256).reshape(
|
||||
b_num + 1, 256
|
||||
)
|
||||
del encoded
|
||||
|
||||
byte_counts += numpy.sum(chunk_counts[:-1], axis=0)
|
||||
|
||||
if w_end == num_windows:
|
||||
byte_counts += chunk_counts[-1]
|
||||
|
||||
window_counts = chunk_counts[:-1] + chunk_counts[1:]
|
||||
sum_c_log_c = numpy.sum(self._C_LOG_C_TABLE[window_counts], axis=1)
|
||||
entropies = 11.0 - sum_c_log_c / 2048.0
|
||||
|
||||
ent_bins = (entropies * 2.0).astype(numpy.int32)
|
||||
numpy.clip(ent_bins, 0, 15, out=ent_bins)
|
||||
|
||||
byte_bins = window_counts.reshape(b_num, 16, 16).sum(axis=2)
|
||||
flat_ent_bins = (ent_bins[:, None] * 16 + numpy.arange(16)).ravel()
|
||||
numpy.add.at(ent_hist, flat_ent_bins, byte_bins.ravel())
|
||||
|
||||
covered_size = (num_windows + 1) * step
|
||||
|
||||
if covered_size < sz:
|
||||
byte_counts += numpy.bincount(arr[covered_size:], minlength=256)
|
||||
|
||||
counts_nonzero = byte_counts[byte_counts > 0]
|
||||
res["FileEntropy"] = float(
|
||||
numpy.log2(sz) - numpy.sum(counts_nonzero * numpy.log2(counts_nonzero)) / sz
|
||||
)
|
||||
|
||||
byte_hist = byte_counts.astype(numpy.float64) / sz
|
||||
res.update(dict(zip(self._BYTE_HIST_KEYS, byte_hist)))
|
||||
|
||||
ent_sum = numpy.sum(ent_hist)
|
||||
|
||||
if ent_sum > 0:
|
||||
ent_hist /= ent_sum
|
||||
|
||||
res.update(dict(zip(self._BYTE_ENT_KEYS, ent_hist)))
|
||||
return res
|
||||
|
||||
def _extract_overlay_features(self, pe, fsize):
|
||||
overlay_offset = pe.get_overlay_data_start_offset()
|
||||
|
||||
if not overlay_offset or overlay_offset >= fsize:
|
||||
return {
|
||||
"HasOverlay": 0.0,
|
||||
"OverlaySize": 0.0,
|
||||
"OverlayRatio": 0.0,
|
||||
"OverlayEntropy": 0.0,
|
||||
}
|
||||
|
||||
overlay_data = pe.get_overlay()
|
||||
|
||||
if not overlay_data:
|
||||
return {
|
||||
"HasOverlay": 0.0,
|
||||
"OverlaySize": 0.0,
|
||||
"OverlayRatio": 0.0,
|
||||
"OverlayEntropy": 0.0,
|
||||
}
|
||||
|
||||
sz = len(overlay_data)
|
||||
return {
|
||||
"HasOverlay": 1.0,
|
||||
"OverlaySize": float(sz),
|
||||
"OverlayRatio": float(sz) / float(fsize),
|
||||
"OverlayEntropy": self._calc_entropy(overlay_data),
|
||||
}
|
||||
|
||||
def _extract_rich_header(self, pe):
|
||||
if not hasattr(pe, "RICH_HEADER") or not pe.RICH_HEADER:
|
||||
return {"HasRichHeader": 0.0, "RichHeaderCount": 0.0}
|
||||
|
||||
return {
|
||||
"HasRichHeader": 1.0,
|
||||
"RichHeaderCount": (
|
||||
float(len(pe.RICH_HEADER.values) // 2) if hasattr(pe.RICH_HEADER, "values") else 0.0
|
||||
),
|
||||
}
|
||||
|
||||
def _extract_ep_anomalies(self, pe):
|
||||
if not hasattr(pe, "OPTIONAL_HEADER") or not hasattr(pe, "sections") or not pe.sections:
|
||||
return {
|
||||
"EntryPointSectionIndex": -1.0,
|
||||
"EntryPointInExecutable": 0.0,
|
||||
"EntryPointInLastSection": 0.0,
|
||||
}
|
||||
|
||||
ep = pe.OPTIONAL_HEADER.AddressOfEntryPoint
|
||||
ep_section_idx = -1
|
||||
ep_in_exec = 0.0
|
||||
|
||||
for idx, sec in enumerate(pe.sections):
|
||||
if sec.VirtualAddress <= ep < (sec.VirtualAddress + sec.Misc_VirtualSize):
|
||||
ep_section_idx = idx
|
||||
|
||||
if sec.Characteristics & 0x20000000:
|
||||
ep_in_exec = 1.0
|
||||
|
||||
break
|
||||
|
||||
return {
|
||||
"EntryPointSectionIndex": float(ep_section_idx),
|
||||
"EntryPointInExecutable": ep_in_exec,
|
||||
"EntryPointInLastSection": 1.0 if ep_section_idx == len(pe.sections) - 1 else 0.0,
|
||||
}
|
||||
|
||||
def _extract_advanced_resources(self, pe, range_entropies=None):
|
||||
res_data = {
|
||||
"ResourceMaxEntropy": 0.0,
|
||||
"ResourceMinEntropy": 0.0,
|
||||
"ResourceMeanEntropy": 0.0,
|
||||
"ResourceLangCount": 0.0,
|
||||
"ResourceRCDataCount": 0.0,
|
||||
}
|
||||
|
||||
if not hasattr(pe, "DIRECTORY_ENTRY_RESOURCE"):
|
||||
return res_data
|
||||
|
||||
entropies = []
|
||||
entropy_iter = iter(range_entropies or ())
|
||||
langs = set()
|
||||
rcdata_count = 0
|
||||
|
||||
for entry_type in pe.DIRECTORY_ENTRY_RESOURCE.entries:
|
||||
if hasattr(entry_type, "id") and entry_type.id == 10:
|
||||
rcdata_count += len(getattr(entry_type.directory, "entries", []))
|
||||
|
||||
if not hasattr(entry_type, "directory"):
|
||||
continue
|
||||
|
||||
for entry_id in entry_type.directory.entries:
|
||||
if not hasattr(entry_id, "directory"):
|
||||
continue
|
||||
|
||||
for entry_lang in entry_id.directory.entries:
|
||||
if hasattr(entry_lang, "id"):
|
||||
langs.add(entry_lang.id)
|
||||
|
||||
if hasattr(entry_lang, "data"):
|
||||
try:
|
||||
entropies.append(float(next(entropy_iter)))
|
||||
except (StopIteration, TypeError, ValueError):
|
||||
entropies.append(0.0)
|
||||
|
||||
res_data["ResourceLangCount"] = float(len(langs))
|
||||
res_data["ResourceRCDataCount"] = float(rcdata_count)
|
||||
|
||||
if entropies:
|
||||
res_data["ResourceMaxEntropy"] = max(entropies)
|
||||
res_data["ResourceMinEntropy"] = min(entropies)
|
||||
res_data["ResourceMeanEntropy"] = sum(entropies) / len(entropies)
|
||||
|
||||
return res_data
|
||||
|
||||
def _extract_load_config(self, pe):
|
||||
cfg = {"HasLoadConfig": 0.0, "HasCFG": 0.0, "HasSEHTable": 0.0}
|
||||
|
||||
if hasattr(pe, "DIRECTORY_ENTRY_LOAD_CONFIG"):
|
||||
cfg["HasLoadConfig"] = 1.0
|
||||
struct = pe.DIRECTORY_ENTRY_LOAD_CONFIG.struct
|
||||
|
||||
if getattr(struct, "GuardCFFunctionTable", 0) != 0:
|
||||
cfg["HasCFG"] = 1.0
|
||||
|
||||
if getattr(struct, "SEHandlerTable", 0) != 0:
|
||||
cfg["HasSEHTable"] = 1.0
|
||||
|
||||
return cfg
|
||||
|
||||
def _extract_security_directory(self, pe, file_path, fsize):
|
||||
res = {"HasSignature": 0.0, "SignatureCount": 0.0}
|
||||
|
||||
if not hasattr(pe, "OPTIONAL_HEADER") or not hasattr(pe.OPTIONAL_HEADER, "DATA_DIRECTORY"):
|
||||
return res
|
||||
|
||||
directories = pe.OPTIONAL_HEADER.DATA_DIRECTORY
|
||||
|
||||
if len(directories) <= 4:
|
||||
return res
|
||||
|
||||
sec_dir = directories[4]
|
||||
offset = int(sec_dir.VirtualAddress)
|
||||
size = int(sec_dir.Size)
|
||||
|
||||
if offset <= 0 or size <= 0 or offset + size > fsize:
|
||||
return res
|
||||
|
||||
res["HasSignature"] = 1.0
|
||||
count = 0
|
||||
curr = offset
|
||||
end = offset + size
|
||||
|
||||
with open(file_path, "rb") as source:
|
||||
while curr + 8 <= end:
|
||||
source.seek(curr)
|
||||
header = source.read(4)
|
||||
|
||||
if len(header) != 4:
|
||||
break
|
||||
|
||||
length = int.from_bytes(header, "little")
|
||||
|
||||
if length < 8 or curr + length > end:
|
||||
break
|
||||
|
||||
count += 1
|
||||
curr += (length + 7) & ~7
|
||||
|
||||
res["SignatureCount"] = float(count)
|
||||
return res
|
||||
|
||||
def extract_features(self, file_path):
|
||||
pe = None
|
||||
|
||||
try:
|
||||
fsize = os.path.getsize(file_path)
|
||||
|
||||
if fsize == 0 or fsize > 4294967296:
|
||||
return None
|
||||
|
||||
base = self._base_defaults()
|
||||
dlls = set()
|
||||
apis = set()
|
||||
pe = pefile.PE(name=file_path, fast_load=True)
|
||||
|
||||
try:
|
||||
pe.parse_rich_header()
|
||||
pe.parse_data_directories(
|
||||
directories=[
|
||||
pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_IMPORT"],
|
||||
pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_EXPORT"],
|
||||
pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_RESOURCE"],
|
||||
pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_DEBUG"],
|
||||
pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_TLS"],
|
||||
pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG"],
|
||||
]
|
||||
)
|
||||
except Exception:
|
||||
log_exception("PYAS_Features.PEFeatureMixin.extract_features:553")
|
||||
pass
|
||||
|
||||
sections = list(getattr(pe, "sections", []))
|
||||
section_ranges = []
|
||||
|
||||
for section in sections:
|
||||
start = max(0, int(section.get_PointerToRawData_adj()))
|
||||
end = min(fsize, start + max(0, int(section.SizeOfRawData)))
|
||||
section_ranges.append((start, max(start, end)))
|
||||
|
||||
resource_ranges = _collect_resource_ranges(pe, fsize)
|
||||
overlay_offset = pe.get_overlay_data_start_offset()
|
||||
|
||||
if not overlay_offset or overlay_offset >= fsize:
|
||||
overlay_offset = fsize
|
||||
|
||||
all_ranges = section_ranges + resource_ranges + [(int(overlay_offset), fsize)]
|
||||
stream_features, range_entropies = _extract_stream_features(
|
||||
file_path, fsize, all_ranges, self
|
||||
)
|
||||
section_entropies = range_entropies[: len(section_ranges)]
|
||||
resource_start = len(section_ranges)
|
||||
resource_entropies = range_entropies[
|
||||
resource_start : resource_start + len(resource_ranges)
|
||||
]
|
||||
overlay_entropy = range_entropies[-1]
|
||||
|
||||
base["TrustSigned"] = 1.0 if self.signer.sign_verify(file_path) else 0.0
|
||||
base["FileSize"] = float(fsize)
|
||||
|
||||
fh = pe.FILE_HEADER
|
||||
base["Machine"] = getattr(fh, "Machine", 0)
|
||||
base["NumberOfSections"] = getattr(fh, "NumberOfSections", 0)
|
||||
base["TimeDateStamp"] = getattr(fh, "TimeDateStamp", 0)
|
||||
base["PointerToSymbolTable"] = getattr(fh, "PointerToSymbolTable", 0)
|
||||
base["NumberOfSymbols"] = getattr(fh, "NumberOfSymbols", 0)
|
||||
base["SizeOfOptionalHeader"] = getattr(fh, "SizeOfOptionalHeader", 0)
|
||||
base["Characteristics"] = getattr(fh, "Characteristics", 0)
|
||||
|
||||
curr_ts = datetime.datetime.utcnow().timestamp()
|
||||
base["HasInvalidTimestamp"] = (
|
||||
1.0
|
||||
if (base["TimeDateStamp"] < 631152000 or base["TimeDateStamp"] > curr_ts + 2592000)
|
||||
else 0.0
|
||||
)
|
||||
base["FileTimeException"] = 1.0 if base["TimeDateStamp"] == 0 else 0.0
|
||||
base["Is64Bit"] = 1.0 if base["Machine"] in (0x8664, 0xAA64, 0x0200) else 0.0
|
||||
base["IsExe"] = 1.0 if pe.is_exe() else 0.0
|
||||
base["IsDll"] = 1.0 if pe.is_dll() else 0.0
|
||||
base["ExceptionCount"] = 0.0
|
||||
|
||||
if hasattr(pe, "OPTIONAL_HEADER"):
|
||||
op = pe.OPTIONAL_HEADER
|
||||
fields = [
|
||||
"Magic",
|
||||
"MajorLinkerVersion",
|
||||
"MinorLinkerVersion",
|
||||
"SizeOfCode",
|
||||
"SizeOfInitializedData",
|
||||
"SizeOfUninitializedData",
|
||||
"AddressOfEntryPoint",
|
||||
"BaseOfCode",
|
||||
"ImageBase",
|
||||
"SectionAlignment",
|
||||
"FileAlignment",
|
||||
"MajorOperatingSystemVersion",
|
||||
"MinorOperatingSystemVersion",
|
||||
"MajorImageVersion",
|
||||
"MinorImageVersion",
|
||||
"MajorSubsystemVersion",
|
||||
"MinorSubsystemVersion",
|
||||
"SizeOfImage",
|
||||
"SizeOfHeaders",
|
||||
"CheckSum",
|
||||
"Subsystem",
|
||||
"DllCharacteristics",
|
||||
"SizeOfStackReserve",
|
||||
"SizeOfStackCommit",
|
||||
"SizeOfHeapReserve",
|
||||
"SizeOfHeapCommit",
|
||||
"LoaderFlags",
|
||||
"NumberOfRvaAndSizes",
|
||||
]
|
||||
|
||||
for f_field in fields:
|
||||
base[f_field] = getattr(op, f_field, 0)
|
||||
|
||||
base["IsDriver"] = 1.0 if base.get("Subsystem") == 1 else 0.0
|
||||
|
||||
if hasattr(op, "DATA_DIRECTORY"):
|
||||
for i, directory in enumerate(op.DATA_DIRECTORY):
|
||||
base[f"DataDirectory_{i}_Size"] = directory.Size
|
||||
base[f"DataDirectory_{i}_VA"] = directory.VirtualAddress
|
||||
|
||||
exc_idx = pefile.DIRECTORY_ENTRY.get("IMAGE_DIRECTORY_ENTRY_EXCEPTION", 3)
|
||||
|
||||
if len(op.DATA_DIRECTORY) > exc_idx and op.DATA_DIRECTORY[exc_idx].Size > 0:
|
||||
base["ExceptionCount"] = (
|
||||
op.DATA_DIRECTORY[exc_idx].Size // 12
|
||||
if base["Machine"] in (0x8664, 0xAA64)
|
||||
else 0.0
|
||||
)
|
||||
|
||||
res_flags_count = dict.fromkeys(self._CHAR_COUNT_KEYS.values(), 0.0)
|
||||
res_flags_ent = dict.fromkeys(self._CHAR_ENT_KEYS.values(), 0.0)
|
||||
res_sec_hash = dict.fromkeys(self._SECTION_HASH_KEYS, 0.0)
|
||||
|
||||
if hasattr(pe, "sections"):
|
||||
base["SectionCount"] = len(pe.sections)
|
||||
entropies = []
|
||||
raw_sizes = []
|
||||
v_sizes = []
|
||||
exec_sec = write_sec = read_sec = sec_exc = 0
|
||||
|
||||
for section_index, section in enumerate(pe.sections):
|
||||
sec_name = section.Name.rstrip(b"\x00")
|
||||
hash_val = zlib.crc32(sec_name) % 50
|
||||
res_sec_hash[self._SECTION_HASH_KEYS[hash_val]] += 1.0
|
||||
|
||||
s_entropy = (
|
||||
section_entropies[section_index]
|
||||
if section_index < len(section_entropies)
|
||||
else 0.0
|
||||
)
|
||||
|
||||
entropies.append(s_entropy)
|
||||
raw_sizes.append(section.SizeOfRawData)
|
||||
v_sizes.append(section.Misc_VirtualSize)
|
||||
|
||||
if section.Characteristics & 0x20000000:
|
||||
exec_sec += 1
|
||||
|
||||
if section.Characteristics & 0x80000000:
|
||||
write_sec += 1
|
||||
|
||||
if section.Characteristics & 0x40000000:
|
||||
read_sec += 1
|
||||
|
||||
if section.SizeOfRawData + section.PointerToRawData > fsize:
|
||||
sec_exc = 1
|
||||
|
||||
for flag in self._CHAR_FLAGS:
|
||||
if section.Characteristics & flag:
|
||||
res_flags_count[self._CHAR_COUNT_KEYS[flag]] += 1.0
|
||||
res_flags_ent[self._CHAR_ENT_KEYS[flag]] += s_entropy
|
||||
|
||||
base["SectionMaxEntropy"] = max(entropies) if entropies else 0.0
|
||||
base["SectionMinEntropy"] = min(entropies) if entropies else 0.0
|
||||
base["SectionMeanEntropy"] = sum(entropies) / len(entropies) if entropies else 0.0
|
||||
base["SectionMaxRawSize"] = max(raw_sizes) if raw_sizes else 0.0
|
||||
base["SectionMinRawSize"] = min(raw_sizes) if raw_sizes else 0.0
|
||||
base["SectionMeanRawSize"] = sum(raw_sizes) / len(raw_sizes) if raw_sizes else 0.0
|
||||
base["SectionMaxVSize"] = max(v_sizes) if v_sizes else 0.0
|
||||
base["SectionMinVSize"] = min(v_sizes) if v_sizes else 0.0
|
||||
base["SectionMeanVSize"] = sum(v_sizes) / len(v_sizes) if v_sizes else 0.0
|
||||
base["ExecutableSections"] = float(exec_sec)
|
||||
base["WritableSections"] = float(write_sec)
|
||||
base["ReadableSections"] = float(read_sec)
|
||||
base["SectionException"] = float(sec_exc)
|
||||
|
||||
for flag in self._CHAR_FLAGS:
|
||||
cnt_key = self._CHAR_COUNT_KEYS[flag]
|
||||
|
||||
if res_flags_count[cnt_key] > 0:
|
||||
res_flags_ent[self._CHAR_ENT_KEYS[flag]] /= res_flags_count[cnt_key]
|
||||
|
||||
base.update(res_flags_count)
|
||||
base.update(res_flags_ent)
|
||||
base.update(res_sec_hash)
|
||||
|
||||
string_keys = [
|
||||
"FileDescription",
|
||||
"FileVersion",
|
||||
"ProductName",
|
||||
"ProductVersion",
|
||||
"CompanyName",
|
||||
"LegalCopyright",
|
||||
"Comments",
|
||||
"InternalName",
|
||||
"LegalTrademarks",
|
||||
"SpecialBuild",
|
||||
"PrivateBuild",
|
||||
]
|
||||
|
||||
for key in string_keys:
|
||||
base[f"{key}Length"] = 0.0
|
||||
|
||||
if hasattr(pe, "FileInfo"):
|
||||
for fileinfo_list in pe.FileInfo:
|
||||
for fileinfo in fileinfo_list:
|
||||
|
||||
if getattr(fileinfo, "name", "") in ("StringFileInfo", b"StringFileInfo"):
|
||||
for st in getattr(fileinfo, "StringTable", []):
|
||||
for key, val in st.entries.items():
|
||||
try:
|
||||
k = (
|
||||
key.decode("utf-8", "ignore")
|
||||
if isinstance(key, bytes)
|
||||
else str(key)
|
||||
)
|
||||
|
||||
if k in string_keys:
|
||||
v = (
|
||||
val.decode("utf-8", "ignore")
|
||||
if isinstance(val, bytes)
|
||||
else str(val)
|
||||
)
|
||||
base[f"{k}Length"] = float(len(v))
|
||||
|
||||
except Exception:
|
||||
log_exception(
|
||||
"PYAS_Features.PEFeatureMixin.extract_features:704"
|
||||
)
|
||||
continue
|
||||
|
||||
if hasattr(pe, "DIRECTORY_ENTRY_TLS") and hasattr(pe.DIRECTORY_ENTRY_TLS, "struct"):
|
||||
if getattr(pe.DIRECTORY_ENTRY_TLS.struct, "AddressOfCallBacks", 0) != 0:
|
||||
base["HasTlsCallbacks"] = 1.0
|
||||
|
||||
if hasattr(pe, "VS_FIXEDFILEINFO") and len(pe.VS_FIXEDFILEINFO) > 0:
|
||||
flags = getattr(pe.VS_FIXEDFILEINFO[0], "FileFlags", 0)
|
||||
base["IsDebug"] = 1.0 if flags & 0x1 else 0.0
|
||||
base["IsPreRelease"] = 1.0 if flags & 0x2 else 0.0
|
||||
base["IsPatched"] = 1.0 if flags & 0x4 else 0.0
|
||||
base["IsPrivateBuild"] = 1.0 if flags & 0x8 else 0.0
|
||||
base["IsSpecialBuild"] = 1.0 if flags & 0x20 else 0.0
|
||||
|
||||
if hasattr(pe, "DIRECTORY_ENTRY_IMPORT"):
|
||||
base["ImportCount"] = float(len(pe.DIRECTORY_ENTRY_IMPORT))
|
||||
func_count = 0
|
||||
|
||||
for entry in pe.DIRECTORY_ENTRY_IMPORT:
|
||||
if getattr(entry, "dll", None):
|
||||
try:
|
||||
dlls.add(entry.dll.decode("ascii", "ignore").lower())
|
||||
except Exception:
|
||||
log_exception("PYAS_Features.PEFeatureMixin.extract_features:727")
|
||||
pass
|
||||
|
||||
for imp in getattr(entry, "imports", []):
|
||||
func_count += 1
|
||||
|
||||
if getattr(imp, "name", None):
|
||||
try:
|
||||
apis.add(imp.name.decode("ascii", "ignore"))
|
||||
except Exception:
|
||||
log_exception("PYAS_Features.PEFeatureMixin.extract_features:735")
|
||||
pass
|
||||
|
||||
base["ImportFunctionCount"] = float(func_count)
|
||||
else:
|
||||
base["ImportCount"] = 0.0
|
||||
base["ImportFunctionCount"] = 0.0
|
||||
|
||||
if hasattr(pe, "DIRECTORY_ENTRY_EXPORT") and hasattr(
|
||||
pe.DIRECTORY_ENTRY_EXPORT, "symbols"
|
||||
):
|
||||
base["ExportCount"] = float(len(pe.DIRECTORY_ENTRY_EXPORT.symbols))
|
||||
else:
|
||||
base["ExportCount"] = 0.0
|
||||
|
||||
if hasattr(pe, "DIRECTORY_ENTRY_RESOURCE"):
|
||||
icon_count = 0
|
||||
|
||||
for entry in pe.DIRECTORY_ENTRY_RESOURCE.entries:
|
||||
if getattr(entry, "id", None) == 3 and hasattr(entry, "directory"):
|
||||
icon_count += len(getattr(entry.directory, "entries", []))
|
||||
|
||||
base["IconCount"] = float(icon_count)
|
||||
else:
|
||||
base["IconCount"] = 0.0
|
||||
|
||||
if hasattr(pe, "DIRECTORY_ENTRY_DEBUG"):
|
||||
base["DebugCount"] = float(len(pe.DIRECTORY_ENTRY_DEBUG))
|
||||
else:
|
||||
base["DebugCount"] = 0.0
|
||||
|
||||
base.update(stream_features)
|
||||
overlay_size = fsize - int(overlay_offset)
|
||||
base.update(
|
||||
{
|
||||
"HasOverlay": 1.0 if overlay_size else 0.0,
|
||||
"OverlaySize": float(overlay_size),
|
||||
"OverlayRatio": float(overlay_size) / float(fsize) if overlay_size else 0.0,
|
||||
"OverlayEntropy": overlay_entropy if overlay_size else 0.0,
|
||||
}
|
||||
)
|
||||
base.update(self._extract_rich_header(pe))
|
||||
base.update(self._extract_ep_anomalies(pe))
|
||||
base.update(self._extract_advanced_resources(pe, resource_entropies))
|
||||
base.update(self._extract_load_config(pe))
|
||||
base.update(self._extract_security_directory(pe, file_path, fsize))
|
||||
|
||||
for k in base:
|
||||
base[k] = self._safe_float(base[k])
|
||||
|
||||
return {"Base": base, "DLLs": list(dlls), "APIs": list(apis)}
|
||||
|
||||
except Exception:
|
||||
log_exception("PYAS_Features.PEFeatureMixin.extract_features:782")
|
||||
return None
|
||||
finally:
|
||||
if pe:
|
||||
pe.close()
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
from PYAS_Diagnostics import log_exception, create_log_entry, MAX_LOG_ENTRIES
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import uuid
|
||||
import webview
|
||||
from PYAS_Storage import atomic_write_json
|
||||
|
||||
|
||||
class LogMixin:
|
||||
def load_logs(self):
|
||||
with self.lock_logs:
|
||||
pending_logs = list(self.logs_data)
|
||||
|
||||
if os.path.exists(self.file_log):
|
||||
try:
|
||||
with open(self.file_log, "r", encoding="utf-8") as f:
|
||||
loaded_logs = json.load(f)
|
||||
|
||||
self.logs_data = (loaded_logs + pending_logs)[-MAX_LOG_ENTRIES:]
|
||||
except Exception:
|
||||
log_exception("PYAS_Logs.LogMixin.load_logs:17")
|
||||
self.logs_data = pending_logs
|
||||
|
||||
def log_flush_thread(self):
|
||||
while not getattr(self, "closing", False):
|
||||
time.sleep(5)
|
||||
self.flush_logs_now()
|
||||
|
||||
def write_log(
|
||||
self,
|
||||
level,
|
||||
action,
|
||||
detail=None,
|
||||
code=None,
|
||||
pid=None,
|
||||
file_hash=None,
|
||||
source=None,
|
||||
target=None,
|
||||
operate=None,
|
||||
success=True,
|
||||
):
|
||||
entry = create_log_entry(
|
||||
level, action, detail, code, pid, file_hash, source, target, operate, success
|
||||
)
|
||||
self._append_log_entry(entry)
|
||||
|
||||
if level == "BLOCK" and self.tray_icon:
|
||||
self.trigger_block_notification(action, source, target, code)
|
||||
|
||||
def _append_log_entry(self, entry):
|
||||
with self.lock_logs:
|
||||
self.logs_data.append(entry)
|
||||
|
||||
if len(self.logs_data) > MAX_LOG_ENTRIES:
|
||||
del self.logs_data[:-MAX_LOG_ENTRIES]
|
||||
|
||||
self.logs_dirty = True
|
||||
|
||||
if self._window:
|
||||
js_cmd = f"if(window.updateLogs) window.updateLogs({json.dumps(entry)});"
|
||||
self.ui_queue.put(js_cmd)
|
||||
|
||||
def get_logs(self):
|
||||
with self.lock_logs:
|
||||
return self.logs_data.copy()
|
||||
|
||||
def clear_logs(self, log_ids=None):
|
||||
with self.lock_logs:
|
||||
if log_ids is None:
|
||||
self.logs_data = []
|
||||
else:
|
||||
self.logs_data = [log for log in self.logs_data if log["id"] not in log_ids]
|
||||
|
||||
try:
|
||||
if not self.logs_data:
|
||||
if os.path.exists(self.file_log):
|
||||
os.remove(self.file_log)
|
||||
else:
|
||||
atomic_write_json(self.file_log, self.logs_data)
|
||||
|
||||
except Exception:
|
||||
log_exception("PYAS_Logs.LogMixin.clear_logs:73")
|
||||
self.logs_dirty = True
|
||||
return False
|
||||
|
||||
self.logs_dirty = False
|
||||
return True
|
||||
|
||||
def export_logs(self, log_ids=None):
|
||||
if self._window:
|
||||
path = self._window.create_file_dialog(
|
||||
webview.FileDialog.SAVE, directory="", save_filename="PYAS_Logs.json"
|
||||
)
|
||||
|
||||
if path:
|
||||
target_path = path[0] if isinstance(path, (tuple, list)) else path
|
||||
|
||||
with self.lock_logs:
|
||||
export_data = self.logs_data
|
||||
|
||||
if log_ids is not None:
|
||||
export_data = [log for log in self.logs_data if log["id"] in log_ids]
|
||||
|
||||
try:
|
||||
with open(target_path, "w", encoding="utf-8") as f:
|
||||
json.dump(export_data, f, indent=4, ensure_ascii=False)
|
||||
|
||||
return True
|
||||
except Exception:
|
||||
log_exception("PYAS_Logs.LogMixin.export_logs:95")
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
def flush_logs_now(self):
|
||||
with self.lock_logs:
|
||||
if not getattr(self, "logs_dirty", False):
|
||||
return True
|
||||
|
||||
try:
|
||||
atomic_write_json(self.file_log, self.logs_data)
|
||||
self.logs_dirty = False
|
||||
return True
|
||||
except Exception:
|
||||
log_exception("PYAS_Logs.LogMixin.flush_logs_now:107")
|
||||
return False
|
||||
@@ -0,0 +1,278 @@
|
||||
from PYAS_Diagnostics import log_exception
|
||||
import os
|
||||
import threading
|
||||
import ctypes
|
||||
import ctypes.wintypes
|
||||
from PYAS_WinAPI import SHQUERYRBINFO
|
||||
|
||||
|
||||
class MaintenanceMixin:
|
||||
def _traverse_delete(self, path):
|
||||
deleted = 0
|
||||
|
||||
if not path or not os.path.exists(path):
|
||||
return deleted
|
||||
|
||||
try:
|
||||
items = os.listdir(path)
|
||||
except Exception:
|
||||
log_exception("PYAS_Maintenance.MaintenanceMixin._traverse_delete:16")
|
||||
return deleted
|
||||
|
||||
for fd in items:
|
||||
file = os.path.join(path, fd)
|
||||
|
||||
try:
|
||||
if os.path.isdir(file):
|
||||
if self._is_reparse_point(file):
|
||||
continue
|
||||
|
||||
deleted += self._traverse_delete(file)
|
||||
|
||||
try:
|
||||
os.rmdir(file)
|
||||
except Exception:
|
||||
log_exception("PYAS_Maintenance.MaintenanceMixin._traverse_delete:29")
|
||||
pass
|
||||
|
||||
else:
|
||||
if file.lower().endswith((".sys", ".dll", ".exe", ".ini", ".dat")):
|
||||
continue
|
||||
|
||||
size = os.path.getsize(file)
|
||||
os.remove(file)
|
||||
deleted += size
|
||||
|
||||
except Exception:
|
||||
log_exception("PYAS_Maintenance.MaintenanceMixin._traverse_delete:40")
|
||||
continue
|
||||
|
||||
return deleted
|
||||
|
||||
def _get_junk_dirs(self):
|
||||
return [
|
||||
self.path_temp,
|
||||
self.path_systemp,
|
||||
os.path.join(self.path_system, "SoftwareDistribution", "Download"),
|
||||
]
|
||||
|
||||
def _yield_log_files(self):
|
||||
log_dir = os.path.join(self.path_system, "Logs")
|
||||
|
||||
if os.path.exists(log_dir):
|
||||
for root, _, files in os.walk(log_dir):
|
||||
for file in files:
|
||||
if file.lower().endswith((".log", ".etl", ".evtx")):
|
||||
yield os.path.join(root, file)
|
||||
|
||||
def _get_recycle_bin_size(self):
|
||||
try:
|
||||
info = SHQUERYRBINFO()
|
||||
info.cbSize = ctypes.sizeof(SHQUERYRBINFO)
|
||||
|
||||
if self.shell32.SHQueryRecycleBinW(None, ctypes.byref(info)) == 0 and info.i64Size > 0:
|
||||
return info.i64Size
|
||||
except Exception:
|
||||
log_exception("PYAS_Maintenance.MaintenanceMixin._get_recycle_bin_size:67")
|
||||
pass
|
||||
|
||||
return 0
|
||||
|
||||
def scan_system_junk(self):
|
||||
junk_list = []
|
||||
|
||||
try:
|
||||
for path in self._get_junk_dirs():
|
||||
if os.path.exists(path):
|
||||
for root, _, files in os.walk(path):
|
||||
for file in files:
|
||||
if not file.lower().endswith((".sys", ".dll", ".exe", ".ini", ".dat")):
|
||||
try:
|
||||
fp = os.path.join(root, file)
|
||||
junk_list.append({"path": fp, "size": os.path.getsize(fp)})
|
||||
except Exception:
|
||||
log_exception(
|
||||
"PYAS_Maintenance.MaintenanceMixin.scan_system_junk:83"
|
||||
)
|
||||
pass
|
||||
|
||||
for fp in self._yield_log_files():
|
||||
try:
|
||||
junk_list.append({"path": fp, "size": os.path.getsize(fp)})
|
||||
except Exception:
|
||||
log_exception("PYAS_Maintenance.MaintenanceMixin.scan_system_junk:89")
|
||||
pass
|
||||
|
||||
rb_size = self._get_recycle_bin_size()
|
||||
|
||||
if rb_size > 0:
|
||||
junk_list.append({"path": "Recycle Bin", "size": rb_size})
|
||||
|
||||
return junk_list
|
||||
|
||||
except Exception as e:
|
||||
log_exception("PYAS_Maintenance.MaintenanceMixin.scan_system_junk:98")
|
||||
self.write_log("WARN", "scan_system_junk", detail=str(e), success=False)
|
||||
return []
|
||||
|
||||
def clean_system_junk(self, paths_to_delete=None):
|
||||
total_deleted = 0
|
||||
|
||||
try:
|
||||
if paths_to_delete is not None:
|
||||
for path in paths_to_delete:
|
||||
if path == "Recycle Bin":
|
||||
rb_size = self._get_recycle_bin_size()
|
||||
|
||||
if rb_size > 0 and self.shell32.SHEmptyRecycleBinW(None, None, 7) == 0:
|
||||
total_deleted += rb_size
|
||||
|
||||
continue
|
||||
|
||||
if path.lower().endswith((".sys", ".dll", ".exe", ".ini", ".dat")):
|
||||
continue
|
||||
|
||||
try:
|
||||
size = os.path.getsize(path)
|
||||
os.remove(path)
|
||||
total_deleted += size
|
||||
except Exception:
|
||||
log_exception("PYAS_Maintenance.MaintenanceMixin.clean_system_junk:120")
|
||||
pass
|
||||
else:
|
||||
for path in self._get_junk_dirs():
|
||||
total_deleted += self._traverse_delete(path)
|
||||
|
||||
for fp in self._yield_log_files():
|
||||
try:
|
||||
size = os.path.getsize(fp)
|
||||
os.remove(fp)
|
||||
total_deleted += size
|
||||
except Exception:
|
||||
log_exception("PYAS_Maintenance.MaintenanceMixin.clean_system_junk:131")
|
||||
pass
|
||||
|
||||
rb_size = self._get_recycle_bin_size()
|
||||
|
||||
if rb_size > 0 and self.shell32.SHEmptyRecycleBinW(None, None, 7) == 0:
|
||||
total_deleted += rb_size
|
||||
|
||||
try:
|
||||
for log_type in ["Application", "Security", "Setup", "System"]:
|
||||
log_path = os.path.join(
|
||||
self.path_system, "System32", "winevt", "Logs", f"{log_type}.evtx"
|
||||
)
|
||||
|
||||
size = os.path.getsize(log_path) if os.path.exists(log_path) else 0
|
||||
|
||||
if self._clear_event_log(log_type):
|
||||
total_deleted += size
|
||||
|
||||
except Exception:
|
||||
log_exception("PYAS_Maintenance.MaintenanceMixin.clean_system_junk:146")
|
||||
pass
|
||||
|
||||
self.write_log(
|
||||
"INFO", "Clean Junk", detail=f"Deleted {total_deleted // 1024} KB", operate=True
|
||||
)
|
||||
return total_deleted
|
||||
|
||||
except Exception as e:
|
||||
log_exception("PYAS_Maintenance.MaintenanceMixin.clean_system_junk:152")
|
||||
self.write_log("WARN", "clean_system_junk", detail=str(e), operate=True, success=False)
|
||||
return 0
|
||||
|
||||
def optimize_memory(self, icon=None, item=None):
|
||||
def _optimize():
|
||||
try:
|
||||
hwnd = self.user32.GetForegroundWindow()
|
||||
fg_pid = ctypes.wintypes.DWORD(0)
|
||||
|
||||
if hwnd:
|
||||
self.user32.GetWindowThreadProcessId(hwnd, ctypes.byref(fg_pid))
|
||||
|
||||
pids = self.get_process_list_pids()
|
||||
optimized_count = 0
|
||||
|
||||
skip_set = {
|
||||
"dwm.exe",
|
||||
"explorer.exe",
|
||||
"csrss.exe",
|
||||
"smss.exe",
|
||||
"winlogon.exe",
|
||||
"lsass.exe",
|
||||
"services.exe",
|
||||
"svchost.exe",
|
||||
"wininit.exe",
|
||||
"audiodg.exe",
|
||||
"spoolsv.exe",
|
||||
"sihost.exe",
|
||||
"fontdrvhost.exe",
|
||||
"taskmgr.exe",
|
||||
}
|
||||
|
||||
for pid in pids:
|
||||
if pid <= 4 or pid == self.pid_pyas or pid == fg_pid.value:
|
||||
continue
|
||||
|
||||
name, _ = self.get_exe_info(pid)
|
||||
|
||||
if name and name.lower() in skip_set:
|
||||
continue
|
||||
|
||||
h = self.kernel32.OpenProcess(0x0100, False, pid)
|
||||
|
||||
if h:
|
||||
try:
|
||||
if self.kernel32.SetProcessWorkingSetSize(
|
||||
h, ctypes.c_size_t(-1), ctypes.c_size_t(-1)
|
||||
):
|
||||
optimized_count += 1
|
||||
finally:
|
||||
self.kernel32.CloseHandle(h)
|
||||
|
||||
title = self._loc(
|
||||
{
|
||||
"traditional_switch": "一鍵加速",
|
||||
"simplified_switch": "一键加速",
|
||||
"english_switch": "Memory Boost",
|
||||
"japanese_switch": "メモリ最適化",
|
||||
"korean_switch": "메모리 최적화",
|
||||
"french_switch": "Optimiser",
|
||||
"spanish_switch": "Optimizar",
|
||||
"hindi_switch": "मेमोरी बूस्ट",
|
||||
"arabic_switch": "تسريع",
|
||||
"russian_switch": "Ускорение",
|
||||
"slovenian_switch": "Optimizacija",
|
||||
}
|
||||
)
|
||||
|
||||
msg = self._loc(
|
||||
{
|
||||
"traditional_switch": f"已釋放 {optimized_count} 個背景進程的記憶體",
|
||||
"simplified_switch": f"已释放 {optimized_count} 个后台进程的内存",
|
||||
"english_switch": f"Freed memory for {optimized_count} background processes",
|
||||
"japanese_switch": f"{optimized_count} 個のバックグラウンドプロセスのメモリを解放しました",
|
||||
"korean_switch": f"{optimized_count}개 백그라운드 프로세스 메모리 확보",
|
||||
"french_switch": f"Mémoire libérée pour {optimized_count} processus",
|
||||
"spanish_switch": f"Memoria liberada para {optimized_count} procesos",
|
||||
"hindi_switch": f"{optimized_count} पृष्ठभूमि प्रक्रियाओं के लिए मेमोरी मुक्त की गई",
|
||||
"arabic_switch": f"تم تحرير الذاكرة لـ {optimized_count} من العمليات في الخلفية",
|
||||
"russian_switch": f"Освобождена память {optimized_count} фоновых процессов",
|
||||
"slovenian_switch": f"Sproščen pomnilnik za {optimized_count} procesov v ozadju",
|
||||
}
|
||||
)
|
||||
|
||||
self.show_notification(title, msg)
|
||||
self.write_log(
|
||||
"INFO",
|
||||
"Memory Boost",
|
||||
detail=f"Optimized {optimized_count} processes",
|
||||
operate=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
log_exception("PYAS_Maintenance.MaintenanceMixin.optimize_memory._optimize:213")
|
||||
self.write_log("WARN", "optimize_memory", detail=str(e), success=False)
|
||||
|
||||
threading.Thread(target=_optimize, daemon=True).start()
|
||||
+283
@@ -0,0 +1,283 @@
|
||||
from PYAS_Diagnostics import log_exception
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import zlib
|
||||
import numpy
|
||||
from PYAS_Signature import SignatureScanner as sign_scanner
|
||||
from PYAS_Features import PEFeatureMixin
|
||||
|
||||
|
||||
class PEScanner(PEFeatureMixin):
|
||||
_STRING_PATTERN = re.compile(b"[\x20-\x7E]{5,}")
|
||||
|
||||
_STRING_FAST_PATH_BYTES = 4194304
|
||||
|
||||
_STRING_BATCH_BYTES = 4194304
|
||||
|
||||
_HISTOGRAM_BATCH_WINDOWS = 1024
|
||||
|
||||
_BYTE_HIST_KEYS = [f"ByteHist_{i:02X}" for i in range(256)]
|
||||
|
||||
_BYTE_ENT_KEYS = [f"ByteEnt_{i:02X}" for i in range(256)]
|
||||
|
||||
_STRING_KEYS = [f"StringHist_{i:02d}" for i in range(95)]
|
||||
|
||||
_SECTION_HASH_KEYS = [f"SectionHash_{i:02d}" for i in range(50)]
|
||||
|
||||
_CHAR_FLAGS = [
|
||||
0x00000020,
|
||||
0x00000040,
|
||||
0x00000080,
|
||||
0x02000000,
|
||||
0x20000000,
|
||||
0x40000000,
|
||||
0x80000000,
|
||||
]
|
||||
|
||||
_CHAR_COUNT_KEYS = {flag: f"Char_{flag:08X}_Count" for flag in _CHAR_FLAGS}
|
||||
|
||||
_CHAR_ENT_KEYS = {flag: f"Char_{flag:08X}_MeanEntropy" for flag in _CHAR_FLAGS}
|
||||
|
||||
_C_LOG_C_TABLE = numpy.zeros(2049, dtype=numpy.float64)
|
||||
|
||||
_C_LOG_C_TABLE[1:] = numpy.arange(1, 2049) * numpy.log2(numpy.arange(1, 2049))
|
||||
|
||||
_OPTIONAL_HEADER_FIELDS = [
|
||||
"Magic",
|
||||
"MajorLinkerVersion",
|
||||
"MinorLinkerVersion",
|
||||
"SizeOfCode",
|
||||
"SizeOfInitializedData",
|
||||
"SizeOfUninitializedData",
|
||||
"AddressOfEntryPoint",
|
||||
"BaseOfCode",
|
||||
"ImageBase",
|
||||
"SectionAlignment",
|
||||
"FileAlignment",
|
||||
"MajorOperatingSystemVersion",
|
||||
"MinorOperatingSystemVersion",
|
||||
"MajorImageVersion",
|
||||
"MinorImageVersion",
|
||||
"MajorSubsystemVersion",
|
||||
"MinorSubsystemVersion",
|
||||
"SizeOfImage",
|
||||
"SizeOfHeaders",
|
||||
"CheckSum",
|
||||
"Subsystem",
|
||||
"DllCharacteristics",
|
||||
"SizeOfStackReserve",
|
||||
"SizeOfStackCommit",
|
||||
"SizeOfHeapReserve",
|
||||
"SizeOfHeapCommit",
|
||||
"LoaderFlags",
|
||||
"NumberOfRvaAndSizes",
|
||||
]
|
||||
|
||||
_SECTION_DEFAULTS = {
|
||||
"SectionCount": 0.0,
|
||||
"SectionMaxEntropy": 0.0,
|
||||
"SectionMinEntropy": 0.0,
|
||||
"SectionMeanEntropy": 0.0,
|
||||
"SectionMaxRawSize": 0.0,
|
||||
"SectionMinRawSize": 0.0,
|
||||
"SectionMeanRawSize": 0.0,
|
||||
"SectionMaxVSize": 0.0,
|
||||
"SectionMinVSize": 0.0,
|
||||
"SectionMeanVSize": 0.0,
|
||||
"ExecutableSections": 0.0,
|
||||
"WritableSections": 0.0,
|
||||
"ReadableSections": 0.0,
|
||||
"SectionException": 0.0,
|
||||
}
|
||||
|
||||
_CONDITIONAL_DEFAULTS = {
|
||||
"IsDriver": 0.0,
|
||||
"HasTlsCallbacks": 0.0,
|
||||
"IsDebug": 0.0,
|
||||
"IsPreRelease": 0.0,
|
||||
"IsPatched": 0.0,
|
||||
"IsPrivateBuild": 0.0,
|
||||
"IsSpecialBuild": 0.0,
|
||||
"ImportCount": 0.0,
|
||||
"ImportFunctionCount": 0.0,
|
||||
"ExportCount": 0.0,
|
||||
"IconCount": 0.0,
|
||||
"DebugCount": 0.0,
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self.model = None
|
||||
self.input_name = None
|
||||
self.feature_order = []
|
||||
self.feature_index = {}
|
||||
self.dll_hash_dim = 512
|
||||
self.api_hash_dim = 4096
|
||||
self.dll_hash_pad = 3
|
||||
self.api_hash_pad = 4
|
||||
self.signer = sign_scanner()
|
||||
self.signer.init_windll(["wintrust"])
|
||||
|
||||
def load_path(self, path, callback=None):
|
||||
for root, _, files in os.walk(path):
|
||||
for file in files:
|
||||
full_path = os.path.join(root, file)
|
||||
|
||||
if callback:
|
||||
callback(full_path)
|
||||
|
||||
if full_path.endswith(".onnx"):
|
||||
self.load_model(full_path)
|
||||
|
||||
def load_model(self, model_path):
|
||||
if not os.path.exists(model_path):
|
||||
return
|
||||
|
||||
try:
|
||||
import onnxruntime
|
||||
|
||||
model = onnxruntime.InferenceSession(model_path, providers=["CPUExecutionProvider"])
|
||||
input_name = model.get_inputs()[0].name
|
||||
feat_path = os.path.join(os.path.dirname(model_path), "features.json")
|
||||
feature_order = self.feature_order
|
||||
|
||||
if os.path.exists(feat_path):
|
||||
with open(feat_path, "r", encoding="utf-8") as stream:
|
||||
feature_order = json.load(stream)
|
||||
|
||||
if not isinstance(feature_order, list) or any(
|
||||
not isinstance(feature, str) for feature in feature_order
|
||||
):
|
||||
raise ValueError("Invalid model feature order")
|
||||
|
||||
previous = (
|
||||
self.model,
|
||||
self.input_name,
|
||||
self.feature_order,
|
||||
self.feature_index,
|
||||
self.dll_hash_dim,
|
||||
self.api_hash_dim,
|
||||
self.dll_hash_pad,
|
||||
self.api_hash_pad,
|
||||
)
|
||||
|
||||
try:
|
||||
self.feature_order = feature_order
|
||||
self._parse_hash_dims()
|
||||
self.input_name = input_name
|
||||
self.model = model
|
||||
except Exception:
|
||||
(
|
||||
self.model,
|
||||
self.input_name,
|
||||
self.feature_order,
|
||||
self.feature_index,
|
||||
self.dll_hash_dim,
|
||||
self.api_hash_dim,
|
||||
self.dll_hash_pad,
|
||||
self.api_hash_pad,
|
||||
) = previous
|
||||
raise
|
||||
except Exception:
|
||||
log_exception("PEScanner.load_model")
|
||||
|
||||
def _parse_hash_dims(self):
|
||||
self.feature_index = {feature: index for index, feature in enumerate(self.feature_order)}
|
||||
max_dll = -1
|
||||
max_api = -1
|
||||
|
||||
for feat in self.feature_order:
|
||||
if feat.startswith("DllHash_"):
|
||||
try:
|
||||
val_str = feat.split("_")[1]
|
||||
max_dll = max(max_dll, int(val_str))
|
||||
self.dll_hash_pad = len(val_str)
|
||||
except Exception:
|
||||
log_exception("PYAS_PE.PEScanner._parse_hash_dims:127")
|
||||
pass
|
||||
elif feat.startswith("ApiHash_"):
|
||||
try:
|
||||
val_str = feat.split("_")[1]
|
||||
max_api = max(max_api, int(val_str))
|
||||
self.api_hash_pad = len(val_str)
|
||||
except Exception:
|
||||
log_exception("PYAS_PE.PEScanner._parse_hash_dims:134")
|
||||
pass
|
||||
|
||||
if max_dll >= 0:
|
||||
self.dll_hash_dim = max_dll + 1
|
||||
|
||||
if max_api >= 0:
|
||||
self.api_hash_dim = max_api + 1
|
||||
|
||||
def pe_scan(self, file_path, enhanced_mode=False):
|
||||
if not self.model or not self.feature_order:
|
||||
return False, False
|
||||
|
||||
try:
|
||||
raw_data = self.extract_features(file_path)
|
||||
|
||||
if not raw_data:
|
||||
return False, False
|
||||
|
||||
vec = numpy.zeros((1, len(self.feature_order)), dtype=numpy.float32)
|
||||
base = raw_data.get("Base", {})
|
||||
dlls = set(raw_data.get("DLLs", []))
|
||||
apis = set(raw_data.get("APIs", []))
|
||||
feat_map = self.feature_index
|
||||
|
||||
for k, v in base.items():
|
||||
if k in feat_map:
|
||||
vec[0, feat_map[k]] = v
|
||||
|
||||
for d in dlls:
|
||||
old_fn = f"Dll_{d}"
|
||||
|
||||
if old_fn in feat_map:
|
||||
vec[0, feat_map[old_fn]] = 1.0
|
||||
|
||||
h = zlib.crc32(d.encode("utf-8", "ignore")) % self.dll_hash_dim
|
||||
new_fn = f"DllHash_{h:0{self.dll_hash_pad}d}"
|
||||
|
||||
if new_fn in feat_map:
|
||||
vec[0, feat_map[new_fn]] += 1.0
|
||||
|
||||
for a in apis:
|
||||
old_fn = f"Api_{a}"
|
||||
|
||||
if old_fn in feat_map:
|
||||
vec[0, feat_map[old_fn]] = 1.0
|
||||
|
||||
h = zlib.crc32(a.encode("utf-8", "ignore")) % self.api_hash_dim
|
||||
new_fn = f"ApiHash_{h:0{self.api_hash_pad}d}"
|
||||
|
||||
if new_fn in feat_map:
|
||||
vec[0, feat_map[new_fn]] += 1.0
|
||||
|
||||
outputs = self.model.run(None, {self.input_name: vec})
|
||||
|
||||
del raw_data, vec
|
||||
result = outputs[1]
|
||||
prob = 0.0
|
||||
|
||||
if isinstance(result, list) and len(result) > 0:
|
||||
prob_dict = result[0]
|
||||
|
||||
if hasattr(prob_dict, "get"):
|
||||
prob = float(prob_dict.get(1, prob_dict.get("1", 0.0)))
|
||||
|
||||
elif isinstance(result, numpy.ndarray):
|
||||
if result.ndim == 2 and result.shape[1] > 1:
|
||||
prob = float(result[0][1])
|
||||
|
||||
score = int(prob * 100)
|
||||
|
||||
if score >= 80:
|
||||
return f"Malware:WinPE/General.{score}!ml", score
|
||||
elif enhanced_mode and score >= 50:
|
||||
return f"Suspicious:WinPE/General.{score}!ml", score
|
||||
|
||||
return False, False
|
||||
except Exception:
|
||||
log_exception("PYAS_PE.PEScanner.pe_scan:203")
|
||||
return False, False
|
||||
+763
@@ -0,0 +1,763 @@
|
||||
from PYAS_Diagnostics import log_exception
|
||||
import time
|
||||
import copy
|
||||
import ctypes
|
||||
import ctypes.wintypes
|
||||
from PYAS_WinAPI import POINT, RECT
|
||||
import os
|
||||
import uuid
|
||||
import math
|
||||
|
||||
|
||||
def normalized_path(path):
|
||||
return os.path.normcase(os.path.normpath(path)) if isinstance(path, str) and path else ""
|
||||
|
||||
|
||||
def signature_matches(expected, actual):
|
||||
if not isinstance(expected, dict) or not isinstance(actual, dict):
|
||||
return False
|
||||
|
||||
for key in ("class", "style", "exstyle", "children", "owner"):
|
||||
if key not in expected or expected[key] != actual.get(key):
|
||||
return False
|
||||
|
||||
for key in ("width", "height"):
|
||||
size = expected.get(key)
|
||||
current = actual.get(key)
|
||||
|
||||
if not isinstance(size, (int, float)) or not isinstance(current, (int, float)):
|
||||
return False
|
||||
|
||||
if (
|
||||
not math.isfinite(size)
|
||||
or not math.isfinite(current)
|
||||
or size <= 0
|
||||
or current <= 0
|
||||
or abs(size - current) > max(8, size * 0.08)
|
||||
):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def is_main_anchor(window, target):
|
||||
return (
|
||||
window.get("hwnd") != target.get("hwnd")
|
||||
and window.get("pid") == target.get("pid")
|
||||
and normalized_path(window.get("path")) == normalized_path(target.get("path"))
|
||||
and not window.get("fingerprint", {}).get("owner")
|
||||
and not window.get("fingerprint", {}).get("exstyle", 0) & 0x80
|
||||
and window.get("fingerprint", {}).get("style", 0) & 0x00030000 == 0x00030000
|
||||
and window.get("fingerprint", {}).get("width", 0)
|
||||
* window.get("fingerprint", {}).get("height", 0)
|
||||
> target.get("fingerprint", {}).get("width", 0)
|
||||
* target.get("fingerprint", {}).get("height", 0)
|
||||
* 1.25
|
||||
)
|
||||
|
||||
|
||||
def secondary_candidate(target):
|
||||
fingerprint = target.get("fingerprint", {})
|
||||
return bool(
|
||||
fingerprint.get("owner")
|
||||
or fingerprint.get("exstyle", 0) & 0x80
|
||||
or not fingerprint.get("style", 0) & 0x00030000
|
||||
)
|
||||
|
||||
|
||||
def build_popup_rule(target, windows):
|
||||
if not target or not target.get("path") or not target.get("fingerprint", {}).get("class"):
|
||||
return None
|
||||
|
||||
fingerprint = target["fingerprint"]
|
||||
peers = [
|
||||
window
|
||||
for window in windows
|
||||
if window.get("pid") == target.get("pid")
|
||||
and normalized_path(window.get("path")) == normalized_path(target.get("path"))
|
||||
and window["hwnd"] != target["hwnd"]
|
||||
]
|
||||
identical = any(
|
||||
all(
|
||||
fingerprint.get(key) == window.get("fingerprint", {}).get(key)
|
||||
for key in ("class", "style", "exstyle", "children", "owner")
|
||||
)
|
||||
for window in peers
|
||||
)
|
||||
anchors = [window for window in peers if is_main_anchor(window, target)]
|
||||
distinctive = secondary_candidate(target) or bool(anchors)
|
||||
mode = (
|
||||
"adaptive"
|
||||
if isinstance(fingerprint.get("children"), list)
|
||||
and signature_matches(fingerprint, fingerprint)
|
||||
and distinctive
|
||||
and not identical
|
||||
and not target.get("modal")
|
||||
else "title"
|
||||
)
|
||||
return {
|
||||
"id": uuid.uuid4().hex,
|
||||
"schema_version": 2,
|
||||
"exe": target["exe"],
|
||||
"path": target["path"],
|
||||
"class": fingerprint["class"],
|
||||
"title": target.get("title", ""),
|
||||
"fingerprint": fingerprint,
|
||||
"match_mode": mode,
|
||||
"protected": [
|
||||
window["fingerprint"]
|
||||
for window in peers
|
||||
if not signature_matches(fingerprint, window.get("fingerprint"))
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def popup_rule_matches(rule, target, windows):
|
||||
if not isinstance(rule, dict) or not target:
|
||||
return False
|
||||
|
||||
if rule.get("schema_version") != 2:
|
||||
return bool(
|
||||
rule.get("exe") == target.get("exe")
|
||||
and rule.get("class") == target.get("fingerprint", {}).get("class")
|
||||
and rule.get("title")
|
||||
and rule["title"] == target.get("title")
|
||||
and not target.get("modal")
|
||||
)
|
||||
|
||||
if (
|
||||
not rule.get("id")
|
||||
or not normalized_path(rule.get("path"))
|
||||
or normalized_path(rule["path"]) != normalized_path(target.get("path"))
|
||||
):
|
||||
return False
|
||||
|
||||
fingerprint = target.get("fingerprint", {})
|
||||
|
||||
if not rule.get("class") or rule["class"] != fingerprint.get("class"):
|
||||
return False
|
||||
|
||||
if any(signature_matches(protected, fingerprint) for protected in rule.get("protected", [])):
|
||||
return False
|
||||
|
||||
expected = rule.get("fingerprint", {})
|
||||
structural_match = signature_matches(expected, fingerprint)
|
||||
title_match = bool(rule.get("title") and rule["title"] == target.get("title"))
|
||||
|
||||
if rule.get("match_mode", "adaptive") == "title":
|
||||
if not title_match or not all(
|
||||
expected.get(key) == fingerprint.get(key) for key in ("style", "exstyle", "owner")
|
||||
):
|
||||
return False
|
||||
|
||||
return not any(
|
||||
window.get("hwnd") != target.get("hwnd")
|
||||
and window.get("pid") == target.get("pid")
|
||||
and normalized_path(window.get("path")) == normalized_path(target.get("path"))
|
||||
and window.get("title") == target.get("title")
|
||||
and window.get("fingerprint", {}).get("class") == rule["class"]
|
||||
and all(
|
||||
window.get("fingerprint", {}).get(key) == fingerprint.get(key)
|
||||
for key in ("style", "exstyle", "owner")
|
||||
)
|
||||
for window in windows
|
||||
)
|
||||
|
||||
if not structural_match or target.get("modal"):
|
||||
return False
|
||||
|
||||
peers = [
|
||||
window
|
||||
for window in windows
|
||||
if window.get("pid") == target.get("pid")
|
||||
and normalized_path(window.get("path")) == normalized_path(target.get("path"))
|
||||
and window["hwnd"] != target["hwnd"]
|
||||
]
|
||||
identical = [
|
||||
window for window in peers if signature_matches(expected, window.get("fingerprint"))
|
||||
]
|
||||
|
||||
if identical:
|
||||
return title_match and not any(
|
||||
window.get("title") == target.get("title") for window in identical
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class PopupWindowMixin:
|
||||
def capture_popup_window(self):
|
||||
self._init_popup_api()
|
||||
overlay = self.user32.CreateWindowExW(
|
||||
0x000800A8, "STATIC", "", 0x98000004, 0, 0, 0, 0, None, None, None, None
|
||||
)
|
||||
|
||||
if overlay:
|
||||
self.user32.SetLayeredWindowAttributes(overlay, 0, 120, 2)
|
||||
|
||||
target_hwnd = None
|
||||
last_hwnd = None
|
||||
msg = ctypes.wintypes.MSG()
|
||||
|
||||
start_time = time.monotonic()
|
||||
|
||||
try:
|
||||
while self.user32.GetAsyncKeyState(0x01) & 0x8000:
|
||||
if time.monotonic() - start_time >= 30:
|
||||
return None
|
||||
|
||||
time.sleep(0.01)
|
||||
|
||||
while time.monotonic() - start_time < 30:
|
||||
if self.user32.GetAsyncKeyState(0x1B) & 0x8000:
|
||||
break
|
||||
|
||||
pt = POINT()
|
||||
self.user32.GetCursorPos(ctypes.byref(pt))
|
||||
|
||||
hwnd = self.user32.WindowFromPoint(pt)
|
||||
root_hwnd = self.user32.GetAncestor(hwnd, 2)
|
||||
|
||||
if not root_hwnd:
|
||||
root_hwnd = hwnd
|
||||
|
||||
if root_hwnd and root_hwnd != overlay:
|
||||
if root_hwnd != last_hwnd:
|
||||
last_hwnd = root_hwnd
|
||||
rect = RECT()
|
||||
self.user32.GetWindowRect(root_hwnd, ctypes.byref(rect))
|
||||
self.user32.SetWindowPos(
|
||||
overlay,
|
||||
-1,
|
||||
rect.left,
|
||||
rect.top,
|
||||
rect.right - rect.left,
|
||||
rect.bottom - rect.top,
|
||||
0x0050,
|
||||
)
|
||||
|
||||
if self.user32.GetAsyncKeyState(0x01) & 0x8000:
|
||||
target_hwnd = root_hwnd
|
||||
break
|
||||
|
||||
while self.user32.PeekMessageW(ctypes.byref(msg), 0, 0, 0, 1):
|
||||
self.user32.TranslateMessage(ctypes.byref(msg))
|
||||
self.user32.DispatchMessageW(ctypes.byref(msg))
|
||||
|
||||
time.sleep(0.02)
|
||||
finally:
|
||||
if overlay:
|
||||
self.user32.DestroyWindow(overlay)
|
||||
|
||||
if not target_hwnd:
|
||||
return None
|
||||
|
||||
pid = ctypes.c_ulong(0)
|
||||
self.user32.GetWindowThreadProcessId(target_hwnd, ctypes.byref(pid))
|
||||
|
||||
if pid.value != self.pid_pyas and pid.value > 4:
|
||||
length = self.user32.GetWindowTextLengthW(target_hwnd)
|
||||
title = ctypes.create_unicode_buffer(length + 1)
|
||||
class_name = ctypes.create_unicode_buffer(256)
|
||||
|
||||
self.user32.GetWindowTextW(target_hwnd, title, length + 1)
|
||||
self.user32.GetClassNameW(target_hwnd, class_name, 256)
|
||||
|
||||
proc_name, _ = self.get_exe_info(pid.value)
|
||||
t_str, c_str = str(title.value), str(class_name.value)
|
||||
|
||||
if proc_name and not any(
|
||||
item.get("exe") == proc_name or item.get("class") == c_str
|
||||
for item in self.pass_windows
|
||||
):
|
||||
target = self._popup_window_snapshot(target_hwnd)
|
||||
windows = self._popup_windows({normalized_path(target["path"])}) if target else []
|
||||
rule = build_popup_rule(target, windows)
|
||||
|
||||
if not rule:
|
||||
return {"error": "popup_capture_failed"}
|
||||
|
||||
token = (time.monotonic_ns() & 0x7FFFFFFF) or 1
|
||||
selected = {"hwnd": target_hwnd, "pid": target["pid"], "token": token}
|
||||
|
||||
if not self.user32.SetPropW(target_hwnd, "PYAS_Popup_Selected", token):
|
||||
return {"error": "popup_capture_failed"}
|
||||
|
||||
with self.lock_config:
|
||||
previous = getattr(self, "_popup_selected", {})
|
||||
active = {
|
||||
item.get("id")
|
||||
for item in self.pyas_config.get("block_list", [])
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
|
||||
for identifier, record in list(previous.items()):
|
||||
if identifier not in active:
|
||||
if (
|
||||
self.user32.GetPropW(record["hwnd"], "PYAS_Popup_Selected")
|
||||
== record["token"]
|
||||
):
|
||||
self.user32.RemovePropW(record["hwnd"], "PYAS_Popup_Selected")
|
||||
|
||||
previous.pop(identifier, None)
|
||||
|
||||
previous[rule["id"]] = selected
|
||||
self._popup_selected = previous
|
||||
self._captured_popup_rules = {rule["id"]: copy.deepcopy(rule)}
|
||||
|
||||
return rule
|
||||
|
||||
return {"error": "popup_capture_failed"}
|
||||
|
||||
def _init_popup_api(self):
|
||||
if getattr(self, "_popup_api_ready", False):
|
||||
return
|
||||
|
||||
self._popup_enum_type = ctypes.WINFUNCTYPE(
|
||||
ctypes.wintypes.BOOL, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM
|
||||
)
|
||||
self.user32.CreateWindowExW.argtypes = [
|
||||
ctypes.wintypes.DWORD,
|
||||
ctypes.c_wchar_p,
|
||||
ctypes.c_wchar_p,
|
||||
ctypes.wintypes.DWORD,
|
||||
ctypes.c_int,
|
||||
ctypes.c_int,
|
||||
ctypes.c_int,
|
||||
ctypes.c_int,
|
||||
ctypes.wintypes.HWND,
|
||||
ctypes.wintypes.HMENU,
|
||||
ctypes.wintypes.HINSTANCE,
|
||||
ctypes.c_void_p,
|
||||
]
|
||||
self.user32.CreateWindowExW.restype = ctypes.wintypes.HWND
|
||||
self.user32.DestroyWindow.argtypes = [ctypes.wintypes.HWND]
|
||||
self.user32.DestroyWindow.restype = ctypes.wintypes.BOOL
|
||||
self.user32.PeekMessageW.argtypes = [
|
||||
ctypes.POINTER(ctypes.wintypes.MSG),
|
||||
ctypes.wintypes.HWND,
|
||||
ctypes.c_uint,
|
||||
ctypes.c_uint,
|
||||
ctypes.c_uint,
|
||||
]
|
||||
self.user32.PeekMessageW.restype = ctypes.wintypes.BOOL
|
||||
self.user32.TranslateMessage.argtypes = [ctypes.POINTER(ctypes.wintypes.MSG)]
|
||||
self.user32.TranslateMessage.restype = ctypes.wintypes.BOOL
|
||||
self.user32.DispatchMessageW.argtypes = [ctypes.POINTER(ctypes.wintypes.MSG)]
|
||||
self.user32.DispatchMessageW.restype = ctypes.wintypes.LPARAM
|
||||
|
||||
for name in ("EnumWindows", "EnumChildWindows"):
|
||||
function = getattr(self.user32, name)
|
||||
function.argtypes = (
|
||||
[self._popup_enum_type, ctypes.wintypes.LPARAM]
|
||||
if name == "EnumWindows"
|
||||
else [ctypes.wintypes.HWND, self._popup_enum_type, ctypes.wintypes.LPARAM]
|
||||
)
|
||||
function.restype = ctypes.wintypes.BOOL
|
||||
|
||||
self._popup_get_style = getattr(
|
||||
self.user32, "GetWindowLongPtrW", self.user32.GetWindowLongW
|
||||
)
|
||||
self._popup_get_style.argtypes = [ctypes.wintypes.HWND, ctypes.c_int]
|
||||
self._popup_get_style.restype = ctypes.c_ssize_t
|
||||
|
||||
for name in ("IsWindow", "IsWindowVisible", "IsWindowEnabled", "IsIconic"):
|
||||
function = getattr(self.user32, name)
|
||||
function.argtypes = [ctypes.wintypes.HWND]
|
||||
function.restype = ctypes.wintypes.BOOL
|
||||
|
||||
self.user32.GetWindow.argtypes = [ctypes.wintypes.HWND, ctypes.c_uint]
|
||||
self.user32.GetWindow.restype = ctypes.wintypes.HWND
|
||||
self.user32.GetClientRect.argtypes = [ctypes.wintypes.HWND, ctypes.POINTER(RECT)]
|
||||
self.user32.GetClientRect.restype = ctypes.wintypes.BOOL
|
||||
|
||||
class WINDOWPLACEMENT(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("length", ctypes.wintypes.DWORD),
|
||||
("flags", ctypes.wintypes.DWORD),
|
||||
("showCmd", ctypes.wintypes.DWORD),
|
||||
("ptMinPosition", POINT),
|
||||
("ptMaxPosition", POINT),
|
||||
("rcNormalPosition", RECT),
|
||||
]
|
||||
|
||||
self._popup_placement_type = WINDOWPLACEMENT
|
||||
self.user32.GetWindowPlacement.argtypes = [
|
||||
ctypes.wintypes.HWND,
|
||||
ctypes.POINTER(WINDOWPLACEMENT),
|
||||
]
|
||||
self.user32.GetWindowPlacement.restype = ctypes.wintypes.BOOL
|
||||
self.user32.GetWindowThreadProcessId.argtypes = [
|
||||
ctypes.wintypes.HWND,
|
||||
ctypes.POINTER(ctypes.wintypes.DWORD),
|
||||
]
|
||||
self.user32.GetWindowThreadProcessId.restype = ctypes.wintypes.DWORD
|
||||
self.user32.GetWindowTextLengthW.argtypes = [ctypes.wintypes.HWND]
|
||||
self.user32.GetWindowTextLengthW.restype = ctypes.c_int
|
||||
self.user32.GetWindowTextW.argtypes = [ctypes.wintypes.HWND, ctypes.c_wchar_p, ctypes.c_int]
|
||||
self.user32.GetClassNameW.argtypes = [ctypes.wintypes.HWND, ctypes.c_wchar_p, ctypes.c_int]
|
||||
self.user32.ShowWindowAsync.argtypes = [ctypes.wintypes.HWND, ctypes.c_int]
|
||||
self.user32.ShowWindowAsync.restype = ctypes.wintypes.BOOL
|
||||
self.user32.SetPropW.argtypes = [
|
||||
ctypes.wintypes.HWND,
|
||||
ctypes.c_wchar_p,
|
||||
ctypes.wintypes.HANDLE,
|
||||
]
|
||||
self.user32.SetPropW.restype = ctypes.wintypes.BOOL
|
||||
self.user32.GetPropW.argtypes = [ctypes.wintypes.HWND, ctypes.c_wchar_p]
|
||||
self.user32.GetPropW.restype = ctypes.wintypes.HANDLE
|
||||
self.user32.RemovePropW.argtypes = [ctypes.wintypes.HWND, ctypes.c_wchar_p]
|
||||
self.user32.RemovePropW.restype = ctypes.wintypes.HANDLE
|
||||
|
||||
if hasattr(self.user32, "GetDpiForWindow"):
|
||||
self.user32.GetDpiForWindow.argtypes = [ctypes.wintypes.HWND]
|
||||
self.user32.GetDpiForWindow.restype = ctypes.c_uint
|
||||
|
||||
self._popup_api_ready = True
|
||||
|
||||
def _popup_window_snapshot(self, hwnd, process_info=None):
|
||||
if not self.user32.IsWindow(hwnd):
|
||||
return None
|
||||
|
||||
pid = ctypes.wintypes.DWORD()
|
||||
self.user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
||||
|
||||
if pid.value <= 4 or pid.value == self.pid_pyas:
|
||||
return None
|
||||
|
||||
process_info = {} if process_info is None else process_info
|
||||
|
||||
if pid.value not in process_info:
|
||||
process_info[pid.value] = self.get_exe_info(pid.value)
|
||||
|
||||
exe, path = process_info[pid.value]
|
||||
|
||||
if not exe or not path:
|
||||
return None
|
||||
|
||||
class_name = ctypes.create_unicode_buffer(256)
|
||||
|
||||
if not self.user32.GetClassNameW(hwnd, class_name, 256):
|
||||
return None
|
||||
|
||||
if any(
|
||||
item.get("exe") == exe or item.get("class") == class_name.value
|
||||
for item in self.pass_windows
|
||||
):
|
||||
return None
|
||||
|
||||
rect = RECT()
|
||||
|
||||
if not self.user32.GetClientRect(hwnd, ctypes.byref(rect)):
|
||||
self.user32.GetWindowRect(hwnd, ctypes.byref(rect))
|
||||
|
||||
if self.user32.IsIconic(hwnd):
|
||||
placement = self._popup_placement_type()
|
||||
placement.length = ctypes.sizeof(placement)
|
||||
|
||||
if self.user32.GetWindowPlacement(hwnd, ctypes.byref(placement)):
|
||||
rect = placement.rcNormalPosition
|
||||
|
||||
dpi = self.user32.GetDpiForWindow(hwnd) if hasattr(self.user32, "GetDpiForWindow") else 96
|
||||
dpi = dpi or 96
|
||||
classes = {}
|
||||
child_count = 0
|
||||
|
||||
def enum_child(child, parameter):
|
||||
nonlocal child_count
|
||||
child_count += 1
|
||||
|
||||
if child_count > 128:
|
||||
return False
|
||||
|
||||
name = ctypes.create_unicode_buffer(256)
|
||||
|
||||
if not self.user32.GetClassNameW(child, name, 256):
|
||||
child_count = 129
|
||||
return False
|
||||
|
||||
classes[name.value] = classes.get(name.value, 0) + 1
|
||||
return True
|
||||
|
||||
callback = self._popup_enum_type(enum_child)
|
||||
self.user32.EnumChildWindows(hwnd, callback, 0)
|
||||
children = (
|
||||
None
|
||||
if child_count > 128
|
||||
else [[name, count] for name, count in sorted(classes.items())]
|
||||
)
|
||||
owner_hwnd = self.user32.GetWindow(hwnd, 4)
|
||||
owner = None
|
||||
|
||||
if owner_hwnd:
|
||||
owner_pid = ctypes.wintypes.DWORD()
|
||||
self.user32.GetWindowThreadProcessId(owner_hwnd, ctypes.byref(owner_pid))
|
||||
|
||||
if owner_pid.value not in process_info:
|
||||
process_info[owner_pid.value] = self.get_exe_info(owner_pid.value)
|
||||
|
||||
_, owner_path = process_info[owner_pid.value]
|
||||
owner_class = ctypes.create_unicode_buffer(256)
|
||||
self.user32.GetClassNameW(owner_hwnd, owner_class, 256)
|
||||
owner = {"path": normalized_path(owner_path), "class": owner_class.value}
|
||||
|
||||
title = ctypes.create_unicode_buffer(min(self.user32.GetWindowTextLengthW(hwnd), 4096) + 1)
|
||||
self.user32.GetWindowTextW(hwnd, title, len(title))
|
||||
return {
|
||||
"hwnd": hwnd,
|
||||
"pid": pid.value,
|
||||
"exe": exe,
|
||||
"path": path,
|
||||
"title": title.value,
|
||||
"visible": bool(self.user32.IsWindowVisible(hwnd)),
|
||||
"minimized": bool(self.user32.IsIconic(hwnd)),
|
||||
"modal": bool(owner_hwnd and not self.user32.IsWindowEnabled(owner_hwnd)),
|
||||
"fingerprint": {
|
||||
"class": class_name.value,
|
||||
"style": self._popup_get_style(hwnd, -16) & 0x80CF0000,
|
||||
"exstyle": self._popup_get_style(hwnd, -20) & 0x08040088,
|
||||
"width": round((rect.right - rect.left) * 96 / dpi),
|
||||
"height": round((rect.bottom - rect.top) * 96 / dpi),
|
||||
"children": children,
|
||||
"owner": owner,
|
||||
},
|
||||
}
|
||||
|
||||
def _popup_windows(self, paths=None):
|
||||
self._init_popup_api()
|
||||
windows = []
|
||||
process_info = {}
|
||||
|
||||
def enum_window(hwnd, parameter):
|
||||
try:
|
||||
if paths is not None:
|
||||
pid = ctypes.wintypes.DWORD()
|
||||
self.user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
||||
|
||||
if pid.value not in process_info:
|
||||
process_info[pid.value] = self.get_exe_info(pid.value)
|
||||
|
||||
if normalized_path(process_info[pid.value][1]) not in paths:
|
||||
return True
|
||||
|
||||
snapshot = self._popup_window_snapshot(hwnd, process_info)
|
||||
|
||||
if snapshot:
|
||||
windows.append(snapshot)
|
||||
except Exception:
|
||||
log_exception("PYAS_Popup.PopupWindowMixin._popup_windows.enum_window:246")
|
||||
return True
|
||||
|
||||
return True
|
||||
|
||||
callback = self._popup_enum_type(enum_window)
|
||||
self.user32.EnumWindows(callback, 0)
|
||||
return windows
|
||||
|
||||
def add_popup_rule(self, rule):
|
||||
if not isinstance(rule, dict) or rule.get("schema_version") != 2 or not rule.get("id"):
|
||||
return False
|
||||
|
||||
with self.lock_config:
|
||||
pending = getattr(self, "_captured_popup_rules", {})
|
||||
captured = pending.get(rule["id"])
|
||||
|
||||
if captured != rule:
|
||||
return any(item == rule for item in self.pyas_config.get("block_list", []))
|
||||
|
||||
target_list = self.pyas_config.setdefault("block_list", [])
|
||||
duplicate = next(
|
||||
(
|
||||
item
|
||||
for item in target_list
|
||||
if isinstance(item, dict)
|
||||
and item.get("schema_version") == 2
|
||||
and normalized_path(item.get("path")) == normalized_path(rule.get("path"))
|
||||
and item.get("fingerprint") == rule.get("fingerprint")
|
||||
and item.get("title") == rule.get("title")
|
||||
and item.get("match_mode") == rule.get("match_mode")
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if duplicate:
|
||||
selected = getattr(self, "_popup_selected", {}).pop(rule["id"], None)
|
||||
|
||||
if selected:
|
||||
self._popup_selected[duplicate["id"]] = selected
|
||||
else:
|
||||
target_list.append(copy.deepcopy(captured))
|
||||
self.save_config()
|
||||
self.write_log(
|
||||
"INFO",
|
||||
"Config Update",
|
||||
detail=f"Popup rule added: {rule['exe']} [{rule['id']}]",
|
||||
)
|
||||
|
||||
pending.pop(rule["id"], None)
|
||||
return True
|
||||
|
||||
def _popup_matches(self, rule, target, windows):
|
||||
with self.lock_config:
|
||||
selected = getattr(self, "_popup_selected", {}).get(rule.get("id"))
|
||||
|
||||
if (
|
||||
selected
|
||||
and selected["hwnd"] == target.get("hwnd")
|
||||
and selected["pid"] == target.get("pid")
|
||||
):
|
||||
if self.user32.GetPropW(selected["hwnd"], "PYAS_Popup_Selected") == selected["token"]:
|
||||
return normalized_path(rule.get("path")) == normalized_path(target.get("path"))
|
||||
|
||||
return popup_rule_matches(rule, target, windows)
|
||||
|
||||
def remove_popup_rules(self, identifiers):
|
||||
with self.lock_config:
|
||||
rules = self.pyas_config.get("block_list", [])
|
||||
identifiers = set(identifiers or [])
|
||||
remaining = [
|
||||
rule
|
||||
for index, rule in enumerate(rules)
|
||||
if (rule.get("id") or f"legacy:{index}") not in identifiers
|
||||
]
|
||||
|
||||
if len(remaining) == len(rules):
|
||||
return False
|
||||
|
||||
selected = getattr(self, "_popup_selected", {})
|
||||
|
||||
for identifier in identifiers:
|
||||
record = selected.pop(identifier, None)
|
||||
|
||||
if (
|
||||
record
|
||||
and self.user32.GetPropW(record["hwnd"], "PYAS_Popup_Selected")
|
||||
== record["token"]
|
||||
):
|
||||
self.user32.RemovePropW(record["hwnd"], "PYAS_Popup_Selected")
|
||||
|
||||
self.pyas_config["block_list"] = remaining
|
||||
self.write_log(
|
||||
"INFO",
|
||||
"Config Update",
|
||||
detail=f"Popup rules removed: {len(rules) - len(remaining)}",
|
||||
)
|
||||
self.save_config()
|
||||
return True
|
||||
|
||||
def _restore_popup_windows(self, hidden, active_ids):
|
||||
for hwnd, record in list(hidden.items()):
|
||||
if self.user32.GetPropW(hwnd, "PYAS_Popup_Blocker") != record["token"]:
|
||||
hidden.pop(hwnd, None)
|
||||
elif record["rule_id"] not in active_ids:
|
||||
self.user32.RemovePropW(hwnd, "PYAS_Popup_Blocker")
|
||||
self.user32.ShowWindowAsync(hwnd, 4)
|
||||
hidden.pop(hwnd, None)
|
||||
|
||||
def _hide_popup_window(self, rule, target, windows, hidden):
|
||||
hwnd = target["hwnd"]
|
||||
current = self._popup_window_snapshot(hwnd)
|
||||
|
||||
if (
|
||||
not current
|
||||
or current["pid"] != target["pid"]
|
||||
or not self._popup_matches(rule, current, windows)
|
||||
):
|
||||
return False
|
||||
|
||||
record = hidden.get(hwnd)
|
||||
new_record = (
|
||||
not record or self.user32.GetPropW(hwnd, "PYAS_Popup_Blocker") != record["token"]
|
||||
)
|
||||
|
||||
if new_record:
|
||||
token = (time.monotonic_ns() & 0x7FFFFFFF) or 1
|
||||
|
||||
if not self.user32.SetPropW(hwnd, "PYAS_Popup_Blocker", token):
|
||||
return False
|
||||
|
||||
record = {"token": token, "rule_id": rule["id"]}
|
||||
hidden[hwnd] = record
|
||||
|
||||
if not self.user32.ShowWindowAsync(hwnd, 0):
|
||||
if new_record:
|
||||
self.user32.RemovePropW(hwnd, "PYAS_Popup_Blocker")
|
||||
hidden.pop(hwnd, None)
|
||||
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def popup_intercept_thread(self):
|
||||
hidden = {}
|
||||
last_warning = {}
|
||||
|
||||
while True:
|
||||
try:
|
||||
time.sleep(0.5)
|
||||
self._init_popup_api()
|
||||
|
||||
with self.lock_config:
|
||||
rules = copy.deepcopy(self.pyas_config.get("block_list", []))
|
||||
|
||||
valid_rules = []
|
||||
|
||||
for index, rule in enumerate(rules):
|
||||
if isinstance(rule, dict) and (
|
||||
rule.get("schema_version") == 2
|
||||
and rule.get("id")
|
||||
or rule.get("exe")
|
||||
and rule.get("class")
|
||||
and rule.get("title")
|
||||
):
|
||||
rule.setdefault("id", f"legacy:{index}")
|
||||
valid_rules.append(rule)
|
||||
|
||||
self._restore_popup_windows(hidden, {rule["id"] for rule in valid_rules})
|
||||
|
||||
if not valid_rules:
|
||||
continue
|
||||
|
||||
paths = {normalized_path(rule.get("path")) for rule in valid_rules}
|
||||
windows = self._popup_windows(paths if all(paths) else None)
|
||||
|
||||
for target in windows:
|
||||
if not target.get("visible") or target.get("minimized"):
|
||||
continue
|
||||
|
||||
for rule in valid_rules:
|
||||
if self._popup_matches(rule, target, windows):
|
||||
first_hide = target["hwnd"] not in hidden
|
||||
|
||||
if self._hide_popup_window(rule, target, windows, hidden):
|
||||
if first_hide:
|
||||
self.write_log(
|
||||
"INFO",
|
||||
"Popup Blocker",
|
||||
source=target["path"],
|
||||
detail=f"Hidden popup window: {target['title']}",
|
||||
pid=target["pid"],
|
||||
)
|
||||
elif first_hide:
|
||||
key = (rule["id"], target["hwnd"], target["pid"])
|
||||
now = time.monotonic()
|
||||
|
||||
if now - last_warning.get(key, -30) >= 30:
|
||||
self.write_log(
|
||||
"WARN",
|
||||
"Popup Blocker",
|
||||
source=target["path"],
|
||||
detail="Unable to hide selected window",
|
||||
pid=target["pid"],
|
||||
success=False,
|
||||
)
|
||||
last_warning[key] = now
|
||||
|
||||
break
|
||||
except Exception as e:
|
||||
log_exception("PYAS_Popup.PopupWindowMixin.popup_intercept_thread:369")
|
||||
now = time.monotonic()
|
||||
|
||||
if now - last_warning.get("error", -30) >= 30:
|
||||
self.write_log("WARN", "Popup Blocker", detail=str(e), success=False)
|
||||
last_warning["error"] = now
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
from PYAS_Diagnostics import log_exception
|
||||
import os
|
||||
import re
|
||||
import io
|
||||
import time
|
||||
import ctypes
|
||||
import ctypes.wintypes
|
||||
from PYAS_WinAPI import (
|
||||
IO_COUNTERS,
|
||||
MIB_TCPROW_OWNER_PID,
|
||||
PROCESSENTRY32W,
|
||||
PROCESS_BASIC_INFORMATION,
|
||||
PYAS_PROCESS_TERMINATE_ACCESS,
|
||||
UNICODE_STRING,
|
||||
)
|
||||
|
||||
|
||||
class ProcessMixin:
|
||||
def device_path_to_drive(self, path):
|
||||
if not path:
|
||||
return ""
|
||||
|
||||
for d in range(65, 91):
|
||||
drive = f"{chr(d)}:"
|
||||
buf = ctypes.create_unicode_buffer(1024)
|
||||
|
||||
if self.kernel32.QueryDosDeviceW(drive, buf, 1024) and path.startswith(buf.value):
|
||||
return path.replace(buf.value, drive, 1)
|
||||
|
||||
return path
|
||||
|
||||
def get_exe_info(self, pid):
|
||||
name, file_path = None, None
|
||||
h = self.kernel32.OpenProcess(0x1000, False, pid)
|
||||
|
||||
if h:
|
||||
try:
|
||||
buf = ctypes.create_unicode_buffer(1024)
|
||||
size = ctypes.wintypes.DWORD(1024)
|
||||
|
||||
if self.kernel32.QueryFullProcessImageNameW(h, 0, buf, ctypes.byref(size)):
|
||||
file_path = self.norm_path(buf.value)
|
||||
|
||||
if file_path:
|
||||
name = os.path.basename(file_path)
|
||||
|
||||
elif self.psapi.GetProcessImageFileNameW(h, buf, 1024):
|
||||
file_path = self.norm_path(self.device_path_to_drive(buf.value))
|
||||
|
||||
if file_path:
|
||||
name = os.path.basename(file_path)
|
||||
|
||||
finally:
|
||||
self.kernel32.CloseHandle(h)
|
||||
|
||||
return name, file_path
|
||||
|
||||
def get_process_file(self, h_process):
|
||||
buf = ctypes.create_unicode_buffer(1024)
|
||||
|
||||
if self.psapi.GetProcessImageFileNameW(h_process, buf, 1024):
|
||||
return self.norm_path(self.device_path_to_drive(buf.value))
|
||||
|
||||
return ""
|
||||
|
||||
def get_process_cmdline(self, h):
|
||||
try:
|
||||
pbi = PROCESS_BASIC_INFORMATION()
|
||||
returned = ctypes.wintypes.ULONG()
|
||||
|
||||
if (
|
||||
self.ntdll.NtQueryInformationProcess(
|
||||
h, 0, ctypes.byref(pbi), ctypes.sizeof(pbi), ctypes.byref(returned)
|
||||
)
|
||||
!= 0
|
||||
):
|
||||
return ""
|
||||
|
||||
if returned.value != ctypes.sizeof(pbi) or not pbi.PebBaseAddress:
|
||||
return ""
|
||||
|
||||
def read_exact(address, destination):
|
||||
transferred = ctypes.c_size_t()
|
||||
capacity = ctypes.sizeof(destination)
|
||||
return (
|
||||
bool(
|
||||
self.kernel32.ReadProcessMemory(
|
||||
h,
|
||||
ctypes.c_void_p(address),
|
||||
ctypes.byref(destination),
|
||||
capacity,
|
||||
ctypes.byref(transferred),
|
||||
)
|
||||
)
|
||||
and transferred.value == capacity
|
||||
)
|
||||
|
||||
pointer_size = ctypes.sizeof(ctypes.c_void_p)
|
||||
parameters = ctypes.c_void_p()
|
||||
|
||||
if (
|
||||
not read_exact(
|
||||
int(pbi.PebBaseAddress) + (0x20 if pointer_size == 8 else 0x10), parameters
|
||||
)
|
||||
or not parameters.value
|
||||
):
|
||||
return ""
|
||||
|
||||
descriptor = UNICODE_STRING()
|
||||
|
||||
if not read_exact(parameters.value + (0x70 if pointer_size == 8 else 0x40), descriptor):
|
||||
return ""
|
||||
|
||||
length = int(descriptor.Length)
|
||||
|
||||
if not descriptor.Buffer or not length:
|
||||
return ""
|
||||
|
||||
if length % 2 or length > descriptor.MaximumLength:
|
||||
raise ValueError("Invalid remote UTF-16 command-line length")
|
||||
|
||||
buffer = (ctypes.c_ubyte * length)()
|
||||
|
||||
if not read_exact(int(descriptor.Buffer), buffer):
|
||||
return ""
|
||||
|
||||
return bytes(buffer).decode("utf-16-le", errors="surrogatepass")
|
||||
except Exception:
|
||||
log_exception("ProcessMixin.get_process_cmdline")
|
||||
return ""
|
||||
|
||||
def extract_paths_from_cmdline(self, cmdline):
|
||||
if not cmdline:
|
||||
return []
|
||||
|
||||
found = []
|
||||
|
||||
for m in re.finditer(r'"([^"]+)"|\'([^\']+)\'', cmdline):
|
||||
path = m.group(1) or m.group(2)
|
||||
|
||||
if re.match(r"^[A-Za-z]:\\|^\\\\", path):
|
||||
found.append(path)
|
||||
|
||||
if not found:
|
||||
m = re.search(
|
||||
r'([A-Za-z]:\\[^\*?"<>\|]+\.(?:exe|dll|bat|cmd|vbs|sys|com|pif))',
|
||||
cmdline,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
if m:
|
||||
found.append(m.group(1))
|
||||
|
||||
if not found:
|
||||
argc = ctypes.c_int(0)
|
||||
argv = self.shell32.CommandLineToArgvW(cmdline, ctypes.byref(argc))
|
||||
|
||||
if argv:
|
||||
args = [argv[i] for i in range(argc.value)]
|
||||
self.kernel32.LocalFree(ctypes.cast(argv, ctypes.c_void_p))
|
||||
patterns = [
|
||||
r'([A-Za-z]:\\[^"\']+)',
|
||||
r'(\\\\[^"\']+)',
|
||||
r'(\.\\[^"\']+)',
|
||||
r'(\./[^"\']+)',
|
||||
r'([A-Za-z]:/[^"\']+)',
|
||||
r"([^\s]*\\[^\s]+)",
|
||||
]
|
||||
|
||||
for arg in args:
|
||||
for p in patterns:
|
||||
for match in re.finditer(p, arg):
|
||||
found.append(match.group(1).strip('"').strip("'"))
|
||||
|
||||
return list(dict.fromkeys([p.strip('"').strip("'") for p in found]))
|
||||
|
||||
def _enum_processes(self):
|
||||
pe = PROCESSENTRY32W()
|
||||
pe.dwSize = ctypes.sizeof(PROCESSENTRY32W)
|
||||
snapshot = self.kernel32.CreateToolhelp32Snapshot(0x00000002, 0)
|
||||
|
||||
if snapshot in (-1, 0xFFFFFFFF, 0xFFFFFFFFFFFFFFFF):
|
||||
return
|
||||
|
||||
try:
|
||||
if self.kernel32.Process32FirstW(snapshot, ctypes.byref(pe)):
|
||||
while True:
|
||||
yield pe.th32ProcessID, pe.szExeFile
|
||||
|
||||
if not self.kernel32.Process32NextW(snapshot, ctypes.byref(pe)):
|
||||
break
|
||||
finally:
|
||||
self.kernel32.CloseHandle(snapshot)
|
||||
|
||||
def get_process_list(self):
|
||||
result = []
|
||||
|
||||
for pid, exe_name in self._enum_processes():
|
||||
name, file_path = None, None
|
||||
|
||||
if pid > 4:
|
||||
name, file_path = self.get_exe_info(pid)
|
||||
|
||||
if not name:
|
||||
try:
|
||||
name = exe_name
|
||||
except Exception:
|
||||
log_exception("PYAS_Process.ProcessMixin.get_process_list:140")
|
||||
name = "Unknown"
|
||||
|
||||
result.append({"pid": pid, "name": name, "path": file_path or "None"})
|
||||
|
||||
return result
|
||||
|
||||
def get_process_list_pids(self):
|
||||
return {pid for pid, _ in self._enum_processes()}
|
||||
|
||||
def kill_process(self, pid, expected_path=None):
|
||||
try:
|
||||
h = self.kernel32.OpenProcess(PYAS_PROCESS_TERMINATE_ACCESS, False, pid)
|
||||
|
||||
if h:
|
||||
try:
|
||||
return self._terminate_process_handle(h, pid=pid, expected_path=expected_path)
|
||||
finally:
|
||||
self.kernel32.CloseHandle(h)
|
||||
|
||||
except Exception as e:
|
||||
log_exception("PYAS_Process.ProcessMixin.kill_process:159")
|
||||
self.write_log(
|
||||
"WARN", "kill_process", pid=pid, detail=str(e), operate=True, success=False
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
def get_connections_list(self):
|
||||
connections = set()
|
||||
|
||||
try:
|
||||
size = ctypes.wintypes.DWORD()
|
||||
|
||||
if self.iphlpapi.GetExtendedTcpTable(None, ctypes.byref(size), True, 2, 5, 0) != 122:
|
||||
return connections
|
||||
|
||||
buf = ctypes.create_string_buffer(size.value)
|
||||
|
||||
if self.iphlpapi.GetExtendedTcpTable(buf, ctypes.byref(size), True, 2, 5, 0) != 0:
|
||||
return connections
|
||||
|
||||
num_entries = ctypes.cast(buf, ctypes.POINTER(ctypes.wintypes.DWORD)).contents.value
|
||||
|
||||
for i in range(num_entries):
|
||||
row = MIB_TCPROW_OWNER_PID.from_address(
|
||||
ctypes.addressof(buf)
|
||||
+ ctypes.sizeof(ctypes.wintypes.DWORD)
|
||||
+ i * ctypes.sizeof(MIB_TCPROW_OWNER_PID)
|
||||
)
|
||||
connections.add((row.dwOwningPid, row.dwRemoteAddr, row.dwRemotePort))
|
||||
|
||||
except Exception:
|
||||
log_exception("PYAS_Process.ProcessMixin.get_connections_list:179")
|
||||
pass
|
||||
|
||||
return connections
|
||||
|
||||
def get_traffic_list(self):
|
||||
conns = self.get_connections_list()
|
||||
conn_map = {}
|
||||
|
||||
for pid, _, _ in conns:
|
||||
conn_map[pid] = conn_map.get(pid, 0) + 1
|
||||
|
||||
current_io = {}
|
||||
current_time = time.time()
|
||||
|
||||
with self.lock_io:
|
||||
time_diff = current_time - self.last_io_time
|
||||
|
||||
if time_diff <= 0:
|
||||
time_diff = 1
|
||||
|
||||
self.last_io_time = current_time
|
||||
old_counters = self.last_io_counters.copy()
|
||||
|
||||
result = []
|
||||
exist_process = self.get_process_list_pids()
|
||||
|
||||
for pid in exist_process:
|
||||
name, file_path = "Unknown", ""
|
||||
|
||||
if pid > 4:
|
||||
name, file_path = self.get_exe_info(pid)
|
||||
else:
|
||||
name = "System"
|
||||
|
||||
down_speed, up_speed = 0, 0
|
||||
h = self.kernel32.OpenProcess(0x1000, False, pid)
|
||||
|
||||
if h:
|
||||
try:
|
||||
io = IO_COUNTERS()
|
||||
|
||||
if self.kernel32.GetProcessIoCounters(h, ctypes.byref(io)):
|
||||
current_io[pid] = (io.ReadTransferCount, io.WriteTransferCount)
|
||||
|
||||
if pid in old_counters:
|
||||
old_read, old_write = old_counters[pid]
|
||||
down_speed = max(0, (io.ReadTransferCount - old_read) / time_diff)
|
||||
up_speed = max(0, (io.WriteTransferCount - old_write) / time_diff)
|
||||
|
||||
finally:
|
||||
self.kernel32.CloseHandle(h)
|
||||
|
||||
count = conn_map.get(pid, 0)
|
||||
|
||||
if count > 0 or down_speed > 0 or up_speed > 0:
|
||||
result.append(
|
||||
{
|
||||
"pid": pid,
|
||||
"name": name,
|
||||
"path": file_path or "None",
|
||||
"down": int(down_speed),
|
||||
"up": int(up_speed),
|
||||
"conn": count,
|
||||
}
|
||||
)
|
||||
|
||||
with self.lock_io:
|
||||
self.last_io_counters = current_io
|
||||
|
||||
return result
|
||||
|
||||
def _terminate_process_handle(self, handle, pid=None, expected_path=None):
|
||||
file_path = self.norm_path(self.get_process_file(handle))
|
||||
|
||||
if self.path_equal(file_path, self.file_pyas):
|
||||
return False
|
||||
|
||||
if expected_path and not self.path_equal(file_path, expected_path):
|
||||
return False
|
||||
|
||||
return bool(self.kernel32.TerminateProcess(handle, 0))
|
||||
+134
-1942
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,555 @@
|
||||
from PYAS_WinAPI import iter_file_notifications
|
||||
from PYAS_Scheduler import TaskScheduler
|
||||
from PYAS_Diagnostics import log_exception
|
||||
from PYAS_Diagnostics import observe_future
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
import ctypes
|
||||
import ctypes.wintypes
|
||||
from PYAS_WinAPI import FILE_NOTIFY_INFORMATION, MEMORY_BASIC_INFORMATION
|
||||
from PYAS_WinAPI import (
|
||||
ERROR_LOCK_VIOLATION,
|
||||
ERROR_SHARING_VIOLATION,
|
||||
FILE_ATTRIBUTE_NORMAL,
|
||||
FILE_SCAN_DEBOUNCE_SECONDS,
|
||||
FILE_SCAN_RETRY_SECONDS,
|
||||
FILE_SHARE_READ,
|
||||
GENERIC_READ,
|
||||
INVALID_HANDLE_VALUE,
|
||||
OPEN_EXISTING,
|
||||
PYAS_PROCESS_NETWORK_ACCESS,
|
||||
PYAS_PROCESS_SCAN_ACCESS,
|
||||
)
|
||||
|
||||
|
||||
class RealtimeMixin:
|
||||
def protect_proc_thread(self):
|
||||
with self.lock_proc:
|
||||
self.exist_process = self.get_process_list_pids()
|
||||
|
||||
while True:
|
||||
with self.lock_config:
|
||||
if not self.pyas_config.get("process_switch", False):
|
||||
break
|
||||
|
||||
try:
|
||||
time.sleep(0.1)
|
||||
cur = self.get_process_list_pids()
|
||||
|
||||
with self.lock_proc:
|
||||
new_pids = cur - self.exist_process
|
||||
self.exist_process = cur
|
||||
|
||||
for pid in new_pids:
|
||||
h = self.kernel32.OpenProcess(PYAS_PROCESS_SCAN_ACCESS, False, pid)
|
||||
|
||||
if h:
|
||||
with self.lock_config:
|
||||
should_suspend = self.pyas_config.get("suspend_switch", True)
|
||||
|
||||
if should_suspend:
|
||||
self.ntdll.NtSuspendProcess(h)
|
||||
|
||||
with self.lock_proc:
|
||||
self.suspended_procs.add(h)
|
||||
|
||||
try:
|
||||
observe_future(
|
||||
self.proc_pool.submit(
|
||||
self.handle_new_process, pid, h, should_suspend
|
||||
),
|
||||
"RealtimeMixin.handle_new_process",
|
||||
)
|
||||
|
||||
except Exception:
|
||||
log_exception("PYAS_Realtime.RealtimeMixin.protect_proc_thread:39")
|
||||
|
||||
if should_suspend:
|
||||
self.ntdll.NtResumeProcess(h)
|
||||
|
||||
with self.lock_proc:
|
||||
self.suspended_procs.discard(h)
|
||||
|
||||
self.kernel32.CloseHandle(h)
|
||||
except Exception as e:
|
||||
log_exception("PYAS_Realtime.RealtimeMixin.protect_proc_thread:46")
|
||||
self.write_log("WARN", "protect_proc_thread", detail=str(e), success=False)
|
||||
|
||||
def handle_new_process(self, pid, h=None, suspended=None):
|
||||
if suspended is None:
|
||||
with self.lock_config:
|
||||
suspended = self.pyas_config.get("suspend_switch", True)
|
||||
|
||||
if not h:
|
||||
h = self.kernel32.OpenProcess(PYAS_PROCESS_SCAN_ACCESS, False, pid)
|
||||
|
||||
if not h:
|
||||
return
|
||||
|
||||
if suspended:
|
||||
self.ntdll.NtSuspendProcess(h)
|
||||
|
||||
with self.lock_proc:
|
||||
if not hasattr(self, "suspended_procs"):
|
||||
self.suspended_procs = set()
|
||||
|
||||
self.suspended_procs.add(h)
|
||||
|
||||
try:
|
||||
cmdline = self.get_process_cmdline(h)
|
||||
process_file = self.get_process_file(h)
|
||||
|
||||
if "-scan" in cmdline and self.path_equal(process_file, self.file_pyas):
|
||||
return
|
||||
|
||||
raw_targets = self.extract_paths_from_cmdline(cmdline)
|
||||
|
||||
if process_file:
|
||||
raw_targets.append(process_file)
|
||||
|
||||
all_targets = []
|
||||
|
||||
for p in raw_targets:
|
||||
np = self.norm_path(self.device_path_to_drive(p))
|
||||
|
||||
if np and np not in all_targets:
|
||||
all_targets.append(np)
|
||||
|
||||
with self.lock_config:
|
||||
ext_filter = self.pyas_config.get("suffix_switch", True)
|
||||
suffix = self.pyas_config.get("suffix", [])
|
||||
load_switch = self.pyas_config.get("load_switch", True)
|
||||
|
||||
scan_targets = []
|
||||
|
||||
for file_path in all_targets:
|
||||
if not file_path or not os.path.isfile(file_path):
|
||||
continue
|
||||
|
||||
if ext_filter and os.path.splitext(file_path)[-1].lower() not in suffix:
|
||||
continue
|
||||
|
||||
if self.is_in_whitelist(file_path):
|
||||
continue
|
||||
|
||||
if (
|
||||
process_file
|
||||
and process_file.lower().endswith("explorer.exe")
|
||||
and "/select" in cmdline.lower()
|
||||
):
|
||||
continue
|
||||
|
||||
scan_targets.append(file_path)
|
||||
|
||||
if not scan_targets:
|
||||
return
|
||||
|
||||
virus_found = False
|
||||
|
||||
for file_path in scan_targets:
|
||||
result = self.safe_scan_engine(file_path)
|
||||
self.cloud_check(file_path)
|
||||
|
||||
if result:
|
||||
self._terminate_process_handle(h, pid=pid)
|
||||
self.write_log(
|
||||
"BLOCK",
|
||||
"Process Block",
|
||||
pid=pid,
|
||||
source=file_path,
|
||||
file_hash=self.calc_file_hash(file_path),
|
||||
)
|
||||
virus_found = True
|
||||
break
|
||||
|
||||
if not virus_found and load_switch:
|
||||
hidden_virus_path = self.scan_process_memory(pid, h)
|
||||
|
||||
if hidden_virus_path:
|
||||
self._terminate_process_handle(h, pid=pid)
|
||||
self.write_log(
|
||||
"BLOCK",
|
||||
"Process DLL Block",
|
||||
pid=pid,
|
||||
source=hidden_virus_path,
|
||||
file_hash=self.calc_file_hash(hidden_virus_path),
|
||||
)
|
||||
|
||||
finally:
|
||||
if suspended:
|
||||
try:
|
||||
self.ntdll.NtResumeProcess(h)
|
||||
except Exception:
|
||||
log_exception("PYAS_Realtime.RealtimeMixin.handle_new_process:123")
|
||||
pass
|
||||
|
||||
with self.lock_proc:
|
||||
self.suspended_procs.discard(h)
|
||||
|
||||
try:
|
||||
self.kernel32.CloseHandle(h)
|
||||
except Exception:
|
||||
log_exception("PYAS_Realtime.RealtimeMixin.handle_new_process:129")
|
||||
pass
|
||||
|
||||
def scan_process_memory(self, pid, h_process):
|
||||
scanned_paths = set()
|
||||
address = 0
|
||||
mbi = MEMORY_BASIC_INFORMATION()
|
||||
system_dir = self.path_system.lower()
|
||||
|
||||
buf = ctypes.create_unicode_buffer(1024)
|
||||
max_address = 0x7FFFFFFFFFFF if ctypes.sizeof(ctypes.c_void_p) == 8 else 0x7FFFFFFF
|
||||
|
||||
with self.lock_config:
|
||||
ext_filter = self.pyas_config.get("suffix_switch", True)
|
||||
suffix = self.pyas_config.get("suffix", [])
|
||||
|
||||
try:
|
||||
while address < max_address 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 file_path not in scanned_paths
|
||||
and self.path_system not in file_path
|
||||
):
|
||||
|
||||
scanned_paths.add(file_path)
|
||||
file_path_lower = file_path.lower()
|
||||
|
||||
if not file_path_lower.startswith(
|
||||
system_dir
|
||||
) and not self.is_in_whitelist(file_path):
|
||||
ext = os.path.splitext(file_path_lower)[-1]
|
||||
|
||||
if ext != ".exe" and (not ext_filter or ext in suffix):
|
||||
if self.safe_scan_engine(file_path):
|
||||
return file_path
|
||||
|
||||
self.cloud_check(file_path)
|
||||
|
||||
if mbi.RegionSize == 0:
|
||||
break
|
||||
|
||||
address += mbi.RegionSize
|
||||
|
||||
finally:
|
||||
scanned_paths.clear()
|
||||
|
||||
return None
|
||||
|
||||
def protect_file_thread(self):
|
||||
with self.lock_file_ops:
|
||||
if getattr(self, "h_dir_file", None):
|
||||
return
|
||||
|
||||
hDir = self.kernel32.CreateFileW(
|
||||
self.path_user, 0x0001, 0x00000007, None, 3, 0x02000000, None
|
||||
)
|
||||
|
||||
if not hDir or hDir == -1:
|
||||
return
|
||||
|
||||
with self.lock_file_ops:
|
||||
self.h_dir_file = hDir
|
||||
|
||||
try:
|
||||
buffer = ctypes.create_string_buffer(262144)
|
||||
temp_prefix = os.path.normcase(self.path_temp)
|
||||
|
||||
if not temp_prefix.endswith(os.sep):
|
||||
temp_prefix += os.sep
|
||||
|
||||
while True:
|
||||
with self.lock_config:
|
||||
if not self.pyas_config.get("document_switch", False):
|
||||
break
|
||||
|
||||
try:
|
||||
bytes_returned = ctypes.wintypes.DWORD()
|
||||
res = self.kernel32.ReadDirectoryChangesW(
|
||||
self.h_dir_file,
|
||||
buffer,
|
||||
ctypes.sizeof(buffer),
|
||||
True,
|
||||
0x0000001F,
|
||||
ctypes.byref(bytes_returned),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
|
||||
if not res:
|
||||
raise OSError(ctypes.get_last_error(), "Directory watcher read failed")
|
||||
|
||||
if bytes_returned.value == 0:
|
||||
self._schedule_file_task(
|
||||
"recovery", self.path_user, lambda _: self._recover_file_tasks(), 0
|
||||
)
|
||||
continue
|
||||
|
||||
for action, raw_filename in iter_file_notifications(
|
||||
buffer, bytes_returned.value
|
||||
):
|
||||
if raw_filename and action in [1, 3, 5]:
|
||||
file_path = self.norm_path(
|
||||
os.path.join(self.path_user, raw_filename), must_exist=True
|
||||
)
|
||||
|
||||
if file_path and not self.is_in_whitelist(file_path):
|
||||
norm_path = os.path.normcase(file_path)
|
||||
|
||||
if not (
|
||||
norm_path.startswith(temp_prefix)
|
||||
and norm_path[len(temp_prefix) :].startswith("_mei")
|
||||
):
|
||||
with self.lock_config:
|
||||
ext_filter = self.pyas_config.get("suffix_switch", True)
|
||||
suffix = self.pyas_config.get("suffix", [])
|
||||
|
||||
if (
|
||||
not ext_filter
|
||||
or os.path.splitext(file_path)[-1].lower() in suffix
|
||||
):
|
||||
self._queue_file_scan(file_path)
|
||||
|
||||
except Exception:
|
||||
log_exception("PYAS_Realtime.RealtimeMixin.protect_file_thread:230")
|
||||
|
||||
with self.lock_config:
|
||||
enabled = self.pyas_config.get("document_switch", False)
|
||||
|
||||
if not enabled or self.closing:
|
||||
break
|
||||
|
||||
self._schedule_file_task(
|
||||
"recovery", self.path_user, lambda _: self._recover_file_tasks(), 0
|
||||
)
|
||||
time.sleep(0.25)
|
||||
finally:
|
||||
with self.lock_file_ops:
|
||||
if getattr(self, "h_dir_file", None):
|
||||
try:
|
||||
self.kernel32.CloseHandle(self.h_dir_file)
|
||||
except Exception:
|
||||
log_exception("PYAS_Realtime.RealtimeMixin.protect_file_thread:237")
|
||||
pass
|
||||
|
||||
self.h_dir_file = None
|
||||
|
||||
def _cancel_pending_file_tasks(self, task_name=None, target_path=None):
|
||||
scheduler = getattr(self, "file_scheduler", None)
|
||||
|
||||
if scheduler is None:
|
||||
return
|
||||
|
||||
target = os.path.normcase(target_path) if target_path else None
|
||||
prefix = target + os.sep if target else None
|
||||
scheduler.cancel(
|
||||
lambda key: (not task_name or key[0] == task_name)
|
||||
and (not target or key[1] == target or key[1].startswith(prefix))
|
||||
)
|
||||
|
||||
def _cancel_pending_file_scans(self):
|
||||
self._cancel_pending_file_tasks("scan")
|
||||
|
||||
def _schedule_file_task(self, task_name, file_path, callback, delay):
|
||||
with self.lock_file_ops:
|
||||
if self.closing:
|
||||
return False
|
||||
|
||||
if getattr(self, "file_scheduler", None) is None:
|
||||
self.file_scheduler = TaskScheduler(self._recover_file_tasks)
|
||||
|
||||
scheduler = self.file_scheduler
|
||||
|
||||
return scheduler.schedule(
|
||||
(task_name, os.path.normcase(file_path)),
|
||||
delay,
|
||||
callback,
|
||||
(file_path,),
|
||||
critical=task_name in ("delete", "lock", "verify"),
|
||||
)
|
||||
|
||||
def _recover_file_tasks(self):
|
||||
self.write_log(
|
||||
"WARN",
|
||||
"File Event Recovery",
|
||||
detail="Pending task capacity reached; recovering by rescan",
|
||||
success=False,
|
||||
)
|
||||
|
||||
with self.lock_config:
|
||||
quarantined = list(self.pyas_config.get("quarantine", []))
|
||||
|
||||
for entry in quarantined:
|
||||
if self.closing:
|
||||
return
|
||||
|
||||
path = entry.get("file") if isinstance(entry, dict) else entry
|
||||
|
||||
if path:
|
||||
self.lock_file(path, True, quiet=True)
|
||||
|
||||
for file_path, _ in self.yield_files(self.path_user):
|
||||
with self.lock_config:
|
||||
enabled = self.pyas_config.get("document_switch", False)
|
||||
ext_filter = self.pyas_config.get("suffix_switch", True)
|
||||
suffix = self.pyas_config.get("suffix", [])
|
||||
|
||||
if self.closing or not enabled:
|
||||
break
|
||||
|
||||
if self.is_in_whitelist(file_path):
|
||||
continue
|
||||
|
||||
norm_path = os.path.normcase(file_path)
|
||||
temp_prefix = os.path.normcase(self.path_temp).rstrip(os.sep) + os.sep
|
||||
|
||||
if norm_path.startswith(temp_prefix) and norm_path[len(temp_prefix) :].startswith(
|
||||
"_mei"
|
||||
):
|
||||
continue
|
||||
|
||||
if not ext_filter or os.path.splitext(norm_path)[-1] in suffix:
|
||||
self.handle_new_file(file_path)
|
||||
|
||||
def _queue_file_scan(self, file_path, delay=FILE_SCAN_DEBOUNCE_SECONDS):
|
||||
self._schedule_file_task("scan", file_path, self.handle_new_file, delay)
|
||||
|
||||
def _acquire_file_scan_gate(self, file_path):
|
||||
ctypes.set_last_error(0)
|
||||
handle = self.kernel32.CreateFileW(
|
||||
file_path,
|
||||
GENERIC_READ,
|
||||
FILE_SHARE_READ,
|
||||
None,
|
||||
OPEN_EXISTING,
|
||||
FILE_ATTRIBUTE_NORMAL,
|
||||
None,
|
||||
)
|
||||
|
||||
if not handle or handle == INVALID_HANDLE_VALUE:
|
||||
return None, ctypes.get_last_error()
|
||||
|
||||
return handle, 0
|
||||
|
||||
def handle_new_file(self, file_path):
|
||||
try:
|
||||
with self.lock_config:
|
||||
if not self.pyas_config.get("document_switch", False):
|
||||
return
|
||||
|
||||
gate_handle, error = self._acquire_file_scan_gate(file_path)
|
||||
|
||||
if not gate_handle:
|
||||
if error in (ERROR_SHARING_VIOLATION, ERROR_LOCK_VIOLATION):
|
||||
self._queue_file_scan(file_path, FILE_SCAN_RETRY_SECONDS)
|
||||
|
||||
return
|
||||
|
||||
try:
|
||||
with self.lock_file_ops:
|
||||
if file_path in self.virus_lock:
|
||||
return
|
||||
|
||||
result = self.safe_scan_engine(file_path)
|
||||
self.cloud_check(file_path)
|
||||
|
||||
if result:
|
||||
if (
|
||||
self.manage_named_list(
|
||||
"quarantine", [file_path], action="add", lock_func=self.lock_file
|
||||
)
|
||||
> 0
|
||||
):
|
||||
self.write_log(
|
||||
"BLOCK",
|
||||
"File Block",
|
||||
source=file_path,
|
||||
file_hash=self.calc_file_hash(file_path),
|
||||
)
|
||||
finally:
|
||||
self.kernel32.CloseHandle(gate_handle)
|
||||
except Exception:
|
||||
log_exception("PYAS_Realtime.RealtimeMixin.handle_new_file:341")
|
||||
pass
|
||||
|
||||
def protect_net_thread(self):
|
||||
with self.lock_net:
|
||||
self.exist_connections = set()
|
||||
|
||||
while True:
|
||||
with self.lock_config:
|
||||
if not self.pyas_config.get("network_switch", False):
|
||||
break
|
||||
|
||||
try:
|
||||
time.sleep(0.5)
|
||||
conns = self.get_connections_list()
|
||||
|
||||
with self.lock_net:
|
||||
new_conns = conns - self.exist_connections
|
||||
self.exist_connections = conns
|
||||
|
||||
for key in new_conns:
|
||||
observe_future(
|
||||
self.protect_pool.submit(self.handle_new_connection, key),
|
||||
"RealtimeMixin.handle_new_connection",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
log_exception("PYAS_Realtime.RealtimeMixin.protect_net_thread:363")
|
||||
self.write_log("WARN", "protect_net_thread", detail=str(e), success=False)
|
||||
|
||||
with self.lock_net:
|
||||
self.exist_connections = set()
|
||||
|
||||
def handle_new_connection(self, key):
|
||||
pid, remote_addr, remote_port = key
|
||||
|
||||
try:
|
||||
h = self.kernel32.OpenProcess(PYAS_PROCESS_NETWORK_ACCESS, False, pid)
|
||||
|
||||
if not h:
|
||||
return
|
||||
|
||||
try:
|
||||
remote_ip = f"{remote_addr & 0xFF}.{(remote_addr >> 8) & 0xFF}.{(remote_addr >> 16) & 0xFF}.{(remote_addr >> 24) & 0xFF}"
|
||||
file_path = self.norm_path(self.get_process_file(h))
|
||||
|
||||
if (
|
||||
file_path
|
||||
and not self.is_in_whitelist(file_path)
|
||||
and hasattr(self.heuristic, "network")
|
||||
and remote_ip in self.heuristic.network
|
||||
):
|
||||
self._terminate_process_handle(h, pid=pid)
|
||||
self.write_log(
|
||||
"BLOCK",
|
||||
"Network Block",
|
||||
pid=pid,
|
||||
source=file_path,
|
||||
target=remote_ip,
|
||||
file_hash=self.calc_file_hash(file_path),
|
||||
)
|
||||
|
||||
finally:
|
||||
self.kernel32.CloseHandle(h)
|
||||
|
||||
except Exception as e:
|
||||
log_exception("PYAS_Realtime.RealtimeMixin.handle_new_connection:387")
|
||||
self.write_log("WARN", "handle_new_connection", pid=pid, detail=str(e), success=False)
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
from PYAS_Diagnostics import log_exception
|
||||
import os
|
||||
import yara
|
||||
|
||||
|
||||
class RuleScanner:
|
||||
def __init__(self):
|
||||
self.rules = None
|
||||
self.network = []
|
||||
|
||||
def load_path(self, path, callback=None):
|
||||
yara_files = {}
|
||||
|
||||
for root, _, files in os.walk(path):
|
||||
for file in files:
|
||||
|
||||
full_path = os.path.join(root, file)
|
||||
|
||||
if callback:
|
||||
callback(full_path)
|
||||
|
||||
ext = os.path.splitext(file)[1].lower()
|
||||
|
||||
if ext in (".yara", ".yar"):
|
||||
namespace = os.path.relpath(full_path, path).replace(os.sep, "_")
|
||||
yara_files[namespace] = full_path
|
||||
|
||||
elif ext in (".yc", ".yrc"):
|
||||
self.load_compiled_rule(full_path)
|
||||
|
||||
elif ext in (".ip", ".txt"):
|
||||
self.load_network_list(full_path)
|
||||
|
||||
if yara_files:
|
||||
self.compile_all_rules(yara_files)
|
||||
|
||||
def load_compiled_rule(self, file):
|
||||
try:
|
||||
self.rules = yara.load(file)
|
||||
except Exception:
|
||||
log_exception("PYAS_Rules.RuleScanner.load_compiled_rule:38")
|
||||
pass
|
||||
|
||||
def load_network_list(self, file):
|
||||
try:
|
||||
with open(file, "r", encoding="utf-8", errors="ignore") as f:
|
||||
self.network.extend(line.strip() for line in f if line.strip())
|
||||
except Exception:
|
||||
log_exception("PYAS_Rules.RuleScanner.load_network_list:45")
|
||||
pass
|
||||
|
||||
def compile_all_rules(self, file_map):
|
||||
try:
|
||||
self.rules = yara.compile(filepaths=file_map)
|
||||
except Exception:
|
||||
log_exception("PYAS_Rules.RuleScanner.compile_all_rules:51")
|
||||
pass
|
||||
|
||||
def yara_scan(self, file_path):
|
||||
try:
|
||||
if not self.rules:
|
||||
return False, False
|
||||
|
||||
matches = self.rules.match(filepath=file_path)
|
||||
|
||||
if matches:
|
||||
rule_name = str(matches[0])
|
||||
|
||||
try:
|
||||
label = rule_name.split("_")[0]
|
||||
platform = rule_name.split("_")[1]
|
||||
family = rule_name.split("_")[2]
|
||||
|
||||
return f"{label}:{platform}/{family}.{len(matches)}!yr", 100
|
||||
except Exception:
|
||||
log_exception("PYAS_Rules.RuleScanner.yara_scan:70")
|
||||
return rule_name, 100
|
||||
|
||||
return False, False
|
||||
except Exception:
|
||||
log_exception("PYAS_Rules.RuleScanner.yara_scan:74")
|
||||
return False, False
|
||||
|
||||
def yara_mem_scan(self, pid):
|
||||
try:
|
||||
if not self.rules:
|
||||
return False, False
|
||||
|
||||
matches = self.rules.match(pid=pid)
|
||||
|
||||
if matches:
|
||||
rule_name = str(matches[0])
|
||||
|
||||
try:
|
||||
label = rule_name.split("_")[0]
|
||||
platform = rule_name.split("_")[1]
|
||||
family = rule_name.split("_")[2]
|
||||
|
||||
return f"{label}:{platform}/{family}.{len(matches)}!ym", 100
|
||||
except Exception:
|
||||
log_exception("PYAS_Rules.RuleScanner.yara_mem_scan:91")
|
||||
return rule_name, 100
|
||||
|
||||
return False, False
|
||||
except Exception:
|
||||
log_exception("PYAS_Rules.RuleScanner.yara_mem_scan:95")
|
||||
return False, False
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
from PYAS_Diagnostics import log_exception, report_log_path
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import queue
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from PYAS_Engine import rule_scanner, pe_scanner, cloud_scanner
|
||||
from PYAS_Version import VERSION
|
||||
|
||||
|
||||
class RuntimeMixin:
|
||||
def init_environ(self):
|
||||
self.python = sys.executable
|
||||
|
||||
if getattr(sys, "frozen", False):
|
||||
self.file_pyas = self.norm_path(sys.executable)
|
||||
else:
|
||||
self.file_pyas = self.norm_path(os.path.abspath(sys.argv[0]))
|
||||
|
||||
self.args_pyas = sys.argv[1:]
|
||||
self.path_pyas = os.path.dirname(self.file_pyas)
|
||||
self.pid_pyas = int(os.getpid())
|
||||
|
||||
self.path_appdata = os.environ.get("APPDATA")
|
||||
self.path_localappdata = os.environ.get("LOCALAPPDATA") or self.path_appdata
|
||||
self.path_name = os.environ.get("USERNAME")
|
||||
self.path_temp = os.environ.get(
|
||||
"TEMP", f"C:\\Users\\{self.path_name}\\AppData\\Local\\Temp"
|
||||
)
|
||||
self.path_config = os.environ.get("ALLUSERSPROFILE", "C:\\ProgramData")
|
||||
self.path_system = os.environ.get("SYSTEMROOT", "C:\\Windows")
|
||||
self.path_user = os.environ.get("USERPROFILE", f"C:\\Users\\{self.path_name}")
|
||||
|
||||
self.path_systemp = os.path.join(self.path_system, "Temp")
|
||||
self.file_config = os.path.join(self.path_config, "PYAS", "Config.json")
|
||||
self.file_log = report_log_path()
|
||||
self.path_webview = os.path.join(
|
||||
self.path_localappdata or self.path_temp, "PYAS", "WebView2"
|
||||
)
|
||||
self.file_webview_log = self.file_log
|
||||
self.path_properties = os.path.join(self.path_pyas, "Engine", "Properties")
|
||||
self.path_heuristic = os.path.join(self.path_pyas, "Engine", "Heuristic")
|
||||
self.path_protect = os.path.join(self.path_pyas, "Plugins", "Filter")
|
||||
self.path_rules = os.path.join(self.path_pyas, "Plugins", "Rules")
|
||||
self.path_drivers = os.path.join(self.path_protect, "PYAS_Driver.sys")
|
||||
|
||||
def init_variables(self):
|
||||
self.lock_workers = threading.RLock()
|
||||
self.feature_workers = {}
|
||||
self.feature_restarts = set()
|
||||
|
||||
self.heuristic = rule_scanner()
|
||||
self.properties = pe_scanner()
|
||||
self.cloud = cloud_scanner()
|
||||
self.cloud_queue = queue.Queue()
|
||||
|
||||
self.ui_queue = queue.Queue()
|
||||
self.start_daemon_thread(self.ui_dispatcher_thread)
|
||||
|
||||
self.driver_port = None
|
||||
self.driver_stop_event = threading.Event()
|
||||
self.driver_listener_ready_event = threading.Event()
|
||||
self.driver_listener_failed_event = threading.Event()
|
||||
self.driver_listener_thread = None
|
||||
|
||||
self.ui_ready_event = threading.Event()
|
||||
self.engine_initialized = False
|
||||
|
||||
self.scan_running = False
|
||||
self.scan_preparing = False
|
||||
self.scan_stop_requested = False
|
||||
self.scan_finished = False
|
||||
|
||||
self.virus_lock = {}
|
||||
self.virus_results = []
|
||||
|
||||
self.scan_count = 0
|
||||
self.scan_events = {}
|
||||
self.hash_cache = {}
|
||||
self.file_scheduler = None
|
||||
|
||||
self.mbr_backup = {}
|
||||
|
||||
self.cloud_pending = set()
|
||||
self.cloud_cancel_event = threading.Event()
|
||||
|
||||
self.autostart_mode = "unknown"
|
||||
|
||||
self.last_io_counters = {}
|
||||
self.last_io_time = time.time()
|
||||
self.suspended_procs = set()
|
||||
|
||||
self.lock_driver = threading.RLock()
|
||||
self.lock_driver_unload = threading.Lock()
|
||||
self.driver_unload_worker = None
|
||||
self.driver_unload_result = None
|
||||
self.closing = False
|
||||
|
||||
self.lock_update = threading.RLock()
|
||||
self.lock_proc = threading.RLock()
|
||||
self.lock_net = threading.RLock()
|
||||
self.lock_io = threading.RLock()
|
||||
|
||||
self.pyas_default = {
|
||||
"version": VERSION,
|
||||
"api_host": "https://pyas-security.com/",
|
||||
"api_key": "fBRZxYS1UxykM-qzNOlKOEl63WILzlvgNMn6QfsG6FXCAAIktCrOPTAfY5_hEyuZ",
|
||||
"suffix": [
|
||||
".exe",
|
||||
".dll",
|
||||
".sys",
|
||||
".ocx",
|
||||
".scr",
|
||||
".efi",
|
||||
".acm",
|
||||
".ax",
|
||||
".cpl",
|
||||
".drv",
|
||||
".com",
|
||||
".mui",
|
||||
".pyd",
|
||||
".wfx",
|
||||
".api",
|
||||
".awx",
|
||||
".rll",
|
||||
".winmd",
|
||||
".bat",
|
||||
".cmd",
|
||||
".ps1",
|
||||
".vbs",
|
||||
".vbe",
|
||||
".wsf",
|
||||
".reg",
|
||||
".html",
|
||||
".js",
|
||||
".jse",
|
||||
".jsp",
|
||||
".php",
|
||||
".hta",
|
||||
".lnk",
|
||||
".py",
|
||||
".sh",
|
||||
".url",
|
||||
".rtf",
|
||||
".ini",
|
||||
],
|
||||
"size": 256 * 1024 * 1024,
|
||||
"language": "english_switch",
|
||||
"theme": "system_switch",
|
||||
"first_launch": True,
|
||||
"process_switch": False,
|
||||
"suspend_switch": True,
|
||||
"load_switch": True,
|
||||
"document_switch": False,
|
||||
"system_switch": False,
|
||||
"driver_switch": False,
|
||||
"network_switch": False,
|
||||
"extension_switch": False,
|
||||
"sensitive_switch": False,
|
||||
"cloud_switch": False,
|
||||
"suffix_switch": True,
|
||||
"autostart_switch": True,
|
||||
"context_switch": True,
|
||||
"custom_rule": [],
|
||||
"white_list": [],
|
||||
"quarantine": [],
|
||||
"block_list": [],
|
||||
}
|
||||
|
||||
self.pass_windows = [
|
||||
{"exe": "System Idle Process", "class": "", "title": ""},
|
||||
{"exe": "", "class": "Windows.UI.Core.CoreWindow", "title": ""},
|
||||
{"exe": "explorer.exe", "class": "", "title": ""},
|
||||
]
|
||||
|
||||
self.scan_pool = ThreadPoolExecutor(max_workers=2)
|
||||
self.protect_pool = ThreadPoolExecutor(max_workers=8)
|
||||
self.proc_pool = ThreadPoolExecutor(max_workers=16)
|
||||
self.start_daemon_thread(self.log_flush_thread)
|
||||
|
||||
for _ in range(2):
|
||||
self.start_daemon_thread(self.cloud_worker)
|
||||
|
||||
def ui_dispatcher_thread(self):
|
||||
while not getattr(self, "closing", False):
|
||||
batch = []
|
||||
|
||||
try:
|
||||
batch.append(self.ui_queue.get(timeout=0.1))
|
||||
|
||||
while len(batch) < 50:
|
||||
try:
|
||||
batch.append(self.ui_queue.get_nowait())
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
if self._window:
|
||||
self._window.evaluate_js("".join(batch))
|
||||
except queue.Empty:
|
||||
continue
|
||||
except Exception:
|
||||
log_exception("PYAS_Runtime.RuntimeMixin.ui_dispatcher_thread:146")
|
||||
pass
|
||||
finally:
|
||||
for _ in batch:
|
||||
self.ui_queue.task_done()
|
||||
|
||||
def start_feature_thread(self, target, switch):
|
||||
with self.lock_workers:
|
||||
existing = self.feature_workers.get(switch)
|
||||
|
||||
if existing and existing.is_alive():
|
||||
self.feature_restarts.add(switch)
|
||||
return existing
|
||||
|
||||
def worker():
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
target()
|
||||
except Exception:
|
||||
log_exception(f"RuntimeMixin.{target.__name__}")
|
||||
|
||||
with self.lock_config:
|
||||
enabled = self.pyas_config.get(switch, False)
|
||||
|
||||
if not enabled or self.closing:
|
||||
break
|
||||
|
||||
self.write_log(
|
||||
"WARN",
|
||||
"Protection Recovery",
|
||||
detail=f"Restarting {switch}",
|
||||
success=False,
|
||||
)
|
||||
time.sleep(0.5)
|
||||
finally:
|
||||
with self.lock_workers:
|
||||
if self.feature_workers.get(switch) is threading.current_thread():
|
||||
self.feature_workers.pop(switch, None)
|
||||
|
||||
restart = switch in self.feature_restarts
|
||||
self.feature_restarts.discard(switch)
|
||||
|
||||
with self.lock_config:
|
||||
enabled = self.pyas_config.get(switch, False)
|
||||
|
||||
if restart and enabled and not self.closing:
|
||||
self.start_feature_thread(target, switch)
|
||||
|
||||
thread = threading.Thread(target=worker, daemon=True, name=f"PYAS.{target.__name__}")
|
||||
self.feature_workers[switch] = thread
|
||||
thread.start()
|
||||
return thread
|
||||
+288
-166
@@ -1,11 +1,18 @@
|
||||
import os, gc, json, time, stat, threading
|
||||
import ctypes, ctypes.wintypes
|
||||
|
||||
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:
|
||||
class ScannerMixin(CloudQueueMixin):
|
||||
def init_engine_thread(self):
|
||||
try:
|
||||
self.start_daemon_thread(self.backup_mbr)
|
||||
@@ -23,8 +30,9 @@ class ScannerMixin:
|
||||
if "-scan" in self.args_pyas:
|
||||
try:
|
||||
idx = self.args_pyas.index("-scan")
|
||||
self.trigger_context_scan(self.args_pyas[idx+1])
|
||||
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:
|
||||
@@ -34,40 +42,61 @@ class ScannerMixin:
|
||||
autostart_enabled = self.pyas_config.get("autostart_switch", True)
|
||||
|
||||
self.register_context_menu(context_enabled)
|
||||
self.manage_autostart(autostart_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_daemon_thread(self.protect_proc_thread)
|
||||
self.start_feature_thread(self.protect_proc_thread, "process_switch")
|
||||
|
||||
if self.pyas_config.get("document_switch"):
|
||||
self.start_daemon_thread(self.protect_file_thread)
|
||||
self.start_feature_thread(self.protect_file_thread, "document_switch")
|
||||
|
||||
if self.pyas_config.get("system_switch"):
|
||||
self.start_daemon_thread(self.protect_system_thread)
|
||||
self.start_feature_thread(self.protect_system_thread, "system_switch")
|
||||
|
||||
if self.pyas_config.get("network_switch"):
|
||||
self.start_daemon_thread(self.protect_net_thread)
|
||||
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)
|
||||
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
|
||||
|
||||
@@ -82,16 +111,16 @@ class ScannerMixin:
|
||||
try:
|
||||
if os.path.islink(path):
|
||||
return True
|
||||
if hasattr(os.path, 'isjunction') and os.path.isjunction(path):
|
||||
|
||||
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)
|
||||
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)
|
||||
@@ -99,72 +128,122 @@ class ScannerMixin:
|
||||
|
||||
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
|
||||
|
||||
file_hash = self.calc_file_hash(norm_path)
|
||||
cache_key = file_hash if file_hash else os.path.normcase(norm_path)
|
||||
gate, error = self._acquire_file_scan_gate(norm_path)
|
||||
|
||||
with self.lock_file_ops:
|
||||
if cache_key in self.hash_cache:
|
||||
return self.hash_cache[cache_key]
|
||||
|
||||
if cache_key in self.scan_events:
|
||||
event = self.scan_events[cache_key]
|
||||
needs_scan = False
|
||||
else:
|
||||
event = threading.Event()
|
||||
self.scan_events[cache_key] = event
|
||||
needs_scan = True
|
||||
|
||||
if not needs_scan:
|
||||
event.wait(timeout=60)
|
||||
with self.lock_file_ops:
|
||||
return self.hash_cache.get(cache_key, False)
|
||||
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:
|
||||
result = self.scan_engine(norm_path)
|
||||
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 len(self.hash_cache) > 10000:
|
||||
for k in list(self.hash_cache.keys())[:1000]:
|
||||
del self.hash_cache[k]
|
||||
if cache_key in self.hash_cache:
|
||||
return self.hash_cache[cache_key]
|
||||
|
||||
self.hash_cache[cache_key] = result
|
||||
return result
|
||||
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:
|
||||
with self.lock_file_ops:
|
||||
if cache_key in self.scan_events:
|
||||
self.scan_events[cache_key].set()
|
||||
del self.scan_events[cache_key]
|
||||
self.kernel32.CloseHandle(gate)
|
||||
|
||||
gc.collect()
|
||||
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())
|
||||
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 {
|
||||
@@ -178,18 +257,18 @@ class ScannerMixin:
|
||||
"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"
|
||||
"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)
|
||||
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())
|
||||
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
|
||||
@@ -206,7 +285,7 @@ class ScannerMixin:
|
||||
|
||||
def _begin_scan_preparation(self):
|
||||
with self.lock_virus:
|
||||
if getattr(self, 'scan_running', False) or getattr(self, 'scan_preparing', False):
|
||||
if getattr(self, "scan_running", False) or getattr(self, "scan_preparing", False):
|
||||
return False
|
||||
|
||||
self.scan_preparing = True
|
||||
@@ -219,10 +298,12 @@ class ScannerMixin:
|
||||
|
||||
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):
|
||||
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):
|
||||
if getattr(self, "scan_stop_requested", False):
|
||||
self.scan_running = False
|
||||
self.scan_preparing = False
|
||||
self.scan_finished = True
|
||||
@@ -236,22 +317,37 @@ class ScannerMixin:
|
||||
self.scan_count = 0
|
||||
self.scan_start = time.time()
|
||||
|
||||
self.scan_pool.submit(self.scan_worker, targets)
|
||||
return True
|
||||
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):
|
||||
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, '/'))});"
|
||||
@@ -260,6 +356,7 @@ class ScannerMixin:
|
||||
last_update = current_time
|
||||
|
||||
was_locked = False
|
||||
|
||||
try:
|
||||
with self.lock_file_ops:
|
||||
if norm_path in self.virus_lock:
|
||||
@@ -270,35 +367,49 @@ class ScannerMixin:
|
||||
continue
|
||||
|
||||
with self.lock_virus:
|
||||
if not self.scan_running or getattr(self, 'scan_stop_requested', False):
|
||||
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:
|
||||
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):
|
||||
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:
|
||||
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.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:
|
||||
self.write_log("WARN", "Scan Engine", source=norm_path, detail=str(e), success=False)
|
||||
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:
|
||||
@@ -306,30 +417,36 @@ class ScannerMixin:
|
||||
|
||||
finally:
|
||||
with self.lock_virus:
|
||||
cancelled = getattr(self, 'scan_stop_requested', False)
|
||||
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)
|
||||
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});"
|
||||
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)
|
||||
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):
|
||||
@@ -345,6 +462,7 @@ class ScannerMixin:
|
||||
return self._finish_scan_cancelled()
|
||||
|
||||
fp = os.path.join(self.path_user, folder)
|
||||
|
||||
if os.path.exists(fp):
|
||||
targets.append(fp)
|
||||
|
||||
@@ -362,32 +480,51 @@ class ScannerMixin:
|
||||
if self._is_scan_cancel_requested():
|
||||
return self._finish_scan_cancelled()
|
||||
|
||||
if proc["path"] and proc["path"] != "None":
|
||||
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)):
|
||||
|
||||
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):
|
||||
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):
|
||||
|
||||
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)
|
||||
@@ -402,107 +539,143 @@ class ScannerMixin:
|
||||
|
||||
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):
|
||||
|
||||
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)
|
||||
|
||||
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"])
|
||||
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
|
||||
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)
|
||||
|
||||
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, '/'))});")
|
||||
self._window.evaluate_js(
|
||||
f"if(window.updateDeleteProgress) window.updateDeleteProgress({json.dumps(path.replace(os.sep, '/'))});"
|
||||
)
|
||||
|
||||
last_update = current_time
|
||||
|
||||
@@ -511,19 +684,23 @@ class ScannerMixin:
|
||||
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)
|
||||
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))
|
||||
@@ -532,11 +709,27 @@ class ScannerMixin:
|
||||
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)
|
||||
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)
|
||||
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]
|
||||
@@ -554,10 +747,13 @@ class ScannerMixin:
|
||||
"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)}."
|
||||
"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});")
|
||||
self._window.evaluate_js(
|
||||
f"if(window.finishScan) window.finishScan({json.dumps(messages)}, {remaining});"
|
||||
)
|
||||
|
||||
return deleted_paths
|
||||
|
||||
@@ -566,77 +762,3 @@ class ScannerMixin:
|
||||
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)
|
||||
|
||||
####################################################################################################
|
||||
|
||||
def cloud_check(self, file_path):
|
||||
norm_path = self.norm_path(file_path)
|
||||
if not norm_path:
|
||||
return
|
||||
|
||||
cache_key = os.path.normcase(norm_path)
|
||||
with self.lock_file_ops:
|
||||
if cache_key in self.cloud_pending:
|
||||
return
|
||||
|
||||
self.cloud_pending.add(cache_key)
|
||||
|
||||
self.cloud_queue.put(norm_path)
|
||||
|
||||
def cloud_worker(self):
|
||||
while True:
|
||||
try:
|
||||
file_path = self.cloud_queue.get()
|
||||
cache_key = os.path.normcase(file_path)
|
||||
|
||||
try:
|
||||
self.perform_cloud_scan(file_path)
|
||||
finally:
|
||||
with self.lock_file_ops:
|
||||
self.cloud_pending.discard(cache_key)
|
||||
|
||||
self.cloud_queue.task_done()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def perform_cloud_scan(self, file_path):
|
||||
was_locked = False
|
||||
try:
|
||||
with self.lock_config:
|
||||
if not self.pyas_config.get("cloud_switch", True):
|
||||
return False
|
||||
|
||||
api_host, api_key, max_size = self.pyas_config.get("api_host"), self.pyas_config.get("api_key"), self.pyas_config.get("size", 256 * 1024 * 1024)
|
||||
|
||||
if not os.path.exists(file_path) or not os.path.isfile(file_path):
|
||||
return False
|
||||
|
||||
with self.lock_file_ops:
|
||||
if file_path in self.virus_lock:
|
||||
self.lock_file(file_path, False)
|
||||
was_locked = True
|
||||
|
||||
if os.path.getsize(file_path) > max_size:
|
||||
if was_locked:
|
||||
self.lock_file(file_path, True)
|
||||
return False
|
||||
|
||||
file_hash = self.calc_file_hash(file_path)
|
||||
success, sha256 = self.cloud.upload_file(file_path, api_host, api_key, file_hash=file_hash)
|
||||
if was_locked:
|
||||
self.lock_file(file_path, True)
|
||||
was_locked = False
|
||||
|
||||
if not success:
|
||||
self.write_log("WARN", "Cloud API", source=file_path, detail="Failed", success=False)
|
||||
|
||||
except Exception as e:
|
||||
self.write_log("WARN", "perform_cloud_scan", detail=str(e), success=False)
|
||||
|
||||
finally:
|
||||
if was_locked:
|
||||
try:
|
||||
self.lock_file(file_path, True)
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
@@ -0,0 +1,160 @@
|
||||
import heapq
|
||||
import itertools
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from PYAS_Diagnostics import log_exception
|
||||
|
||||
|
||||
class TaskScheduler:
|
||||
def __init__(self, on_overflow, max_pending=2048, max_workers=4):
|
||||
self.max_pending = max_pending
|
||||
self.max_workers = max_workers
|
||||
self.on_overflow = on_overflow
|
||||
|
||||
self.condition = threading.Condition()
|
||||
self.pending = {}
|
||||
self.heap = []
|
||||
self.running = set()
|
||||
self.sequence = itertools.count()
|
||||
|
||||
self.overflow = False
|
||||
self.closed = False
|
||||
|
||||
self.pool = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="PYAS.File")
|
||||
|
||||
self.thread = threading.Thread(
|
||||
target=self._dispatch, name="PYAS.FileScheduler", daemon=True
|
||||
)
|
||||
|
||||
self.thread.start()
|
||||
|
||||
def schedule(self, key, delay, callback, args=(), critical=False):
|
||||
with self.condition:
|
||||
if self.closed:
|
||||
return False
|
||||
|
||||
worker = threading.current_thread().name.startswith("PYAS.File_")
|
||||
|
||||
limit = self.max_pending + (self.max_workers if worker and critical else 0)
|
||||
|
||||
while key not in self.pending and len(self.pending) >= limit:
|
||||
if not critical or worker:
|
||||
self.overflow = True
|
||||
self.condition.notify_all()
|
||||
return False
|
||||
|
||||
self.condition.wait()
|
||||
|
||||
if self.closed:
|
||||
return False
|
||||
|
||||
token = next(self.sequence)
|
||||
item = (time.monotonic() + max(0, delay), token, key, callback, args)
|
||||
self.pending[key] = item
|
||||
heapq.heappush(self.heap, item[:3])
|
||||
|
||||
if len(self.heap) > max(64, 2 * len(self.pending)):
|
||||
self.heap = [item[:3] for item in self.pending.values()]
|
||||
heapq.heapify(self.heap)
|
||||
|
||||
self.condition.notify_all()
|
||||
return True
|
||||
|
||||
def cancel(self, predicate=None):
|
||||
with self.condition:
|
||||
for key in list(self.pending):
|
||||
if predicate is None or predicate(key):
|
||||
self.pending.pop(key, None)
|
||||
|
||||
self.heap = [item[:3] for item in self.pending.values()]
|
||||
heapq.heapify(self.heap)
|
||||
|
||||
if predicate is None:
|
||||
self.overflow = False
|
||||
|
||||
self.condition.notify_all()
|
||||
|
||||
def _dispatch(self):
|
||||
while True:
|
||||
with self.condition:
|
||||
if self.closed:
|
||||
return
|
||||
|
||||
if len(self.running) >= self.max_workers:
|
||||
self.condition.wait()
|
||||
continue
|
||||
|
||||
if (
|
||||
self.overflow
|
||||
and len(self.pending) < max(1, self.max_pending // 2)
|
||||
and "__recovery__" not in self.running
|
||||
):
|
||||
self.overflow = False
|
||||
item = (0, next(self.sequence), "__recovery__", self.on_overflow, ())
|
||||
else:
|
||||
blocked = []
|
||||
item = None
|
||||
|
||||
while self.heap:
|
||||
due, token, key = self.heap[0]
|
||||
current = self.pending.get(key)
|
||||
|
||||
if current is None or current[1] != token:
|
||||
heapq.heappop(self.heap)
|
||||
continue
|
||||
|
||||
if key in self.running:
|
||||
blocked.append(heapq.heappop(self.heap))
|
||||
continue
|
||||
|
||||
if due > time.monotonic():
|
||||
break
|
||||
|
||||
heapq.heappop(self.heap)
|
||||
item = self.pending.pop(key)
|
||||
break
|
||||
|
||||
for entry in blocked:
|
||||
heapq.heappush(self.heap, entry)
|
||||
|
||||
if item is None:
|
||||
self.condition.wait(timeout=0.1 if self.heap else None)
|
||||
continue
|
||||
|
||||
self.running.add(item[2])
|
||||
self.condition.notify_all()
|
||||
|
||||
try:
|
||||
self.pool.submit(self._run, item)
|
||||
except Exception:
|
||||
log_exception("TaskScheduler.submit")
|
||||
|
||||
with self.condition:
|
||||
self.running.discard(item[2])
|
||||
self.overflow = True
|
||||
self.condition.wait(timeout=0.5)
|
||||
|
||||
def _run(self, item):
|
||||
try:
|
||||
with self.condition:
|
||||
if self.closed:
|
||||
return
|
||||
|
||||
item[3](*item[4])
|
||||
except Exception:
|
||||
log_exception("TaskScheduler.callback")
|
||||
finally:
|
||||
with self.condition:
|
||||
self.running.discard(item[2])
|
||||
self.condition.notify_all()
|
||||
|
||||
def close(self, wait=False):
|
||||
with self.condition:
|
||||
self.closed = True
|
||||
self.pending.clear()
|
||||
self.heap.clear()
|
||||
self.condition.notify_all()
|
||||
|
||||
self.thread.join(timeout=1)
|
||||
self.pool.shutdown(wait=wait, cancel_futures=True)
|
||||
@@ -0,0 +1,69 @@
|
||||
from PYAS_Diagnostics import log_exception
|
||||
import os
|
||||
import ctypes
|
||||
import ctypes.wintypes
|
||||
from PYAS_WinAPI import GUID, WINTRUST_DATA, WINTRUST_DATA_UNION, WINTRUST_FILE_INFO
|
||||
|
||||
|
||||
class SignatureScanner:
|
||||
def __init__(self):
|
||||
self.verify = GUID(
|
||||
0x00AAC56B,
|
||||
0xCD44,
|
||||
0x11D0,
|
||||
(ctypes.c_ubyte * 8)(0x8C, 0xC2, 0x00, 0xC0, 0x4F, 0xC2, 0x95, 0xEE),
|
||||
)
|
||||
|
||||
def init_windll(self, path):
|
||||
for name in path:
|
||||
try:
|
||||
setattr(self, name.lower(), ctypes.WinDLL(name, use_last_error=True))
|
||||
except Exception:
|
||||
log_exception("PYAS_Signature.SignatureScanner.init_windll:14")
|
||||
pass
|
||||
|
||||
try:
|
||||
self.WinVerifyTrust = self.wintrust.WinVerifyTrust
|
||||
self.WinVerifyTrust.restype = ctypes.wintypes.LONG
|
||||
self.WinVerifyTrust.argtypes = [
|
||||
ctypes.wintypes.HWND,
|
||||
ctypes.POINTER(GUID),
|
||||
ctypes.c_void_p,
|
||||
]
|
||||
except Exception:
|
||||
log_exception("PYAS_Signature.SignatureScanner.init_windll:21")
|
||||
pass
|
||||
|
||||
def sign_verify(self, file_path):
|
||||
if os.name != "nt":
|
||||
return False
|
||||
|
||||
try:
|
||||
fi = WINTRUST_FILE_INFO()
|
||||
fi.cbStruct = ctypes.sizeof(WINTRUST_FILE_INFO)
|
||||
fi.pcwszFilePath = os.path.abspath(file_path)
|
||||
fi.hFile = None
|
||||
fi.pgKnownSubject = None
|
||||
|
||||
wt_union = WINTRUST_DATA_UNION()
|
||||
wt_union.pFile = ctypes.pointer(fi)
|
||||
|
||||
td = WINTRUST_DATA()
|
||||
td.cbStruct = ctypes.sizeof(WINTRUST_DATA)
|
||||
td.pPolicyCallbackData = None
|
||||
td.pSIPClientData = None
|
||||
td.dwUIChoice = 2
|
||||
td.fdwRevocationChecks = 0
|
||||
td.dwUnionChoice = 1
|
||||
td.u = wt_union
|
||||
td.dwStateAction = 0
|
||||
td.hWVTStateData = None
|
||||
td.pwszURLReference = None
|
||||
td.dwProvFlags = 0
|
||||
td.dwUIContext = 0
|
||||
td.pSignatureSettings = None
|
||||
|
||||
return self.WinVerifyTrust(None, ctypes.byref(self.verify), ctypes.byref(td)) == 0
|
||||
except Exception:
|
||||
log_exception("PYAS_Signature.SignatureScanner.sign_verify:55")
|
||||
return False
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
from PYAS_Diagnostics import log_exception
|
||||
import ctypes
|
||||
import importlib.metadata
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import uuid
|
||||
from PYAS_Diagnostics import configure_report_logging
|
||||
|
||||
|
||||
def configure_startup_logging():
|
||||
return configure_report_logging()
|
||||
|
||||
|
||||
def bootstrap_startup():
|
||||
log_path = configure_startup_logging()
|
||||
logger = logging.getLogger("PYAS.Startup")
|
||||
|
||||
for stream in ("stdout", "stderr"):
|
||||
if getattr(sys, stream) is None:
|
||||
setattr(sys, stream, open(os.devnull, "w"))
|
||||
|
||||
def exception_hook(exc_type, exc_value, traceback):
|
||||
logger.critical("Unhandled startup exception", exc_info=(exc_type, exc_value, traceback))
|
||||
|
||||
if not any(
|
||||
arg in sys.argv
|
||||
for arg in ("-hide", "-h", "-quit", "-driver-unload", "-driver-uninstall")
|
||||
):
|
||||
try:
|
||||
ctypes.windll.user32.MessageBoxW(
|
||||
None,
|
||||
f"PYAS failed to start.\n\n{exc_value}\n\nDiagnostic log: {log_path or 'unavailable'}",
|
||||
"PYAS Security",
|
||||
0x10,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Could not display startup error")
|
||||
|
||||
sys.excepthook = exception_hook
|
||||
threading.excepthook = lambda args: logger.error(
|
||||
"Unhandled thread exception: %s",
|
||||
args.thread.name,
|
||||
exc_info=(args.exc_type, args.exc_value, args.exc_traceback),
|
||||
)
|
||||
logger.info(
|
||||
"Starting PYAS; Python=%s; platform=%s; frozen=%s; recovery=%s",
|
||||
sys.version.split()[0],
|
||||
platform.platform(),
|
||||
getattr(sys, "frozen", False),
|
||||
os.environ.get("PYAS_WEBVIEW_RECOVERY", "0"),
|
||||
)
|
||||
|
||||
for package in ("pywebview", "pythonnet", "pyinstaller"):
|
||||
try:
|
||||
logger.info("Dependency %s=%s", package, importlib.metadata.version(package))
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
logger.info("Dependency metadata unavailable: %s", package)
|
||||
|
||||
return log_path
|
||||
|
||||
|
||||
def prepare_webview_profile(preferred_path):
|
||||
logger = logging.getLogger("PYAS.Startup")
|
||||
candidates = [preferred_path, os.path.join(tempfile.gettempdir(), "PYAS", "WebView2")]
|
||||
|
||||
for path in dict.fromkeys(candidates):
|
||||
try:
|
||||
os.makedirs(path, exist_ok=True)
|
||||
|
||||
with tempfile.TemporaryFile(dir=path) as probe:
|
||||
probe.write(b"PYAS")
|
||||
probe.flush()
|
||||
probe.seek(0)
|
||||
|
||||
if probe.read() != b"PYAS":
|
||||
raise OSError("Profile readback failed")
|
||||
|
||||
logger.info("WebView2 profile ready: %s", path)
|
||||
return path
|
||||
except OSError:
|
||||
logger.exception("WebView2 profile is not writable: %s", path)
|
||||
|
||||
raise OSError("No writable WebView2 user data folder is available")
|
||||
|
||||
|
||||
def check_webview_dependencies(webview):
|
||||
from webview.util import interop_dll_path
|
||||
import sysconfig
|
||||
|
||||
logger = logging.getLogger("PYAS.Startup")
|
||||
architecture = (
|
||||
"win-x86"
|
||||
if ctypes.sizeof(ctypes.c_void_p) == 4
|
||||
else ("win-arm64" if "arm64" in sysconfig.get_platform().lower() else "win-x64")
|
||||
)
|
||||
loader_directory = interop_dll_path(architecture)
|
||||
loader_path = os.path.join(loader_directory, "WebView2Loader.dll")
|
||||
loader = ctypes.WinDLL(loader_path)
|
||||
get_version = loader.GetAvailableCoreWebView2BrowserVersionString
|
||||
get_version.argtypes = [ctypes.c_wchar_p, ctypes.POINTER(ctypes.c_void_p)]
|
||||
get_version.restype = ctypes.c_long
|
||||
version_pointer = ctypes.c_void_p()
|
||||
runtime_path = webview.settings.get("WEBVIEW2_RUNTIME_PATH") or None
|
||||
result = get_version(runtime_path, ctypes.byref(version_pointer))
|
||||
|
||||
try:
|
||||
if result < 0 or not version_pointer.value:
|
||||
raise RuntimeError(
|
||||
f"WebView2 Runtime detection failed: HRESULT 0x{result & 0xFFFFFFFF:08X}"
|
||||
)
|
||||
|
||||
version = ctypes.wstring_at(version_pointer)
|
||||
logger.info("WebView2 Runtime=%s; loader=%s", version, loader_path)
|
||||
finally:
|
||||
if version_pointer.value:
|
||||
ole32 = ctypes.WinDLL("ole32")
|
||||
ole32.CoTaskMemFree.argtypes = [ctypes.c_void_p]
|
||||
ole32.CoTaskMemFree.restype = None
|
||||
ole32.CoTaskMemFree(version_pointer)
|
||||
|
||||
os.environ["PATH"] = loader_directory + os.pathsep + os.environ.get("PATH", "")
|
||||
import clr
|
||||
|
||||
clr.AddReference("System.Windows.Forms")
|
||||
|
||||
for name in ("Microsoft.Web.WebView2.Core.dll", "Microsoft.Web.WebView2.WinForms.dll"):
|
||||
dll_path = interop_dll_path(name)
|
||||
clr.AddReference(dll_path)
|
||||
logger.info("WebView2 managed dependency loaded: %s", dll_path)
|
||||
|
||||
return version
|
||||
|
||||
|
||||
def wait_for_recovery_parent():
|
||||
parent = os.environ.pop("PYAS_WEBVIEW_RECOVERY_PARENT", None)
|
||||
|
||||
if not parent:
|
||||
return
|
||||
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
kernel32.OpenProcess.argtypes = [ctypes.c_ulong, ctypes.c_int, ctypes.c_ulong]
|
||||
kernel32.OpenProcess.restype = ctypes.c_void_p
|
||||
kernel32.WaitForSingleObject.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
|
||||
kernel32.WaitForSingleObject.restype = ctypes.c_ulong
|
||||
kernel32.CloseHandle.argtypes = [ctypes.c_void_p]
|
||||
kernel32.CloseHandle.restype = ctypes.c_int
|
||||
handle = kernel32.OpenProcess(0x00100000, False, int(parent))
|
||||
|
||||
if handle:
|
||||
try:
|
||||
if kernel32.WaitForSingleObject(handle, 10000) == 0x102:
|
||||
raise RuntimeError("Previous PYAS instance did not exit for WebView2 recovery")
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
|
||||
|
||||
def restart_with_new_webview_profile(profile_path, script_path):
|
||||
logger = logging.getLogger("PYAS.Startup")
|
||||
|
||||
if os.environ.get("PYAS_WEBVIEW_RECOVERY") == "1":
|
||||
return False
|
||||
|
||||
try:
|
||||
recovery_path = prepare_webview_profile(profile_path + "-Recovery-" + uuid.uuid4().hex)
|
||||
environment = os.environ.copy()
|
||||
environment.update(
|
||||
{
|
||||
"PYAS_WEBVIEW_RECOVERY": "1",
|
||||
"PYAS_WEBVIEW_PROFILE": recovery_path,
|
||||
"PYAS_WEBVIEW_RECOVERY_PARENT": str(os.getpid()),
|
||||
"PYINSTALLER_RESET_ENVIRONMENT": "1",
|
||||
}
|
||||
)
|
||||
command = [sys.executable]
|
||||
|
||||
if not getattr(sys, "frozen", False):
|
||||
command.append(os.path.abspath(script_path))
|
||||
|
||||
command.extend(sys.argv[1:])
|
||||
working_directory = (
|
||||
os.path.dirname(sys.executable)
|
||||
if getattr(sys, "frozen", False)
|
||||
else os.path.dirname(os.path.abspath(script_path))
|
||||
)
|
||||
subprocess.Popen(
|
||||
command,
|
||||
env=environment,
|
||||
cwd=working_directory,
|
||||
creationflags=0x08000000,
|
||||
close_fds=True,
|
||||
)
|
||||
logger.warning("Restarting once with a new WebView2 profile: %s", recovery_path)
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("Could not restart for WebView2 profile recovery")
|
||||
return False
|
||||
@@ -0,0 +1,26 @@
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
|
||||
def atomic_write_json(path, data):
|
||||
directory = os.path.dirname(os.path.abspath(path))
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
descriptor, temporary_path = tempfile.mkstemp(prefix=".pyas-", suffix=".tmp", dir=directory)
|
||||
|
||||
try:
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
||||
descriptor = None
|
||||
|
||||
json.dump(data, stream, indent=4, ensure_ascii=False)
|
||||
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
|
||||
os.replace(temporary_path, path)
|
||||
finally:
|
||||
if descriptor is not None:
|
||||
os.close(descriptor)
|
||||
|
||||
if os.path.exists(temporary_path):
|
||||
os.unlink(temporary_path)
|
||||
+596
@@ -0,0 +1,596 @@
|
||||
from PYAS_Diagnostics import log_exception
|
||||
import os
|
||||
import time
|
||||
import shutil
|
||||
import winreg
|
||||
import subprocess
|
||||
import ctypes
|
||||
import ctypes.wintypes
|
||||
from PYAS_WinAPI import (
|
||||
FILE_ATTRIBUTE_NORMAL,
|
||||
FILE_SHARE_READ,
|
||||
FILE_SHARE_WRITE,
|
||||
INTERNAL_STORAGE_BUS_TYPES,
|
||||
INVALID_HANDLE_VALUE,
|
||||
IOCTL_STORAGE_GET_HOTPLUG_INFO,
|
||||
IOCTL_STORAGE_QUERY_PROPERTY,
|
||||
OPEN_EXISTING,
|
||||
PROPERTY_STANDARD_QUERY,
|
||||
STORAGE_DESCRIPTOR_HEADER,
|
||||
STORAGE_DEVICE_DESCRIPTOR,
|
||||
STORAGE_DEVICE_PROPERTY,
|
||||
STORAGE_HOTPLUG_INFO,
|
||||
STORAGE_PROPERTY_QUERY,
|
||||
)
|
||||
|
||||
|
||||
class SystemMixin:
|
||||
def _is_internal_physical_drive(self, drive):
|
||||
drive_path = rf"\\.\PhysicalDrive{drive}"
|
||||
handle = self.kernel32.CreateFileW(
|
||||
drive_path,
|
||||
0,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
None,
|
||||
OPEN_EXISTING,
|
||||
FILE_ATTRIBUTE_NORMAL,
|
||||
None,
|
||||
)
|
||||
|
||||
if handle in (None, INVALID_HANDLE_VALUE):
|
||||
return False
|
||||
|
||||
try:
|
||||
query = STORAGE_PROPERTY_QUERY(STORAGE_DEVICE_PROPERTY, PROPERTY_STANDARD_QUERY)
|
||||
header = STORAGE_DESCRIPTOR_HEADER()
|
||||
bytes_returned = ctypes.wintypes.DWORD(0)
|
||||
|
||||
if not self.kernel32.DeviceIoControl(
|
||||
handle,
|
||||
IOCTL_STORAGE_QUERY_PROPERTY,
|
||||
ctypes.byref(query),
|
||||
ctypes.sizeof(query),
|
||||
ctypes.byref(header),
|
||||
ctypes.sizeof(header),
|
||||
ctypes.byref(bytes_returned),
|
||||
None,
|
||||
):
|
||||
return False
|
||||
|
||||
minimum_size = STORAGE_DEVICE_DESCRIPTOR.BusType.offset + ctypes.sizeof(ctypes.c_int)
|
||||
|
||||
if (
|
||||
bytes_returned.value < ctypes.sizeof(header)
|
||||
or header.Size < minimum_size
|
||||
or header.Size > 65536
|
||||
):
|
||||
return False
|
||||
|
||||
descriptor_buffer = ctypes.create_string_buffer(header.Size)
|
||||
bytes_returned.value = 0
|
||||
|
||||
if not self.kernel32.DeviceIoControl(
|
||||
handle,
|
||||
IOCTL_STORAGE_QUERY_PROPERTY,
|
||||
ctypes.byref(query),
|
||||
ctypes.sizeof(query),
|
||||
descriptor_buffer,
|
||||
ctypes.sizeof(descriptor_buffer),
|
||||
ctypes.byref(bytes_returned),
|
||||
None,
|
||||
):
|
||||
return False
|
||||
|
||||
descriptor = ctypes.cast(
|
||||
descriptor_buffer, ctypes.POINTER(STORAGE_DEVICE_DESCRIPTOR)
|
||||
).contents
|
||||
|
||||
if (
|
||||
bytes_returned.value < minimum_size
|
||||
or descriptor.RemovableMedia
|
||||
or descriptor.BusType not in INTERNAL_STORAGE_BUS_TYPES
|
||||
):
|
||||
return False
|
||||
|
||||
hotplug = STORAGE_HOTPLUG_INFO()
|
||||
hotplug.Size = ctypes.sizeof(hotplug)
|
||||
bytes_returned.value = 0
|
||||
|
||||
if not self.kernel32.DeviceIoControl(
|
||||
handle,
|
||||
IOCTL_STORAGE_GET_HOTPLUG_INFO,
|
||||
None,
|
||||
0,
|
||||
ctypes.byref(hotplug),
|
||||
ctypes.sizeof(hotplug),
|
||||
ctypes.byref(bytes_returned),
|
||||
None,
|
||||
):
|
||||
return False
|
||||
|
||||
if bytes_returned.value < ctypes.sizeof(hotplug):
|
||||
return False
|
||||
|
||||
return not (hotplug.MediaRemovable or hotplug.MediaHotplug or hotplug.DeviceHotplug)
|
||||
except Exception:
|
||||
log_exception("PYAS_System.SystemMixin._is_internal_physical_drive:80")
|
||||
return False
|
||||
finally:
|
||||
self.kernel32.CloseHandle(handle)
|
||||
|
||||
def backup_mbr(self, max_drives=26):
|
||||
self.mbr_backup = {}
|
||||
|
||||
for drive in range(max_drives):
|
||||
if not self._is_internal_physical_drive(drive):
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(rf"\\.\PhysicalDrive{drive}", "rb") as f:
|
||||
mbr = f.read(512)
|
||||
|
||||
if len(mbr) == 512 and mbr[510:512] == b"\x55\xAA":
|
||||
self.mbr_backup[drive] = mbr
|
||||
|
||||
except Exception:
|
||||
log_exception("PYAS_System.SystemMixin.backup_mbr:97")
|
||||
continue
|
||||
|
||||
def check_system_mbr(self):
|
||||
for drive, mbr_value in list(self.mbr_backup.items()):
|
||||
try:
|
||||
with open(rf"\\.\PhysicalDrive{drive}", "rb") as f:
|
||||
if f.read(512) != mbr_value:
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
log_exception("PYAS_System.SystemMixin.check_system_mbr:107")
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
def repair_system_mbr(self):
|
||||
for drive, mbr_value in list(self.mbr_backup.items()):
|
||||
drive_path = rf"\\.\PhysicalDrive{drive}"
|
||||
|
||||
try:
|
||||
with open(drive_path, "rb+") as f:
|
||||
if f.read(512) != mbr_value:
|
||||
f.seek(0)
|
||||
f.write(mbr_value)
|
||||
self.write_log("INFO", "MBR Repaired", source=drive_path, operate=True)
|
||||
|
||||
except Exception:
|
||||
log_exception("PYAS_System.SystemMixin.repair_system_mbr:120")
|
||||
pass
|
||||
|
||||
def _get_restrict_lists(self):
|
||||
permissions = [
|
||||
"NoControlPanel",
|
||||
"NoDrives",
|
||||
"NoFileMenu",
|
||||
"NoFind",
|
||||
"NoStartMenuPinnedList",
|
||||
"NoSetFolders",
|
||||
"NoSetFolderOptions",
|
||||
"NoViewOnDrive",
|
||||
"NoClose",
|
||||
"NoDesktop",
|
||||
"NoLogoff",
|
||||
"NoFolderOptions",
|
||||
"RestrictRun",
|
||||
"NoViewContextMenu",
|
||||
"HideClock",
|
||||
"NoStartMenuMyGames",
|
||||
"NoStartMenuMyMusic",
|
||||
"DisableCMD",
|
||||
"NoAddingComponents",
|
||||
"NoWinKeys",
|
||||
"NoStartMenuLogOff",
|
||||
"NoSimpleNetIDList",
|
||||
"NoLowDiskSpaceChecks",
|
||||
"DisableLockWorkstation",
|
||||
"Restrict_Run",
|
||||
"DisableTaskMgr",
|
||||
"DisableRegistryTools",
|
||||
"DisableChangePassword",
|
||||
"Wallpaper",
|
||||
"NoComponents",
|
||||
"NoStartMenuMorePrograms",
|
||||
"NoActiveDesktop",
|
||||
"NoSetActiveDesktop",
|
||||
"NoRecentDocsMenu",
|
||||
"NoWindowsUpdate",
|
||||
"NoChangeStartMenu",
|
||||
"NoFavoritesMenu",
|
||||
"NoRecentDocsHistory",
|
||||
"NoSetTaskbar",
|
||||
"NoSMHelp",
|
||||
"NoTrayContextMenu",
|
||||
"NoManageMyComputerVerb",
|
||||
"NoRealMode",
|
||||
"NoRun",
|
||||
"ClearRecentDocsOnExit",
|
||||
"NoActiveDesktopChanges",
|
||||
"NoStartMenuNetworkPlaces",
|
||||
]
|
||||
paths = [
|
||||
(winreg.HKEY_CURRENT_USER, r"SOFTWARE\Policies\Microsoft\MMC"),
|
||||
(winreg.HKEY_CURRENT_USER, r"SOFTWARE\Policies\Microsoft\Windows\System"),
|
||||
(
|
||||
winreg.HKEY_CURRENT_USER,
|
||||
r"SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\Explorer",
|
||||
),
|
||||
(
|
||||
winreg.HKEY_CURRENT_USER,
|
||||
r"SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System",
|
||||
),
|
||||
(
|
||||
winreg.HKEY_LOCAL_MACHINE,
|
||||
r"SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\Explorer",
|
||||
),
|
||||
(
|
||||
winreg.HKEY_LOCAL_MACHINE,
|
||||
r"SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System",
|
||||
),
|
||||
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"),
|
||||
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Policies\Microsoft\Windows\System"),
|
||||
]
|
||||
return permissions, paths
|
||||
|
||||
def check_system_restrict(self):
|
||||
permissions, paths = self._get_restrict_lists()
|
||||
|
||||
for hkey, path in paths:
|
||||
for val in permissions:
|
||||
if self._reg_read(hkey, path, val) is not None:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def check_system_file_type(self):
|
||||
for root in [winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER]:
|
||||
for ext in [".exe", ".bat", ".cmd", ".com"]:
|
||||
if self._reg_read(root, rf"SOFTWARE\Classes\{ext}", "") != (
|
||||
"exefile" if ext == ".exe" else ext[1:] + "file"
|
||||
):
|
||||
return True
|
||||
|
||||
for cmd in ["open", "runas"]:
|
||||
if (
|
||||
self._reg_read(root, rf"SOFTWARE\Classes\exefile\shell\{cmd}\command", "")
|
||||
!= '"%1" %*'
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def check_system_file_icon(self):
|
||||
return any(
|
||||
self._reg_read(root, r"SOFTWARE\Classes\exefile\DefaultIcon", "") != "%1"
|
||||
for root in [winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER]
|
||||
)
|
||||
|
||||
def check_system_image(self):
|
||||
try:
|
||||
with winreg.OpenKey(
|
||||
winreg.HKEY_LOCAL_MACHINE,
|
||||
r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options",
|
||||
0,
|
||||
winreg.KEY_READ,
|
||||
) as reg:
|
||||
i = 0
|
||||
|
||||
while True:
|
||||
try:
|
||||
subkey = winreg.EnumKey(reg, i)
|
||||
|
||||
with winreg.OpenKey(reg, subkey, 0, winreg.KEY_READ) as sub_reg:
|
||||
for val in ["Debugger", "UseFilter", "GlobalFlag", "MitigationOptions"]:
|
||||
try:
|
||||
winreg.QueryValueEx(sub_reg, val)
|
||||
return True
|
||||
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
i += 1
|
||||
except OSError:
|
||||
log_exception("PYAS_System.SystemMixin.check_system_image:185")
|
||||
break
|
||||
|
||||
except Exception:
|
||||
log_exception("PYAS_System.SystemMixin.check_system_image:188")
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
def check_system_wallpaper(self):
|
||||
return self._reg_read(
|
||||
winreg.HKEY_CURRENT_USER, r"Control Panel\Desktop", "Wallpaper"
|
||||
) != os.path.join(self.path_system, "web", "wallpaper", "Windows", "img0.jpg")
|
||||
|
||||
def scan_system_repair(self):
|
||||
items = []
|
||||
|
||||
for drive, mbr_value in list(self.mbr_backup.items()):
|
||||
drive_path = rf"\\.\PhysicalDrive{drive}"
|
||||
|
||||
try:
|
||||
with open(drive_path, "rb") as f:
|
||||
if f.read(512) != mbr_value:
|
||||
items.append(
|
||||
{"id": f"mbr|{drive}", "display": "repair_mbr", "path": drive_path}
|
||||
)
|
||||
|
||||
except Exception:
|
||||
log_exception("PYAS_System.SystemMixin.scan_system_repair:205")
|
||||
pass
|
||||
|
||||
permissions, paths = self._get_restrict_lists()
|
||||
|
||||
for hkey, path in paths:
|
||||
for val in permissions:
|
||||
|
||||
if self._reg_read(hkey, path, val) is not None:
|
||||
root = "HKLM" if hkey == winreg.HKEY_LOCAL_MACHINE else "HKCU"
|
||||
items.append(
|
||||
{
|
||||
"id": f"restrict|{root}\\{path}\\{val}",
|
||||
"display": "repair_limit",
|
||||
"path": rf"{root}\{path}\{val}",
|
||||
}
|
||||
)
|
||||
|
||||
for root in [winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER]:
|
||||
root_str = "HKLM" if root == winreg.HKEY_LOCAL_MACHINE else "HKCU"
|
||||
|
||||
for ext in [".exe", ".bat", ".cmd", ".com"]:
|
||||
expected = "exefile" if ext == ".exe" else ext[1:] + "file"
|
||||
|
||||
if self._reg_read(root, rf"SOFTWARE\Classes\{ext}", "") != expected:
|
||||
items.append(
|
||||
{
|
||||
"id": f"file_type|{root_str}\\{ext}",
|
||||
"display": "repair_assoc",
|
||||
"path": rf"{root_str}\SOFTWARE\Classes\{ext}",
|
||||
}
|
||||
)
|
||||
|
||||
for cmd in ["open", "runas"]:
|
||||
if (
|
||||
self._reg_read(root, rf"SOFTWARE\Classes\exefile\shell\{cmd}\command", "")
|
||||
!= '"%1" %*'
|
||||
):
|
||||
items.append(
|
||||
{
|
||||
"id": f"file_type|{root_str}\\exefile\\{cmd}",
|
||||
"display": "repair_assoc",
|
||||
"path": rf"{root_str}\SOFTWARE\Classes\exefile\shell\{cmd}\command",
|
||||
}
|
||||
)
|
||||
|
||||
if self._reg_read(root, r"SOFTWARE\Classes\exefile\DefaultIcon", "") != "%1":
|
||||
items.append(
|
||||
{
|
||||
"id": f"file_icon|{root_str}",
|
||||
"display": "repair_icon",
|
||||
"path": rf"{root_str}\SOFTWARE\Classes\exefile\DefaultIcon",
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
with winreg.OpenKey(
|
||||
winreg.HKEY_LOCAL_MACHINE,
|
||||
r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options",
|
||||
0,
|
||||
winreg.KEY_READ,
|
||||
) as reg:
|
||||
i = 0
|
||||
|
||||
while True:
|
||||
try:
|
||||
subkey = winreg.EnumKey(reg, i)
|
||||
|
||||
with winreg.OpenKey(reg, subkey, 0, winreg.KEY_READ) as sub_reg:
|
||||
for val in ["Debugger", "UseFilter", "GlobalFlag", "MitigationOptions"]:
|
||||
try:
|
||||
winreg.QueryValueEx(sub_reg, val)
|
||||
items.append(
|
||||
{
|
||||
"id": f"image|{subkey}",
|
||||
"display": "repair_hijack",
|
||||
"path": rf"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\{subkey}",
|
||||
}
|
||||
)
|
||||
break
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
i += 1
|
||||
except OSError:
|
||||
log_exception("PYAS_System.SystemMixin.scan_system_repair:247")
|
||||
break
|
||||
|
||||
except Exception:
|
||||
log_exception("PYAS_System.SystemMixin.scan_system_repair:250")
|
||||
pass
|
||||
|
||||
wp = self._reg_read(winreg.HKEY_CURRENT_USER, r"Control Panel\Desktop", "Wallpaper")
|
||||
expected_wp = os.path.join(self.path_system, "web", "wallpaper", "Windows", "img0.jpg")
|
||||
|
||||
if wp != expected_wp:
|
||||
items.append(
|
||||
{
|
||||
"id": "wallpaper|0",
|
||||
"display": "repair_wallpaper",
|
||||
"path": wp if wp else r"HKCU\Control Panel\Desktop\Wallpaper",
|
||||
}
|
||||
)
|
||||
|
||||
return items
|
||||
|
||||
def repair_system_restrict(self):
|
||||
permissions, paths = self._get_restrict_lists()
|
||||
|
||||
for hkey, path in paths:
|
||||
for val in permissions:
|
||||
self._reg_delete(hkey, path, val)
|
||||
|
||||
def repair_system_file_type(self):
|
||||
for root in [winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER]:
|
||||
for ext in [".exe", ".bat", ".cmd", ".com"]:
|
||||
self._reg_write(
|
||||
root,
|
||||
rf"SOFTWARE\Classes\{ext}",
|
||||
None,
|
||||
winreg.REG_SZ,
|
||||
"exefile" if ext == ".exe" else ext[1:] + "file",
|
||||
)
|
||||
|
||||
for cmd in ["open", "runas"]:
|
||||
self._reg_write(
|
||||
root,
|
||||
rf"SOFTWARE\Classes\exefile\shell\{cmd}\command",
|
||||
None,
|
||||
winreg.REG_SZ,
|
||||
'"%1" %*',
|
||||
)
|
||||
|
||||
def repair_system_file_icon(self):
|
||||
for root in [winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER]:
|
||||
self._reg_write(
|
||||
root, r"SOFTWARE\Classes\exefile\DefaultIcon", None, winreg.REG_SZ, "%1"
|
||||
)
|
||||
|
||||
def repair_system_image(self):
|
||||
try:
|
||||
with winreg.OpenKey(
|
||||
winreg.HKEY_LOCAL_MACHINE,
|
||||
r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options",
|
||||
0,
|
||||
winreg.KEY_ALL_ACCESS,
|
||||
) as reg:
|
||||
i = 0
|
||||
|
||||
while True:
|
||||
try:
|
||||
subkey = winreg.EnumKey(reg, i)
|
||||
|
||||
with winreg.OpenKey(reg, subkey, 0, winreg.KEY_ALL_ACCESS) as sub_reg:
|
||||
for val in ["Debugger", "UseFilter", "GlobalFlag", "MitigationOptions"]:
|
||||
try:
|
||||
winreg.DeleteValue(sub_reg, val)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
i += 1
|
||||
except OSError:
|
||||
log_exception("PYAS_System.SystemMixin.repair_system_image:292")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
log_exception("PYAS_System.SystemMixin.repair_system_image:295")
|
||||
self.write_log("WARN", "repair_system_image", detail=str(e), success=False)
|
||||
|
||||
def repair_system_wallpaper(self):
|
||||
try:
|
||||
wallpaper = os.path.join(self.path_system, "web", "wallpaper", "Windows", "img0.jpg")
|
||||
|
||||
if not os.path.exists(wallpaper):
|
||||
return
|
||||
|
||||
self._reg_write(
|
||||
winreg.HKEY_CURRENT_USER,
|
||||
r"Control Panel\Desktop",
|
||||
"Wallpaper",
|
||||
winreg.REG_SZ,
|
||||
wallpaper,
|
||||
)
|
||||
theme_dir = os.path.join(self.path_appdata, "Microsoft", "Windows", "Themes")
|
||||
|
||||
for fname in ["TranscodedWallpaper", "TranscodedWallpaper.tmp"]:
|
||||
try:
|
||||
os.remove(os.path.join(theme_dir, fname))
|
||||
except Exception:
|
||||
log_exception("PYAS_System.SystemMixin.repair_system_wallpaper:310")
|
||||
pass
|
||||
|
||||
shutil.rmtree(os.path.join(theme_dir, "CachedFiles"), ignore_errors=True)
|
||||
self.user32.SystemParametersInfoW(20, 0, wallpaper, 3)
|
||||
|
||||
except Exception as e:
|
||||
log_exception("PYAS_System.SystemMixin.repair_system_wallpaper:316")
|
||||
self.write_log("WARN", "repair_system_wallpaper", detail=str(e), success=False)
|
||||
|
||||
def execute_system_repair(self, items):
|
||||
try:
|
||||
types = set(item.split("|")[0] for item in items)
|
||||
|
||||
if "mbr" in types:
|
||||
self.repair_system_mbr()
|
||||
|
||||
if "restrict" in types:
|
||||
self.repair_system_restrict()
|
||||
|
||||
if "file_type" in types:
|
||||
self.repair_system_file_type()
|
||||
|
||||
if "file_icon" in types:
|
||||
self.repair_system_file_icon()
|
||||
|
||||
if "image" in types:
|
||||
self.repair_system_image()
|
||||
|
||||
if "wallpaper" in types:
|
||||
self.repair_system_wallpaper()
|
||||
|
||||
self.write_log(
|
||||
"INFO", "System Repair", detail=f"Repaired {len(items)} items", operate=True
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
log_exception("PYAS_System.SystemMixin.execute_system_repair:339")
|
||||
self.write_log(
|
||||
"WARN", "execute_system_repair", detail=str(e), operate=True, success=False
|
||||
)
|
||||
return False
|
||||
|
||||
def protect_system_thread(self):
|
||||
while True:
|
||||
with self.lock_config:
|
||||
if not self.pyas_config.get("system_switch", False):
|
||||
break
|
||||
|
||||
try:
|
||||
self.repair_system_mbr()
|
||||
self.repair_system_restrict()
|
||||
self.repair_system_file_type()
|
||||
self.repair_system_file_icon()
|
||||
self.repair_system_image()
|
||||
self.check_process_survival()
|
||||
time.sleep(0.5)
|
||||
|
||||
except Exception as e:
|
||||
log_exception("PYAS_System.SystemMixin.protect_system_thread:357")
|
||||
self.write_log("WARN", "protect_system_thread", detail=str(e), success=False)
|
||||
|
||||
def check_process_survival(self):
|
||||
try:
|
||||
running = any(
|
||||
exe_name.lower() == "explorer.exe" for _, exe_name in self._enum_processes()
|
||||
)
|
||||
|
||||
if not running:
|
||||
explorer = self._find_windows_tool("explorer.exe")
|
||||
|
||||
if not explorer:
|
||||
return
|
||||
|
||||
subprocess.Popen([explorer])
|
||||
self.write_log("INFO", "System Restart", source="explorer.exe")
|
||||
|
||||
except Exception:
|
||||
log_exception("PYAS_System.SystemMixin.check_process_survival:371")
|
||||
pass
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
from PYAS_Diagnostics import log_exception
|
||||
import os
|
||||
import time
|
||||
import shutil
|
||||
|
||||
|
||||
class ThreatMixin:
|
||||
def manage_named_list(self, list_key, files, action="add", lock_func=None):
|
||||
if list_key == "quarantine" and lock_func is None:
|
||||
lock_func = self.lock_file
|
||||
|
||||
norm_paths = self.norm_path(files or [], must_exist=False)
|
||||
|
||||
if isinstance(norm_paths, str):
|
||||
norm_paths = [norm_paths] if norm_paths else []
|
||||
|
||||
if not norm_paths:
|
||||
return 0
|
||||
|
||||
if list_key == "custom_rule":
|
||||
acted_items = []
|
||||
os.makedirs(self.path_rules, exist_ok=True)
|
||||
|
||||
for file_path in norm_paths:
|
||||
if action == "add":
|
||||
if os.path.exists(file_path) and file_path.lower().endswith(".json"):
|
||||
dest = os.path.join(self.path_rules, os.path.basename(file_path))
|
||||
|
||||
if file_path != dest:
|
||||
try:
|
||||
shutil.copy2(file_path, dest)
|
||||
acted_items.append(dest)
|
||||
|
||||
except Exception:
|
||||
log_exception("PYAS_Threats.ThreatMixin.manage_named_list:34")
|
||||
pass
|
||||
else:
|
||||
acted_items.append(dest)
|
||||
|
||||
elif action == "remove":
|
||||
try:
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
|
||||
acted_items.append(file_path)
|
||||
except Exception:
|
||||
log_exception("PYAS_Threats.ThreatMixin.manage_named_list:45")
|
||||
pass
|
||||
|
||||
if acted_items and getattr(self, "driver_port", None):
|
||||
self.clear_driver_rules()
|
||||
|
||||
for f in os.listdir(self.path_rules):
|
||||
if f.lower().endswith(".json"):
|
||||
self.load_driver_rule_file(os.path.join(self.path_rules, f))
|
||||
|
||||
return len(acted_items)
|
||||
|
||||
if action == "add":
|
||||
if list_key == "white_list":
|
||||
self.remove_list_items("quarantine", norm_paths)
|
||||
|
||||
elif list_key == "quarantine":
|
||||
self.remove_list_items("white_list", norm_paths)
|
||||
|
||||
acted_items = []
|
||||
|
||||
with self.lock_config:
|
||||
target_list = self.pyas_config.setdefault(list_key, [])
|
||||
|
||||
if action == "add":
|
||||
for path in norm_paths:
|
||||
path_case = os.path.normcase(path)
|
||||
exists = False
|
||||
|
||||
for item in target_list:
|
||||
val = item.get("file", "") if isinstance(item, dict) else item
|
||||
np = self.norm_path(val, must_exist=False)
|
||||
|
||||
if np and os.path.normcase(np) == path_case:
|
||||
exists = True
|
||||
break
|
||||
|
||||
if not exists:
|
||||
if lock_func:
|
||||
lock_func(path, True)
|
||||
|
||||
new_item = {"file": path, "time": time.time()}
|
||||
is_directory = None
|
||||
|
||||
if list_key == "white_list":
|
||||
is_directory = os.path.isdir(path)
|
||||
new_item["is_dir"] = is_directory
|
||||
|
||||
target_list.append(new_item)
|
||||
acted_items.append(path)
|
||||
|
||||
if list_key == "white_list":
|
||||
self.sync_driver_whitelist(path, True, is_directory)
|
||||
|
||||
elif action == "remove":
|
||||
norm_paths_case = {os.path.normcase(p) for p in norm_paths}
|
||||
new_list = []
|
||||
|
||||
for item in target_list:
|
||||
val = item.get("file", "") if isinstance(item, dict) else item
|
||||
|
||||
if val:
|
||||
np = self.norm_path(val, must_exist=False)
|
||||
|
||||
if np and os.path.normcase(np) in norm_paths_case:
|
||||
if lock_func:
|
||||
lock_func(val, False)
|
||||
|
||||
acted_items.append(val)
|
||||
|
||||
if list_key == "white_list":
|
||||
is_directory = (
|
||||
item.get("is_dir") if isinstance(item, dict) else None
|
||||
)
|
||||
self.sync_driver_whitelist(val, False, is_directory)
|
||||
|
||||
continue
|
||||
|
||||
new_list.append(item)
|
||||
|
||||
target_list[:] = new_list
|
||||
|
||||
if acted_items:
|
||||
self.write_log(
|
||||
"INFO", "Config Update", detail=f"List [{list_key}] {action}: {acted_items}"
|
||||
)
|
||||
self.save_config()
|
||||
|
||||
return len(acted_items)
|
||||
|
||||
def remove_list_items(self, list_key, paths_to_remove):
|
||||
if list_key == "custom_rule":
|
||||
return self.manage_named_list(list_key, paths_to_remove, action="remove") > 0
|
||||
|
||||
with self.lock_config:
|
||||
target_list = self.pyas_config.get(list_key, [])
|
||||
original_len = len(target_list)
|
||||
|
||||
norm_paths_to_remove = set()
|
||||
|
||||
for p in paths_to_remove:
|
||||
np = self.norm_path(p, must_exist=False)
|
||||
|
||||
if np:
|
||||
norm_paths_to_remove.add(os.path.normcase(np))
|
||||
|
||||
if list_key == "quarantine":
|
||||
for item in target_list:
|
||||
val = item.get("file") if isinstance(item, dict) else item
|
||||
|
||||
if val:
|
||||
np = self.norm_path(val, must_exist=False)
|
||||
|
||||
if np and os.path.normcase(np) in norm_paths_to_remove:
|
||||
self.lock_file(val, False)
|
||||
|
||||
new_list = []
|
||||
removed_items = []
|
||||
|
||||
for item in target_list:
|
||||
val = (
|
||||
item.get("file") or item.get("exe") or item.get("title")
|
||||
if isinstance(item, dict)
|
||||
else item
|
||||
)
|
||||
|
||||
if val:
|
||||
np = self.norm_path(val, must_exist=False)
|
||||
|
||||
if np and os.path.normcase(np) in norm_paths_to_remove:
|
||||
removed_items.append(val)
|
||||
|
||||
if list_key == "white_list":
|
||||
is_directory = item.get("is_dir") if isinstance(item, dict) else None
|
||||
self.sync_driver_whitelist(val, False, is_directory)
|
||||
|
||||
continue
|
||||
|
||||
new_list.append(item)
|
||||
|
||||
self.pyas_config[list_key] = new_list
|
||||
|
||||
if len(self.pyas_config[list_key]) < original_len:
|
||||
self.write_log(
|
||||
"INFO", "Config Update", detail=f"List [{list_key}] remove: {removed_items}"
|
||||
)
|
||||
self.save_config()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def extract_list_items(self, paths, dest_dir):
|
||||
if not dest_dir or not os.path.exists(dest_dir):
|
||||
return False
|
||||
|
||||
extracted_count = 0
|
||||
|
||||
for raw_path in paths:
|
||||
src = self.norm_path(raw_path, must_exist=True)
|
||||
|
||||
if not src:
|
||||
continue
|
||||
|
||||
base_name = os.path.basename(src)
|
||||
target_path = os.path.join(dest_dir, base_name)
|
||||
|
||||
if os.path.exists(target_path):
|
||||
name, ext = os.path.splitext(base_name)
|
||||
counter = 1
|
||||
|
||||
while os.path.exists(target_path):
|
||||
target_path = os.path.join(dest_dir, f"{name} ({counter}){ext}")
|
||||
counter += 1
|
||||
|
||||
was_locked = False
|
||||
src_norm = os.path.normcase(src)
|
||||
src_dir = src_norm + os.sep
|
||||
|
||||
with self.lock_file_ops:
|
||||
for locked_path in self.virus_lock:
|
||||
locked_norm = os.path.normcase(locked_path)
|
||||
|
||||
if locked_norm == src_norm or locked_norm.startswith(src_dir):
|
||||
was_locked = True
|
||||
break
|
||||
|
||||
if was_locked:
|
||||
self.lock_file(src, False)
|
||||
|
||||
try:
|
||||
if os.path.isdir(src):
|
||||
shutil.copytree(src, target_path)
|
||||
else:
|
||||
shutil.copy2(src, target_path)
|
||||
|
||||
extracted_count += 1
|
||||
self.write_log("INFO", "File Extract", source=src, target=target_path, operate=True)
|
||||
|
||||
except Exception as e:
|
||||
log_exception("PYAS_Threats.ThreatMixin.extract_list_items:210")
|
||||
self.write_log(
|
||||
"WARN", "extract_list_items", source=src, detail=str(e), success=False
|
||||
)
|
||||
finally:
|
||||
if was_locked:
|
||||
self.lock_file(src, True)
|
||||
|
||||
return extracted_count > 0
|
||||
+201
-1173
File diff suppressed because it is too large.
Load diff
+21
-12
@@ -1,12 +1,21 @@
|
||||
import pyinstaller_versionfile
|
||||
|
||||
pyinstaller_versionfile.create_versionfile(
|
||||
output_file="versionfile.txt",
|
||||
version='3.7.0.0',
|
||||
company_name="PYAS Security",
|
||||
file_description="PYAS Security Antivirus",
|
||||
internal_name="PYAS",
|
||||
legal_copyright="PYAS Security",
|
||||
original_filename="PYAS.exe",
|
||||
product_name="PYAS"
|
||||
)
|
||||
VERSION = "3.7.1"
|
||||
FILE_VERSION = VERSION + ".0"
|
||||
|
||||
|
||||
def create_version_file(output_file="versionfile.txt"):
|
||||
import pyinstaller_versionfile
|
||||
|
||||
pyinstaller_versionfile.create_versionfile(
|
||||
output_file=output_file,
|
||||
version=FILE_VERSION,
|
||||
company_name="PYAS Security",
|
||||
file_description="PYAS Security Antivirus",
|
||||
internal_name="PYAS",
|
||||
legal_copyright="PYAS Security",
|
||||
original_filename="PYAS.exe",
|
||||
product_name="PYAS",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
create_version_file()
|
||||
+649
@@ -0,0 +1,649 @@
|
||||
import struct
|
||||
from PYAS_Diagnostics import log_exception
|
||||
import ctypes
|
||||
import ctypes.wintypes
|
||||
|
||||
PROCESS_TERMINATE = 0x0001
|
||||
PROCESS_QUERY_INFORMATION = 0x0400
|
||||
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
PYAS_PROCESS_TERMINATE_ACCESS = (
|
||||
PROCESS_TERMINATE | PROCESS_QUERY_INFORMATION | PROCESS_QUERY_LIMITED_INFORMATION
|
||||
)
|
||||
|
||||
|
||||
class PROCESSENTRY32W(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("dwSize", ctypes.wintypes.DWORD),
|
||||
("cntUsage", ctypes.wintypes.DWORD),
|
||||
("th32ProcessID", ctypes.wintypes.DWORD),
|
||||
("th32DefaultHeapID", ctypes.wintypes.LPVOID),
|
||||
("th32ModuleID", ctypes.wintypes.DWORD),
|
||||
("cntThreads", ctypes.wintypes.DWORD),
|
||||
("th32ParentProcessID", ctypes.wintypes.DWORD),
|
||||
("pcPriClassBase", ctypes.wintypes.LONG),
|
||||
("dwFlags", ctypes.wintypes.DWORD),
|
||||
("szExeFile", ctypes.wintypes.WCHAR * 260),
|
||||
]
|
||||
|
||||
|
||||
class MIB_TCPROW_OWNER_PID(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("dwState", ctypes.wintypes.DWORD),
|
||||
("dwLocalAddr", ctypes.wintypes.DWORD),
|
||||
("dwLocalPort", ctypes.wintypes.DWORD),
|
||||
("dwRemoteAddr", ctypes.wintypes.DWORD),
|
||||
("dwRemotePort", ctypes.wintypes.DWORD),
|
||||
("dwOwningPid", ctypes.wintypes.DWORD),
|
||||
]
|
||||
|
||||
|
||||
class FILE_NOTIFY_INFORMATION(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("NextEntryOffset", ctypes.wintypes.DWORD),
|
||||
("Action", ctypes.wintypes.DWORD),
|
||||
("FileNameLength", ctypes.wintypes.DWORD),
|
||||
("FileName", ctypes.wintypes.WCHAR * 1024),
|
||||
]
|
||||
|
||||
|
||||
class PROCESS_BASIC_INFORMATION(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("Reserved1", ctypes.wintypes.LPVOID),
|
||||
("PebBaseAddress", ctypes.wintypes.LPVOID),
|
||||
("Reserved2", ctypes.wintypes.LPVOID * 2),
|
||||
("UniqueProcessId", ctypes.wintypes.LPVOID),
|
||||
("Reserved3", ctypes.wintypes.LPVOID),
|
||||
]
|
||||
|
||||
|
||||
class SHQUERYRBINFO(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("cbSize", ctypes.wintypes.DWORD),
|
||||
("i64Size", ctypes.c_int64),
|
||||
("i64NumItems", ctypes.c_int64),
|
||||
]
|
||||
|
||||
|
||||
class UNICODE_STRING(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("Length", ctypes.wintypes.USHORT),
|
||||
("MaximumLength", ctypes.wintypes.USHORT),
|
||||
("Buffer", ctypes.c_void_p),
|
||||
]
|
||||
|
||||
|
||||
class FILTER_MESSAGE_HEADER(ctypes.Structure):
|
||||
_fields_ = [("ReplyLength", ctypes.wintypes.ULONG), ("MessageId", ctypes.c_uint64)]
|
||||
|
||||
|
||||
class PYAS_MESSAGE(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("MessageCode", ctypes.wintypes.ULONG),
|
||||
("ProcessId", ctypes.wintypes.ULONG),
|
||||
("Path", ctypes.wintypes.WCHAR * 1024),
|
||||
]
|
||||
|
||||
|
||||
class PYAS_FULL_MESSAGE(ctypes.Structure):
|
||||
_fields_ = [("Header", FILTER_MESSAGE_HEADER), ("Data", PYAS_MESSAGE)]
|
||||
|
||||
|
||||
class PYAS_USER_MESSAGE(ctypes.Structure):
|
||||
_fields_ = [("Command", ctypes.wintypes.ULONG), ("Path", ctypes.wintypes.WCHAR * 1024)]
|
||||
|
||||
|
||||
class COPYDATASTRUCT(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("dwData", ctypes.c_size_t),
|
||||
("cbData", ctypes.wintypes.DWORD),
|
||||
("lpData", ctypes.c_void_p),
|
||||
]
|
||||
|
||||
|
||||
class IO_COUNTERS(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("ReadOperationCount", ctypes.c_ulonglong),
|
||||
("WriteOperationCount", ctypes.c_ulonglong),
|
||||
("OtherOperationCount", ctypes.c_ulonglong),
|
||||
("ReadTransferCount", ctypes.c_ulonglong),
|
||||
("WriteTransferCount", ctypes.c_ulonglong),
|
||||
("OtherTransferCount", ctypes.c_ulonglong),
|
||||
]
|
||||
|
||||
|
||||
class MEMORY_BASIC_INFORMATION(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("BaseAddress", ctypes.c_void_p),
|
||||
("AllocationBase", ctypes.c_void_p),
|
||||
("AllocationProtect", ctypes.wintypes.DWORD),
|
||||
("RegionSize", ctypes.c_size_t),
|
||||
("State", ctypes.wintypes.DWORD),
|
||||
("Protect", ctypes.wintypes.DWORD),
|
||||
("Type", ctypes.wintypes.DWORD),
|
||||
]
|
||||
|
||||
|
||||
class LUID(ctypes.Structure):
|
||||
_fields_ = [("LowPart", ctypes.wintypes.DWORD), ("HighPart", ctypes.wintypes.LONG)]
|
||||
|
||||
|
||||
class LUID_AND_ATTRIBUTES(ctypes.Structure):
|
||||
_fields_ = [("Luid", LUID), ("Attributes", ctypes.wintypes.DWORD)]
|
||||
|
||||
|
||||
class TOKEN_PRIVILEGES(ctypes.Structure):
|
||||
_fields_ = [("PrivilegeCount", ctypes.wintypes.DWORD), ("Privileges", LUID_AND_ATTRIBUTES * 1)]
|
||||
|
||||
|
||||
class SERVICE_STATUS_PROCESS(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("dwServiceType", ctypes.wintypes.DWORD),
|
||||
("dwCurrentState", ctypes.wintypes.DWORD),
|
||||
("dwControlsAccepted", ctypes.wintypes.DWORD),
|
||||
("dwWin32ExitCode", ctypes.wintypes.DWORD),
|
||||
("dwServiceSpecificExitCode", ctypes.wintypes.DWORD),
|
||||
("dwCheckPoint", ctypes.wintypes.DWORD),
|
||||
("dwWaitHint", ctypes.wintypes.DWORD),
|
||||
("dwProcessId", ctypes.wintypes.DWORD),
|
||||
("dwServiceFlags", ctypes.wintypes.DWORD),
|
||||
]
|
||||
|
||||
|
||||
class POINT(ctypes.Structure):
|
||||
_fields_ = [("x", ctypes.c_long), ("y", ctypes.c_long)]
|
||||
|
||||
|
||||
class RECT(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("left", ctypes.c_long),
|
||||
("top", ctypes.c_long),
|
||||
("right", ctypes.c_long),
|
||||
("bottom", ctypes.c_long),
|
||||
]
|
||||
|
||||
|
||||
FLT_PORT_FLAG_SYNC_HANDLE = 0x00000001
|
||||
HRESULT_IO_PENDING = 0x800703E5
|
||||
ERROR_OPERATION_ABORTED = 995
|
||||
ERROR_NOT_FOUND = 1168
|
||||
ERROR_SHARING_VIOLATION = 32
|
||||
ERROR_LOCK_VIOLATION = 33
|
||||
WAIT_OBJECT_0 = 0
|
||||
WAIT_TIMEOUT = 258
|
||||
INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value
|
||||
GENERIC_READ = 0x80000000
|
||||
FILE_SHARE_READ = 0x00000001
|
||||
FILE_SHARE_WRITE = 0x00000002
|
||||
OPEN_EXISTING = 3
|
||||
FILE_ATTRIBUTE_NORMAL = 0x00000080
|
||||
IOCTL_STORAGE_QUERY_PROPERTY = 0x002D1400
|
||||
IOCTL_STORAGE_GET_HOTPLUG_INFO = 0x002D0C14
|
||||
STORAGE_DEVICE_PROPERTY = 0
|
||||
PROPERTY_STANDARD_QUERY = 0
|
||||
INTERNAL_STORAGE_BUS_TYPES = frozenset({1, 3, 8, 10, 11, 17, 18, 19})
|
||||
FILE_SCAN_DEBOUNCE_SECONDS = 0.35
|
||||
FILE_SCAN_RETRY_SECONDS = 0.25
|
||||
FILE_LOCK_RETRY_SECONDS = 0.5
|
||||
PYAS_CONNECTION_MAGIC = 0x53415950
|
||||
PYAS_CONNECTION_VERSION = 1
|
||||
PROCESS_VM_READ = 0x0010
|
||||
PROCESS_SUSPEND_RESUME = 0x0800
|
||||
PYAS_PROCESS_SCAN_ACCESS = (
|
||||
PROCESS_TERMINATE
|
||||
| PROCESS_VM_READ
|
||||
| PROCESS_QUERY_INFORMATION
|
||||
| PROCESS_SUSPEND_RESUME
|
||||
| PROCESS_QUERY_LIMITED_INFORMATION
|
||||
)
|
||||
PYAS_PROCESS_NETWORK_ACCESS = (
|
||||
PROCESS_TERMINATE | PROCESS_QUERY_INFORMATION | PROCESS_QUERY_LIMITED_INFORMATION
|
||||
)
|
||||
|
||||
|
||||
class PYAS_CONNECTION_CONTEXT(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("Size", ctypes.wintypes.ULONG),
|
||||
("Version", ctypes.wintypes.ULONG),
|
||||
("Magic", ctypes.wintypes.ULONG),
|
||||
("ProcessId", ctypes.wintypes.ULONG),
|
||||
]
|
||||
|
||||
|
||||
class OVERLAPPED(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("Internal", ctypes.c_size_t),
|
||||
("InternalHigh", ctypes.c_size_t),
|
||||
("Offset", ctypes.wintypes.DWORD),
|
||||
("OffsetHigh", ctypes.wintypes.DWORD),
|
||||
("hEvent", ctypes.wintypes.HANDLE),
|
||||
]
|
||||
|
||||
|
||||
class STORAGE_PROPERTY_QUERY(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("PropertyId", ctypes.c_int),
|
||||
("QueryType", ctypes.c_int),
|
||||
("AdditionalParameters", ctypes.c_ubyte * 1),
|
||||
]
|
||||
|
||||
|
||||
class STORAGE_DESCRIPTOR_HEADER(ctypes.Structure):
|
||||
_fields_ = [("Version", ctypes.wintypes.DWORD), ("Size", ctypes.wintypes.DWORD)]
|
||||
|
||||
|
||||
class STORAGE_DEVICE_DESCRIPTOR(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("Version", ctypes.wintypes.DWORD),
|
||||
("Size", ctypes.wintypes.DWORD),
|
||||
("DeviceType", ctypes.c_ubyte),
|
||||
("DeviceTypeModifier", ctypes.c_ubyte),
|
||||
("RemovableMedia", ctypes.c_ubyte),
|
||||
("CommandQueueing", ctypes.c_ubyte),
|
||||
("VendorIdOffset", ctypes.wintypes.DWORD),
|
||||
("ProductIdOffset", ctypes.wintypes.DWORD),
|
||||
("ProductRevisionOffset", ctypes.wintypes.DWORD),
|
||||
("SerialNumberOffset", ctypes.wintypes.DWORD),
|
||||
("BusType", ctypes.c_int),
|
||||
("RawPropertiesLength", ctypes.wintypes.DWORD),
|
||||
]
|
||||
|
||||
|
||||
class STORAGE_HOTPLUG_INFO(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("Size", ctypes.wintypes.DWORD),
|
||||
("MediaRemovable", ctypes.c_ubyte),
|
||||
("MediaHotplug", ctypes.c_ubyte),
|
||||
("DeviceHotplug", ctypes.c_ubyte),
|
||||
("WriteCacheEnableOverride", ctypes.c_ubyte),
|
||||
]
|
||||
|
||||
|
||||
class GUID(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("Data1", ctypes.wintypes.DWORD),
|
||||
("Data2", ctypes.wintypes.WORD),
|
||||
("Data3", ctypes.wintypes.WORD),
|
||||
("Data4", ctypes.c_ubyte * 8),
|
||||
]
|
||||
|
||||
|
||||
class WINTRUST_FILE_INFO(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("cbStruct", ctypes.wintypes.DWORD),
|
||||
("pcwszFilePath", ctypes.wintypes.LPCWSTR),
|
||||
("hFile", ctypes.wintypes.HANDLE),
|
||||
("pgKnownSubject", ctypes.wintypes.LPVOID),
|
||||
]
|
||||
|
||||
|
||||
class WINTRUST_DATA_UNION(ctypes.Union):
|
||||
_fields_ = [
|
||||
("pFile", ctypes.POINTER(WINTRUST_FILE_INFO)),
|
||||
("pCatalog", ctypes.wintypes.LPVOID),
|
||||
("pBlob", ctypes.wintypes.LPVOID),
|
||||
("pSgnr", ctypes.wintypes.LPVOID),
|
||||
("pCert", ctypes.wintypes.LPVOID),
|
||||
]
|
||||
|
||||
|
||||
class WINTRUST_DATA(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("cbStruct", ctypes.wintypes.DWORD),
|
||||
("pPolicyCallbackData", ctypes.wintypes.LPVOID),
|
||||
("pSIPClientData", ctypes.wintypes.LPVOID),
|
||||
("dwUIChoice", ctypes.wintypes.DWORD),
|
||||
("fdwRevocationChecks", ctypes.wintypes.DWORD),
|
||||
("dwUnionChoice", ctypes.wintypes.DWORD),
|
||||
("u", WINTRUST_DATA_UNION),
|
||||
("dwStateAction", ctypes.wintypes.DWORD),
|
||||
("hWVTStateData", ctypes.wintypes.HANDLE),
|
||||
("pwszURLReference", ctypes.wintypes.LPCWSTR),
|
||||
("dwProvFlags", ctypes.wintypes.DWORD),
|
||||
("dwUIContext", ctypes.wintypes.DWORD),
|
||||
("pSignatureSettings", ctypes.wintypes.LPVOID),
|
||||
]
|
||||
|
||||
|
||||
class WindowsMixin:
|
||||
def init_windll(self):
|
||||
for name in [
|
||||
"ntdll",
|
||||
"Psapi",
|
||||
"user32",
|
||||
"kernel32",
|
||||
"iphlpapi",
|
||||
"shell32",
|
||||
"fltlib",
|
||||
"advapi32",
|
||||
]:
|
||||
try:
|
||||
setattr(self, name.lower(), ctypes.WinDLL(name, use_last_error=True))
|
||||
except Exception as e:
|
||||
log_exception("PYAS_WinAPI.WindowsMixin.init_windll:323")
|
||||
self.write_log("WARN", "init_windll", detail=str(e), success=False)
|
||||
|
||||
self.user32.FindWindowW.argtypes = [ctypes.c_wchar_p, ctypes.c_wchar_p]
|
||||
self.user32.FindWindowW.restype = ctypes.wintypes.HWND
|
||||
self.user32.ShowWindow.argtypes = [ctypes.wintypes.HWND, ctypes.c_int]
|
||||
self.user32.ShowWindow.restype = ctypes.wintypes.BOOL
|
||||
self.user32.SendMessageTimeoutW.argtypes = [
|
||||
ctypes.wintypes.HWND,
|
||||
ctypes.wintypes.UINT,
|
||||
ctypes.wintypes.WPARAM,
|
||||
ctypes.c_void_p,
|
||||
ctypes.wintypes.UINT,
|
||||
ctypes.wintypes.UINT,
|
||||
ctypes.c_void_p,
|
||||
]
|
||||
self.user32.SendMessageTimeoutW.restype = ctypes.wintypes.LPARAM
|
||||
|
||||
self.user32.WindowFromPoint.argtypes = [POINT]
|
||||
self.user32.WindowFromPoint.restype = ctypes.wintypes.HWND
|
||||
self.user32.GetAncestor.argtypes = [ctypes.wintypes.HWND, ctypes.c_uint]
|
||||
self.user32.GetAncestor.restype = ctypes.wintypes.HWND
|
||||
self.user32.GetCursorPos.argtypes = [ctypes.POINTER(POINT)]
|
||||
self.user32.GetCursorPos.restype = ctypes.wintypes.BOOL
|
||||
self.user32.GetWindowRect.argtypes = [ctypes.wintypes.HWND, ctypes.POINTER(RECT)]
|
||||
self.user32.GetWindowRect.restype = ctypes.wintypes.BOOL
|
||||
self.user32.SetWindowPos.argtypes = [
|
||||
ctypes.wintypes.HWND,
|
||||
ctypes.wintypes.HWND,
|
||||
ctypes.c_int,
|
||||
ctypes.c_int,
|
||||
ctypes.c_int,
|
||||
ctypes.c_int,
|
||||
ctypes.wintypes.UINT,
|
||||
]
|
||||
self.user32.SetWindowPos.restype = ctypes.wintypes.BOOL
|
||||
self.user32.SetLayeredWindowAttributes.argtypes = [
|
||||
ctypes.wintypes.HWND,
|
||||
ctypes.wintypes.DWORD,
|
||||
ctypes.c_byte,
|
||||
ctypes.wintypes.DWORD,
|
||||
]
|
||||
self.user32.SetLayeredWindowAttributes.restype = ctypes.wintypes.BOOL
|
||||
self.user32.CreateWindowExW.restype = ctypes.wintypes.HWND
|
||||
|
||||
self.ntdll.NtQueryInformationProcess.argtypes = [
|
||||
ctypes.wintypes.HANDLE,
|
||||
ctypes.wintypes.ULONG,
|
||||
ctypes.c_void_p,
|
||||
ctypes.wintypes.ULONG,
|
||||
ctypes.POINTER(ctypes.wintypes.ULONG),
|
||||
]
|
||||
self.ntdll.NtQueryInformationProcess.restype = ctypes.wintypes.ULONG
|
||||
self.ntdll.NtSuspendProcess.argtypes = [ctypes.wintypes.HANDLE]
|
||||
self.ntdll.NtSuspendProcess.restype = ctypes.c_ulong
|
||||
self.ntdll.NtResumeProcess.argtypes = [ctypes.wintypes.HANDLE]
|
||||
self.ntdll.NtResumeProcess.restype = ctypes.c_ulong
|
||||
|
||||
self.shell32.CommandLineToArgvW.argtypes = [
|
||||
ctypes.wintypes.LPCWSTR,
|
||||
ctypes.POINTER(ctypes.c_int),
|
||||
]
|
||||
self.shell32.CommandLineToArgvW.restype = ctypes.POINTER(ctypes.wintypes.LPWSTR)
|
||||
self.shell32.SHEmptyRecycleBinW.argtypes = [
|
||||
ctypes.wintypes.HWND,
|
||||
ctypes.c_wchar_p,
|
||||
ctypes.wintypes.DWORD,
|
||||
]
|
||||
self.shell32.SHEmptyRecycleBinW.restype = ctypes.c_long
|
||||
self.shell32.SHQueryRecycleBinW.argtypes = [ctypes.c_wchar_p, ctypes.POINTER(SHQUERYRBINFO)]
|
||||
self.shell32.SHQueryRecycleBinW.restype = ctypes.c_long
|
||||
|
||||
self.fltlib.FilterConnectCommunicationPort.argtypes = [
|
||||
ctypes.wintypes.LPCWSTR,
|
||||
ctypes.wintypes.DWORD,
|
||||
ctypes.c_void_p,
|
||||
ctypes.wintypes.DWORD,
|
||||
ctypes.c_void_p,
|
||||
ctypes.POINTER(ctypes.wintypes.HANDLE),
|
||||
]
|
||||
self.fltlib.FilterConnectCommunicationPort.restype = ctypes.c_long
|
||||
self.fltlib.FilterGetMessage.argtypes = [
|
||||
ctypes.wintypes.HANDLE,
|
||||
ctypes.c_void_p,
|
||||
ctypes.wintypes.DWORD,
|
||||
ctypes.c_void_p,
|
||||
]
|
||||
self.fltlib.FilterGetMessage.restype = ctypes.c_long
|
||||
self.fltlib.FilterSendMessage.argtypes = [
|
||||
ctypes.wintypes.HANDLE,
|
||||
ctypes.c_void_p,
|
||||
ctypes.wintypes.DWORD,
|
||||
ctypes.c_void_p,
|
||||
ctypes.wintypes.DWORD,
|
||||
ctypes.POINTER(ctypes.wintypes.DWORD),
|
||||
]
|
||||
self.fltlib.FilterSendMessage.restype = ctypes.c_long
|
||||
self.fltlib.FilterUnload.argtypes = [ctypes.wintypes.LPCWSTR]
|
||||
self.fltlib.FilterUnload.restype = ctypes.c_long
|
||||
|
||||
self.advapi32.OpenProcessToken.argtypes = [
|
||||
ctypes.wintypes.HANDLE,
|
||||
ctypes.wintypes.DWORD,
|
||||
ctypes.POINTER(ctypes.wintypes.HANDLE),
|
||||
]
|
||||
self.advapi32.OpenProcessToken.restype = ctypes.wintypes.BOOL
|
||||
self.advapi32.LookupPrivilegeValueW.argtypes = [
|
||||
ctypes.wintypes.LPCWSTR,
|
||||
ctypes.wintypes.LPCWSTR,
|
||||
ctypes.POINTER(LUID),
|
||||
]
|
||||
self.advapi32.LookupPrivilegeValueW.restype = ctypes.wintypes.BOOL
|
||||
self.advapi32.AdjustTokenPrivileges.argtypes = [
|
||||
ctypes.wintypes.HANDLE,
|
||||
ctypes.wintypes.BOOL,
|
||||
ctypes.POINTER(TOKEN_PRIVILEGES),
|
||||
ctypes.wintypes.DWORD,
|
||||
ctypes.POINTER(TOKEN_PRIVILEGES),
|
||||
ctypes.POINTER(ctypes.wintypes.DWORD),
|
||||
]
|
||||
self.advapi32.AdjustTokenPrivileges.restype = ctypes.wintypes.BOOL
|
||||
self.advapi32.OpenSCManagerW.argtypes = [
|
||||
ctypes.wintypes.LPCWSTR,
|
||||
ctypes.wintypes.LPCWSTR,
|
||||
ctypes.wintypes.DWORD,
|
||||
]
|
||||
self.advapi32.OpenSCManagerW.restype = ctypes.wintypes.HANDLE
|
||||
self.advapi32.CreateServiceW.argtypes = [
|
||||
ctypes.wintypes.HANDLE,
|
||||
ctypes.wintypes.LPCWSTR,
|
||||
ctypes.wintypes.LPCWSTR,
|
||||
ctypes.wintypes.DWORD,
|
||||
ctypes.wintypes.DWORD,
|
||||
ctypes.wintypes.DWORD,
|
||||
ctypes.wintypes.DWORD,
|
||||
ctypes.wintypes.LPCWSTR,
|
||||
ctypes.wintypes.LPCWSTR,
|
||||
ctypes.POINTER(ctypes.wintypes.DWORD),
|
||||
ctypes.wintypes.LPCWSTR,
|
||||
ctypes.wintypes.LPCWSTR,
|
||||
ctypes.wintypes.LPCWSTR,
|
||||
]
|
||||
self.advapi32.CreateServiceW.restype = ctypes.wintypes.HANDLE
|
||||
self.advapi32.OpenServiceW.argtypes = [
|
||||
ctypes.wintypes.HANDLE,
|
||||
ctypes.wintypes.LPCWSTR,
|
||||
ctypes.wintypes.DWORD,
|
||||
]
|
||||
self.advapi32.OpenServiceW.restype = ctypes.wintypes.HANDLE
|
||||
self.advapi32.ChangeServiceConfigW.argtypes = [
|
||||
ctypes.wintypes.HANDLE,
|
||||
ctypes.wintypes.DWORD,
|
||||
ctypes.wintypes.DWORD,
|
||||
ctypes.wintypes.DWORD,
|
||||
ctypes.wintypes.LPCWSTR,
|
||||
ctypes.wintypes.LPCWSTR,
|
||||
ctypes.POINTER(ctypes.wintypes.DWORD),
|
||||
ctypes.wintypes.LPCWSTR,
|
||||
ctypes.wintypes.LPCWSTR,
|
||||
ctypes.wintypes.LPCWSTR,
|
||||
ctypes.wintypes.LPCWSTR,
|
||||
]
|
||||
self.advapi32.ChangeServiceConfigW.restype = ctypes.wintypes.BOOL
|
||||
self.advapi32.StartServiceW.argtypes = [
|
||||
ctypes.wintypes.HANDLE,
|
||||
ctypes.wintypes.DWORD,
|
||||
ctypes.POINTER(ctypes.wintypes.LPCWSTR),
|
||||
]
|
||||
self.advapi32.StartServiceW.restype = ctypes.wintypes.BOOL
|
||||
self.advapi32.QueryServiceStatusEx.argtypes = [
|
||||
ctypes.wintypes.HANDLE,
|
||||
ctypes.c_int,
|
||||
ctypes.POINTER(ctypes.c_ubyte),
|
||||
ctypes.wintypes.DWORD,
|
||||
ctypes.POINTER(ctypes.wintypes.DWORD),
|
||||
]
|
||||
self.advapi32.QueryServiceStatusEx.restype = ctypes.wintypes.BOOL
|
||||
self.advapi32.DeleteService.argtypes = [ctypes.wintypes.HANDLE]
|
||||
self.advapi32.DeleteService.restype = ctypes.wintypes.BOOL
|
||||
self.advapi32.CloseServiceHandle.argtypes = [ctypes.wintypes.HANDLE]
|
||||
self.advapi32.CloseServiceHandle.restype = ctypes.wintypes.BOOL
|
||||
|
||||
self.kernel32.GetCurrentProcess.argtypes = []
|
||||
self.kernel32.GetCurrentProcess.restype = ctypes.wintypes.HANDLE
|
||||
self.kernel32.CreateToolhelp32Snapshot.restype = ctypes.wintypes.HANDLE
|
||||
self.kernel32.CreateToolhelp32Snapshot.argtypes = [
|
||||
ctypes.wintypes.DWORD,
|
||||
ctypes.wintypes.DWORD,
|
||||
]
|
||||
self.kernel32.Process32FirstW.restype = ctypes.wintypes.BOOL
|
||||
self.kernel32.Process32FirstW.argtypes = [ctypes.wintypes.HANDLE, ctypes.c_void_p]
|
||||
self.kernel32.Process32NextW.restype = ctypes.wintypes.BOOL
|
||||
self.kernel32.Process32NextW.argtypes = [ctypes.wintypes.HANDLE, ctypes.c_void_p]
|
||||
self.kernel32.OpenProcess.restype = ctypes.wintypes.HANDLE
|
||||
self.kernel32.OpenProcess.argtypes = [
|
||||
ctypes.wintypes.DWORD,
|
||||
ctypes.wintypes.BOOL,
|
||||
ctypes.wintypes.DWORD,
|
||||
]
|
||||
self.kernel32.CloseHandle.restype = ctypes.wintypes.BOOL
|
||||
self.kernel32.CloseHandle.argtypes = [ctypes.wintypes.HANDLE]
|
||||
self.kernel32.CreateEventW.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.wintypes.BOOL,
|
||||
ctypes.wintypes.BOOL,
|
||||
ctypes.wintypes.LPCWSTR,
|
||||
]
|
||||
self.kernel32.CreateEventW.restype = ctypes.wintypes.HANDLE
|
||||
self.kernel32.WaitForSingleObject.argtypes = [ctypes.wintypes.HANDLE, ctypes.wintypes.DWORD]
|
||||
self.kernel32.WaitForSingleObject.restype = ctypes.wintypes.DWORD
|
||||
self.kernel32.CancelIoEx.argtypes = [ctypes.wintypes.HANDLE, ctypes.c_void_p]
|
||||
self.kernel32.CancelIoEx.restype = ctypes.wintypes.BOOL
|
||||
self.kernel32.GetOverlappedResult.argtypes = [
|
||||
ctypes.wintypes.HANDLE,
|
||||
ctypes.c_void_p,
|
||||
ctypes.POINTER(ctypes.wintypes.DWORD),
|
||||
ctypes.wintypes.BOOL,
|
||||
]
|
||||
self.kernel32.GetOverlappedResult.restype = ctypes.wintypes.BOOL
|
||||
self.kernel32.TerminateProcess.restype = ctypes.wintypes.BOOL
|
||||
self.kernel32.TerminateProcess.argtypes = [ctypes.wintypes.HANDLE, ctypes.c_uint]
|
||||
self.kernel32.CreateMutexW.restype = ctypes.wintypes.HANDLE
|
||||
self.kernel32.CreateMutexW.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.wintypes.BOOL,
|
||||
ctypes.c_wchar_p,
|
||||
]
|
||||
self.kernel32.CreateFileW.restype = ctypes.wintypes.HANDLE
|
||||
self.kernel32.CreateFileW.argtypes = [
|
||||
ctypes.c_wchar_p,
|
||||
ctypes.wintypes.DWORD,
|
||||
ctypes.wintypes.DWORD,
|
||||
ctypes.c_void_p,
|
||||
ctypes.wintypes.DWORD,
|
||||
ctypes.wintypes.DWORD,
|
||||
ctypes.wintypes.HANDLE,
|
||||
]
|
||||
self.kernel32.DeviceIoControl.restype = ctypes.wintypes.BOOL
|
||||
self.kernel32.DeviceIoControl.argtypes = [
|
||||
ctypes.wintypes.HANDLE,
|
||||
ctypes.wintypes.DWORD,
|
||||
ctypes.c_void_p,
|
||||
ctypes.wintypes.DWORD,
|
||||
ctypes.c_void_p,
|
||||
ctypes.wintypes.DWORD,
|
||||
ctypes.POINTER(ctypes.wintypes.DWORD),
|
||||
ctypes.c_void_p,
|
||||
]
|
||||
self.kernel32.ReadProcessMemory.restype = ctypes.wintypes.BOOL
|
||||
self.kernel32.ReadProcessMemory.argtypes = [
|
||||
ctypes.wintypes.HANDLE,
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_size_t),
|
||||
]
|
||||
self.kernel32.QueryFullProcessImageNameW.restype = ctypes.wintypes.BOOL
|
||||
self.kernel32.QueryFullProcessImageNameW.argtypes = [
|
||||
ctypes.wintypes.HANDLE,
|
||||
ctypes.wintypes.DWORD,
|
||||
ctypes.wintypes.LPWSTR,
|
||||
ctypes.POINTER(ctypes.wintypes.DWORD),
|
||||
]
|
||||
self.kernel32.QueryDosDeviceW.restype = ctypes.wintypes.DWORD
|
||||
self.kernel32.QueryDosDeviceW.argtypes = [
|
||||
ctypes.wintypes.LPCWSTR,
|
||||
ctypes.wintypes.LPWSTR,
|
||||
ctypes.wintypes.DWORD,
|
||||
]
|
||||
self.kernel32.GetProcessIoCounters.argtypes = [
|
||||
ctypes.wintypes.HANDLE,
|
||||
ctypes.POINTER(IO_COUNTERS),
|
||||
]
|
||||
self.kernel32.GetProcessIoCounters.restype = ctypes.wintypes.BOOL
|
||||
self.kernel32.VirtualQueryEx.argtypes = [
|
||||
ctypes.wintypes.HANDLE,
|
||||
ctypes.c_void_p,
|
||||
ctypes.POINTER(MEMORY_BASIC_INFORMATION),
|
||||
ctypes.c_size_t,
|
||||
]
|
||||
self.kernel32.VirtualQueryEx.restype = ctypes.c_size_t
|
||||
self.kernel32.SetProcessWorkingSetSize.argtypes = [
|
||||
ctypes.wintypes.HANDLE,
|
||||
ctypes.c_size_t,
|
||||
ctypes.c_size_t,
|
||||
]
|
||||
self.kernel32.SetProcessWorkingSetSize.restype = ctypes.wintypes.BOOL
|
||||
|
||||
self.psapi.GetMappedFileNameW.argtypes = [
|
||||
ctypes.wintypes.HANDLE,
|
||||
ctypes.c_void_p,
|
||||
ctypes.wintypes.LPWSTR,
|
||||
ctypes.wintypes.DWORD,
|
||||
]
|
||||
self.psapi.GetMappedFileNameW.restype = ctypes.wintypes.DWORD
|
||||
|
||||
|
||||
def iter_file_notifications(buffer, size):
|
||||
view = memoryview(buffer).cast("B")
|
||||
|
||||
if size < 0 or size > len(view):
|
||||
raise ValueError("Invalid file notification buffer size")
|
||||
|
||||
offset = 0
|
||||
|
||||
while offset < size:
|
||||
if offset + 12 > size:
|
||||
raise ValueError("Truncated file notification header")
|
||||
|
||||
next_offset, action, length = struct.unpack_from("<III", view, offset)
|
||||
|
||||
if length % 2 or offset + 12 + length > size:
|
||||
raise ValueError("Invalid file notification length")
|
||||
|
||||
if next_offset and (
|
||||
next_offset < 12 + length or next_offset % 4 or offset + next_offset >= size
|
||||
):
|
||||
raise ValueError("Invalid file notification offset")
|
||||
|
||||
filename = bytes(view[offset + 12 : offset + 12 + length]).decode(
|
||||
"utf-16-le", errors="surrogatepass"
|
||||
)
|
||||
yield action, filename
|
||||
|
||||
if not next_offset:
|
||||
break
|
||||
|
||||
offset += next_offset
|
||||
@@ -19,6 +19,8 @@
|
||||
|
||||
## Overview
|
||||
|
||||
Current desktop source version: **3.7.1** (`PYAS_Version.py`; Windows file version `3.7.1.0`). The source version does not imply that a matching installer has already been published.
|
||||
|
||||
PYAS Security is a source-available Windows endpoint security project that combines multiple detection and protection layers in one desktop application. It brings together local PE-file machine-learning inference, YARA-based matching, digital-signature inspection, optional cloud analysis, real-time user-mode monitoring, and a native Windows minifilter driver.
|
||||
|
||||
The project is designed both as a usable security application and as an engineering platform for studying malware detection, Windows internals, rule-driven prevention, model training, and online file analysis.
|
||||
@@ -42,7 +44,7 @@ The project is designed both as a usable security application and as an engineer
|
||||
| Static rules | File and process-memory matching | YARA rules compiled and loaded by `rule_scanner` |
|
||||
| PE machine learning | Feature extraction and local classification | `pefile`, NumPy, ONNX Runtime |
|
||||
| Signature trust | Authenticode verification | Windows WinVerifyTrust APIs |
|
||||
| Cloud analysis | Upload, polling, rescan, and result retrieval | HTTPS API with chunked upload support |
|
||||
| Cloud analysis | Optional queued submission; client methods for polling and rescanning | `PYAS_Cloud.py`, HTTPS and chunked upload |
|
||||
| Process protection | New-process inspection, optional suspension, termination controls | Win32/NT APIs and worker pools |
|
||||
| File protection | Directory change monitoring, debounced real-time scans, file locking | Windows file notification APIs |
|
||||
| Memory protection | Process-memory YARA scanning and kernel callbacks | User-mode scanner plus driver rules |
|
||||
@@ -50,119 +52,124 @@ The project is designed both as a usable security application and as an engineer
|
||||
| Kernel enforcement | File, process/thread, registry, memory, image-load, and boot/disk controls | Native C++ minifilter driver |
|
||||
| Recovery and maintenance | Quarantine, system repair, startup management, cleanup, MBR backup/check | Python application services |
|
||||
|
||||
Protection modules are individually configurable. Several active-protection switches are disabled by default so users can enable only the controls appropriate for their environment.
|
||||
Protection modules are individually configurable. Cloud submission is disabled by default. Several active-protection switches are also disabled by default so users can enable only the controls appropriate for their environment.
|
||||
|
||||
## Detection pipeline
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[File or process event] --> B{Scope and policy checks}
|
||||
B -->|Allowed| C[Hash and metadata]
|
||||
B -->|Excluded| Z[Skip]
|
||||
C --> D[Digital signature verification]
|
||||
C --> E[YARA rule scan]
|
||||
C --> F[PE feature extraction]
|
||||
F --> G[ONNX model inference]
|
||||
D --> H[Local verdict aggregation]
|
||||
E --> H
|
||||
G --> H
|
||||
H --> I{Cloud analysis enabled?}
|
||||
I -->|Yes| J[Hash lookup / chunked upload]
|
||||
J --> K[Cloud report]
|
||||
I -->|No| L[Local result]
|
||||
K --> M[Alert and response]
|
||||
L --> M
|
||||
B -->|Allowed| C[Shared scan entry: stable file gate and hash]
|
||||
C --> D{Content and policy cache hit?}
|
||||
D -->|Yes| H[Cached local verdict]
|
||||
D -->|No| E[Local engines: signature / YARA / PE and ONNX]
|
||||
E --> F[Local verdict and eligible cache publication]
|
||||
F --> H
|
||||
H --> M[Alert and configured response]
|
||||
M --> N[Quarantine / delete / allow]
|
||||
H -. optional submission .-> I{Cloud switch enabled?}
|
||||
I -->|Yes| J[Cloud queue: hash lookup / chunked upload]
|
||||
I -->|No| K[No upload]
|
||||
```
|
||||
|
||||
`PYAS_Scanner.py` coordinates local scanning and remediation. The common scan entry holds a read handle that denies concurrent writes/deletes while hashing and classifying. If the handle cannot be acquired, classification still runs and a retry is scheduled, but the result is not published to the shared cache.
|
||||
|
||||
`PYAS_Cloud.py` contains both `CloudScanner` and `CloudQueueMixin`. The desktop cloud worker currently submits files; it does not automatically call `get_result()` or merge a remote verdict into local remediation. Polling and rescan methods remain available to explicit client callers.
|
||||
|
||||
## System architecture
|
||||
|
||||
The desktop application currently consists of **27 `PYAS*.py` modules**. `WindowAPI` in `PYAS.py` combines `_MainMixin`, `ScannerMixin`, `ToolsMixin`, and `ProtectMixin` on one application instance. The mixins share the existing configuration, state, queues, and locks rather than creating separate service instances.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph UI[Presentation layer]
|
||||
WEB[HTML / CSS / JavaScript]
|
||||
WV[WebView2 desktop shell]
|
||||
TRAY[System tray and Windows shell integration]
|
||||
end
|
||||
UI[Interface: HTML / CSS / JavaScript] <--> WV[WebView2]
|
||||
WV <--> CORE[PYAS.py: WindowAPI / tray / window messages]
|
||||
START[PYAS_Startup.py: diagnostics and profile recovery] --> WV
|
||||
CORE --> MAIN[Main mixins: Runtime / Config / Logs / WinAPI]
|
||||
CORE --> SCAN[PYAS_Scanner.py: ScannerMixin]
|
||||
CORE --> TOOLS[PYAS_Tools.py: ToolsMixin]
|
||||
CORE --> PROTECT[PYAS_Protect.py: ProtectMixin]
|
||||
|
||||
subgraph APP[Python application layer]
|
||||
CORE[PYAS.py orchestration]
|
||||
SCAN[ScannerMixin]
|
||||
PROTECT[ProtectMixin]
|
||||
TOOLS[ToolsMixin]
|
||||
ENGINE[PYAS_Engine.py]
|
||||
end
|
||||
MAIN --> ENGINE[PYAS_Engine.py: compatible engine exports]
|
||||
SCAN --> LOCAL[Rules / Signature / PE]
|
||||
ENGINE --> LOCAL
|
||||
LOCAL --> FEATURES[PYAS_Features.py]
|
||||
LOCAL --> ASSETS[Engine: YARA assets and ONNX models]
|
||||
SCAN --> CLOUD[PYAS_Cloud.py: client and cloud queue]
|
||||
CLOUD -. optional HTTPS submission .-> ONLINE[Analyze: separate online analysis service]
|
||||
|
||||
subgraph DETECT[Detection engines]
|
||||
YARA[YARA rules]
|
||||
PE[PE feature extractor]
|
||||
ONNX[ONNX Runtime models]
|
||||
SIGN[Authenticode verification]
|
||||
CLOUD[PYAS Cloud API]
|
||||
end
|
||||
TOOLS --> UTIL[Autostart / Process / Maintenance / Threats]
|
||||
PROTECT --> RT[PYAS_Realtime.py]
|
||||
PROTECT --> SYS[PYAS_System.py]
|
||||
PROTECT --> POP[PYAS_Popup.py: matching and window operations]
|
||||
PROTECT --> DRIVER[PYAS_Driver.py]
|
||||
RT --> SCHED[PYAS_Scheduler.py: bounded delayed work]
|
||||
DRIVER <--> PORT[Filter Manager communication port]
|
||||
PORT <--> KERNEL[Plugins/Filter: native minifilter]
|
||||
KERNEL --> POLICY[Plugins/Rules: protection policies]
|
||||
|
||||
subgraph KERNEL[Windows kernel layer]
|
||||
PORT[Filter Manager communication port]
|
||||
RULES[Dynamic rule engine and trust cache]
|
||||
FILE[File-system minifilter]
|
||||
PROC[Process and thread callbacks]
|
||||
REG[Registry callbacks]
|
||||
MEM[Memory and image-load controls]
|
||||
BOOT[Boot and disk I/O controls]
|
||||
end
|
||||
|
||||
subgraph ONLINE[Online analysis platform]
|
||||
API[Flask application and REST API]
|
||||
QUEUE[Priority analysis workers]
|
||||
DB[(PostgreSQL)]
|
||||
STORAGE[(Sample and report storage)]
|
||||
end
|
||||
|
||||
WEB <--> WV
|
||||
WV <--> CORE
|
||||
TRAY <--> CORE
|
||||
CORE --> SCAN
|
||||
CORE --> PROTECT
|
||||
CORE --> TOOLS
|
||||
SCAN --> ENGINE
|
||||
PROTECT --> ENGINE
|
||||
ENGINE --> YARA
|
||||
ENGINE --> PE
|
||||
PE --> ONNX
|
||||
ENGINE --> SIGN
|
||||
ENGINE -. optional HTTPS .-> CLOUD
|
||||
PROTECT <--> PORT
|
||||
PORT <--> RULES
|
||||
RULES --> FILE
|
||||
RULES --> PROC
|
||||
RULES --> REG
|
||||
RULES --> MEM
|
||||
RULES --> BOOT
|
||||
CLOUD --> API
|
||||
API --> QUEUE
|
||||
QUEUE --> DB
|
||||
QUEUE --> STORAGE
|
||||
MAIN --> STORE[PYAS_Storage.py: atomic JSON writes]
|
||||
CORE -. exception reporting .-> DIAG[PYAS_Diagnostics.py]
|
||||
```
|
||||
|
||||
- **Application state:** `PYAS_Runtime.py` owns initialization, UI dispatch, and feature-worker lifecycle. `PYAS_Config.py` applies switches and persists settings after successful operations; failures restore the prior state and attempt the corresponding rollback. Driver disable state is committed only after unloading succeeds.
|
||||
- **Protection:** `PYAS_Realtime.py` handles process, file, and network monitoring; `PYAS_System.py` handles system protection and repair; `PYAS_Driver.py` owns driver lifecycle and communication; `PYAS_Popup.py` combines popup matching with native window operations.
|
||||
- **Shared infrastructure:** `PYAS_Process.py` centralizes process enumeration and operations; `PYAS_WinAPI.py` centralizes Win32/NT declarations, native structures, and file-notification decoding. `PYAS_Scheduler.py` uses one scheduler and at most four workers, with a default 2048 pending-work limit, bounded critical retry capacity, and recovery scans on overflow.
|
||||
- **Storage and diagnostics:** configuration and reports use atomic JSON replacement via `PYAS_Storage.py`. `PYAS_Diagnostics.py` supplies consistent exception context and tracebacks, including background futures and workers, with bounded repeat suppression. The UI text log trims from 100,000 UTF-16 code units to approximately 90,000; tables render at most 1,000 rows, while log retention/export allows 10,000 records. The scan cache allows 100,000 entries and evicts 10,000 at capacity.
|
||||
- **Compatibility:** `PYAS_Engine.py` continues to export `sign_scanner`, `rule_scanner`, `pe_scanner`, and `cloud_scanner` as aliases for the implementation classes. Cloud queue logic resides in `PYAS_Cloud.py`; native popup operations reside in `PYAS_Popup.py`; Windows API declarations reside in `PYAS_WinAPI.py`.
|
||||
|
||||
See [ARCHITECTURE.md](ARCHITECTURE.md) for state ownership, rollback behavior, performance limits, diagnostics, and validation details.
|
||||
|
||||
## Repository layout
|
||||
|
||||
```text
|
||||
PYAS/
|
||||
├── PYAS.py # Desktop entry point, UI bridge, configuration and lifecycle
|
||||
├── PYAS_Engine.py # YARA, PE/ML, signature and cloud scanning engines
|
||||
├── PYAS_Scanner.py # Scan scheduling, workers, results and remediation
|
||||
├── PYAS_Protect.py # Real-time protection and driver lifecycle/communication
|
||||
├── PYAS_Tools.py # Windows utilities, startup, process, network and cleanup tools
|
||||
├── PYAS_Version.py # Windows executable version metadata
|
||||
├── PYAS.py # Desktop entry point, WindowAPI, tray, messages and lifecycle
|
||||
├── PYAS_Startup.py # WebView2 startup checks, diagnostics and profile recovery
|
||||
├── PYAS_Runtime.py # Environment/state initialization, UI queue and feature workers
|
||||
├── PYAS_Config.py # Settings, switch application, persistence and rollback
|
||||
├── PYAS_Logs.py # Activity reports, bounded history, export and flushing
|
||||
├── PYAS_WinAPI.py # Shared Win32/NT constants, structures and API declarations
|
||||
├── PYAS_Storage.py # Atomic JSON persistence
|
||||
├── PYAS_Diagnostics.py # Exception logging, repeat suppression and future observation
|
||||
├── PYAS_Scheduler.py # Bounded delayed work, debounce and overflow recovery
|
||||
├── PYAS_Tools.py # Windows utility facade and general system operations
|
||||
├── PYAS_Autostart.py # SID-bound elevated startup tasks and startup management
|
||||
├── PYAS_Process.py # Shared process enumeration, command lines and termination
|
||||
├── PYAS_Maintenance.py # Cleanup and memory-maintenance utilities
|
||||
├── PYAS_Threats.py # Named threat lists and threat extraction/removal
|
||||
├── PYAS_Protect.py # Protection facade, allowlists and file locking
|
||||
├── PYAS_Driver.py # Driver install/unload, listener and communication protocol
|
||||
├── PYAS_System.py # System monitoring/repair and MBR protection
|
||||
├── PYAS_Realtime.py # Process, file and network protection workers
|
||||
├── PYAS_Popup.py # Popup fingerprints, matching and native window operations
|
||||
├── PYAS_Scanner.py # Scan coordination, shared verdict cache and remediation
|
||||
├── PYAS_Engine.py # Compatible exports for the local/cloud engine classes
|
||||
├── PYAS_Signature.py # Authenticode signature verification
|
||||
├── PYAS_Rules.py # YARA and heuristic rule scanning
|
||||
├── PYAS_PE.py # PE model loading, feature ordering and ONNX classification
|
||||
├── PYAS_Features.py # PE structural/statistical feature extraction
|
||||
├── PYAS_Cloud.py # Cloud HTTP client, session reuse, queue and cancellation
|
||||
├── PYAS_Version.py # Source version and Windows executable metadata generation
|
||||
├── ARCHITECTURE.md # Detailed architecture and validation notes
|
||||
├── README.md # Project overview and source layout
|
||||
├── Engine/
|
||||
│ ├── Heuristic/ # YARA signatures and rule assets
|
||||
│ └── Properties/ # PE feature models and training/inference utilities
|
||||
├── Interface/ # WebView2 HTML, CSS, JavaScript and icons
|
||||
└── Plugins/
|
||||
├── Filter/ # Native Windows minifilter driver source
|
||||
└── Rules/ # Driver protection policies
|
||||
│ ├── Heuristic/ # Desktop YARA signatures and rule assets
|
||||
│ └── Properties/ # PE feature models and training/inference utilities
|
||||
├── Interface/ # WebView2 HTML, CSS, JavaScript and icons
|
||||
├── Plugins/
|
||||
│ ├── Filter/ # Native Windows minifilter components
|
||||
│ └── Rules/ # Driver protection policies
|
||||
├── Install/
|
||||
│ └── Inno.Installer.Source/ # Inno Setup installer source
|
||||
├── Analyze/ # Separate online analysis platform
|
||||
├── Experimental/ # Experimental/research components
|
||||
└── tests/ # Python regression and JavaScript UI tests
|
||||
```
|
||||
|
||||
This lists all desktop Python modules and the main project directories. Additional engine assets, generated files, caches, local backups, and duplicate development directories are not part of this architecture inventory. Packaging must include all 27 desktop modules and the required runtime assets.
|
||||
|
||||
## Getting started
|
||||
|
||||
### Option 1 — Install a packaged release
|
||||
@@ -238,7 +245,7 @@ PYAS stores machine-wide configuration and reports under:
|
||||
%ProgramData%\PYAS\Report.json
|
||||
```
|
||||
|
||||
WebView2 user data and its diagnostic log are stored under the current user's local application-data directory. Configuration includes scan limits, enabled protection layers, language and theme preferences, custom rule references, allow/block lists, and quarantine metadata.
|
||||
WebView2 user data is stored under the current user's local application-data directory. Startup, WebView, background exceptions, and activity records share `Report.json` (up to 10,000 records). New records include the desktop source `version`; exception tracebacks are stored in `detail`. If the machine-wide log directory is not writable, logging uses `PYAS/Report.json` in the temporary directory for that session. Configuration includes scan limits, enabled protection layers, language and theme preferences, custom rule references, allow/block lists, and quarantine metadata.
|
||||
|
||||
Before reporting a configuration issue, reproduce it with default settings when safe to do so and remove sensitive paths or file information from logs.
|
||||
|
||||
@@ -258,23 +265,23 @@ The service can be containerized with Docker Compose, but the checked-in deploym
|
||||
|
||||
## Python API example
|
||||
|
||||
`PYAS_Cloud.py` provides a client for the online analysis API:
|
||||
`PYAS_Cloud.py` provides `CloudScanner`, the desktop application's cloud client. The example below explicitly requests the existing client's verdict for a previously submitted SHA-256; it does not upload a new file:
|
||||
|
||||
```python
|
||||
from PYAS_Cloud import PYAS_Client
|
||||
from PYAS_Cloud import CloudScanner
|
||||
|
||||
client = PYAS_Client(
|
||||
client = CloudScanner()
|
||||
verdict = client.get_result(
|
||||
sha256="SHA256_OF_A_PREVIOUSLY_SUBMITTED_FILE",
|
||||
api_host="https://pyas-security.com",
|
||||
api_key="YOUR_API_KEY",
|
||||
hosts="https://pyas-security.com",
|
||||
max_retries=6,
|
||||
interval=10,
|
||||
)
|
||||
|
||||
sha256 = client.upload_file(r"C:\samples\candidate.exe")
|
||||
if sha256 and client.wait_for_analysis(sha256):
|
||||
report = client.get_report(sha256)
|
||||
print(report)
|
||||
print(verdict)
|
||||
```
|
||||
|
||||
Only submit files that you are authorized to upload. Samples may contain confidential or personal information.
|
||||
`get_result()` returns a supported malicious classification string or `False`; `False` is not a complete remote report or a guarantee that a sample is safe. The desktop's queued submission path does not call this method automatically. Only submit files that you are authorized to upload; samples may contain confidential or personal information.
|
||||
|
||||
## Building the driver
|
||||
|
||||
@@ -346,4 +353,4 @@ The repository contains component-specific license files and bundled third-party
|
||||
|
||||
Built for Windows security research, layered endpoint defense, and transparent experimentation.
|
||||
|
||||
</div>
|
||||
</div>
|
||||
Reference in new issue
Block a user