201 lines
6.5 KiB
Python
201 lines
6.5 KiB
Python
"""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
|