remove infra.md.example, infra.md is the source of truth
This commit is contained in:
17
ayn-antivirus/ayn_antivirus/scanners/__init__.py
Normal file
17
ayn-antivirus/ayn_antivirus/scanners/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""AYN Antivirus scanner modules."""
|
||||
|
||||
from ayn_antivirus.scanners.base import BaseScanner
|
||||
from ayn_antivirus.scanners.container_scanner import ContainerScanner
|
||||
from ayn_antivirus.scanners.file_scanner import FileScanner
|
||||
from ayn_antivirus.scanners.memory_scanner import MemoryScanner
|
||||
from ayn_antivirus.scanners.network_scanner import NetworkScanner
|
||||
from ayn_antivirus.scanners.process_scanner import ProcessScanner
|
||||
|
||||
__all__ = [
|
||||
"BaseScanner",
|
||||
"ContainerScanner",
|
||||
"FileScanner",
|
||||
"MemoryScanner",
|
||||
"NetworkScanner",
|
||||
"ProcessScanner",
|
||||
]
|
||||
58
ayn-antivirus/ayn_antivirus/scanners/base.py
Normal file
58
ayn-antivirus/ayn_antivirus/scanners/base.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Abstract base class for all AYN scanners."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseScanner(ABC):
|
||||
"""Common interface that every scanner module must implement.
|
||||
|
||||
Subclasses provide a ``scan`` method whose *target* argument type varies
|
||||
by scanner (a file path, a PID, a network connection, etc.).
|
||||
"""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Identity
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Short, machine-friendly scanner identifier (e.g. ``"file_scanner"``)."""
|
||||
...
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def description(self) -> str:
|
||||
"""Human-readable one-liner describing what this scanner does."""
|
||||
...
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Scanning
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@abstractmethod
|
||||
def scan(self, target: Any) -> Any:
|
||||
"""Run the scanner against *target* and return a result object.
|
||||
|
||||
The concrete return type is defined by each subclass.
|
||||
"""
|
||||
...
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers available to all subclasses
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _log_info(self, msg: str, *args: Any) -> None:
|
||||
logger.info("[%s] " + msg, self.name, *args)
|
||||
|
||||
def _log_warning(self, msg: str, *args: Any) -> None:
|
||||
logger.warning("[%s] " + msg, self.name, *args)
|
||||
|
||||
def _log_error(self, msg: str, *args: Any) -> None:
|
||||
logger.error("[%s] " + msg, self.name, *args)
|
||||
1285
ayn-antivirus/ayn_antivirus/scanners/container_scanner.py
Normal file
1285
ayn-antivirus/ayn_antivirus/scanners/container_scanner.py
Normal file
File diff suppressed because it is too large
Load Diff
258
ayn-antivirus/ayn_antivirus/scanners/file_scanner.py
Normal file
258
ayn-antivirus/ayn_antivirus/scanners/file_scanner.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""File-system scanner for AYN Antivirus.
|
||||
|
||||
Walks directories, gathers file metadata, hashes files, and classifies
|
||||
them by type (ELF binary, script, suspicious extension) so that downstream
|
||||
detectors can focus on high-value targets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import grp
|
||||
import logging
|
||||
import os
|
||||
import pwd
|
||||
import stat
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Generator, List, Optional
|
||||
|
||||
from ayn_antivirus.constants import (
|
||||
MAX_FILE_SIZE,
|
||||
SUSPICIOUS_EXTENSIONS,
|
||||
)
|
||||
from ayn_antivirus.scanners.base import BaseScanner
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Well-known magic bytes
|
||||
# ---------------------------------------------------------------------------
|
||||
_ELF_MAGIC = b"\x7fELF"
|
||||
_SCRIPT_SHEBANGS = (b"#!", b"#!/")
|
||||
_PE_MAGIC = b"MZ"
|
||||
|
||||
|
||||
class FileScanner(BaseScanner):
|
||||
"""Enumerates, classifies, and hashes files on disk.
|
||||
|
||||
This scanner does **not** perform threat detection itself — it prepares
|
||||
the metadata that detectors (YARA, hash-lookup, heuristic) consume.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
max_file_size:
|
||||
Skip files larger than this (bytes). Defaults to
|
||||
:pydata:`constants.MAX_FILE_SIZE`.
|
||||
"""
|
||||
|
||||
def __init__(self, max_file_size: int = MAX_FILE_SIZE) -> None:
|
||||
self.max_file_size = max_file_size
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseScanner interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "file_scanner"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Enumerates and classifies files on disk"
|
||||
|
||||
def scan(self, target: Any) -> Dict[str, Any]:
|
||||
"""Scan a single file and return its metadata + hash.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
target:
|
||||
A path (``str`` or ``Path``) to the file.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Keys: ``path``, ``size``, ``hash``, ``is_elf``, ``is_script``,
|
||||
``suspicious_ext``, ``info``, ``header``, ``error``.
|
||||
"""
|
||||
filepath = Path(target)
|
||||
result: Dict[str, Any] = {
|
||||
"path": str(filepath),
|
||||
"size": 0,
|
||||
"hash": "",
|
||||
"is_elf": False,
|
||||
"is_script": False,
|
||||
"suspicious_ext": False,
|
||||
"info": {},
|
||||
"header": b"",
|
||||
"error": None,
|
||||
}
|
||||
|
||||
try:
|
||||
info = self.get_file_info(filepath)
|
||||
result["info"] = info
|
||||
result["size"] = info.get("size", 0)
|
||||
except OSError as exc:
|
||||
result["error"] = str(exc)
|
||||
return result
|
||||
|
||||
if result["size"] > self.max_file_size:
|
||||
result["error"] = f"Exceeds max size ({result['size']} > {self.max_file_size})"
|
||||
return result
|
||||
|
||||
try:
|
||||
result["hash"] = self.compute_hash(filepath)
|
||||
except OSError as exc:
|
||||
result["error"] = f"Hash failed: {exc}"
|
||||
return result
|
||||
|
||||
try:
|
||||
result["header"] = self.read_file_header(filepath)
|
||||
except OSError:
|
||||
pass # non-fatal
|
||||
|
||||
result["is_elf"] = self.is_elf_binary(filepath)
|
||||
result["is_script"] = self.is_script(filepath)
|
||||
result["suspicious_ext"] = self.is_suspicious_extension(filepath)
|
||||
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Directory walking
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def walk_directory(
|
||||
path: str | Path,
|
||||
recursive: bool = True,
|
||||
exclude_patterns: Optional[List[str]] = None,
|
||||
) -> Generator[Path, None, None]:
|
||||
"""Yield every regular file under *path*.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path:
|
||||
Root directory to walk.
|
||||
recursive:
|
||||
If ``False``, only yield files in the top-level directory.
|
||||
exclude_patterns:
|
||||
Path prefixes or glob-style patterns to skip. A file is skipped
|
||||
if its absolute path starts with any pattern string.
|
||||
"""
|
||||
root = Path(path).resolve()
|
||||
exclude = [str(Path(p).resolve()) for p in (exclude_patterns or [])]
|
||||
|
||||
if root.is_file():
|
||||
yield root
|
||||
return
|
||||
|
||||
iterator = root.rglob("*") if recursive else root.iterdir()
|
||||
try:
|
||||
for entry in iterator:
|
||||
if not entry.is_file():
|
||||
continue
|
||||
entry_str = str(entry)
|
||||
if any(entry_str.startswith(ex) for ex in exclude):
|
||||
continue
|
||||
yield entry
|
||||
except PermissionError:
|
||||
logger.warning("Permission denied walking: %s", root)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# File metadata
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def get_file_info(path: str | Path) -> Dict[str, Any]:
|
||||
"""Return a metadata dict for the file at *path*.
|
||||
|
||||
Keys
|
||||
----
|
||||
size, permissions, permissions_octal, owner, group, modified_time,
|
||||
created_time, is_symlink, is_suid, is_sgid.
|
||||
|
||||
Raises
|
||||
------
|
||||
OSError
|
||||
If the file cannot be stat'd.
|
||||
"""
|
||||
p = Path(path)
|
||||
st = p.stat()
|
||||
mode = st.st_mode
|
||||
|
||||
# Owner / group — fall back gracefully on systems without the user.
|
||||
try:
|
||||
owner = pwd.getpwuid(st.st_uid).pw_name
|
||||
except (KeyError, ImportError):
|
||||
owner = str(st.st_uid)
|
||||
|
||||
try:
|
||||
group = grp.getgrgid(st.st_gid).gr_name
|
||||
except (KeyError, ImportError):
|
||||
group = str(st.st_gid)
|
||||
|
||||
return {
|
||||
"size": st.st_size,
|
||||
"permissions": stat.filemode(mode),
|
||||
"permissions_octal": oct(mode & 0o7777),
|
||||
"owner": owner,
|
||||
"group": group,
|
||||
"modified_time": datetime.utcfromtimestamp(st.st_mtime).isoformat(),
|
||||
"created_time": datetime.utcfromtimestamp(st.st_ctime).isoformat(),
|
||||
"is_symlink": p.is_symlink(),
|
||||
"is_suid": bool(mode & stat.S_ISUID),
|
||||
"is_sgid": bool(mode & stat.S_ISGID),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Hashing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def compute_hash(path: str | Path, algorithm: str = "sha256") -> str:
|
||||
"""Compute file hash. Delegates to canonical implementation."""
|
||||
from ayn_antivirus.utils.helpers import hash_file
|
||||
return hash_file(str(path), algo=algorithm)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Header / magic number
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def read_file_header(path: str | Path, size: int = 8192) -> bytes:
|
||||
"""Read the first *size* bytes of a file (for magic-number checks).
|
||||
|
||||
Raises
|
||||
------
|
||||
OSError
|
||||
If the file cannot be opened.
|
||||
"""
|
||||
with open(path, "rb") as fh:
|
||||
return fh.read(size)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Type classification
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def is_elf_binary(path: str | Path) -> bool:
|
||||
"""Return ``True`` if *path* begins with the ELF magic number."""
|
||||
try:
|
||||
with open(path, "rb") as fh:
|
||||
return fh.read(4) == _ELF_MAGIC
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def is_script(path: str | Path) -> bool:
|
||||
"""Return ``True`` if *path* starts with a shebang (``#!``)."""
|
||||
try:
|
||||
with open(path, "rb") as fh:
|
||||
head = fh.read(3)
|
||||
return any(head.startswith(s) for s in _SCRIPT_SHEBANGS)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def is_suspicious_extension(path: str | Path) -> bool:
|
||||
"""Return ``True`` if the file suffix is in :pydata:`SUSPICIOUS_EXTENSIONS`."""
|
||||
return Path(path).suffix.lower() in SUSPICIOUS_EXTENSIONS
|
||||
332
ayn-antivirus/ayn_antivirus/scanners/memory_scanner.py
Normal file
332
ayn-antivirus/ayn_antivirus/scanners/memory_scanner.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""Process memory scanner for AYN Antivirus.
|
||||
|
||||
Reads ``/proc/<pid>/maps`` and ``/proc/<pid>/mem`` on Linux to search for
|
||||
injected code, suspicious byte patterns (mining pool URLs, known malware
|
||||
strings), and anomalous RWX memory regions.
|
||||
|
||||
Most operations require **root** privileges. On non-Linux systems the
|
||||
scanner gracefully returns empty results.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Sequence
|
||||
|
||||
from ayn_antivirus.constants import CRYPTO_POOL_DOMAINS
|
||||
from ayn_antivirus.scanners.base import BaseScanner
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default byte-level patterns to search for in process memory.
|
||||
_DEFAULT_PATTERNS: List[bytes] = [
|
||||
# Mining pool URLs
|
||||
*(domain.encode() for domain in CRYPTO_POOL_DOMAINS),
|
||||
# Common miner stratum strings
|
||||
b"stratum+tcp://",
|
||||
b"stratum+ssl://",
|
||||
b"stratum2+tcp://",
|
||||
# Suspicious shell commands sometimes found in injected memory
|
||||
b"/bin/sh -c",
|
||||
b"/bin/bash -i",
|
||||
b"/dev/tcp/",
|
||||
# Known malware markers
|
||||
b"PAYLOAD_START",
|
||||
b"x86_64-linux-gnu",
|
||||
b"ELF\x02\x01\x01",
|
||||
]
|
||||
|
||||
# Size of chunks when reading /proc/<pid>/mem.
|
||||
_MEM_READ_CHUNK = 65536
|
||||
|
||||
# Regex to parse a single line from /proc/<pid>/maps.
|
||||
# address perms offset dev inode pathname
|
||||
# 7f1c2a000000-7f1c2a021000 rw-p 00000000 00:00 0 [heap]
|
||||
_MAPS_RE = re.compile(
|
||||
r"^([0-9a-f]+)-([0-9a-f]+)\s+(r[w-][x-][ps-])\s+\S+\s+\S+\s+\d+\s*(.*)",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
class MemoryScanner(BaseScanner):
|
||||
"""Scan process memory for injected code and suspicious patterns.
|
||||
|
||||
.. note::
|
||||
This scanner only works on Linux where ``/proc`` is available.
|
||||
Operations on ``/proc/<pid>/mem`` typically require root or
|
||||
``CAP_SYS_PTRACE``.
|
||||
"""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseScanner interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "memory_scanner"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Scans process memory for injected code and malicious patterns"
|
||||
|
||||
def scan(self, target: Any) -> Dict[str, Any]:
|
||||
"""Scan a single process by PID.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
target:
|
||||
The PID (``int``) of the process to inspect.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
``pid``, ``rwx_regions``, ``pattern_matches``, ``strings_sample``,
|
||||
``error``.
|
||||
"""
|
||||
pid = int(target)
|
||||
result: Dict[str, Any] = {
|
||||
"pid": pid,
|
||||
"rwx_regions": [],
|
||||
"pattern_matches": [],
|
||||
"strings_sample": [],
|
||||
"error": None,
|
||||
}
|
||||
|
||||
if not Path("/proc").is_dir():
|
||||
result["error"] = "Not a Linux system — /proc not available"
|
||||
return result
|
||||
|
||||
try:
|
||||
result["rwx_regions"] = self.find_injected_code(pid)
|
||||
result["pattern_matches"] = self.scan_for_patterns(pid, _DEFAULT_PATTERNS)
|
||||
result["strings_sample"] = self.get_memory_strings(pid, min_length=8)[:200]
|
||||
except PermissionError:
|
||||
result["error"] = f"Permission denied reading /proc/{pid}/mem (need root)"
|
||||
except FileNotFoundError:
|
||||
result["error"] = f"Process {pid} no longer exists"
|
||||
except Exception as exc:
|
||||
result["error"] = str(exc)
|
||||
logger.exception("Error scanning memory for PID %d", pid)
|
||||
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# /proc/<pid>/maps parsing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _read_maps(pid: int) -> List[Dict[str, Any]]:
|
||||
"""Parse ``/proc/<pid>/maps`` and return a list of memory regions.
|
||||
|
||||
Each dict contains ``start`` (int), ``end`` (int), ``perms`` (str),
|
||||
``pathname`` (str).
|
||||
|
||||
Raises
|
||||
------
|
||||
FileNotFoundError
|
||||
If the process does not exist.
|
||||
PermissionError
|
||||
If the caller cannot read the maps file.
|
||||
"""
|
||||
maps_path = Path(f"/proc/{pid}/maps")
|
||||
content = maps_path.read_text()
|
||||
|
||||
regions: List[Dict[str, Any]] = []
|
||||
for match in _MAPS_RE.finditer(content):
|
||||
regions.append({
|
||||
"start": int(match.group(1), 16),
|
||||
"end": int(match.group(2), 16),
|
||||
"perms": match.group(3),
|
||||
"pathname": match.group(4).strip(),
|
||||
})
|
||||
return regions
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Memory reading helper
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _read_region(pid: int, start: int, end: int) -> bytes:
|
||||
"""Read bytes from ``/proc/<pid>/mem`` between *start* and *end*.
|
||||
|
||||
Returns as many bytes as could be read; silently returns partial
|
||||
data if parts of the region are not readable.
|
||||
"""
|
||||
mem_path = f"/proc/{pid}/mem"
|
||||
data = bytearray()
|
||||
try:
|
||||
fd = os.open(mem_path, os.O_RDONLY)
|
||||
try:
|
||||
os.lseek(fd, start, os.SEEK_SET)
|
||||
remaining = end - start
|
||||
while remaining > 0:
|
||||
chunk_size = min(_MEM_READ_CHUNK, remaining)
|
||||
try:
|
||||
chunk = os.read(fd, chunk_size)
|
||||
except OSError:
|
||||
break
|
||||
if not chunk:
|
||||
break
|
||||
data.extend(chunk)
|
||||
remaining -= len(chunk)
|
||||
finally:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass # region may be unmapped by the time we read
|
||||
return bytes(data)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public scanning methods
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def scan_process_memory(self, pid: int) -> List[Dict[str, Any]]:
|
||||
"""Scan all readable regions of a process's address space.
|
||||
|
||||
Returns a list of dicts, one per region, containing ``start``,
|
||||
``end``, ``perms``, ``pathname``, and a boolean ``has_suspicious``
|
||||
flag set when default patterns are found.
|
||||
|
||||
Raises
|
||||
------
|
||||
PermissionError, FileNotFoundError
|
||||
"""
|
||||
regions = self._read_maps(pid)
|
||||
results: List[Dict[str, Any]] = []
|
||||
|
||||
for region in regions:
|
||||
# Only read regions that are at least readable.
|
||||
if not region["perms"].startswith("r"):
|
||||
continue
|
||||
|
||||
size = region["end"] - region["start"]
|
||||
if size > 50 * 1024 * 1024:
|
||||
continue # skip very large regions to avoid OOM
|
||||
|
||||
data = self._read_region(pid, region["start"], region["end"])
|
||||
has_suspicious = any(pat in data for pat in _DEFAULT_PATTERNS)
|
||||
|
||||
results.append({
|
||||
"start": hex(region["start"]),
|
||||
"end": hex(region["end"]),
|
||||
"perms": region["perms"],
|
||||
"pathname": region["pathname"],
|
||||
"size": size,
|
||||
"has_suspicious": has_suspicious,
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
def find_injected_code(self, pid: int) -> List[Dict[str, Any]]:
|
||||
"""Find memory regions with **RWX** (read-write-execute) permissions.
|
||||
|
||||
Legitimate applications rarely need RWX regions. Their presence may
|
||||
indicate code injection, JIT shellcode, or a packed/encrypted payload
|
||||
that has been unpacked at runtime.
|
||||
|
||||
Returns a list of dicts with ``start``, ``end``, ``perms``,
|
||||
``pathname``, ``size``.
|
||||
"""
|
||||
regions = self._read_maps(pid)
|
||||
rwx: List[Dict[str, Any]] = []
|
||||
|
||||
for region in regions:
|
||||
perms = region["perms"]
|
||||
# RWX = positions: r(0) w(1) x(2)
|
||||
if len(perms) >= 3 and perms[0] == "r" and perms[1] == "w" and perms[2] == "x":
|
||||
size = region["end"] - region["start"]
|
||||
rwx.append({
|
||||
"start": hex(region["start"]),
|
||||
"end": hex(region["end"]),
|
||||
"perms": perms,
|
||||
"pathname": region["pathname"],
|
||||
"size": size,
|
||||
"severity": "HIGH",
|
||||
"reason": f"RWX region ({size} bytes) — possible code injection",
|
||||
})
|
||||
|
||||
return rwx
|
||||
|
||||
def get_memory_strings(
|
||||
self,
|
||||
pid: int,
|
||||
min_length: int = 6,
|
||||
) -> List[str]:
|
||||
"""Extract printable ASCII strings from readable memory regions.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
min_length:
|
||||
Minimum string length to keep.
|
||||
|
||||
Returns a list of decoded strings (capped at 500 chars each).
|
||||
"""
|
||||
regions = self._read_maps(pid)
|
||||
strings: List[str] = []
|
||||
printable_re = re.compile(rb"[\x20-\x7e]{%d,}" % min_length)
|
||||
|
||||
for region in regions:
|
||||
if not region["perms"].startswith("r"):
|
||||
continue
|
||||
size = region["end"] - region["start"]
|
||||
if size > 10 * 1024 * 1024:
|
||||
continue # skip huge regions
|
||||
|
||||
data = self._read_region(pid, region["start"], region["end"])
|
||||
for match in printable_re.finditer(data):
|
||||
s = match.group().decode("ascii", errors="replace")
|
||||
strings.append(s[:500])
|
||||
|
||||
# Cap total to avoid unbounded memory usage.
|
||||
if len(strings) >= 10_000:
|
||||
return strings
|
||||
|
||||
return strings
|
||||
|
||||
def scan_for_patterns(
|
||||
self,
|
||||
pid: int,
|
||||
patterns: Optional[Sequence[bytes]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search process memory for specific byte patterns.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
patterns:
|
||||
Byte strings to search for. Defaults to
|
||||
:pydata:`_DEFAULT_PATTERNS` (mining pool URLs, stratum prefixes,
|
||||
shell commands).
|
||||
|
||||
Returns a list of dicts with ``pattern``, ``region_start``,
|
||||
``region_perms``, ``offset``.
|
||||
"""
|
||||
if patterns is None:
|
||||
patterns = _DEFAULT_PATTERNS
|
||||
|
||||
regions = self._read_maps(pid)
|
||||
matches: List[Dict[str, Any]] = []
|
||||
|
||||
for region in regions:
|
||||
if not region["perms"].startswith("r"):
|
||||
continue
|
||||
size = region["end"] - region["start"]
|
||||
if size > 50 * 1024 * 1024:
|
||||
continue
|
||||
|
||||
data = self._read_region(pid, region["start"], region["end"])
|
||||
for pat in patterns:
|
||||
idx = data.find(pat)
|
||||
if idx != -1:
|
||||
matches.append({
|
||||
"pattern": pat.decode("utf-8", errors="replace"),
|
||||
"region_start": hex(region["start"]),
|
||||
"region_perms": region["perms"],
|
||||
"region_pathname": region["pathname"],
|
||||
"offset": idx,
|
||||
"severity": "HIGH",
|
||||
"reason": f"Suspicious pattern found in memory: {pat[:60]!r}",
|
||||
})
|
||||
|
||||
return matches
|
||||
328
ayn-antivirus/ayn_antivirus/scanners/network_scanner.py
Normal file
328
ayn-antivirus/ayn_antivirus/scanners/network_scanner.py
Normal file
@@ -0,0 +1,328 @@
|
||||
"""Network scanner for AYN Antivirus.
|
||||
|
||||
Inspects active TCP/UDP connections for traffic to known mining pools,
|
||||
suspicious ports, and unexpected listening services. Also audits
|
||||
``/etc/resolv.conf`` for DNS hijacking indicators.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import psutil
|
||||
|
||||
from ayn_antivirus.constants import (
|
||||
CRYPTO_POOL_DOMAINS,
|
||||
SUSPICIOUS_PORTS,
|
||||
)
|
||||
from ayn_antivirus.scanners.base import BaseScanner
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Well-known system services that are *expected* to listen — extend as needed.
|
||||
_EXPECTED_LISTENERS = {
|
||||
22: "sshd",
|
||||
53: "systemd-resolved",
|
||||
80: "nginx",
|
||||
443: "nginx",
|
||||
3306: "mysqld",
|
||||
5432: "postgres",
|
||||
6379: "redis-server",
|
||||
8080: "java",
|
||||
}
|
||||
|
||||
# Known-malicious / suspicious public DNS servers sometimes injected by
|
||||
# malware into resolv.conf to redirect DNS queries.
|
||||
_SUSPICIOUS_DNS_SERVERS = [
|
||||
"8.8.4.4", # not inherently bad, but worth noting if unexpected
|
||||
"1.0.0.1",
|
||||
"208.67.222.123",
|
||||
"198.54.117.10",
|
||||
"77.88.8.7",
|
||||
"94.140.14.14",
|
||||
]
|
||||
|
||||
|
||||
class NetworkScanner(BaseScanner):
|
||||
"""Scan active network connections for suspicious activity.
|
||||
|
||||
Wraps :func:`psutil.net_connections` and enriches each connection with
|
||||
process ownership and threat classification.
|
||||
"""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseScanner interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "network_scanner"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Inspects network connections for mining pools and suspicious ports"
|
||||
|
||||
def scan(self, target: Any = None) -> Dict[str, Any]:
|
||||
"""Run a full network scan.
|
||||
|
||||
*target* is ignored — all connections are inspected.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
``total``, ``suspicious``, ``unexpected_listeners``, ``dns_issues``.
|
||||
"""
|
||||
all_conns = self.get_all_connections()
|
||||
suspicious = self.find_suspicious_connections()
|
||||
listeners = self.check_listening_ports()
|
||||
dns = self.check_dns_queries()
|
||||
|
||||
return {
|
||||
"total": len(all_conns),
|
||||
"suspicious": suspicious,
|
||||
"unexpected_listeners": listeners,
|
||||
"dns_issues": dns,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Connection enumeration
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def get_all_connections() -> List[Dict[str, Any]]:
|
||||
"""Return a snapshot of every inet connection.
|
||||
|
||||
Each dict contains: ``fd``, ``family``, ``type``, ``local_addr``,
|
||||
``remote_addr``, ``status``, ``pid``, ``process_name``.
|
||||
"""
|
||||
result: List[Dict[str, Any]] = []
|
||||
try:
|
||||
connections = psutil.net_connections(kind="inet")
|
||||
except psutil.AccessDenied:
|
||||
logger.warning("Insufficient permissions to read network connections")
|
||||
return result
|
||||
|
||||
for conn in connections:
|
||||
local = f"{conn.laddr.ip}:{conn.laddr.port}" if conn.laddr else ""
|
||||
remote = f"{conn.raddr.ip}:{conn.raddr.port}" if conn.raddr else ""
|
||||
|
||||
proc_name = ""
|
||||
if conn.pid:
|
||||
try:
|
||||
proc_name = psutil.Process(conn.pid).name()
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
proc_name = "?"
|
||||
|
||||
result.append({
|
||||
"fd": conn.fd,
|
||||
"family": str(conn.family),
|
||||
"type": str(conn.type),
|
||||
"local_addr": local,
|
||||
"remote_addr": remote,
|
||||
"status": conn.status,
|
||||
"pid": conn.pid,
|
||||
"process_name": proc_name,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Suspicious-connection detection
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def find_suspicious_connections(self) -> List[Dict[str, Any]]:
|
||||
"""Identify connections to known mining pools or suspicious ports.
|
||||
|
||||
Checks remote addresses against :pydata:`constants.CRYPTO_POOL_DOMAINS`
|
||||
and :pydata:`constants.SUSPICIOUS_PORTS`.
|
||||
"""
|
||||
suspicious: List[Dict[str, Any]] = []
|
||||
|
||||
try:
|
||||
connections = psutil.net_connections(kind="inet")
|
||||
except psutil.AccessDenied:
|
||||
logger.warning("Insufficient permissions to read network connections")
|
||||
return suspicious
|
||||
|
||||
for conn in connections:
|
||||
raddr = conn.raddr
|
||||
if not raddr:
|
||||
continue
|
||||
|
||||
remote_ip = raddr.ip
|
||||
remote_port = raddr.port
|
||||
local_str = f"{conn.laddr.ip}:{conn.laddr.port}" if conn.laddr else "?"
|
||||
remote_str = f"{remote_ip}:{remote_port}"
|
||||
|
||||
proc_info = self.resolve_process_for_connection(conn)
|
||||
|
||||
# Suspicious port.
|
||||
if remote_port in SUSPICIOUS_PORTS:
|
||||
suspicious.append({
|
||||
"local_addr": local_str,
|
||||
"remote_addr": remote_str,
|
||||
"pid": conn.pid,
|
||||
"process": proc_info,
|
||||
"status": conn.status,
|
||||
"reason": f"Connection on known mining port {remote_port}",
|
||||
"severity": "HIGH",
|
||||
})
|
||||
|
||||
# Mining-pool domain (substring match on IP / hostname).
|
||||
for domain in CRYPTO_POOL_DOMAINS:
|
||||
if domain in remote_ip:
|
||||
suspicious.append({
|
||||
"local_addr": local_str,
|
||||
"remote_addr": remote_str,
|
||||
"pid": conn.pid,
|
||||
"process": proc_info,
|
||||
"status": conn.status,
|
||||
"reason": f"Connection to known mining pool: {domain}",
|
||||
"severity": "CRITICAL",
|
||||
})
|
||||
break
|
||||
|
||||
return suspicious
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Listening-port audit
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def check_listening_ports() -> List[Dict[str, Any]]:
|
||||
"""Return listening sockets that are *not* in the expected-services list.
|
||||
|
||||
Unexpected listeners may indicate a backdoor or reverse shell.
|
||||
"""
|
||||
unexpected: List[Dict[str, Any]] = []
|
||||
|
||||
try:
|
||||
connections = psutil.net_connections(kind="inet")
|
||||
except psutil.AccessDenied:
|
||||
logger.warning("Insufficient permissions to read network connections")
|
||||
return unexpected
|
||||
|
||||
for conn in connections:
|
||||
if conn.status != "LISTEN":
|
||||
continue
|
||||
|
||||
port = conn.laddr.port if conn.laddr else None
|
||||
if port is None:
|
||||
continue
|
||||
|
||||
proc_name = ""
|
||||
if conn.pid:
|
||||
try:
|
||||
proc_name = psutil.Process(conn.pid).name()
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
proc_name = "?"
|
||||
|
||||
expected_name = _EXPECTED_LISTENERS.get(port)
|
||||
if expected_name and expected_name in proc_name:
|
||||
continue # known good
|
||||
|
||||
# Skip very common ephemeral / system ports when we can't resolve.
|
||||
if port > 49152:
|
||||
continue
|
||||
|
||||
if port not in _EXPECTED_LISTENERS:
|
||||
unexpected.append({
|
||||
"port": port,
|
||||
"local_addr": f"{conn.laddr.ip}:{port}" if conn.laddr else f"?:{port}",
|
||||
"pid": conn.pid,
|
||||
"process_name": proc_name,
|
||||
"reason": f"Unexpected listening service on port {port}",
|
||||
"severity": "MEDIUM",
|
||||
})
|
||||
|
||||
return unexpected
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Process resolution
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def resolve_process_for_connection(conn: Any) -> Dict[str, Any]:
|
||||
"""Return basic process info for a ``psutil`` connection object.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
``pid``, ``name``, ``cmdline``, ``username``.
|
||||
"""
|
||||
info: Dict[str, Any] = {
|
||||
"pid": conn.pid,
|
||||
"name": "",
|
||||
"cmdline": [],
|
||||
"username": "",
|
||||
}
|
||||
if not conn.pid:
|
||||
return info
|
||||
|
||||
try:
|
||||
proc = psutil.Process(conn.pid)
|
||||
info["name"] = proc.name()
|
||||
info["cmdline"] = proc.cmdline()
|
||||
info["username"] = proc.username()
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
pass
|
||||
|
||||
return info
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DNS audit
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def check_dns_queries() -> List[Dict[str, Any]]:
|
||||
"""Audit ``/etc/resolv.conf`` for suspicious DNS server entries.
|
||||
|
||||
Malware sometimes rewrites ``resolv.conf`` to redirect DNS through an
|
||||
attacker-controlled resolver, enabling man-in-the-middle attacks or
|
||||
DNS-based C2 communication.
|
||||
"""
|
||||
issues: List[Dict[str, Any]] = []
|
||||
resolv_path = Path("/etc/resolv.conf")
|
||||
|
||||
if not resolv_path.exists():
|
||||
return issues
|
||||
|
||||
try:
|
||||
content = resolv_path.read_text()
|
||||
except PermissionError:
|
||||
logger.warning("Cannot read /etc/resolv.conf")
|
||||
return issues
|
||||
|
||||
nameserver_re = re.compile(r"^\s*nameserver\s+(\S+)", re.MULTILINE)
|
||||
for match in nameserver_re.finditer(content):
|
||||
server = match.group(1)
|
||||
|
||||
if server in _SUSPICIOUS_DNS_SERVERS:
|
||||
issues.append({
|
||||
"server": server,
|
||||
"file": str(resolv_path),
|
||||
"reason": f"Potentially suspicious DNS server: {server}",
|
||||
"severity": "MEDIUM",
|
||||
})
|
||||
|
||||
# Flag non-RFC1918 / non-loopback servers that look unusual.
|
||||
if not (
|
||||
server.startswith("127.")
|
||||
or server.startswith("10.")
|
||||
or server.startswith("192.168.")
|
||||
or server.startswith("172.")
|
||||
or server == "::1"
|
||||
):
|
||||
# External DNS — not inherently bad but worth logging if the
|
||||
# admin didn't set it intentionally.
|
||||
issues.append({
|
||||
"server": server,
|
||||
"file": str(resolv_path),
|
||||
"reason": f"External DNS server configured: {server}",
|
||||
"severity": "LOW",
|
||||
})
|
||||
|
||||
return issues
|
||||
387
ayn-antivirus/ayn_antivirus/scanners/process_scanner.py
Normal file
387
ayn-antivirus/ayn_antivirus/scanners/process_scanner.py
Normal file
@@ -0,0 +1,387 @@
|
||||
"""Process scanner for AYN Antivirus.
|
||||
|
||||
Inspects running processes for known crypto-miners, anomalous CPU usage,
|
||||
and hidden / stealth processes. Uses ``psutil`` for cross-platform process
|
||||
enumeration and ``/proc`` on Linux for hidden-process detection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import psutil
|
||||
|
||||
from ayn_antivirus.constants import (
|
||||
CRYPTO_MINER_PROCESS_NAMES,
|
||||
HIGH_CPU_THRESHOLD,
|
||||
)
|
||||
from ayn_antivirus.scanners.base import BaseScanner
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProcessScanner(BaseScanner):
|
||||
"""Scan running processes for malware, miners, and anomalies.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
cpu_threshold:
|
||||
CPU-usage percentage above which a process is flagged. Defaults to
|
||||
:pydata:`constants.HIGH_CPU_THRESHOLD`.
|
||||
"""
|
||||
|
||||
def __init__(self, cpu_threshold: float = HIGH_CPU_THRESHOLD) -> None:
|
||||
self.cpu_threshold = cpu_threshold
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseScanner interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "process_scanner"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Inspects running processes for miners and suspicious activity"
|
||||
|
||||
def scan(self, target: Any = None) -> Dict[str, Any]:
|
||||
"""Run a full process scan.
|
||||
|
||||
*target* is ignored — all live processes are inspected.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
``total``, ``suspicious``, ``high_cpu``, ``hidden``.
|
||||
"""
|
||||
all_procs = self.get_all_processes()
|
||||
suspicious = self.find_suspicious_processes()
|
||||
high_cpu = self.find_high_cpu_processes()
|
||||
hidden = self.find_hidden_processes()
|
||||
|
||||
return {
|
||||
"total": len(all_procs),
|
||||
"suspicious": suspicious,
|
||||
"high_cpu": high_cpu,
|
||||
"hidden": hidden,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Process enumeration
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def get_all_processes() -> List[Dict[str, Any]]:
|
||||
"""Return a snapshot of every running process.
|
||||
|
||||
Each dict contains: ``pid``, ``name``, ``cmdline``, ``cpu_percent``,
|
||||
``memory_percent``, ``username``, ``create_time``, ``connections``,
|
||||
``open_files``.
|
||||
"""
|
||||
result: List[Dict[str, Any]] = []
|
||||
attrs = [
|
||||
"pid", "name", "cmdline", "cpu_percent",
|
||||
"memory_percent", "username", "create_time",
|
||||
]
|
||||
|
||||
for proc in psutil.process_iter(attrs):
|
||||
try:
|
||||
info = proc.info
|
||||
# Connections and open files are expensive; fetch lazily.
|
||||
try:
|
||||
connections = [
|
||||
{
|
||||
"fd": c.fd,
|
||||
"family": str(c.family),
|
||||
"type": str(c.type),
|
||||
"laddr": f"{c.laddr.ip}:{c.laddr.port}" if c.laddr else "",
|
||||
"raddr": f"{c.raddr.ip}:{c.raddr.port}" if c.raddr else "",
|
||||
"status": c.status,
|
||||
}
|
||||
for c in proc.net_connections()
|
||||
]
|
||||
except (psutil.AccessDenied, psutil.NoSuchProcess, OSError):
|
||||
connections = []
|
||||
|
||||
try:
|
||||
open_files = [f.path for f in proc.open_files()]
|
||||
except (psutil.AccessDenied, psutil.NoSuchProcess, OSError):
|
||||
open_files = []
|
||||
|
||||
create_time = info.get("create_time")
|
||||
result.append({
|
||||
"pid": info["pid"],
|
||||
"name": info.get("name", ""),
|
||||
"cmdline": info.get("cmdline") or [],
|
||||
"cpu_percent": info.get("cpu_percent") or 0.0,
|
||||
"memory_percent": info.get("memory_percent") or 0.0,
|
||||
"username": info.get("username", "?"),
|
||||
"create_time": (
|
||||
datetime.utcfromtimestamp(create_time).isoformat()
|
||||
if create_time
|
||||
else None
|
||||
),
|
||||
"connections": connections,
|
||||
"open_files": open_files,
|
||||
})
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||||
continue
|
||||
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Suspicious-process detection
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def find_suspicious_processes(self) -> List[Dict[str, Any]]:
|
||||
"""Return processes whose name or command line matches a known miner.
|
||||
|
||||
Matches are case-insensitive against
|
||||
:pydata:`constants.CRYPTO_MINER_PROCESS_NAMES`.
|
||||
"""
|
||||
suspicious: List[Dict[str, Any]] = []
|
||||
|
||||
for proc in psutil.process_iter(["pid", "name", "cmdline", "cpu_percent", "username"]):
|
||||
try:
|
||||
info = proc.info
|
||||
pname = (info.get("name") or "").lower()
|
||||
cmdline = " ".join(info.get("cmdline") or []).lower()
|
||||
|
||||
for miner in CRYPTO_MINER_PROCESS_NAMES:
|
||||
if miner in pname or miner in cmdline:
|
||||
suspicious.append({
|
||||
"pid": info["pid"],
|
||||
"name": info.get("name", ""),
|
||||
"cmdline": info.get("cmdline") or [],
|
||||
"cpu_percent": info.get("cpu_percent") or 0.0,
|
||||
"username": info.get("username", "?"),
|
||||
"matched_signature": miner,
|
||||
"reason": f"Known miner process: {miner}",
|
||||
"severity": "CRITICAL",
|
||||
})
|
||||
break # one match per process
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||||
continue
|
||||
|
||||
return suspicious
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# High-CPU detection
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def find_high_cpu_processes(
|
||||
self,
|
||||
threshold: Optional[float] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Return processes whose CPU usage exceeds *threshold* percent.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
threshold:
|
||||
Override the instance-level ``cpu_threshold``.
|
||||
"""
|
||||
limit = threshold if threshold is not None else self.cpu_threshold
|
||||
high: List[Dict[str, Any]] = []
|
||||
|
||||
for proc in psutil.process_iter(["pid", "name", "cmdline", "cpu_percent", "username"]):
|
||||
try:
|
||||
info = proc.info
|
||||
cpu = info.get("cpu_percent") or 0.0
|
||||
if cpu > limit:
|
||||
high.append({
|
||||
"pid": info["pid"],
|
||||
"name": info.get("name", ""),
|
||||
"cmdline": info.get("cmdline") or [],
|
||||
"cpu_percent": cpu,
|
||||
"username": info.get("username", "?"),
|
||||
"reason": f"High CPU usage: {cpu:.1f}%",
|
||||
"severity": "HIGH",
|
||||
})
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||||
continue
|
||||
|
||||
return high
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Hidden-process detection (Linux only)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def find_hidden_processes() -> List[Dict[str, Any]]:
|
||||
"""Detect processes visible in ``/proc`` but hidden from ``psutil``.
|
||||
|
||||
On non-Linux systems this returns an empty list.
|
||||
|
||||
A mismatch may indicate a userland rootkit that hooks the process
|
||||
listing syscalls.
|
||||
"""
|
||||
proc_dir = Path("/proc")
|
||||
if not proc_dir.is_dir():
|
||||
return [] # not Linux
|
||||
|
||||
# PIDs visible via /proc filesystem.
|
||||
proc_pids: set[int] = set()
|
||||
try:
|
||||
for entry in proc_dir.iterdir():
|
||||
if entry.name.isdigit():
|
||||
proc_pids.add(int(entry.name))
|
||||
except PermissionError:
|
||||
logger.warning("Cannot enumerate /proc")
|
||||
return []
|
||||
|
||||
# PIDs visible via psutil (which ultimately calls getdents / readdir).
|
||||
psutil_pids = set(psutil.pids())
|
||||
|
||||
hidden: List[Dict[str, Any]] = []
|
||||
for pid in proc_pids - psutil_pids:
|
||||
# Read whatever we can from /proc/<pid>.
|
||||
name = ""
|
||||
cmdline = ""
|
||||
try:
|
||||
comm = proc_dir / str(pid) / "comm"
|
||||
if comm.exists():
|
||||
name = comm.read_text().strip()
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
cl = proc_dir / str(pid) / "cmdline"
|
||||
if cl.exists():
|
||||
cmdline = cl.read_bytes().replace(b"\x00", b" ").decode(errors="replace").strip()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
hidden.append({
|
||||
"pid": pid,
|
||||
"name": name,
|
||||
"cmdline": cmdline,
|
||||
"reason": "Process visible in /proc but hidden from psutil (possible rootkit)",
|
||||
"severity": "CRITICAL",
|
||||
})
|
||||
|
||||
return hidden
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Single-process detail
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def get_process_details(pid: int) -> Dict[str, Any]:
|
||||
"""Return comprehensive information about a single process.
|
||||
|
||||
Raises
|
||||
------
|
||||
psutil.NoSuchProcess
|
||||
If the PID does not exist.
|
||||
psutil.AccessDenied
|
||||
If the caller lacks permission to inspect the process.
|
||||
"""
|
||||
proc = psutil.Process(pid)
|
||||
with proc.oneshot():
|
||||
info: Dict[str, Any] = {
|
||||
"pid": proc.pid,
|
||||
"name": proc.name(),
|
||||
"exe": "",
|
||||
"cmdline": proc.cmdline(),
|
||||
"status": proc.status(),
|
||||
"username": "",
|
||||
"cpu_percent": proc.cpu_percent(interval=0.1),
|
||||
"memory_percent": proc.memory_percent(),
|
||||
"memory_info": {},
|
||||
"create_time": datetime.utcfromtimestamp(proc.create_time()).isoformat(),
|
||||
"cwd": "",
|
||||
"open_files": [],
|
||||
"connections": [],
|
||||
"threads": proc.num_threads(),
|
||||
"nice": None,
|
||||
"environ": {},
|
||||
}
|
||||
|
||||
try:
|
||||
info["exe"] = proc.exe()
|
||||
except (psutil.AccessDenied, OSError):
|
||||
pass
|
||||
|
||||
try:
|
||||
info["username"] = proc.username()
|
||||
except psutil.AccessDenied:
|
||||
pass
|
||||
|
||||
try:
|
||||
mem = proc.memory_info()
|
||||
info["memory_info"] = {"rss": mem.rss, "vms": mem.vms}
|
||||
except (psutil.AccessDenied, OSError):
|
||||
pass
|
||||
|
||||
try:
|
||||
info["cwd"] = proc.cwd()
|
||||
except (psutil.AccessDenied, OSError):
|
||||
pass
|
||||
|
||||
try:
|
||||
info["open_files"] = [f.path for f in proc.open_files()]
|
||||
except (psutil.AccessDenied, OSError):
|
||||
pass
|
||||
|
||||
try:
|
||||
info["connections"] = [
|
||||
{
|
||||
"laddr": f"{c.laddr.ip}:{c.laddr.port}" if c.laddr else "",
|
||||
"raddr": f"{c.raddr.ip}:{c.raddr.port}" if c.raddr else "",
|
||||
"status": c.status,
|
||||
}
|
||||
for c in proc.net_connections()
|
||||
]
|
||||
except (psutil.AccessDenied, OSError):
|
||||
pass
|
||||
|
||||
try:
|
||||
info["nice"] = proc.nice()
|
||||
except (psutil.AccessDenied, OSError):
|
||||
pass
|
||||
|
||||
try:
|
||||
info["environ"] = dict(proc.environ())
|
||||
except (psutil.AccessDenied, OSError):
|
||||
pass
|
||||
|
||||
return info
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Process control
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def kill_process(pid: int) -> bool:
|
||||
"""Send ``SIGKILL`` to the process with *pid*.
|
||||
|
||||
Returns ``True`` if the signal was delivered successfully, ``False``
|
||||
otherwise (e.g. the process no longer exists or permission denied).
|
||||
"""
|
||||
try:
|
||||
proc = psutil.Process(pid)
|
||||
proc.kill() # SIGKILL
|
||||
proc.wait(timeout=5)
|
||||
logger.info("Killed process %d (%s)", pid, proc.name())
|
||||
return True
|
||||
except psutil.NoSuchProcess:
|
||||
logger.warning("Process %d no longer exists", pid)
|
||||
return False
|
||||
except psutil.AccessDenied:
|
||||
logger.error("Permission denied killing process %d", pid)
|
||||
# Fall back to raw signal as a last resort.
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
logger.info("Killed process %d via os.kill", pid)
|
||||
return True
|
||||
except OSError as exc:
|
||||
logger.error("os.kill(%d) failed: %s", pid, exc)
|
||||
return False
|
||||
except psutil.TimeoutExpired:
|
||||
logger.warning("Process %d did not exit within timeout", pid)
|
||||
return False
|
||||
Reference in New Issue
Block a user