1253 lines
41 KiB
Python
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]"
|
|
)
|