Files
calvana/ayn-antivirus/ayn_antivirus/cli.py

1253 lines
41 KiB
Python

"""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 <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]"
)