remove infra.md.example, infra.md is the source of truth

This commit is contained in:
Azreen Jamal
2026-03-03 03:06:13 +08:00
parent 1ad3033cc1
commit a3c6d09350
86 changed files with 17093 additions and 39 deletions

View 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",
]

View 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)

File diff suppressed because it is too large Load Diff

View 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

View 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

View 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

View 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