Spaces:
Sleeping
Sleeping
Initial APK scanner microservice deployment
Browse files- Dockerfile +26 -0
- README.md +17 -5
- app/__init__.py +0 -0
- app/config.py +107 -0
- app/services/__init__.py +0 -0
- app/services/apk_intelligence/__init__.py +0 -0
- app/services/apk_intelligence/analyzer.py +270 -0
- app/services/apk_intelligence/decision_engine.py +138 -0
- app/services/apk_intelligence/engines/__init__.py +0 -0
- app/services/apk_intelligence/engines/androguard_engine.py +373 -0
- app/services/apk_intelligence/engines/apkid_engine.py +251 -0
- app/services/apk_intelligence/engines/ml_engine.py +361 -0
- app/services/apk_intelligence/engines/mobsf_client.py +299 -0
- app/services/apk_intelligence/engines/yara_engine.py +284 -0
- app/services/apk_intelligence/models.py +348 -0
- app/services/apk_intelligence/threat_explainer.py +1009 -0
- app/services/identity/__init__.py +0 -0
- app/services/identity/bank_registry.py +160 -0
- app/services/identity/impersonation_engine.py +186 -0
- app/utils.py +27 -0
- main.py +101 -0
- models/apk_classifier.pkl +3 -0
- requirements.txt +18 -0
- rules/android_banker.yar +64 -0
- rules/otp_stealer.yar +53 -0
- rules/rat.yar +58 -0
Dockerfile
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 6 |
+
gcc \
|
| 7 |
+
libpq-dev \
|
| 8 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
+
|
| 10 |
+
COPY requirements.txt .
|
| 11 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 12 |
+
|
| 13 |
+
COPY app/ ./app/
|
| 14 |
+
COPY rules/ ./rules/
|
| 15 |
+
COPY models/ ./models/
|
| 16 |
+
COPY main.py .
|
| 17 |
+
|
| 18 |
+
RUN useradd -m -u 1000 scanner && chown -R scanner /app
|
| 19 |
+
USER scanner
|
| 20 |
+
|
| 21 |
+
EXPOSE 7860
|
| 22 |
+
|
| 23 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
| 24 |
+
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:7860/health')"
|
| 25 |
+
|
| 26 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "2"]
|
README.md
CHANGED
|
@@ -1,10 +1,22 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: ShieldYONO APK Scanner
|
| 3 |
+
emoji: π‘οΈ
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: red
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# ShieldYONO APK Scanner
|
| 12 |
+
|
| 13 |
+
FastAPI microservice for Android APK threat analysis.
|
| 14 |
+
|
| 15 |
+
## Endpoints
|
| 16 |
+
- `GET /health` β engine status
|
| 17 |
+
- `POST /analyze` β upload APK for analysis (multipart/form-data: `file`, optional `package_name`, `app_label`)
|
| 18 |
+
|
| 19 |
+
## Engines
|
| 20 |
+
- **YARA** β signature-based malware rules (android_banker, otp_stealer, rat)
|
| 21 |
+
- **Androguard** β static manifest + permission analysis
|
| 22 |
+
- **ML classifier** β gradient-boosted model (disabled in free tier)
|
app/__init__.py
ADDED
|
File without changes
|
app/config.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ShieldYONO Threat Intelligence Backend
|
| 3 |
+
config.py β Central configuration via Pydantic BaseSettings
|
| 4 |
+
|
| 5 |
+
All values can be overridden via environment variables or a .env file.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from functools import lru_cache
|
| 11 |
+
from typing import List
|
| 12 |
+
|
| 13 |
+
from pydantic import Field
|
| 14 |
+
from pydantic_settings import BaseSettings
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class Settings(BaseSettings):
|
| 18 |
+
# ββ Application βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 19 |
+
APP_NAME: str = "ShieldYONO Threat Intelligence API"
|
| 20 |
+
APP_VERSION: str = "1.0.0"
|
| 21 |
+
DEBUG: bool = False
|
| 22 |
+
|
| 23 |
+
# ββ API βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 24 |
+
API_V1_PREFIX: str = "/api/v1"
|
| 25 |
+
ALLOWED_ORIGINS: List[str] = ["*"]
|
| 26 |
+
|
| 27 |
+
# ββ PostgreSQL βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 28 |
+
POSTGRES_HOST: str = "localhost"
|
| 29 |
+
POSTGRES_PORT: int = 5432
|
| 30 |
+
POSTGRES_DB: str = "shieldyono"
|
| 31 |
+
POSTGRES_USER: str = "shieldyono"
|
| 32 |
+
POSTGRES_PASSWORD: str = "shieldyono_secret"
|
| 33 |
+
|
| 34 |
+
@property
|
| 35 |
+
def DATABASE_URL(self) -> str: # noqa: N802
|
| 36 |
+
return (
|
| 37 |
+
f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
|
| 38 |
+
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
@property
|
| 42 |
+
def SYNC_DATABASE_URL(self) -> str: # noqa: N802
|
| 43 |
+
return (
|
| 44 |
+
f"postgresql://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
|
| 45 |
+
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
# ββ Redis βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 49 |
+
REDIS_HOST: str = "localhost"
|
| 50 |
+
REDIS_PORT: int = 6379
|
| 51 |
+
REDIS_DB: int = 0
|
| 52 |
+
REDIS_PASSWORD: str = ""
|
| 53 |
+
|
| 54 |
+
@property
|
| 55 |
+
def REDIS_URL(self) -> str: # noqa: N802
|
| 56 |
+
if self.REDIS_PASSWORD:
|
| 57 |
+
return f"redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
|
| 58 |
+
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
|
| 59 |
+
|
| 60 |
+
# ββ Celery ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 61 |
+
CELERY_TASK_SERIALIZER: str = "json"
|
| 62 |
+
CELERY_RESULT_SERIALIZER: str = "json"
|
| 63 |
+
CELERY_TASK_TRACK_STARTED: bool = True
|
| 64 |
+
CELERY_TASK_TIME_LIMIT: int = 300 # seconds
|
| 65 |
+
|
| 66 |
+
# ββ File Upload βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 67 |
+
MAX_APK_SIZE_MB: int = 100
|
| 68 |
+
UPLOAD_DIR: str = "/tmp/shieldyono/uploads"
|
| 69 |
+
|
| 70 |
+
# ββ MobSF βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 71 |
+
MOBSF_URL: str = "http://localhost:8000"
|
| 72 |
+
MOBSF_API_KEY: str = "mock_api_key"
|
| 73 |
+
MOBSF_ENABLED: bool = False # Toggle to True when MobSF container is up
|
| 74 |
+
|
| 75 |
+
# ββ ML Engine βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 76 |
+
ML_MODEL_PATH: str = "models/apk_classifier.pkl"
|
| 77 |
+
ML_ENABLED: bool = False # Toggle to True when real model is present
|
| 78 |
+
|
| 79 |
+
# ββ Behaviour Intelligence Engine βββββββββββββββββββββββββββββββββββββββββ
|
| 80 |
+
BEHAVIOUR_MODEL_PATH: str = "models/behaviour_model.pkl"
|
| 81 |
+
# Falls back to ml/behaviour_intelligence/behaviour_model.pkl automatically in dev;
|
| 82 |
+
# copy that file to backend/models/ for production deployment.
|
| 83 |
+
|
| 84 |
+
# ββ YARA ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 85 |
+
YARA_RULES_DIR: str = "rules" # resolved relative to backend/ root by YaraEngine
|
| 86 |
+
YARA_ENABLED: bool = True
|
| 87 |
+
|
| 88 |
+
# ββ APK Scanner microservice ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 89 |
+
# Local docker-compose: http://apk-scanner:7860
|
| 90 |
+
# HF Spaces production: https://your-space.hf.space
|
| 91 |
+
APK_SCANNER_URL: str = ""
|
| 92 |
+
|
| 93 |
+
# ββ Analysis Thresholds βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 94 |
+
MALWARE_PROBABILITY_THRESHOLD: float = 0.7
|
| 95 |
+
SUSPICIOUS_PROBABILITY_THRESHOLD: float = 0.4
|
| 96 |
+
HIGH_RISK_LOCAL_SCORE: float = 70.0
|
| 97 |
+
|
| 98 |
+
# ββ Cache TTL (seconds) βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 99 |
+
ANALYSIS_CACHE_TTL: int = 3600 # 1 hour β same hash β cached result
|
| 100 |
+
|
| 101 |
+
model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "case_sensitive": True}
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@lru_cache(maxsize=1)
|
| 105 |
+
def get_settings() -> Settings:
|
| 106 |
+
"""Cached singleton β import and call this everywhere."""
|
| 107 |
+
return Settings()
|
app/services/__init__.py
ADDED
|
File without changes
|
app/services/apk_intelligence/__init__.py
ADDED
|
File without changes
|
app/services/apk_intelligence/analyzer.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
services/apk_intelligence/analyzer.py
|
| 3 |
+
β Orchestrates all analysis engines for a single APK submission.
|
| 4 |
+
|
| 5 |
+
This is the main entry point called by the API route and the Celery task.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import asyncio
|
| 11 |
+
import logging
|
| 12 |
+
import os
|
| 13 |
+
import tempfile
|
| 14 |
+
import uuid
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from typing import Optional
|
| 17 |
+
|
| 18 |
+
import aiofiles
|
| 19 |
+
|
| 20 |
+
from app.config import get_settings
|
| 21 |
+
from app.repositories.apk_repository import ApkRepository
|
| 22 |
+
from app.services.apk_intelligence.engines.androguard_engine import run_androguard_analysis
|
| 23 |
+
from app.services.apk_intelligence.engines.apkid_engine import run_apkid_analysis
|
| 24 |
+
from app.services.apk_intelligence.engines.ml_engine import run_ml_analysis
|
| 25 |
+
from app.services.apk_intelligence.engines.mobsf_client import run_mobsf_analysis
|
| 26 |
+
from app.services.apk_intelligence.engines.yara_engine import run_yara_scan
|
| 27 |
+
from app.services.apk_intelligence.models import (
|
| 28 |
+
ApkAnalysisRequest,
|
| 29 |
+
EngineResults,
|
| 30 |
+
ScanStep,
|
| 31 |
+
ThreatIntelligenceResponse,
|
| 32 |
+
)
|
| 33 |
+
from app.services.apk_intelligence.threat_explainer import run_threat_explanation
|
| 34 |
+
from app.services.identity.impersonation_engine import run_impersonation_check
|
| 35 |
+
from app.services.risk.fusion import fuse_results
|
| 36 |
+
from app.utils import fire_and_forget
|
| 37 |
+
|
| 38 |
+
logger = logging.getLogger(__name__)
|
| 39 |
+
settings = get_settings()
|
| 40 |
+
|
| 41 |
+
_apk_repo = ApkRepository()
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 45 |
+
|
| 46 |
+
def _derive_apk_name(package_name: str) -> str:
|
| 47 |
+
"""Derive a human-friendly app name from a reverse-DNS package name."""
|
| 48 |
+
parts = [p for p in package_name.split(".") if p]
|
| 49 |
+
if not parts:
|
| 50 |
+
return package_name
|
| 51 |
+
last = parts[-1].replace("_", " ")
|
| 52 |
+
return " ".join(w.capitalize() for w in last.split())
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _build_scan_pipeline(
|
| 56 |
+
engine_results: EngineResults,
|
| 57 |
+
metadata: ApkAnalysisRequest,
|
| 58 |
+
) -> list[ScanStep]:
|
| 59 |
+
"""Build the per-engine scan steps for the Flutter UI timeline."""
|
| 60 |
+
steps: list[ScanStep] = []
|
| 61 |
+
|
| 62 |
+
# 1. Certificate Verification
|
| 63 |
+
cert_detail = (
|
| 64 |
+
f"Certificate {metadata.certificateHash[:16]}β¦ verified"
|
| 65 |
+
if metadata.certificateHash
|
| 66 |
+
else "App signature checked"
|
| 67 |
+
)
|
| 68 |
+
steps.append(ScanStep(engine="Certificate Verification", status="PASSED", details=cert_detail))
|
| 69 |
+
|
| 70 |
+
# 2. Androguard
|
| 71 |
+
ag = engine_results.androguard
|
| 72 |
+
if ag.error:
|
| 73 |
+
steps.append(ScanStep(engine="Androguard", status="ERROR", details=ag.error[:80]))
|
| 74 |
+
else:
|
| 75 |
+
perm_count = len(ag.permissions) if ag.permissions else len(metadata.permissions)
|
| 76 |
+
detail = f"{perm_count} permissions analysed"
|
| 77 |
+
if ag.is_obfuscated:
|
| 78 |
+
detail += ", obfuscation detected"
|
| 79 |
+
if ag.dynamic_code_loading:
|
| 80 |
+
detail += ", dynamic code loading"
|
| 81 |
+
steps.append(ScanStep(engine="Androguard", status="COMPLETED", details=detail))
|
| 82 |
+
|
| 83 |
+
# 3. YARA
|
| 84 |
+
yara = engine_results.yara
|
| 85 |
+
if yara.error:
|
| 86 |
+
steps.append(ScanStep(engine="YARA", status="ERROR", details=yara.error[:80]))
|
| 87 |
+
elif yara.matched and yara.rule_names:
|
| 88 |
+
rules = ", ".join(yara.rule_names[:3])
|
| 89 |
+
suffix = f" (+{len(yara.rule_names) - 3} more)" if len(yara.rule_names) > 3 else ""
|
| 90 |
+
steps.append(ScanStep(engine="YARA", status="THREAT_FOUND", details=f"{rules}{suffix} matched"))
|
| 91 |
+
else:
|
| 92 |
+
steps.append(ScanStep(engine="YARA", status="COMPLETED", details="No malicious signatures matched"))
|
| 93 |
+
|
| 94 |
+
# 4. APKiD
|
| 95 |
+
apkid = engine_results.apkid
|
| 96 |
+
if apkid.error:
|
| 97 |
+
steps.append(ScanStep(engine="APKiD", status="ERROR", details=apkid.error[:80]))
|
| 98 |
+
elif apkid.is_packed:
|
| 99 |
+
packers = ", ".join(apkid.packers[:2])
|
| 100 |
+
steps.append(ScanStep(engine="APKiD", status="COMPLETED", details=f"Obfuscation detected: {packers}"))
|
| 101 |
+
else:
|
| 102 |
+
steps.append(ScanStep(engine="APKiD", status="COMPLETED", details="No packers or obfuscators detected"))
|
| 103 |
+
|
| 104 |
+
# 5. ML Model
|
| 105 |
+
ml = engine_results.ml
|
| 106 |
+
if ml.error:
|
| 107 |
+
steps.append(ScanStep(engine="ML Model", status="ERROR", details=ml.error[:80]))
|
| 108 |
+
else:
|
| 109 |
+
prob_pct = int(ml.malware_probability * 100)
|
| 110 |
+
detail = f"{prob_pct}% malware probability"
|
| 111 |
+
if ml.threat_family:
|
| 112 |
+
detail += f" (family: {ml.threat_family})"
|
| 113 |
+
steps.append(ScanStep(engine="ML Model", status="COMPLETED", details=detail))
|
| 114 |
+
|
| 115 |
+
# 6. MobSF (only when it actually ran)
|
| 116 |
+
mobsf = engine_results.mobsf
|
| 117 |
+
if mobsf.available:
|
| 118 |
+
if mobsf.error:
|
| 119 |
+
steps.append(ScanStep(engine="MobSF", status="ERROR", details=mobsf.error[:80]))
|
| 120 |
+
elif mobsf.score > 70:
|
| 121 |
+
steps.append(ScanStep(
|
| 122 |
+
engine="MobSF",
|
| 123 |
+
status="THREAT_FOUND",
|
| 124 |
+
details=f"High risk: {mobsf.score}/100 danger score",
|
| 125 |
+
))
|
| 126 |
+
else:
|
| 127 |
+
steps.append(ScanStep(
|
| 128 |
+
engine="MobSF",
|
| 129 |
+
status="COMPLETED",
|
| 130 |
+
details=f"Static analysis: {mobsf.score}/100 danger score",
|
| 131 |
+
))
|
| 132 |
+
|
| 133 |
+
return steps
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
class APKAnalyzer:
|
| 137 |
+
"""
|
| 138 |
+
Orchestrates multi-engine APK threat intelligence analysis.
|
| 139 |
+
|
| 140 |
+
Usage:
|
| 141 |
+
analyzer = APKAnalyzer()
|
| 142 |
+
result = await analyzer.analyze(apk_bytes, metadata)
|
| 143 |
+
"""
|
| 144 |
+
|
| 145 |
+
async def analyze(
|
| 146 |
+
self,
|
| 147 |
+
apk_bytes: bytes,
|
| 148 |
+
metadata: ApkAnalysisRequest,
|
| 149 |
+
analysis_id: Optional[str] = None,
|
| 150 |
+
) -> ThreatIntelligenceResponse:
|
| 151 |
+
"""
|
| 152 |
+
Run all analysis engines concurrently and fuse into a final verdict.
|
| 153 |
+
|
| 154 |
+
Args:
|
| 155 |
+
apk_bytes: Raw APK file content.
|
| 156 |
+
metadata: Metadata from the Android SDK (package name, permissions, etc.).
|
| 157 |
+
analysis_id: Optional UUID for tracing (auto-generated if omitted).
|
| 158 |
+
|
| 159 |
+
Returns:
|
| 160 |
+
ThreatIntelligenceResponse β wire-format compatible with the Android SDK.
|
| 161 |
+
"""
|
| 162 |
+
analysis_id = analysis_id or str(uuid.uuid4())
|
| 163 |
+
logger.info(
|
| 164 |
+
"[%s] Starting analysis for package=%s, size=%d bytes",
|
| 165 |
+
analysis_id, metadata.packageName, len(apk_bytes),
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
# Save APK to a temp file (engines need a file path)
|
| 169 |
+
apk_path = await self._save_apk(apk_bytes, analysis_id)
|
| 170 |
+
|
| 171 |
+
try:
|
| 172 |
+
# Phase 1: engines that feed ML feature extraction
|
| 173 |
+
(
|
| 174 |
+
yara_result,
|
| 175 |
+
androguard_result,
|
| 176 |
+
apkid_result,
|
| 177 |
+
) = await asyncio.gather(
|
| 178 |
+
run_yara_scan(apk_path),
|
| 179 |
+
run_androguard_analysis(apk_path, metadata.permissions),
|
| 180 |
+
run_apkid_analysis(apk_path),
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
# Phase 2: ML (uses phase-1 results) + independent engines concurrently
|
| 184 |
+
(
|
| 185 |
+
ml_result,
|
| 186 |
+
mobsf_result,
|
| 187 |
+
impersonation_result,
|
| 188 |
+
) = await asyncio.gather(
|
| 189 |
+
run_ml_analysis(androguard_result, yara_result, apkid_result),
|
| 190 |
+
run_mobsf_analysis(apk_path, metadata.packageName),
|
| 191 |
+
run_impersonation_check(
|
| 192 |
+
package_name=metadata.packageName,
|
| 193 |
+
app_name=metadata.appName,
|
| 194 |
+
certificate_hash=metadata.certificateHash,
|
| 195 |
+
),
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
engine_results = EngineResults(
|
| 199 |
+
yara=yara_result,
|
| 200 |
+
androguard=androguard_result,
|
| 201 |
+
apkid=apkid_result,
|
| 202 |
+
ml=ml_result,
|
| 203 |
+
mobsf=mobsf_result,
|
| 204 |
+
impersonation=impersonation_result,
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
response = fuse_results(
|
| 208 |
+
engine_results=engine_results,
|
| 209 |
+
local_risk=metadata.localRisk,
|
| 210 |
+
package_name=metadata.packageName,
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
response.explanation = run_threat_explanation(engine_results, response)
|
| 214 |
+
|
| 215 |
+
# Populate extended UI fields (backward-compatible)
|
| 216 |
+
response.package_name = metadata.packageName
|
| 217 |
+
response.apk_name = metadata.appName or _derive_apk_name(metadata.packageName)
|
| 218 |
+
response.risk_score = response.malware_probability
|
| 219 |
+
response.scan_pipeline = _build_scan_pipeline(engine_results, metadata)
|
| 220 |
+
if response.explanation and response.explanation.analyst_summary.recommended_actions:
|
| 221 |
+
response.recommendations = list(
|
| 222 |
+
response.explanation.analyst_summary.recommended_actions[:5]
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
logger.info(
|
| 226 |
+
"[%s] Analysis complete: verdict=%s, probability=%.3f, threat_category=%s",
|
| 227 |
+
analysis_id, response.verdict, response.malware_probability,
|
| 228 |
+
response.explanation.analyst_summary.threat_category,
|
| 229 |
+
)
|
| 230 |
+
|
| 231 |
+
fire_and_forget(_apk_repo.save_scan(metadata, response))
|
| 232 |
+
|
| 233 |
+
return response
|
| 234 |
+
|
| 235 |
+
except Exception as exc:
|
| 236 |
+
logger.exception("[%s] Analysis failed: %s", analysis_id, exc)
|
| 237 |
+
# Return an UNKNOWN verdict rather than crashing the endpoint
|
| 238 |
+
return ThreatIntelligenceResponse(
|
| 239 |
+
verdict="UNKNOWN", # type: ignore[arg-type]
|
| 240 |
+
malware_probability=0.0,
|
| 241 |
+
yara_matches=[],
|
| 242 |
+
mobsf_score=-1,
|
| 243 |
+
threat_family=None,
|
| 244 |
+
explanations=[f"Analysis error: {exc}"],
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
finally:
|
| 248 |
+
# Always clean up temp file
|
| 249 |
+
await self._cleanup_apk(apk_path)
|
| 250 |
+
|
| 251 |
+
async def _save_apk(self, apk_bytes: bytes, analysis_id: str) -> Path:
|
| 252 |
+
"""Persist APK bytes to a temp file and return the path."""
|
| 253 |
+
upload_dir = Path(settings.UPLOAD_DIR)
|
| 254 |
+
upload_dir.mkdir(parents=True, exist_ok=True)
|
| 255 |
+
|
| 256 |
+
apk_path = upload_dir / f"{analysis_id}.apk"
|
| 257 |
+
async with aiofiles.open(apk_path, "wb") as f:
|
| 258 |
+
await f.write(apk_bytes)
|
| 259 |
+
|
| 260 |
+
logger.debug("[%s] APK saved to %s", analysis_id, apk_path)
|
| 261 |
+
return apk_path
|
| 262 |
+
|
| 263 |
+
async def _cleanup_apk(self, apk_path: Path) -> None:
|
| 264 |
+
"""Remove temp APK file after analysis."""
|
| 265 |
+
try:
|
| 266 |
+
if apk_path.exists():
|
| 267 |
+
apk_path.unlink()
|
| 268 |
+
logger.debug("Cleaned up %s", apk_path)
|
| 269 |
+
except Exception as exc:
|
| 270 |
+
logger.warning("Failed to clean up APK file %s: %s", apk_path, exc)
|
app/services/apk_intelligence/decision_engine.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
services/apk_intelligence/decision_engine.py
|
| 3 |
+
β Policy-based decision layer on top of the fused threat intelligence.
|
| 4 |
+
|
| 5 |
+
The decision engine translates a ThreatIntelligenceResponse into an
|
| 6 |
+
actionable recommendation for the banking app:
|
| 7 |
+
- BLOCK: Do not allow the app to proceed; show a threat alert.
|
| 8 |
+
- WARN: Allow with a strong warning to the user.
|
| 9 |
+
- MONITOR: Allow but flag for security team review.
|
| 10 |
+
- ALLOW: No action required.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import logging
|
| 16 |
+
from enum import Enum
|
| 17 |
+
from typing import Optional
|
| 18 |
+
|
| 19 |
+
from pydantic import BaseModel
|
| 20 |
+
|
| 21 |
+
from app.services.apk_intelligence.models import ThreatIntelligenceResponse, Verdict
|
| 22 |
+
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class Action(str, Enum):
|
| 27 |
+
BLOCK = "BLOCK"
|
| 28 |
+
WARN = "WARN"
|
| 29 |
+
MONITOR = "MONITOR"
|
| 30 |
+
ALLOW = "ALLOW"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class Decision(BaseModel):
|
| 34 |
+
"""Policy decision for a scanned APK."""
|
| 35 |
+
action: Action
|
| 36 |
+
reason: str
|
| 37 |
+
risk_level: str # "CRITICAL" | "HIGH" | "MEDIUM" | "LOW"
|
| 38 |
+
recommended_message: str # User-facing message for the banking app
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class DecisionEngine:
|
| 42 |
+
"""
|
| 43 |
+
Converts a ThreatIntelligenceResponse into a structured Decision.
|
| 44 |
+
|
| 45 |
+
Policy rules (evaluated in priority order):
|
| 46 |
+
1. MALICIOUS verdict β BLOCK
|
| 47 |
+
2. YARA match present β BLOCK (known malware signature)
|
| 48 |
+
3. Impersonation detected (in explanations) β BLOCK
|
| 49 |
+
4. SUSPICIOUS verdict β WARN
|
| 50 |
+
5. Malware probability > 0.4 β WARN
|
| 51 |
+
6. MobSF score > 80 β MONITOR
|
| 52 |
+
7. Otherwise β ALLOW
|
| 53 |
+
"""
|
| 54 |
+
|
| 55 |
+
def decide(self, result: ThreatIntelligenceResponse) -> Decision:
|
| 56 |
+
"""Evaluate policy rules and return the appropriate decision."""
|
| 57 |
+
|
| 58 |
+
# Rule 1: Confirmed malware
|
| 59 |
+
if result.verdict == Verdict.MALICIOUS:
|
| 60 |
+
family = result.threat_family or "unknown family"
|
| 61 |
+
return Decision(
|
| 62 |
+
action=Action.BLOCK,
|
| 63 |
+
reason=f"Confirmed malware detected: {family}",
|
| 64 |
+
risk_level="CRITICAL",
|
| 65 |
+
recommended_message=(
|
| 66 |
+
"This application has been identified as malware "
|
| 67 |
+
f"({family}). It has been blocked to protect your account."
|
| 68 |
+
),
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
# Rule 2: YARA signature match
|
| 72 |
+
if result.yara_matches:
|
| 73 |
+
rules = ", ".join(result.yara_matches[:3])
|
| 74 |
+
return Decision(
|
| 75 |
+
action=Action.BLOCK,
|
| 76 |
+
reason=f"Known malware signature detected: {rules}",
|
| 77 |
+
risk_level="CRITICAL",
|
| 78 |
+
recommended_message=(
|
| 79 |
+
"A known malicious pattern was detected in this application. "
|
| 80 |
+
"It has been blocked for your security."
|
| 81 |
+
),
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
# Rule 3: Banking app impersonation
|
| 85 |
+
if any("impersonat" in exp.lower() for exp in result.explanations):
|
| 86 |
+
return Decision(
|
| 87 |
+
action=Action.BLOCK,
|
| 88 |
+
reason="Banking app impersonation detected",
|
| 89 |
+
risk_level="HIGH",
|
| 90 |
+
recommended_message=(
|
| 91 |
+
"This application appears to be impersonating a legitimate banking app. "
|
| 92 |
+
"It has been blocked. Please download official apps from trusted stores only."
|
| 93 |
+
),
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
# Rule 4: Suspicious verdict
|
| 97 |
+
if result.verdict == Verdict.SUSPICIOUS:
|
| 98 |
+
return Decision(
|
| 99 |
+
action=Action.WARN,
|
| 100 |
+
reason="Suspicious indicators detected",
|
| 101 |
+
risk_level="HIGH",
|
| 102 |
+
recommended_message=(
|
| 103 |
+
"This application exhibits suspicious behaviour. "
|
| 104 |
+
"We recommend removing it before using banking services."
|
| 105 |
+
),
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
# Rule 5: High malware probability without confirmed verdict
|
| 109 |
+
if result.malware_probability > 0.4:
|
| 110 |
+
return Decision(
|
| 111 |
+
action=Action.WARN,
|
| 112 |
+
reason=f"Elevated malware probability ({result.malware_probability:.0%})",
|
| 113 |
+
risk_level="MEDIUM",
|
| 114 |
+
recommended_message=(
|
| 115 |
+
"An application on your device has been flagged as potentially dangerous. "
|
| 116 |
+
"Please review your installed apps."
|
| 117 |
+
),
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
# Rule 6: High MobSF score (static analysis flags)
|
| 121 |
+
if result.mobsf_score > 80:
|
| 122 |
+
return Decision(
|
| 123 |
+
action=Action.MONITOR,
|
| 124 |
+
reason=f"High static analysis risk score ({result.mobsf_score}/100)",
|
| 125 |
+
risk_level="MEDIUM",
|
| 126 |
+
recommended_message=(
|
| 127 |
+
"An application on your device has unusual permissions or code patterns. "
|
| 128 |
+
"It is being monitored."
|
| 129 |
+
),
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
# Rule 7: Clean
|
| 133 |
+
return Decision(
|
| 134 |
+
action=Action.ALLOW,
|
| 135 |
+
reason="No significant threats detected",
|
| 136 |
+
risk_level="LOW",
|
| 137 |
+
recommended_message="No threats detected. You may proceed safely.",
|
| 138 |
+
)
|
app/services/apk_intelligence/engines/__init__.py
ADDED
|
File without changes
|
app/services/apk_intelligence/engines/androguard_engine.py
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
engines/androguard_engine.py
|
| 3 |
+
β Static analysis of APK structure using Androguard.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import asyncio
|
| 9 |
+
import functools
|
| 10 |
+
import hashlib
|
| 11 |
+
import logging
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Optional
|
| 14 |
+
|
| 15 |
+
from app.services.apk_intelligence.models import (
|
| 16 |
+
AndroguardResult,
|
| 17 |
+
ApkComponents,
|
| 18 |
+
ApkIdentity,
|
| 19 |
+
MlFeatures,
|
| 20 |
+
SecurityFeatures,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
_ANALYSIS_TIMEOUT = 60 # seconds
|
| 26 |
+
|
| 27 |
+
_SMS_PERMISSIONS = frozenset({
|
| 28 |
+
"android.permission.READ_SMS",
|
| 29 |
+
"android.permission.RECEIVE_SMS",
|
| 30 |
+
"android.permission.SEND_SMS",
|
| 31 |
+
})
|
| 32 |
+
|
| 33 |
+
_HIGH_RISK_PERMISSIONS = frozenset({
|
| 34 |
+
"android.permission.READ_SMS",
|
| 35 |
+
"android.permission.SEND_SMS",
|
| 36 |
+
"android.permission.RECEIVE_SMS",
|
| 37 |
+
"android.permission.RECORD_AUDIO",
|
| 38 |
+
"android.permission.PROCESS_OUTGOING_CALLS",
|
| 39 |
+
"android.permission.BIND_ACCESSIBILITY_SERVICE",
|
| 40 |
+
"android.permission.BIND_DEVICE_ADMIN",
|
| 41 |
+
})
|
| 42 |
+
|
| 43 |
+
_SUSPICIOUS_RECEIVER_ACTIONS = frozenset({
|
| 44 |
+
"android.provider.Telephony.SMS_RECEIVED",
|
| 45 |
+
"android.provider.Telephony.SMS_DELIVER",
|
| 46 |
+
"android.intent.action.BOOT_COMPLETED",
|
| 47 |
+
"android.intent.action.MY_PACKAGE_REPLACED",
|
| 48 |
+
"android.net.conn.CONNECTIVITY_CHANGE",
|
| 49 |
+
})
|
| 50 |
+
|
| 51 |
+
_ANDROID_NS = "http://schemas.android.com/apk/res/android"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class AndroguardEngine:
|
| 55 |
+
"""
|
| 56 |
+
Static APK analysis using Androguard.
|
| 57 |
+
|
| 58 |
+
Runs synchronous Androguard in a thread-pool executor to remain async-compatible.
|
| 59 |
+
Degrades gracefully when Androguard is unavailable or the APK is corrupted β
|
| 60 |
+
it never propagates exceptions to the caller.
|
| 61 |
+
"""
|
| 62 |
+
|
| 63 |
+
async def analyze(self, apk_path: Path) -> AndroguardResult:
|
| 64 |
+
"""
|
| 65 |
+
Analyze an APK file and return structured intelligence.
|
| 66 |
+
|
| 67 |
+
Args:
|
| 68 |
+
apk_path: Path to the APK file on disk.
|
| 69 |
+
|
| 70 |
+
Returns:
|
| 71 |
+
AndroguardResult with identity, permissions, components, and security indicators.
|
| 72 |
+
On any failure the result carries a non-None ``error`` field.
|
| 73 |
+
"""
|
| 74 |
+
if not apk_path.exists():
|
| 75 |
+
logger.warning("Androguard: APK file not found: %s", apk_path)
|
| 76 |
+
return AndroguardResult(error="APK file not found")
|
| 77 |
+
|
| 78 |
+
try:
|
| 79 |
+
loop = asyncio.get_running_loop()
|
| 80 |
+
return await asyncio.wait_for(
|
| 81 |
+
loop.run_in_executor(
|
| 82 |
+
None,
|
| 83 |
+
functools.partial(self._sync_analyze, apk_path),
|
| 84 |
+
),
|
| 85 |
+
timeout=_ANALYSIS_TIMEOUT,
|
| 86 |
+
)
|
| 87 |
+
except asyncio.TimeoutError:
|
| 88 |
+
logger.error(
|
| 89 |
+
"Androguard: analysis timed out after %ds for %s",
|
| 90 |
+
_ANALYSIS_TIMEOUT, apk_path,
|
| 91 |
+
)
|
| 92 |
+
return AndroguardResult(error=f"Analysis timed out after {_ANALYSIS_TIMEOUT}s")
|
| 93 |
+
except Exception as exc:
|
| 94 |
+
logger.exception("Androguard: unexpected failure for %s", apk_path)
|
| 95 |
+
return AndroguardResult(error=str(exc))
|
| 96 |
+
|
| 97 |
+
# ββ Synchronous core (runs in executor) ββββββββββββββββββββββββββββββββββββ
|
| 98 |
+
|
| 99 |
+
def _sync_analyze(self, apk_path: Path) -> AndroguardResult:
|
| 100 |
+
try:
|
| 101 |
+
from androguard.core.apk import APK # type: ignore[import-untyped]
|
| 102 |
+
except ImportError:
|
| 103 |
+
logger.warning("Androguard not installed β returning mock result")
|
| 104 |
+
return _mock_result(apk_path)
|
| 105 |
+
|
| 106 |
+
logger.debug("Androguard: parsing %s", apk_path)
|
| 107 |
+
|
| 108 |
+
try:
|
| 109 |
+
apk = APK(str(apk_path))
|
| 110 |
+
except Exception as exc:
|
| 111 |
+
logger.warning("Androguard: cannot parse %s β %s", apk_path, exc)
|
| 112 |
+
return AndroguardResult(error=f"Invalid APK: {exc}")
|
| 113 |
+
|
| 114 |
+
identity = self._extract_identity(apk)
|
| 115 |
+
permissions = self._extract_permissions(apk)
|
| 116 |
+
components = self._extract_components(apk)
|
| 117 |
+
security_features, suspicious_activities = self._extract_security(
|
| 118 |
+
apk, permissions, components
|
| 119 |
+
)
|
| 120 |
+
ml_features = _build_ml_features(permissions, components)
|
| 121 |
+
|
| 122 |
+
perm_set = set(permissions)
|
| 123 |
+
is_obfuscated = _has_obfuscated_names(components)
|
| 124 |
+
native_code = bool(list(apk.get_libraries()))
|
| 125 |
+
dynamic_code_loading = (
|
| 126 |
+
"android.permission.REQUEST_INSTALL_PACKAGES" in perm_set
|
| 127 |
+
or sum(1 for _ in apk.get_dex_names()) > 1
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
logger.info(
|
| 131 |
+
"Androguard: pkg=%s perms=%d activities=%d services=%d receivers=%d "
|
| 132 |
+
"exported=%d obfuscated=%s native=%s",
|
| 133 |
+
identity.package,
|
| 134 |
+
len(permissions),
|
| 135 |
+
len(components.activities),
|
| 136 |
+
len(components.services),
|
| 137 |
+
len(components.receivers),
|
| 138 |
+
security_features.exported_count,
|
| 139 |
+
is_obfuscated,
|
| 140 |
+
native_code,
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
return AndroguardResult(
|
| 144 |
+
is_obfuscated=is_obfuscated,
|
| 145 |
+
native_code=native_code,
|
| 146 |
+
dynamic_code_loading=dynamic_code_loading,
|
| 147 |
+
suspicious_activities=suspicious_activities,
|
| 148 |
+
identity=identity,
|
| 149 |
+
permissions=permissions,
|
| 150 |
+
components=components,
|
| 151 |
+
security_features=security_features,
|
| 152 |
+
ml_features=ml_features,
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
# ββ Identity βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 156 |
+
|
| 157 |
+
def _extract_identity(self, apk) -> ApkIdentity:
|
| 158 |
+
return ApkIdentity(
|
| 159 |
+
package=_safe_str(apk.get_package()),
|
| 160 |
+
app_name=_safe_str(apk.get_app_name()),
|
| 161 |
+
version_name=_safe_str(apk.get_androidversion_name()),
|
| 162 |
+
version_code=_safe_str(apk.get_androidversion_code()),
|
| 163 |
+
certificate_sha256=self._extract_cert_sha256(apk),
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
def _extract_cert_sha256(self, apk) -> Optional[str]:
|
| 167 |
+
"""
|
| 168 |
+
Try each signing scheme (V3 β V2 β V1) and return the SHA-256
|
| 169 |
+
fingerprint of the first certificate found.
|
| 170 |
+
"""
|
| 171 |
+
# V3 / V31
|
| 172 |
+
for getter in (
|
| 173 |
+
getattr(apk, "get_certificates_der_v31", None),
|
| 174 |
+
getattr(apk, "get_certificates_der_v3", None),
|
| 175 |
+
getattr(apk, "get_certificates_der_v2", None),
|
| 176 |
+
):
|
| 177 |
+
if getter is None:
|
| 178 |
+
continue
|
| 179 |
+
try:
|
| 180 |
+
certs = getter()
|
| 181 |
+
if certs:
|
| 182 |
+
return hashlib.sha256(certs[0]).hexdigest()
|
| 183 |
+
except Exception:
|
| 184 |
+
continue
|
| 185 |
+
|
| 186 |
+
# V1 β iterate META-INF/*.RSA / *.DSA / *.EC signatures
|
| 187 |
+
try:
|
| 188 |
+
for sig_name in apk.get_signature_names():
|
| 189 |
+
raw = apk.get_certificate_der(sig_name)
|
| 190 |
+
if raw:
|
| 191 |
+
return hashlib.sha256(raw).hexdigest()
|
| 192 |
+
except Exception:
|
| 193 |
+
pass
|
| 194 |
+
|
| 195 |
+
# Last resort: Certificate objects that expose a dump() method
|
| 196 |
+
try:
|
| 197 |
+
certs = apk.get_certificates()
|
| 198 |
+
if certs:
|
| 199 |
+
cert = certs[0]
|
| 200 |
+
for attr in ("sha256_fingerprint", "fingerprint"):
|
| 201 |
+
val = getattr(cert, attr, None)
|
| 202 |
+
if val and isinstance(val, str):
|
| 203 |
+
return val.replace(":", "").lower()
|
| 204 |
+
if hasattr(cert, "dump"):
|
| 205 |
+
return hashlib.sha256(cert.dump()).hexdigest()
|
| 206 |
+
except Exception:
|
| 207 |
+
pass
|
| 208 |
+
|
| 209 |
+
return None
|
| 210 |
+
|
| 211 |
+
# ββ Permissions ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 212 |
+
|
| 213 |
+
def _extract_permissions(self, apk) -> list[str]:
|
| 214 |
+
try:
|
| 215 |
+
return list(apk.get_permissions() or [])
|
| 216 |
+
except Exception:
|
| 217 |
+
return []
|
| 218 |
+
|
| 219 |
+
# ββ Components βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 220 |
+
|
| 221 |
+
def _extract_components(self, apk) -> ApkComponents:
|
| 222 |
+
def _safe(fn) -> list[str]:
|
| 223 |
+
try:
|
| 224 |
+
return list(fn() or [])
|
| 225 |
+
except Exception:
|
| 226 |
+
return []
|
| 227 |
+
|
| 228 |
+
return ApkComponents(
|
| 229 |
+
activities=_safe(apk.get_activities),
|
| 230 |
+
services=_safe(apk.get_services),
|
| 231 |
+
receivers=_safe(apk.get_receivers),
|
| 232 |
+
providers=_safe(apk.get_providers),
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
# ββ Security indicators ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 236 |
+
|
| 237 |
+
def _extract_security(
|
| 238 |
+
self,
|
| 239 |
+
apk,
|
| 240 |
+
permissions: list[str],
|
| 241 |
+
components: ApkComponents,
|
| 242 |
+
) -> tuple[SecurityFeatures, list[str]]:
|
| 243 |
+
perm_set = set(permissions)
|
| 244 |
+
suspicious: list[str] = []
|
| 245 |
+
|
| 246 |
+
sms_access = bool(perm_set & _SMS_PERMISSIONS)
|
| 247 |
+
if sms_access:
|
| 248 |
+
suspicious.append(
|
| 249 |
+
f"SMS access: {', '.join(sorted(perm_set & _SMS_PERMISSIONS))}"
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
overlay = "android.permission.SYSTEM_ALERT_WINDOW" in perm_set
|
| 253 |
+
if overlay:
|
| 254 |
+
suspicious.append("SYSTEM_ALERT_WINDOW (overlay capability)")
|
| 255 |
+
|
| 256 |
+
accessibility = "android.permission.BIND_ACCESSIBILITY_SERVICE" in perm_set
|
| 257 |
+
if accessibility:
|
| 258 |
+
suspicious.append("BIND_ACCESSIBILITY_SERVICE (keylogger/screen-reader risk)")
|
| 259 |
+
|
| 260 |
+
if (
|
| 261 |
+
"android.permission.READ_SMS" in perm_set
|
| 262 |
+
and "android.permission.SEND_SMS" in perm_set
|
| 263 |
+
):
|
| 264 |
+
suspicious.append("SMS read+send combination β OTP theft pattern")
|
| 265 |
+
|
| 266 |
+
if "android.permission.BIND_DEVICE_ADMIN" in perm_set:
|
| 267 |
+
suspicious.append("BIND_DEVICE_ADMIN β ransomware/stalkerware pattern")
|
| 268 |
+
|
| 269 |
+
exported_count, suspicious_receivers = self._parse_exported(apk, components)
|
| 270 |
+
|
| 271 |
+
return SecurityFeatures(
|
| 272 |
+
sms_access=sms_access,
|
| 273 |
+
overlay=overlay,
|
| 274 |
+
accessibility_service=accessibility,
|
| 275 |
+
exported_count=exported_count,
|
| 276 |
+
suspicious_receivers=suspicious_receivers,
|
| 277 |
+
), suspicious
|
| 278 |
+
|
| 279 |
+
def _parse_exported(
|
| 280 |
+
self,
|
| 281 |
+
apk,
|
| 282 |
+
components: ApkComponents,
|
| 283 |
+
) -> tuple[int, list[str]]:
|
| 284 |
+
"""Count exported components and collect suspicious receiver action names."""
|
| 285 |
+
try:
|
| 286 |
+
tree = apk.get_android_manifest_xml()
|
| 287 |
+
except Exception:
|
| 288 |
+
total = (
|
| 289 |
+
len(components.activities)
|
| 290 |
+
+ len(components.services)
|
| 291 |
+
+ len(components.receivers)
|
| 292 |
+
+ len(components.providers)
|
| 293 |
+
)
|
| 294 |
+
return total, []
|
| 295 |
+
|
| 296 |
+
exported_count = 0
|
| 297 |
+
suspicious_receivers: list[str] = []
|
| 298 |
+
app_elem = tree.find("application")
|
| 299 |
+
if app_elem is None:
|
| 300 |
+
return 0, []
|
| 301 |
+
|
| 302 |
+
for tag in ("activity", "service", "receiver", "provider"):
|
| 303 |
+
for elem in app_elem.iter(tag):
|
| 304 |
+
exported_attr = elem.get(f"{{{_ANDROID_NS}}}exported", "")
|
| 305 |
+
has_filter = elem.find("intent-filter") is not None
|
| 306 |
+
is_exported = exported_attr.lower() == "true" or (
|
| 307 |
+
has_filter and exported_attr.lower() != "false"
|
| 308 |
+
)
|
| 309 |
+
if is_exported:
|
| 310 |
+
exported_count += 1
|
| 311 |
+
|
| 312 |
+
if tag == "receiver":
|
| 313 |
+
comp_name = elem.get(f"{{{_ANDROID_NS}}}name", "")
|
| 314 |
+
for action_elem in elem.iter("action"):
|
| 315 |
+
action = action_elem.get(f"{{{_ANDROID_NS}}}name", "")
|
| 316 |
+
if action in _SUSPICIOUS_RECEIVER_ACTIONS:
|
| 317 |
+
suspicious_receivers.append(comp_name or action)
|
| 318 |
+
|
| 319 |
+
return exported_count, list(set(suspicious_receivers))
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
# ββ Module-level helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 323 |
+
|
| 324 |
+
def _safe_str(val: object) -> str:
|
| 325 |
+
return str(val) if val is not None else ""
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def _has_obfuscated_names(components: ApkComponents) -> bool:
|
| 329 |
+
"""True if any component name ends with a 1-2 character class segment."""
|
| 330 |
+
all_names = components.activities + components.services + components.receivers
|
| 331 |
+
for fqn in all_names:
|
| 332 |
+
simple = fqn.lstrip("L").rstrip(";").replace("/", ".").rsplit(".", 1)[-1]
|
| 333 |
+
if 1 <= len(simple) <= 2 and simple.isalpha():
|
| 334 |
+
return True
|
| 335 |
+
return False
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
def _build_ml_features(
|
| 339 |
+
permissions: list[str],
|
| 340 |
+
components: ApkComponents,
|
| 341 |
+
) -> MlFeatures:
|
| 342 |
+
return MlFeatures(
|
| 343 |
+
dangerous_permission_count=sum(
|
| 344 |
+
1 for p in permissions if p in _HIGH_RISK_PERMISSIONS
|
| 345 |
+
),
|
| 346 |
+
receiver_count=len(components.receivers),
|
| 347 |
+
service_count=len(components.services),
|
| 348 |
+
)
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
def _mock_result(apk_path: Path) -> AndroguardResult:
|
| 352 |
+
"""Minimal fallback result when Androguard is not installed."""
|
| 353 |
+
file_size = apk_path.stat().st_size if apk_path.exists() else 0
|
| 354 |
+
return AndroguardResult(
|
| 355 |
+
is_obfuscated=False,
|
| 356 |
+
native_code=file_size > 5 * 1024 * 1024,
|
| 357 |
+
dynamic_code_loading=False,
|
| 358 |
+
suspicious_activities=[],
|
| 359 |
+
error="androguard not installed β mock result",
|
| 360 |
+
)
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
# ββ Public API βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 364 |
+
|
| 365 |
+
_engine = AndroguardEngine()
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
async def run_androguard_analysis(
|
| 369 |
+
apk_path: Path,
|
| 370 |
+
permissions: list[str], # kept for backward-compat; ignored when Androguard runs
|
| 371 |
+
) -> AndroguardResult:
|
| 372 |
+
"""Entry-point called by APKAnalyzer; delegates to AndroguardEngine."""
|
| 373 |
+
return await _engine.analyze(apk_path)
|
app/services/apk_intelligence/engines/apkid_engine.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
engines/apkid_engine.py
|
| 3 |
+
β APKiD-based detection of packers, obfuscators, compilers and anti-analysis tricks.
|
| 4 |
+
|
| 5 |
+
Uses real APKiD when installed; degrades gracefully to a byte-level fallback scanner
|
| 6 |
+
when APKiD is absent or when the APK is corrupted. Never raises to the caller.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import asyncio
|
| 12 |
+
import logging
|
| 13 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
from typing import Any
|
| 16 |
+
|
| 17 |
+
from app.services.apk_intelligence.models import APKidResult
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
_ASYNC_TIMEOUT = 60 # seconds β outer asyncio guard
|
| 22 |
+
_APKID_TIMEOUT = 45 # seconds β passed to APKiD's own timeout
|
| 23 |
+
_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="apkid")
|
| 24 |
+
|
| 25 |
+
# ββ Runtime feature flag βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 26 |
+
try:
|
| 27 |
+
import apkid as _apkid_mod # noqa: F401
|
| 28 |
+
_APKID_AVAILABLE = True
|
| 29 |
+
except ImportError:
|
| 30 |
+
_APKID_AVAILABLE = False
|
| 31 |
+
|
| 32 |
+
# ββ Byte-level signatures for fallback scanner ββββββββββββββββββββββββββββββββ
|
| 33 |
+
|
| 34 |
+
_PACKER_SIGS: list[tuple[str, bytes]] = [
|
| 35 |
+
("Bangcle/SecShell", b"bangcle"),
|
| 36 |
+
("Bangcle/SecShell", b"secshell"),
|
| 37 |
+
("Qihoo360Protect", b"qihoo"),
|
| 38 |
+
("Qihoo360Protect", b"360protect"),
|
| 39 |
+
("DexProtector", b"dexprotector"),
|
| 40 |
+
("AppSealing", b"appsealing"),
|
| 41 |
+
("Jiagu", b"jiagu"),
|
| 42 |
+
("NagaProtector", b"naga.protect"),
|
| 43 |
+
("DexGuard", b"dexguard"),
|
| 44 |
+
("Tencent Legu", b"com/tencent/legu"),
|
| 45 |
+
("Baidu Protect", b"com/baidu/protect"),
|
| 46 |
+
("Ali Protect", b"com/ali/mobisec"),
|
| 47 |
+
("iDEA Shield", b"com/idea/"),
|
| 48 |
+
("Medusah", b"medusah"),
|
| 49 |
+
]
|
| 50 |
+
|
| 51 |
+
_OBFUSCATOR_SIGS: list[tuple[str, bytes]] = [
|
| 52 |
+
("ProGuard/R8", b"proguard"),
|
| 53 |
+
("ProGuard/R8", b"proguard-project"),
|
| 54 |
+
("Allatori", b"allatori"),
|
| 55 |
+
("DashO", b"dasho"),
|
| 56 |
+
("Zelix KlassMaster", b"zelix"),
|
| 57 |
+
("Stringer", b"stringer"),
|
| 58 |
+
("DexGuard", b"dexguard"),
|
| 59 |
+
]
|
| 60 |
+
|
| 61 |
+
_ANTI_ANALYSIS_SIGS: list[tuple[str, bytes]] = [
|
| 62 |
+
("emulator-check", b"isemulator"),
|
| 63 |
+
("emulator-check", b"ro.product.model"),
|
| 64 |
+
("emulator-check", b"ro.hardware"),
|
| 65 |
+
("emulator-check", b"goldfish"),
|
| 66 |
+
("emulator-check", b"vbox86"),
|
| 67 |
+
("emulator-check", b"android.os.build"),
|
| 68 |
+
("debugger-check", b"isdebuggerconnected"),
|
| 69 |
+
("debugger-check", b"android.os.debug"),
|
| 70 |
+
("debugger-check", b"jdwp"),
|
| 71 |
+
("anti-vm", b"vmware"),
|
| 72 |
+
("anti-vm", b"virtualbox"),
|
| 73 |
+
("anti-vm", b"bluestacks"),
|
| 74 |
+
("anti-vm", b"noxplayer"),
|
| 75 |
+
("anti-frida", b"frida"),
|
| 76 |
+
("anti-frida", b"frida-gadget"),
|
| 77 |
+
("anti-frida", b"gum-js-loop"),
|
| 78 |
+
("anti-frida", b"frida_"),
|
| 79 |
+
("root-check", b"busybox"),
|
| 80 |
+
("root-check", b"/system/xbin/su"),
|
| 81 |
+
]
|
| 82 |
+
|
| 83 |
+
_COMPILER_SIGS: list[tuple[str, bytes]] = [
|
| 84 |
+
("dx", b"dx\x00"),
|
| 85 |
+
("d8", b"d8 ("),
|
| 86 |
+
("r8", b"r8 ("),
|
| 87 |
+
("r8", b"com.android.tools.r8"),
|
| 88 |
+
("jack", b"jack "),
|
| 89 |
+
("dexmerge", b"dexmerge"),
|
| 90 |
+
("kotlin", b"kotlin/"),
|
| 91 |
+
]
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# ββ Engine class βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 95 |
+
|
| 96 |
+
class ApkidEngine:
|
| 97 |
+
"""
|
| 98 |
+
Identify APK build and protection toolchains.
|
| 99 |
+
|
| 100 |
+
Runs synchronous APKiD (or the byte-level fallback) in a thread-pool executor
|
| 101 |
+
so the asyncio event loop is never blocked. Always returns an APKidResult β
|
| 102 |
+
never raises.
|
| 103 |
+
"""
|
| 104 |
+
|
| 105 |
+
async def analyze(self, apk_path: Path) -> APKidResult:
|
| 106 |
+
if not apk_path.exists():
|
| 107 |
+
return APKidResult(error=f"APK not found: {apk_path}")
|
| 108 |
+
|
| 109 |
+
try:
|
| 110 |
+
result = await asyncio.wait_for(
|
| 111 |
+
asyncio.get_event_loop().run_in_executor(
|
| 112 |
+
_executor,
|
| 113 |
+
self._run_sync,
|
| 114 |
+
apk_path,
|
| 115 |
+
),
|
| 116 |
+
timeout=_ASYNC_TIMEOUT,
|
| 117 |
+
)
|
| 118 |
+
except asyncio.TimeoutError:
|
| 119 |
+
logger.warning("APKiD timed out for %s", apk_path)
|
| 120 |
+
return APKidResult(error="APKiD analysis timed out")
|
| 121 |
+
except Exception as exc:
|
| 122 |
+
logger.exception("APKiD unexpected error for %s", apk_path)
|
| 123 |
+
return APKidResult(error=str(exc))
|
| 124 |
+
|
| 125 |
+
return result
|
| 126 |
+
|
| 127 |
+
# ββ Sync dispatch ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 128 |
+
|
| 129 |
+
def _run_sync(self, apk_path: Path) -> APKidResult:
|
| 130 |
+
if _APKID_AVAILABLE:
|
| 131 |
+
try:
|
| 132 |
+
return self._run_real_apkid(apk_path)
|
| 133 |
+
except Exception as exc:
|
| 134 |
+
logger.warning("Real APKiD failed (%s), falling back to byte scanner", exc)
|
| 135 |
+
|
| 136 |
+
return self._run_fallback(apk_path)
|
| 137 |
+
|
| 138 |
+
# ββ Real APKiD integration βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 139 |
+
|
| 140 |
+
def _run_real_apkid(self, apk_path: Path) -> APKidResult:
|
| 141 |
+
"""
|
| 142 |
+
Call the real APKiD library.
|
| 143 |
+
|
| 144 |
+
APKiD returns {filename: {category: [rule_name, ...]}} where category is
|
| 145 |
+
one of: compiler, packer, obfuscator, anti_debug, anti_vm, anti_disassembly,
|
| 146 |
+
manipulator. We aggregate across all DEX entries inside the APK.
|
| 147 |
+
"""
|
| 148 |
+
# Late import β only reached when _APKID_AVAILABLE is True
|
| 149 |
+
from apkid.apkid import Scanner, Options # type: ignore[import]
|
| 150 |
+
from apkid.rules import RulesManager # type: ignore[import]
|
| 151 |
+
|
| 152 |
+
options = Options(timeout=_APKID_TIMEOUT, verbose=False)
|
| 153 |
+
rules = RulesManager().load()
|
| 154 |
+
scanner = Scanner(rules, options)
|
| 155 |
+
|
| 156 |
+
raw: dict[str, Any] = scanner.scan_list([str(apk_path)])
|
| 157 |
+
|
| 158 |
+
# Merge results across all DEX sections inside the APK
|
| 159 |
+
merged: dict[str, set[str]] = {}
|
| 160 |
+
for _fname, categories in raw.items():
|
| 161 |
+
if not isinstance(categories, dict):
|
| 162 |
+
continue
|
| 163 |
+
for cat, matches in categories.items():
|
| 164 |
+
merged.setdefault(cat, set()).update(
|
| 165 |
+
m if isinstance(m, str) else str(m) for m in (matches or [])
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
packers = sorted(merged.get("packer", set()))
|
| 169 |
+
obfuscators = sorted(merged.get("obfuscator", set()))
|
| 170 |
+
compilers = sorted(merged.get("compiler", set()))
|
| 171 |
+
|
| 172 |
+
anti_cats = ("anti_debug", "anti_vm", "anti_disassembly", "manipulator")
|
| 173 |
+
anti_analysis = sorted(
|
| 174 |
+
t for cat in anti_cats for t in merged.get(cat, set())
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
return _build_result(packers, obfuscators, anti_analysis, compilers)
|
| 178 |
+
|
| 179 |
+
# ββ Fallback byte scanner ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 180 |
+
|
| 181 |
+
def _run_fallback(self, apk_path: Path) -> APKidResult:
|
| 182 |
+
try:
|
| 183 |
+
content = apk_path.read_bytes().lower()
|
| 184 |
+
except Exception as exc:
|
| 185 |
+
return APKidResult(error=f"Could not read APK: {exc}")
|
| 186 |
+
|
| 187 |
+
packers = _scan_unique(content, _PACKER_SIGS)
|
| 188 |
+
obfuscators = _scan_unique(content, _OBFUSCATOR_SIGS)
|
| 189 |
+
compilers = _scan_unique(content, _COMPILER_SIGS)
|
| 190 |
+
anti_analysis = _scan_unique(content, _ANTI_ANALYSIS_SIGS)
|
| 191 |
+
|
| 192 |
+
if not compilers:
|
| 193 |
+
# d8 is the default compiler for modern Android β mark when no other signal
|
| 194 |
+
compilers = ["d8"]
|
| 195 |
+
|
| 196 |
+
return _build_result(packers, obfuscators, anti_analysis, compilers)
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
# ββ Shared result builder + risk scorer βββββββββββββββββββββββββββββββββββββββ
|
| 200 |
+
|
| 201 |
+
def _scan_unique(content: bytes, sigs: list[tuple[str, bytes]]) -> list[str]:
|
| 202 |
+
"""Return deduplicated names for every signature that appears in content."""
|
| 203 |
+
seen: set[str] = set()
|
| 204 |
+
found: list[str] = []
|
| 205 |
+
for name, pattern in sigs:
|
| 206 |
+
if pattern in content and name not in seen:
|
| 207 |
+
seen.add(name)
|
| 208 |
+
found.append(name)
|
| 209 |
+
return found
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def _build_result(
|
| 213 |
+
packers: list[str],
|
| 214 |
+
obfuscators: list[str],
|
| 215 |
+
anti_analysis: list[str],
|
| 216 |
+
compilers: list[str],
|
| 217 |
+
) -> APKidResult:
|
| 218 |
+
score = 0.0
|
| 219 |
+
|
| 220 |
+
if packers:
|
| 221 |
+
score += 0.4
|
| 222 |
+
|
| 223 |
+
if anti_analysis:
|
| 224 |
+
score += 0.3
|
| 225 |
+
|
| 226 |
+
# "heavy" obfuscation = more than one obfuscator tool detected
|
| 227 |
+
if len(obfuscators) > 1:
|
| 228 |
+
score += 0.2
|
| 229 |
+
elif obfuscators:
|
| 230 |
+
score += 0.1 # single obfuscator is common in legitimate apps
|
| 231 |
+
|
| 232 |
+
risk_score = round(min(score, 1.0), 4)
|
| 233 |
+
|
| 234 |
+
return APKidResult(
|
| 235 |
+
is_packed=bool(packers),
|
| 236 |
+
packers=packers,
|
| 237 |
+
obfuscators=obfuscators,
|
| 238 |
+
anti_analysis=anti_analysis,
|
| 239 |
+
compilers=compilers,
|
| 240 |
+
risk_score=risk_score,
|
| 241 |
+
)
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
# ββ Module-level convenience function (used by analyzer.py) βββββββββββββββββββ
|
| 245 |
+
|
| 246 |
+
_engine = ApkidEngine()
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
async def run_apkid_analysis(apk_path: Path) -> APKidResult:
|
| 250 |
+
"""Convenience wrapper kept for backward compatibility with analyzer.py."""
|
| 251 |
+
return await _engine.analyze(apk_path)
|
app/services/apk_intelligence/engines/ml_engine.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
engines/ml_engine.py
|
| 3 |
+
β ML-based malware probability scoring for APK files.
|
| 4 |
+
|
| 5 |
+
Architecture:
|
| 6 |
+
MLFeatureExtractor β converts engine results β fixed 20-dim float vector
|
| 7 |
+
MLModel β wraps a joblib classifier; falls back to heuristic scoring
|
| 8 |
+
run_ml_analysis() β async entry-point called by APKAnalyzer
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import asyncio
|
| 14 |
+
import logging
|
| 15 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from typing import Optional
|
| 18 |
+
|
| 19 |
+
from app.config import get_settings
|
| 20 |
+
from app.services.apk_intelligence.models import (
|
| 21 |
+
AndroguardResult,
|
| 22 |
+
APKidResult,
|
| 23 |
+
MLResult,
|
| 24 |
+
YaraResult,
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
logger = logging.getLogger(__name__)
|
| 28 |
+
settings = get_settings()
|
| 29 |
+
|
| 30 |
+
_ML_TIMEOUT = 30 # seconds
|
| 31 |
+
_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="ml")
|
| 32 |
+
|
| 33 |
+
# ββ Feature index (defines vector order and dimension) ββββββββββββββββββββββββ
|
| 34 |
+
|
| 35 |
+
FEATURE_NAMES: list[str] = [
|
| 36 |
+
# Permission features [0-6]
|
| 37 |
+
"perm_read_sms",
|
| 38 |
+
"perm_receive_sms",
|
| 39 |
+
"perm_send_sms",
|
| 40 |
+
"perm_system_alert_window",
|
| 41 |
+
"perm_camera",
|
| 42 |
+
"perm_record_audio",
|
| 43 |
+
"perm_access_fine_location",
|
| 44 |
+
# Component features [7-9]
|
| 45 |
+
"receiver_count",
|
| 46 |
+
"service_count",
|
| 47 |
+
"exported_component_count",
|
| 48 |
+
# Behaviour features [10-12]
|
| 49 |
+
"sms_access",
|
| 50 |
+
"overlay_detected",
|
| 51 |
+
"accessibility_service",
|
| 52 |
+
# YARA features [13-16]
|
| 53 |
+
"yara_match_count",
|
| 54 |
+
"banking_trojan_match",
|
| 55 |
+
"rat_match",
|
| 56 |
+
"otp_match",
|
| 57 |
+
# APKiD features [17-19]
|
| 58 |
+
"packed",
|
| 59 |
+
"obfuscation_count",
|
| 60 |
+
"anti_analysis_count",
|
| 61 |
+
]
|
| 62 |
+
|
| 63 |
+
FEATURE_DIM = len(FEATURE_NAMES) # 20
|
| 64 |
+
|
| 65 |
+
_PERM_READ_SMS = "android.permission.READ_SMS"
|
| 66 |
+
_PERM_RECEIVE_SMS = "android.permission.RECEIVE_SMS"
|
| 67 |
+
_PERM_SEND_SMS = "android.permission.SEND_SMS"
|
| 68 |
+
_PERM_OVERLAY = "android.permission.SYSTEM_ALERT_WINDOW"
|
| 69 |
+
_PERM_CAMERA = "android.permission.CAMERA"
|
| 70 |
+
_PERM_AUDIO = "android.permission.RECORD_AUDIO"
|
| 71 |
+
_PERM_LOCATION = "android.permission.ACCESS_FINE_LOCATION"
|
| 72 |
+
|
| 73 |
+
_YARA_RULE_TO_FAMILY: dict[str, str] = {
|
| 74 |
+
"BANKING_TROJAN": "BankingTrojan",
|
| 75 |
+
"REMOTE_ACCESS_TROJAN": "RAT",
|
| 76 |
+
"OTP_STEALER": "OTPStealer",
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# ββ Feature extractor βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 81 |
+
|
| 82 |
+
class MLFeatureExtractor:
|
| 83 |
+
"""
|
| 84 |
+
Converts structured engine outputs into a fixed-length float feature vector.
|
| 85 |
+
|
| 86 |
+
The vector is always exactly ``FEATURE_DIM`` (20) elements regardless of
|
| 87 |
+
missing or error-state engine results β missing data maps to 0.0.
|
| 88 |
+
"""
|
| 89 |
+
|
| 90 |
+
def extract(
|
| 91 |
+
self,
|
| 92 |
+
androguard: AndroguardResult,
|
| 93 |
+
yara: YaraResult,
|
| 94 |
+
apkid: APKidResult,
|
| 95 |
+
) -> tuple[list[float], list[str]]:
|
| 96 |
+
"""
|
| 97 |
+
Extract feature vector and report which features are active.
|
| 98 |
+
|
| 99 |
+
Returns:
|
| 100 |
+
(vector, active_features) where ``vector`` has length ``FEATURE_DIM``
|
| 101 |
+
and ``active_features`` lists every feature name with a non-zero value.
|
| 102 |
+
"""
|
| 103 |
+
perm_set = frozenset(androguard.permissions)
|
| 104 |
+
sf = androguard.security_features
|
| 105 |
+
comp = androguard.components
|
| 106 |
+
|
| 107 |
+
# ββ Permission features ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 108 |
+
perm_read_sms = 1.0 if _PERM_READ_SMS in perm_set else 0.0
|
| 109 |
+
perm_recv_sms = 1.0 if _PERM_RECEIVE_SMS in perm_set else 0.0
|
| 110 |
+
perm_send_sms = 1.0 if _PERM_SEND_SMS in perm_set else 0.0
|
| 111 |
+
perm_overlay = 1.0 if _PERM_OVERLAY in perm_set else 0.0
|
| 112 |
+
perm_camera = 1.0 if _PERM_CAMERA in perm_set else 0.0
|
| 113 |
+
perm_audio = 1.0 if _PERM_AUDIO in perm_set else 0.0
|
| 114 |
+
perm_location = 1.0 if _PERM_LOCATION in perm_set else 0.0
|
| 115 |
+
|
| 116 |
+
# ββ Component features βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 117 |
+
receiver_count = float(len(comp.receivers) if comp else 0)
|
| 118 |
+
service_count = float(len(comp.services) if comp else 0)
|
| 119 |
+
exported_count = float(sf.exported_count if sf else 0)
|
| 120 |
+
|
| 121 |
+
# ββ Behaviour features βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 122 |
+
sms_access = 1.0 if sf and sf.sms_access else 0.0
|
| 123 |
+
overlay_det = 1.0 if sf and sf.overlay else 0.0
|
| 124 |
+
accessibility = 1.0 if sf and sf.accessibility_service else 0.0
|
| 125 |
+
|
| 126 |
+
# ββ YARA features ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 127 |
+
rule_names = set(yara.rule_names)
|
| 128 |
+
yara_count = float(len(yara.matches))
|
| 129 |
+
banking_match = 1.0 if "BANKING_TROJAN" in rule_names else 0.0
|
| 130 |
+
rat_match = 1.0 if "REMOTE_ACCESS_TROJAN" in rule_names else 0.0
|
| 131 |
+
otp_match = 1.0 if "OTP_STEALER" in rule_names else 0.0
|
| 132 |
+
|
| 133 |
+
# ββ APKiD features βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 134 |
+
packed = 1.0 if apkid.is_packed else 0.0
|
| 135 |
+
obf_count = float(len(apkid.obfuscators))
|
| 136 |
+
anti_count = float(len(apkid.anti_analysis))
|
| 137 |
+
|
| 138 |
+
vector: list[float] = [
|
| 139 |
+
perm_read_sms, perm_recv_sms, perm_send_sms,
|
| 140 |
+
perm_overlay, perm_camera, perm_audio, perm_location,
|
| 141 |
+
receiver_count, service_count, exported_count,
|
| 142 |
+
sms_access, overlay_det, accessibility,
|
| 143 |
+
yara_count, banking_match, rat_match, otp_match,
|
| 144 |
+
packed, obf_count, anti_count,
|
| 145 |
+
]
|
| 146 |
+
|
| 147 |
+
active = [FEATURE_NAMES[i] for i, v in enumerate(vector) if v != 0.0]
|
| 148 |
+
return vector, active
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
# ββ ML model wrapper ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 152 |
+
|
| 153 |
+
class MLModel:
|
| 154 |
+
"""
|
| 155 |
+
Wraps a joblib-persisted scikit-learn classifier.
|
| 156 |
+
|
| 157 |
+
Loads the model once at first use (lazy). Falls back to a calibrated
|
| 158 |
+
heuristic scorer when the model file is absent, unreadable, or raises.
|
| 159 |
+
"""
|
| 160 |
+
|
| 161 |
+
def __init__(self, model_path: str) -> None:
|
| 162 |
+
self._path = Path(model_path)
|
| 163 |
+
self._clf = None
|
| 164 |
+
self._load_attempted = False
|
| 165 |
+
|
| 166 |
+
# ββ Model loading ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 167 |
+
|
| 168 |
+
def _ensure_loaded(self) -> bool:
|
| 169 |
+
if self._load_attempted:
|
| 170 |
+
return self._clf is not None
|
| 171 |
+
|
| 172 |
+
self._load_attempted = True
|
| 173 |
+
|
| 174 |
+
if not self._path.exists():
|
| 175 |
+
logger.info(
|
| 176 |
+
"ML model not found at %s β heuristic fallback active", self._path
|
| 177 |
+
)
|
| 178 |
+
return False
|
| 179 |
+
|
| 180 |
+
try:
|
| 181 |
+
import joblib # type: ignore[import]
|
| 182 |
+
self._clf = joblib.load(self._path)
|
| 183 |
+
logger.info("ML model loaded from %s", self._path)
|
| 184 |
+
return True
|
| 185 |
+
except Exception as exc:
|
| 186 |
+
logger.warning("Failed to load ML model from %s: %s", self._path, exc)
|
| 187 |
+
return False
|
| 188 |
+
|
| 189 |
+
# ββ Inference ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 190 |
+
|
| 191 |
+
def predict(self, vector: list[float]) -> tuple[float, float]:
|
| 192 |
+
"""
|
| 193 |
+
Run inference and return (malware_probability, confidence), both in [0, 1].
|
| 194 |
+
"""
|
| 195 |
+
if not self._ensure_loaded():
|
| 196 |
+
return self._heuristic_predict(vector)
|
| 197 |
+
|
| 198 |
+
try:
|
| 199 |
+
import numpy as np # type: ignore[import]
|
| 200 |
+
X = np.array(vector, dtype=float).reshape(1, -1)
|
| 201 |
+
proba = self._clf.predict_proba(X)[0]
|
| 202 |
+
# class index 1 = malicious
|
| 203 |
+
prob = float(proba[1]) if len(proba) >= 2 else float(proba[0])
|
| 204 |
+
return round(prob, 4), 0.88
|
| 205 |
+
except Exception as exc:
|
| 206 |
+
logger.warning("predict_proba failed (%s), falling back to heuristic", exc)
|
| 207 |
+
return self._heuristic_predict(vector)
|
| 208 |
+
|
| 209 |
+
def _heuristic_predict(self, vector: list[float]) -> tuple[float, float]:
|
| 210 |
+
"""
|
| 211 |
+
Calibrated heuristic scoring used when no trained model is present.
|
| 212 |
+
|
| 213 |
+
Weights are derived from known banking-malware behaviour patterns and
|
| 214 |
+
represent the relative contribution of each feature to malice probability.
|
| 215 |
+
"""
|
| 216 |
+
weights: list[float] = [
|
| 217 |
+
# Permission features
|
| 218 |
+
0.12, # READ_SMS
|
| 219 |
+
0.08, # RECEIVE_SMS
|
| 220 |
+
0.10, # SEND_SMS
|
| 221 |
+
0.12, # SYSTEM_ALERT_WINDOW
|
| 222 |
+
0.02, # CAMERA
|
| 223 |
+
0.03, # RECORD_AUDIO
|
| 224 |
+
0.02, # ACCESS_FINE_LOCATION
|
| 225 |
+
# Component features (additive per-component risk)
|
| 226 |
+
0.005, # receiver_count
|
| 227 |
+
0.003, # service_count
|
| 228 |
+
0.002, # exported_component_count
|
| 229 |
+
# Behaviour features
|
| 230 |
+
0.15, # sms_access
|
| 231 |
+
0.12, # overlay_detected
|
| 232 |
+
0.15, # accessibility_service
|
| 233 |
+
# YARA features
|
| 234 |
+
0.06, # yara_match_count (per rule)
|
| 235 |
+
0.30, # banking_trojan_match
|
| 236 |
+
0.25, # rat_match
|
| 237 |
+
0.20, # otp_match
|
| 238 |
+
# APKiD features
|
| 239 |
+
0.20, # packed
|
| 240 |
+
0.05, # obfuscation_count (per obfuscator)
|
| 241 |
+
0.04, # anti_analysis_count (per technique)
|
| 242 |
+
]
|
| 243 |
+
|
| 244 |
+
raw = sum(w * v for w, v in zip(weights, vector))
|
| 245 |
+
prob = round(min(raw, 1.0), 4)
|
| 246 |
+
|
| 247 |
+
signals = sum(1 for v in vector if v != 0.0)
|
| 248 |
+
confidence = round(min(0.40 + signals * 0.04, 0.82), 2)
|
| 249 |
+
|
| 250 |
+
return prob, confidence
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
# ββ Indicator builder ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 254 |
+
|
| 255 |
+
def _build_indicators(
|
| 256 |
+
active_features: list[str],
|
| 257 |
+
yara: YaraResult,
|
| 258 |
+
apkid: APKidResult,
|
| 259 |
+
) -> list[str]:
|
| 260 |
+
"""Return up to 10 human-readable indicator strings for the MLResult."""
|
| 261 |
+
indicators: list[str] = []
|
| 262 |
+
|
| 263 |
+
# YARA matches are the most interpretable β list first
|
| 264 |
+
for match in yara.matches:
|
| 265 |
+
indicators.append(f"YARA:{match.rule} [{match.severity}]")
|
| 266 |
+
|
| 267 |
+
# APKiD packers
|
| 268 |
+
for pk in apkid.packers:
|
| 269 |
+
indicators.append(f"Packer:{pk}")
|
| 270 |
+
|
| 271 |
+
# APKiD anti-analysis techniques (one per category)
|
| 272 |
+
seen_aa: set[str] = set()
|
| 273 |
+
for aa in apkid.anti_analysis:
|
| 274 |
+
cat = aa.split("-")[0]
|
| 275 |
+
if cat not in seen_aa:
|
| 276 |
+
indicators.append(f"AntiAnalysis:{aa}")
|
| 277 |
+
seen_aa.add(cat)
|
| 278 |
+
|
| 279 |
+
# High-signal permission / behaviour features
|
| 280 |
+
_HIGH_SIGNAL = {
|
| 281 |
+
"perm_read_sms", "perm_send_sms", "perm_system_alert_window",
|
| 282 |
+
"sms_access", "overlay_detected", "accessibility_service",
|
| 283 |
+
"banking_trojan_match", "rat_match", "otp_match",
|
| 284 |
+
}
|
| 285 |
+
for feat in active_features:
|
| 286 |
+
if feat in _HIGH_SIGNAL:
|
| 287 |
+
indicators.append(f"Feature:{feat}")
|
| 288 |
+
|
| 289 |
+
return indicators[:10]
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def _infer_threat_family(yara: YaraResult) -> Optional[str]:
|
| 293 |
+
"""Map the highest-severity YARA match to a threat family name."""
|
| 294 |
+
for rule in yara.rule_names:
|
| 295 |
+
if rule in _YARA_RULE_TO_FAMILY:
|
| 296 |
+
return _YARA_RULE_TO_FAMILY[rule]
|
| 297 |
+
return None
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
# ββ Module singletons βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 301 |
+
|
| 302 |
+
_extractor = MLFeatureExtractor()
|
| 303 |
+
_model = MLModel(settings.ML_MODEL_PATH)
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
# ββ Synchronous core (runs in executor) βββββββββββββββββββββββββββββββββββββββ
|
| 307 |
+
|
| 308 |
+
def _run_sync(
|
| 309 |
+
androguard: AndroguardResult,
|
| 310 |
+
yara: YaraResult,
|
| 311 |
+
apkid: APKidResult,
|
| 312 |
+
) -> MLResult:
|
| 313 |
+
vector, active = _extractor.extract(androguard, yara, apkid)
|
| 314 |
+
prob, conf = _model.predict(vector)
|
| 315 |
+
indicators = _build_indicators(active, yara, apkid)
|
| 316 |
+
threat_family = _infer_threat_family(yara)
|
| 317 |
+
|
| 318 |
+
return MLResult(
|
| 319 |
+
malware_probability=prob,
|
| 320 |
+
confidence=conf,
|
| 321 |
+
features_used=active,
|
| 322 |
+
top_indicators=indicators,
|
| 323 |
+
threat_family=threat_family,
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
# ββ Public entry-point ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 328 |
+
|
| 329 |
+
async def run_ml_analysis(
|
| 330 |
+
androguard: AndroguardResult,
|
| 331 |
+
yara: YaraResult,
|
| 332 |
+
apkid: APKidResult,
|
| 333 |
+
) -> MLResult:
|
| 334 |
+
"""
|
| 335 |
+
Async entry-point called by APKAnalyzer after the other engines complete.
|
| 336 |
+
|
| 337 |
+
Feature extraction and model inference run inside a thread-pool executor
|
| 338 |
+
so they never block the asyncio event loop. A hard timeout prevents the
|
| 339 |
+
pipeline stalling on a misbehaving model.
|
| 340 |
+
|
| 341 |
+
Returns:
|
| 342 |
+
MLResult β never raises; failures are captured in ``error``.
|
| 343 |
+
"""
|
| 344 |
+
try:
|
| 345 |
+
result = await asyncio.wait_for(
|
| 346 |
+
asyncio.get_running_loop().run_in_executor(
|
| 347 |
+
_executor,
|
| 348 |
+
_run_sync,
|
| 349 |
+
androguard,
|
| 350 |
+
yara,
|
| 351 |
+
apkid,
|
| 352 |
+
),
|
| 353 |
+
timeout=_ML_TIMEOUT,
|
| 354 |
+
)
|
| 355 |
+
return result
|
| 356 |
+
except asyncio.TimeoutError:
|
| 357 |
+
logger.error("ML inference timed out after %ds", _ML_TIMEOUT)
|
| 358 |
+
return MLResult(error=f"ML inference timed out after {_ML_TIMEOUT}s")
|
| 359 |
+
except Exception as exc:
|
| 360 |
+
logger.exception("ML inference unexpected failure")
|
| 361 |
+
return MLResult(error=str(exc))
|
app/services/apk_intelligence/engines/mobsf_client.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
engines/mobsf_client.py
|
| 3 |
+
β MobSF (Mobile Security Framework) REST API client.
|
| 4 |
+
|
| 5 |
+
MobSF runs as a separate Docker container. This client handles the three-step
|
| 6 |
+
REST flow (upload β scan β report) and degrades gracefully when MobSF is
|
| 7 |
+
offline, misconfigured, or disabled β the verdict pipeline is never blocked.
|
| 8 |
+
|
| 9 |
+
Enable with: MOBSF_ENABLED=true, MOBSF_URL=http://localhost:8000, MOBSF_API_KEY=<key>
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import asyncio
|
| 15 |
+
import logging
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from typing import Any
|
| 18 |
+
|
| 19 |
+
import httpx
|
| 20 |
+
|
| 21 |
+
from app.config import get_settings
|
| 22 |
+
from app.services.apk_intelligence.models import MobSFResult
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
settings = get_settings()
|
| 26 |
+
|
| 27 |
+
# Per-step timeouts (MobSF scan can take up to ~90 s on a loaded machine)
|
| 28 |
+
_UPLOAD_TIMEOUT = httpx.Timeout(120.0, connect=10.0)
|
| 29 |
+
_SCAN_TIMEOUT = httpx.Timeout(300.0, connect=10.0)
|
| 30 |
+
_REPORT_TIMEOUT = httpx.Timeout(30.0, connect=10.0)
|
| 31 |
+
|
| 32 |
+
# Outer asyncio guard: if the whole flow exceeds this, return a fallback result
|
| 33 |
+
_ANALYSIS_TIMEOUT = 420 # seconds
|
| 34 |
+
|
| 35 |
+
# Permissions that are strongly associated with banking malware behaviour
|
| 36 |
+
_MALWARE_PERMISSION_KEYWORDS: frozenset[str] = frozenset({
|
| 37 |
+
"READ_SMS",
|
| 38 |
+
"SEND_SMS",
|
| 39 |
+
"RECEIVE_SMS",
|
| 40 |
+
"RECORD_AUDIO",
|
| 41 |
+
"ACCESS_FINE_LOCATION",
|
| 42 |
+
"BIND_ACCESSIBILITY_SERVICE",
|
| 43 |
+
"BIND_DEVICE_ADMIN",
|
| 44 |
+
"READ_CALL_LOG",
|
| 45 |
+
"PROCESS_OUTGOING_CALLS",
|
| 46 |
+
"SYSTEM_ALERT_WINDOW",
|
| 47 |
+
"REQUEST_INSTALL_PACKAGES",
|
| 48 |
+
"CHANGE_NETWORK_STATE",
|
| 49 |
+
"RECEIVE_BOOT_COMPLETED",
|
| 50 |
+
})
|
| 51 |
+
|
| 52 |
+
# Severity labels that indicate a meaningful finding
|
| 53 |
+
_HIGH_SEVERITIES: frozenset[str] = frozenset({"HIGH", "WARNING", "CRITICAL", "DANGER"})
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class MobSFClient:
|
| 57 |
+
"""
|
| 58 |
+
Async client for the MobSF REST API v3.x.
|
| 59 |
+
|
| 60 |
+
Three public methods map directly to MobSF endpoints:
|
| 61 |
+
upload_apk β POST /api/v1/upload
|
| 62 |
+
start_scan β POST /api/v1/scan
|
| 63 |
+
get_report β POST /api/v1/report_json
|
| 64 |
+
|
| 65 |
+
``analyze`` orchestrates all three and parses the result into a
|
| 66 |
+
``MobSFResult``. All exceptions from the HTTP layer propagate up so the
|
| 67 |
+
caller (``analyze``) can catch them uniformly and return a safe fallback.
|
| 68 |
+
"""
|
| 69 |
+
|
| 70 |
+
def __init__(self) -> None:
|
| 71 |
+
self._base_url = settings.MOBSF_URL.rstrip("/")
|
| 72 |
+
self._headers = {"Authorization": settings.MOBSF_API_KEY}
|
| 73 |
+
|
| 74 |
+
# ββ Public methods (one per MobSF endpoint) ββββββββββββββββββββββββββββββββ
|
| 75 |
+
|
| 76 |
+
async def upload_apk(self, apk_path: Path) -> str:
|
| 77 |
+
"""
|
| 78 |
+
Upload the APK to MobSF.
|
| 79 |
+
|
| 80 |
+
Returns:
|
| 81 |
+
file_hash (str) β MobSF's internal identifier for this APK.
|
| 82 |
+
|
| 83 |
+
Raises:
|
| 84 |
+
httpx.HTTPStatusError on 4xx/5xx responses.
|
| 85 |
+
httpx.ConnectError when MobSF is unreachable.
|
| 86 |
+
httpx.TimeoutException on upload timeout.
|
| 87 |
+
"""
|
| 88 |
+
async with httpx.AsyncClient(
|
| 89 |
+
timeout=_UPLOAD_TIMEOUT, headers=self._headers
|
| 90 |
+
) as client:
|
| 91 |
+
with apk_path.open("rb") as f:
|
| 92 |
+
resp = await client.post(
|
| 93 |
+
f"{self._base_url}/api/v1/upload",
|
| 94 |
+
files={"file": (apk_path.name, f, "application/octet-stream")},
|
| 95 |
+
)
|
| 96 |
+
resp.raise_for_status()
|
| 97 |
+
return str(resp.json()["hash"])
|
| 98 |
+
|
| 99 |
+
async def start_scan(self, file_hash: str, file_name: str = "") -> None:
|
| 100 |
+
"""
|
| 101 |
+
Trigger a MobSF static analysis scan for the uploaded APK.
|
| 102 |
+
|
| 103 |
+
Raises:
|
| 104 |
+
httpx.HTTPStatusError on 4xx/5xx responses.
|
| 105 |
+
httpx.TimeoutException when scan acknowledgement times out.
|
| 106 |
+
"""
|
| 107 |
+
async with httpx.AsyncClient(
|
| 108 |
+
timeout=_SCAN_TIMEOUT, headers=self._headers
|
| 109 |
+
) as client:
|
| 110 |
+
resp = await client.post(
|
| 111 |
+
f"{self._base_url}/api/v1/scan",
|
| 112 |
+
data={
|
| 113 |
+
"hash": file_hash,
|
| 114 |
+
"scan_type": "apk",
|
| 115 |
+
"file_name": file_name,
|
| 116 |
+
},
|
| 117 |
+
)
|
| 118 |
+
resp.raise_for_status()
|
| 119 |
+
|
| 120 |
+
async def get_report(self, file_hash: str) -> dict[str, Any]:
|
| 121 |
+
"""
|
| 122 |
+
Fetch the JSON analysis report for a completed MobSF scan.
|
| 123 |
+
|
| 124 |
+
Returns:
|
| 125 |
+
Raw MobSF report as a dict.
|
| 126 |
+
|
| 127 |
+
Raises:
|
| 128 |
+
httpx.HTTPStatusError on 4xx/5xx responses.
|
| 129 |
+
httpx.TimeoutException when report fetch times out.
|
| 130 |
+
"""
|
| 131 |
+
async with httpx.AsyncClient(
|
| 132 |
+
timeout=_REPORT_TIMEOUT, headers=self._headers
|
| 133 |
+
) as client:
|
| 134 |
+
resp = await client.post(
|
| 135 |
+
f"{self._base_url}/api/v1/report_json",
|
| 136 |
+
data={"hash": file_hash},
|
| 137 |
+
)
|
| 138 |
+
resp.raise_for_status()
|
| 139 |
+
return dict(resp.json())
|
| 140 |
+
|
| 141 |
+
# ββ Full analysis orchestrator βββββββββββββββββββββββββββββββββββββββββββββ
|
| 142 |
+
|
| 143 |
+
async def analyze(self, apk_path: Path) -> MobSFResult:
|
| 144 |
+
"""
|
| 145 |
+
Run the complete upload β scan β report flow and return a parsed result.
|
| 146 |
+
|
| 147 |
+
Never raises β all network errors are caught and returned as a
|
| 148 |
+
``MobSFResult`` with ``available=False`` and the error message set.
|
| 149 |
+
"""
|
| 150 |
+
try:
|
| 151 |
+
file_hash = await self.upload_apk(apk_path)
|
| 152 |
+
logger.debug("MobSF upload complete, hash=%s", file_hash)
|
| 153 |
+
|
| 154 |
+
await self.start_scan(file_hash, apk_path.name)
|
| 155 |
+
logger.debug("MobSF scan started for hash=%s", file_hash)
|
| 156 |
+
|
| 157 |
+
report = await self.get_report(file_hash)
|
| 158 |
+
logger.info("MobSF report received for hash=%s", file_hash)
|
| 159 |
+
|
| 160 |
+
return self._parse_report(report)
|
| 161 |
+
|
| 162 |
+
except httpx.ConnectError as exc:
|
| 163 |
+
logger.warning("MobSF unreachable at %s: %s", self._base_url, exc)
|
| 164 |
+
return MobSFResult(available=False, error=f"MobSF unreachable: {exc}")
|
| 165 |
+
|
| 166 |
+
except httpx.TimeoutException as exc:
|
| 167 |
+
logger.warning("MobSF request timed out: %s", exc)
|
| 168 |
+
return MobSFResult(available=False, error=f"MobSF timed out: {exc}")
|
| 169 |
+
|
| 170 |
+
except httpx.HTTPStatusError as exc:
|
| 171 |
+
status = exc.response.status_code
|
| 172 |
+
body = exc.response.text[:200]
|
| 173 |
+
logger.error("MobSF HTTP %s error: %s", status, body)
|
| 174 |
+
return MobSFResult(available=False, error=f"MobSF HTTP {status}")
|
| 175 |
+
|
| 176 |
+
except Exception as exc:
|
| 177 |
+
logger.exception("MobSF unexpected failure")
|
| 178 |
+
return MobSFResult(available=False, error=str(exc))
|
| 179 |
+
|
| 180 |
+
# ββ Report parser ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 181 |
+
|
| 182 |
+
def _parse_report(self, report: dict[str, Any]) -> MobSFResult:
|
| 183 |
+
"""
|
| 184 |
+
Convert a raw MobSF JSON report into a structured ``MobSFResult``.
|
| 185 |
+
|
| 186 |
+
MobSF ``security_score`` convention: **higher = safer** (0β100).
|
| 187 |
+
We invert this to a danger ``score`` and ``risk_score`` so downstream
|
| 188 |
+
fusion logic can treat higher values as more dangerous β matching the
|
| 189 |
+
convention documented in the Android SDK wire format.
|
| 190 |
+
"""
|
| 191 |
+
# ββ Security score βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 192 |
+
raw_score = report.get("security_score")
|
| 193 |
+
if raw_score is None:
|
| 194 |
+
raw_score = report.get("appsec", {}).get("security_score")
|
| 195 |
+
if raw_score is not None:
|
| 196 |
+
security_score: float | None = float(raw_score)
|
| 197 |
+
danger_score = int(100 - security_score)
|
| 198 |
+
risk_score = round(max(100 - security_score, 0) / 100.0, 4)
|
| 199 |
+
else:
|
| 200 |
+
security_score = None
|
| 201 |
+
danger_score = -1
|
| 202 |
+
risk_score = 0.0
|
| 203 |
+
|
| 204 |
+
# ββ Trackers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 205 |
+
trackers: list[str] = [
|
| 206 |
+
t.get("name", "Unknown Tracker")
|
| 207 |
+
for t in report.get("trackers", {}).get("trackers", [])
|
| 208 |
+
if isinstance(t, dict)
|
| 209 |
+
]
|
| 210 |
+
|
| 211 |
+
# ββ Permissions ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 212 |
+
raw_perms = report.get("permissions", {})
|
| 213 |
+
all_permissions: list[str] = list(raw_perms.keys()) if isinstance(raw_perms, dict) else []
|
| 214 |
+
malware_permissions: list[str] = [
|
| 215 |
+
p for p in all_permissions
|
| 216 |
+
if any(kw in p for kw in _MALWARE_PERMISSION_KEYWORDS)
|
| 217 |
+
]
|
| 218 |
+
|
| 219 |
+
# ββ Findings βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 220 |
+
findings: list[str] = []
|
| 221 |
+
|
| 222 |
+
# Manifest analysis (MobSF v3.x: list of dicts)
|
| 223 |
+
for item in report.get("manifest_analysis", []):
|
| 224 |
+
if not isinstance(item, dict):
|
| 225 |
+
continue
|
| 226 |
+
title = item.get("title") or item.get("rule") or ""
|
| 227 |
+
severity = str(item.get("severity", "")).upper()
|
| 228 |
+
if title and severity in _HIGH_SEVERITIES:
|
| 229 |
+
findings.append(f"[Manifest:{severity}] {title}")
|
| 230 |
+
|
| 231 |
+
# Code analysis (MobSF v3.x: dict of {rule_id: {severity, ...}})
|
| 232 |
+
for rule_id, detail in report.get("code_analysis", {}).items():
|
| 233 |
+
if not isinstance(detail, dict):
|
| 234 |
+
continue
|
| 235 |
+
severity = str(detail.get("severity", "")).upper()
|
| 236 |
+
if severity in _HIGH_SEVERITIES:
|
| 237 |
+
findings.append(f"[Code:{severity}] {rule_id}")
|
| 238 |
+
|
| 239 |
+
# appsec findings (older MobSF format / some v3 builds)
|
| 240 |
+
for item in report.get("appsec", {}).get("findings", []):
|
| 241 |
+
if not isinstance(item, dict):
|
| 242 |
+
continue
|
| 243 |
+
title = item.get("title") or item.get("description") or ""
|
| 244 |
+
severity = str(item.get("severity", "")).upper()
|
| 245 |
+
if title:
|
| 246 |
+
findings.append(f"[AppSec:{severity}] {title}")
|
| 247 |
+
|
| 248 |
+
return MobSFResult(
|
| 249 |
+
available=True,
|
| 250 |
+
score=danger_score,
|
| 251 |
+
security_score=security_score,
|
| 252 |
+
risk_score=risk_score,
|
| 253 |
+
findings=findings[:20], # cap to keep API response manageable
|
| 254 |
+
trackers=trackers,
|
| 255 |
+
permissions=all_permissions,
|
| 256 |
+
malware_permissions=malware_permissions,
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
# ββ Module singleton ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 261 |
+
|
| 262 |
+
_client = MobSFClient()
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
# ββ Public entry-point ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 266 |
+
|
| 267 |
+
async def run_mobsf_analysis(
|
| 268 |
+
apk_path: Path,
|
| 269 |
+
package_name: str, # kept for API compatibility; not used by real client
|
| 270 |
+
) -> MobSFResult:
|
| 271 |
+
"""
|
| 272 |
+
Async entry-point called by APKAnalyzer.
|
| 273 |
+
|
| 274 |
+
Returns immediately with an empty result when ``MOBSF_ENABLED=False``.
|
| 275 |
+
When enabled, delegates to ``MobSFClient.analyze`` wrapped in a hard
|
| 276 |
+
``asyncio.wait_for`` timeout so the verdict pipeline is never blocked.
|
| 277 |
+
|
| 278 |
+
Returns:
|
| 279 |
+
MobSFResult β never raises.
|
| 280 |
+
"""
|
| 281 |
+
if not settings.MOBSF_ENABLED:
|
| 282 |
+
return MobSFResult()
|
| 283 |
+
|
| 284 |
+
try:
|
| 285 |
+
return await asyncio.wait_for(
|
| 286 |
+
_client.analyze(apk_path),
|
| 287 |
+
timeout=_ANALYSIS_TIMEOUT,
|
| 288 |
+
)
|
| 289 |
+
except asyncio.TimeoutError:
|
| 290 |
+
logger.error(
|
| 291 |
+
"MobSF analysis timed out after %ds for %s", _ANALYSIS_TIMEOUT, apk_path
|
| 292 |
+
)
|
| 293 |
+
return MobSFResult(
|
| 294 |
+
available=False,
|
| 295 |
+
error=f"MobSF analysis timed out after {_ANALYSIS_TIMEOUT}s",
|
| 296 |
+
)
|
| 297 |
+
except Exception as exc:
|
| 298 |
+
logger.exception("run_mobsf_analysis unexpected failure")
|
| 299 |
+
return MobSFResult(available=False, error=str(exc))
|
app/services/apk_intelligence/engines/yara_engine.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
engines/yara_engine.py
|
| 3 |
+
β YARA rule scanning for APK binaries.
|
| 4 |
+
|
| 5 |
+
Rules are loaded once at module import (application startup) from the
|
| 6 |
+
``rules/`` directory adjacent to the ``backend/`` root. All scanning
|
| 7 |
+
runs in a thread-pool executor so the async event loop is never blocked.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import asyncio
|
| 13 |
+
import functools
|
| 14 |
+
import logging
|
| 15 |
+
import time
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from typing import Optional
|
| 18 |
+
|
| 19 |
+
from app.config import get_settings
|
| 20 |
+
from app.services.apk_intelligence.models import YaraMatch, YaraResult
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
# yara-python is an optional dependency; fall back gracefully if absent.
|
| 25 |
+
try:
|
| 26 |
+
import yara as _yara # type: ignore[import-untyped]
|
| 27 |
+
_YARA_AVAILABLE = True
|
| 28 |
+
except ImportError:
|
| 29 |
+
_yara = None # type: ignore[assignment]
|
| 30 |
+
_YARA_AVAILABLE = False
|
| 31 |
+
|
| 32 |
+
_SCAN_TIMEOUT_SEC = 25 # yara.Rules.match() internal timeout
|
| 33 |
+
_ASYNC_TIMEOUT_SEC = 30 # asyncio.wait_for() outer timeout
|
| 34 |
+
|
| 35 |
+
# Severity β numeric weight used for risk_score calculation
|
| 36 |
+
_SEVERITY_WEIGHT: dict[str, float] = {
|
| 37 |
+
"CRITICAL": 1.0,
|
| 38 |
+
"HIGH": 0.8,
|
| 39 |
+
"MEDIUM": 0.5,
|
| 40 |
+
"LOW": 0.2,
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
# The backend/ root is four parents above this file:
|
| 44 |
+
# engines/yara_engine.py β engines/ β apk_intelligence/ β services/ β app/ β backend/
|
| 45 |
+
_BACKEND_ROOT = Path(__file__).parents[4]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _resolve_rules_dir(config_path: str) -> Path:
|
| 49 |
+
"""Return an absolute Path for the YARA rules directory."""
|
| 50 |
+
p = Path(config_path)
|
| 51 |
+
if p.is_absolute():
|
| 52 |
+
return p
|
| 53 |
+
# Prefer backend-root-relative resolution (works in Docker and local dev)
|
| 54 |
+
candidate = _BACKEND_ROOT / p
|
| 55 |
+
if candidate.exists():
|
| 56 |
+
return candidate
|
| 57 |
+
return Path.cwd() / p
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class YaraEngine:
|
| 61 |
+
"""
|
| 62 |
+
Loads compiled YARA rules at construction and scans APKs asynchronously.
|
| 63 |
+
|
| 64 |
+
A single module-level instance is created at import time so rules are
|
| 65 |
+
compiled exactly once per worker process.
|
| 66 |
+
"""
|
| 67 |
+
|
| 68 |
+
def __init__(self, rules_dir: Optional[Path] = None) -> None:
|
| 69 |
+
settings = get_settings()
|
| 70 |
+
self._rules_dir: Path = rules_dir or _resolve_rules_dir(settings.YARA_RULES_DIR)
|
| 71 |
+
self._compiled: object = None # yara.Rules once loaded
|
| 72 |
+
self._load_error: Optional[str] = None
|
| 73 |
+
self._rule_count: int = 0
|
| 74 |
+
self._load_rules()
|
| 75 |
+
|
| 76 |
+
# ββ Rule loading βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 77 |
+
|
| 78 |
+
def _load_rules(self) -> None:
|
| 79 |
+
if not _YARA_AVAILABLE:
|
| 80 |
+
self._load_error = "yara-python not installed"
|
| 81 |
+
logger.warning("YaraEngine: yara-python not installed β scans will return empty results")
|
| 82 |
+
return
|
| 83 |
+
|
| 84 |
+
if not self._rules_dir.exists():
|
| 85 |
+
self._load_error = f"Rules directory not found: {self._rules_dir}"
|
| 86 |
+
logger.warning("YaraEngine: %s", self._load_error)
|
| 87 |
+
return
|
| 88 |
+
|
| 89 |
+
rule_files: dict[str, str] = {
|
| 90 |
+
f.stem: str(f)
|
| 91 |
+
for f in sorted(self._rules_dir.glob("*.yar"))
|
| 92 |
+
}
|
| 93 |
+
if not rule_files:
|
| 94 |
+
self._load_error = f"No .yar files found in {self._rules_dir}"
|
| 95 |
+
logger.warning("YaraEngine: %s", self._load_error)
|
| 96 |
+
return
|
| 97 |
+
|
| 98 |
+
try:
|
| 99 |
+
self._compiled = _yara.compile(filepaths=rule_files)
|
| 100 |
+
self._rule_count = len(rule_files)
|
| 101 |
+
logger.info(
|
| 102 |
+
"YaraEngine: loaded %d rule file(s) from %s: %s",
|
| 103 |
+
self._rule_count,
|
| 104 |
+
self._rules_dir,
|
| 105 |
+
list(rule_files.keys()),
|
| 106 |
+
)
|
| 107 |
+
except Exception as exc:
|
| 108 |
+
self._load_error = f"Failed to compile YARA rules: {exc}"
|
| 109 |
+
logger.error("YaraEngine: %s", self._load_error)
|
| 110 |
+
|
| 111 |
+
# ββ Public async interface βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 112 |
+
|
| 113 |
+
async def analyze(self, apk_path: Path) -> YaraResult:
|
| 114 |
+
"""
|
| 115 |
+
Scan an APK file against all loaded YARA rules.
|
| 116 |
+
|
| 117 |
+
Args:
|
| 118 |
+
apk_path: Path to the APK on disk.
|
| 119 |
+
|
| 120 |
+
Returns:
|
| 121 |
+
YaraResult β never raises; errors are captured in the ``error`` field.
|
| 122 |
+
"""
|
| 123 |
+
if not apk_path.exists():
|
| 124 |
+
logger.warning("YaraEngine: APK not found: %s", apk_path)
|
| 125 |
+
return YaraResult(error="APK file not found")
|
| 126 |
+
|
| 127 |
+
if self._compiled is None:
|
| 128 |
+
logger.debug("YaraEngine: no compiled rules (%s) β returning empty result", self._load_error)
|
| 129 |
+
return YaraResult(error=self._load_error)
|
| 130 |
+
|
| 131 |
+
try:
|
| 132 |
+
loop = asyncio.get_running_loop()
|
| 133 |
+
return await asyncio.wait_for(
|
| 134 |
+
loop.run_in_executor(
|
| 135 |
+
None,
|
| 136 |
+
functools.partial(self._sync_scan, apk_path),
|
| 137 |
+
),
|
| 138 |
+
timeout=_ASYNC_TIMEOUT_SEC,
|
| 139 |
+
)
|
| 140 |
+
except asyncio.TimeoutError:
|
| 141 |
+
logger.error(
|
| 142 |
+
"YaraEngine: async timeout after %ds for %s",
|
| 143 |
+
_ASYNC_TIMEOUT_SEC, apk_path,
|
| 144 |
+
)
|
| 145 |
+
return YaraResult(error=f"Scan timed out after {_ASYNC_TIMEOUT_SEC}s")
|
| 146 |
+
except Exception as exc:
|
| 147 |
+
logger.exception("YaraEngine: unexpected error scanning %s", apk_path)
|
| 148 |
+
return YaraResult(error=str(exc))
|
| 149 |
+
|
| 150 |
+
# ββ Synchronous scan (runs inside thread-pool executor) βββββββββββββββββββ
|
| 151 |
+
|
| 152 |
+
def _extract_scan_buffer(self, apk_path: Path) -> bytes:
|
| 153 |
+
"""
|
| 154 |
+
Build a flat byte buffer of decompressed APK content for YARA to scan.
|
| 155 |
+
|
| 156 |
+
APK files are ZIP archives where DEX and manifest entries are almost
|
| 157 |
+
always DEFLATE-compressed. YARA scanning the raw .apk file sees only
|
| 158 |
+
compressed bytes and can never match plaintext strings. We decompress
|
| 159 |
+
the key entries (classes*.dex + AndroidManifest.xml) and concatenate
|
| 160 |
+
them so that YARA's string matchers work against real plaintext content.
|
| 161 |
+
|
| 162 |
+
Falls back to raw file bytes if the file is not a valid ZIP.
|
| 163 |
+
"""
|
| 164 |
+
import zipfile
|
| 165 |
+
|
| 166 |
+
try:
|
| 167 |
+
with zipfile.ZipFile(apk_path, "r") as zf:
|
| 168 |
+
parts: list[bytes] = []
|
| 169 |
+
for info in zf.infolist():
|
| 170 |
+
name = info.filename
|
| 171 |
+
if (
|
| 172 |
+
name == "AndroidManifest.xml"
|
| 173 |
+
or name.startswith("classes") and name.endswith(".dex")
|
| 174 |
+
or name.endswith(".smali")
|
| 175 |
+
):
|
| 176 |
+
try:
|
| 177 |
+
parts.append(zf.read(name))
|
| 178 |
+
except Exception:
|
| 179 |
+
pass
|
| 180 |
+
if parts:
|
| 181 |
+
return b"\n".join(parts)
|
| 182 |
+
except Exception:
|
| 183 |
+
pass
|
| 184 |
+
|
| 185 |
+
return apk_path.read_bytes()
|
| 186 |
+
|
| 187 |
+
def _sync_scan(self, apk_path: Path) -> YaraResult:
|
| 188 |
+
t0 = time.perf_counter()
|
| 189 |
+
|
| 190 |
+
scan_data = self._extract_scan_buffer(apk_path)
|
| 191 |
+
|
| 192 |
+
try:
|
| 193 |
+
raw_matches = self._compiled.match( # type: ignore[union-attr]
|
| 194 |
+
data=scan_data,
|
| 195 |
+
timeout=_SCAN_TIMEOUT_SEC,
|
| 196 |
+
)
|
| 197 |
+
except _yara.TimeoutError:
|
| 198 |
+
elapsed = time.perf_counter() - t0
|
| 199 |
+
logger.error(
|
| 200 |
+
"YaraEngine: YARA timeout after %.1fs for %s",
|
| 201 |
+
elapsed, apk_path,
|
| 202 |
+
)
|
| 203 |
+
return YaraResult(
|
| 204 |
+
scan_time=round(elapsed, 3),
|
| 205 |
+
error=f"YARA scan timed out after {_SCAN_TIMEOUT_SEC}s",
|
| 206 |
+
)
|
| 207 |
+
except _yara.Error as exc:
|
| 208 |
+
elapsed = time.perf_counter() - t0
|
| 209 |
+
logger.warning("YaraEngine: scan error for %s β %s", apk_path, exc)
|
| 210 |
+
return YaraResult(
|
| 211 |
+
scan_time=round(elapsed, 3),
|
| 212 |
+
error=str(exc),
|
| 213 |
+
)
|
| 214 |
+
except Exception as exc:
|
| 215 |
+
elapsed = time.perf_counter() - t0
|
| 216 |
+
logger.exception("YaraEngine: unexpected scan error for %s", apk_path)
|
| 217 |
+
return YaraResult(
|
| 218 |
+
scan_time=round(elapsed, 3),
|
| 219 |
+
error=str(exc),
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
elapsed = time.perf_counter() - t0
|
| 223 |
+
|
| 224 |
+
matches = [_convert_match(m) for m in raw_matches]
|
| 225 |
+
rule_names = [m.rule for m in matches]
|
| 226 |
+
risk_score = _compute_risk_score(matches)
|
| 227 |
+
|
| 228 |
+
if matches:
|
| 229 |
+
logger.info(
|
| 230 |
+
"YaraEngine: %d match(es) in %.3fs for %s β %s",
|
| 231 |
+
len(matches), elapsed, apk_path.name,
|
| 232 |
+
[f"{m.rule}({m.severity})" for m in matches],
|
| 233 |
+
)
|
| 234 |
+
else:
|
| 235 |
+
logger.debug("YaraEngine: no matches in %.3fs for %s", elapsed, apk_path.name)
|
| 236 |
+
|
| 237 |
+
return YaraResult(
|
| 238 |
+
matched=bool(matches),
|
| 239 |
+
rule_names=rule_names,
|
| 240 |
+
matches=matches,
|
| 241 |
+
risk_score=round(risk_score, 4),
|
| 242 |
+
scan_time=round(elapsed, 3),
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
# ββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 247 |
+
|
| 248 |
+
def _convert_match(m: object) -> YaraMatch:
|
| 249 |
+
"""Convert a yara.Match object to our YaraMatch model."""
|
| 250 |
+
meta: dict = getattr(m, "meta", {}) or {}
|
| 251 |
+
return YaraMatch(
|
| 252 |
+
rule=getattr(m, "rule", "unknown"),
|
| 253 |
+
severity=str(meta.get("severity", "MEDIUM")).upper(),
|
| 254 |
+
description=str(meta.get("description", "")),
|
| 255 |
+
tags=list(getattr(m, "tags", []) or []),
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def _compute_risk_score(matches: list[YaraMatch]) -> float:
|
| 260 |
+
"""
|
| 261 |
+
Aggregate severity weights into a single 0.0β1.0 risk score.
|
| 262 |
+
Starts at the highest individual severity and adds 10% per additional match.
|
| 263 |
+
"""
|
| 264 |
+
if not matches:
|
| 265 |
+
return 0.0
|
| 266 |
+
weights = sorted(
|
| 267 |
+
[_SEVERITY_WEIGHT.get(m.severity, 0.5) for m in matches],
|
| 268 |
+
reverse=True,
|
| 269 |
+
)
|
| 270 |
+
base = weights[0]
|
| 271 |
+
extra = sum(w * 0.10 for w in weights[1:])
|
| 272 |
+
return min(base + extra, 1.0)
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
# ββ Module-level singleton (loaded at import / startup) βββββββββββββββββββββββ
|
| 276 |
+
|
| 277 |
+
_engine = YaraEngine()
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
# ββ Public API (backward-compatible wrapper) ββββββββββββββββββββββββββββββββββ
|
| 281 |
+
|
| 282 |
+
async def run_yara_scan(apk_path: Path) -> YaraResult:
|
| 283 |
+
"""Entry-point called by APKAnalyzer; delegates to the module-level YaraEngine."""
|
| 284 |
+
return await _engine.analyze(apk_path)
|
app/services/apk_intelligence/models.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
services/apk_intelligence/models.py
|
| 3 |
+
β Pydantic schemas for APK analysis request/response.
|
| 4 |
+
|
| 5 |
+
These are the wire-format types used by the REST API layer.
|
| 6 |
+
They MUST match the Android SDK ThreatIntelligenceResult exactly.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from enum import Enum
|
| 12 |
+
from typing import List, Optional
|
| 13 |
+
|
| 14 |
+
from pydantic import BaseModel, Field, field_validator
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# ββ Enums βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 18 |
+
|
| 19 |
+
class Verdict(str, Enum):
|
| 20 |
+
"""Mirrors com.sbi.shieldyono.network.CloudVerdict"""
|
| 21 |
+
CLEAN = "CLEAN"
|
| 22 |
+
SUSPICIOUS = "SUSPICIOUS"
|
| 23 |
+
MALICIOUS = "MALICIOUS"
|
| 24 |
+
UNKNOWN = "UNKNOWN"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# ββ Request βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 28 |
+
|
| 29 |
+
class ApkAnalysisRequest(BaseModel):
|
| 30 |
+
"""
|
| 31 |
+
Metadata fields sent alongside the APK binary as multipart form fields.
|
| 32 |
+
Maps to com.sbi.shieldyono.network.ApkAnalysisRequest in the Android SDK.
|
| 33 |
+
"""
|
| 34 |
+
packageName: str = Field(..., description="Fully-qualified package name from APK manifest")
|
| 35 |
+
appName: Optional[str] = Field(None, description="Human-readable app label")
|
| 36 |
+
certificateHash: Optional[str] = Field(None, description="SHA-256 of the signing certificate (hex)")
|
| 37 |
+
permissions: List[str] = Field(default_factory=list, description="Permissions declared in the APK manifest")
|
| 38 |
+
localRisk: float = Field(0.0, ge=0.0, le=100.0, description="Local risk score (0β100) from the SDK")
|
| 39 |
+
|
| 40 |
+
@field_validator("permissions", mode="before")
|
| 41 |
+
@classmethod
|
| 42 |
+
def parse_permissions(cls, v: object) -> List[str]:
|
| 43 |
+
"""Accept comma-separated string or list (multipart encoding quirk)."""
|
| 44 |
+
if isinstance(v, str):
|
| 45 |
+
return [p.strip() for p in v.split(",") if p.strip()]
|
| 46 |
+
return v or []
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
# ββ Engine Results (internal) ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 50 |
+
|
| 51 |
+
class YaraMatch(BaseModel):
|
| 52 |
+
rule: str
|
| 53 |
+
severity: str = "MEDIUM"
|
| 54 |
+
description: str = ""
|
| 55 |
+
tags: List[str] = Field(default_factory=list)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class YaraResult(BaseModel):
|
| 59 |
+
matched: bool = False
|
| 60 |
+
rule_names: List[str] = Field(default_factory=list) # kept for fusion.py compat
|
| 61 |
+
matches: List["YaraMatch"] = Field(default_factory=list)
|
| 62 |
+
risk_score: float = 0.0
|
| 63 |
+
scan_time: float = 0.0
|
| 64 |
+
error: Optional[str] = None
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class ApkIdentity(BaseModel):
|
| 68 |
+
package: str = ""
|
| 69 |
+
app_name: str = ""
|
| 70 |
+
version_name: str = ""
|
| 71 |
+
version_code: str = ""
|
| 72 |
+
certificate_sha256: Optional[str] = None
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class ApkComponents(BaseModel):
|
| 76 |
+
activities: List[str] = Field(default_factory=list)
|
| 77 |
+
services: List[str] = Field(default_factory=list)
|
| 78 |
+
receivers: List[str] = Field(default_factory=list)
|
| 79 |
+
providers: List[str] = Field(default_factory=list)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class SecurityFeatures(BaseModel):
|
| 83 |
+
sms_access: bool = False
|
| 84 |
+
overlay: bool = False
|
| 85 |
+
accessibility_service: bool = False
|
| 86 |
+
exported_count: int = 0
|
| 87 |
+
suspicious_receivers: List[str] = Field(default_factory=list)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class MlFeatures(BaseModel):
|
| 91 |
+
dangerous_permission_count: int = 0
|
| 92 |
+
receiver_count: int = 0
|
| 93 |
+
service_count: int = 0
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
class AndroguardResult(BaseModel):
|
| 97 |
+
# Core flags consumed by fusion.py
|
| 98 |
+
is_obfuscated: bool = False
|
| 99 |
+
native_code: bool = False
|
| 100 |
+
dynamic_code_loading: bool = False
|
| 101 |
+
suspicious_activities: List[str] = Field(default_factory=list)
|
| 102 |
+
error: Optional[str] = None
|
| 103 |
+
|
| 104 |
+
# Rich structured output from real Androguard analysis
|
| 105 |
+
identity: Optional[ApkIdentity] = None
|
| 106 |
+
permissions: List[str] = Field(default_factory=list)
|
| 107 |
+
components: Optional[ApkComponents] = None
|
| 108 |
+
security_features: Optional[SecurityFeatures] = None
|
| 109 |
+
ml_features: Optional[MlFeatures] = None
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
class APKidResult(BaseModel):
|
| 113 |
+
is_packed: bool = False
|
| 114 |
+
packers: List[str] = Field(default_factory=list)
|
| 115 |
+
obfuscators: List[str] = Field(default_factory=list)
|
| 116 |
+
anti_analysis: List[str] = Field(default_factory=list)
|
| 117 |
+
compilers: List[str] = Field(default_factory=list)
|
| 118 |
+
risk_score: float = Field(0.0, ge=0.0, le=1.0)
|
| 119 |
+
error: Optional[str] = None
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
class MLResult(BaseModel):
|
| 123 |
+
malware_probability: float = 0.0
|
| 124 |
+
threat_family: Optional[str] = None
|
| 125 |
+
confidence: float = 0.0
|
| 126 |
+
features_used: List[str] = Field(default_factory=list)
|
| 127 |
+
top_indicators: List[str] = Field(default_factory=list)
|
| 128 |
+
error: Optional[str] = None
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
class MobSFResult(BaseModel):
|
| 132 |
+
available: bool = False
|
| 133 |
+
score: int = -1 # danger score 0-100 (lower=safer); -1=not run
|
| 134 |
+
security_score: Optional[float] = None # raw MobSF score (higher=safer)
|
| 135 |
+
risk_score: float = 0.0 # normalised danger 0.0-1.0
|
| 136 |
+
findings: List[str] = Field(default_factory=list)
|
| 137 |
+
trackers: List[str] = Field(default_factory=list)
|
| 138 |
+
permissions: List[str] = Field(default_factory=list)
|
| 139 |
+
malware_permissions: List[str] = Field(default_factory=list)
|
| 140 |
+
error: Optional[str] = None
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
class ImpersonationResult(BaseModel):
|
| 144 |
+
is_impersonating: bool = False
|
| 145 |
+
impersonated_bank: Optional[str] = None
|
| 146 |
+
similarity_score: float = 0.0
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
class EngineResults(BaseModel):
|
| 150 |
+
"""Aggregated raw results from all analysis engines (internal use)."""
|
| 151 |
+
yara: YaraResult = Field(default_factory=YaraResult)
|
| 152 |
+
androguard: AndroguardResult = Field(default_factory=AndroguardResult)
|
| 153 |
+
apkid: APKidResult = Field(default_factory=APKidResult)
|
| 154 |
+
ml: MLResult = Field(default_factory=MLResult)
|
| 155 |
+
mobsf: MobSFResult = Field(default_factory=MobSFResult)
|
| 156 |
+
impersonation: ImpersonationResult = Field(default_factory=ImpersonationResult)
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
class ScanStep(BaseModel):
|
| 160 |
+
"""One step in the visible scan pipeline sent to the Flutter UI."""
|
| 161 |
+
engine: str = Field(..., description="Engine or step name shown to the user")
|
| 162 |
+
status: str = Field(
|
| 163 |
+
...,
|
| 164 |
+
description="PASSED | COMPLETED | THREAT_FOUND | ERROR | SKIPPED",
|
| 165 |
+
)
|
| 166 |
+
details: str = Field("", description="Short human-readable detail for this step")
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
# ββ API Response (matches Android SDK ThreatIntelligenceResult) ββββββββββββββββ
|
| 170 |
+
|
| 171 |
+
class ThreatIntelligenceResponse(BaseModel):
|
| 172 |
+
"""
|
| 173 |
+
Wire-format response from POST /api/v1/apk/analyze.
|
| 174 |
+
Field names and JSON keys MUST match the SDK @SerializedName annotations.
|
| 175 |
+
"""
|
| 176 |
+
verdict: Verdict = Field(..., description="Overall verdict for this APK")
|
| 177 |
+
malware_probability: float = Field(
|
| 178 |
+
..., ge=0.0, le=1.0,
|
| 179 |
+
description="ML probability that this APK is malware (0.0β1.0)",
|
| 180 |
+
serialization_alias="malware_probability",
|
| 181 |
+
)
|
| 182 |
+
yara_matches: List[str] = Field(
|
| 183 |
+
default_factory=list,
|
| 184 |
+
description="YARA rule names that matched the APK",
|
| 185 |
+
serialization_alias="yara_matches",
|
| 186 |
+
)
|
| 187 |
+
mobsf_score: int = Field(
|
| 188 |
+
-1,
|
| 189 |
+
description="MobSF static-analysis score (0β100, lower=safer; -1=not run)",
|
| 190 |
+
serialization_alias="mobsf_score",
|
| 191 |
+
)
|
| 192 |
+
threat_family: Optional[str] = Field(
|
| 193 |
+
None,
|
| 194 |
+
description="Identified malware family / threat cluster name",
|
| 195 |
+
serialization_alias="threat_family",
|
| 196 |
+
)
|
| 197 |
+
explanations: List[str] = Field(
|
| 198 |
+
default_factory=list,
|
| 199 |
+
description="Human-readable explanations for the verdict",
|
| 200 |
+
)
|
| 201 |
+
explanation: Optional["ThreatExplanation"] = Field(
|
| 202 |
+
None,
|
| 203 |
+
description="Full explainable threat report (user + analyst views)",
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
# ββ Extended fields for Flutter UI (backward-compatible, all have defaults) ββ
|
| 207 |
+
apk_name: Optional[str] = Field(None, description="Human-readable app or file name")
|
| 208 |
+
package_name: Optional[str] = Field(None, description="APK package identifier")
|
| 209 |
+
risk_score: float = Field(0.0, ge=0.0, le=1.0, description="Fused risk score 0.0β1.0")
|
| 210 |
+
scan_pipeline: List[ScanStep] = Field(
|
| 211 |
+
default_factory=list,
|
| 212 |
+
description="Per-engine scan steps with statuses for the UI timeline",
|
| 213 |
+
)
|
| 214 |
+
recommendations: List[str] = Field(
|
| 215 |
+
default_factory=list,
|
| 216 |
+
description="Ordered remediation and investigation recommendations",
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
model_config = {"populate_by_name": True}
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
# ββ Status / Task wrappers ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 223 |
+
|
| 224 |
+
class AnalysisStatus(str, Enum):
|
| 225 |
+
QUEUED = "QUEUED"
|
| 226 |
+
PROCESSING = "PROCESSING"
|
| 227 |
+
COMPLETE = "COMPLETE"
|
| 228 |
+
FAILED = "FAILED"
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
class AnalysisTaskResponse(BaseModel):
|
| 232 |
+
"""Returned immediately when an async task is queued."""
|
| 233 |
+
task_id: str
|
| 234 |
+
status: AnalysisStatus = AnalysisStatus.QUEUED
|
| 235 |
+
message: str = "APK queued for analysis"
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
class ErrorResponse(BaseModel):
|
| 239 |
+
error: str
|
| 240 |
+
detail: Optional[str] = None
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
# ββ Threat Explanation models βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 244 |
+
|
| 245 |
+
class RiskFactor(BaseModel):
|
| 246 |
+
"""A single contributing factor to the threat assessment."""
|
| 247 |
+
factor: str # Human-readable name, e.g. "OTP interception capability"
|
| 248 |
+
impact: int # Relative weight 0-100
|
| 249 |
+
evidence: str # Specific evidence string
|
| 250 |
+
severity: str # CRITICAL | HIGH | MEDIUM | LOW
|
| 251 |
+
engine: str # Which engine detected this signal
|
| 252 |
+
signal_id: str # Machine-readable identifier for deduplication / mapping
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
class UserMessage(BaseModel):
|
| 256 |
+
"""Plain-English output for end users β no security jargon."""
|
| 257 |
+
title: str # e.g. "Dangerous App Detected"
|
| 258 |
+
body: str # 2-3 sentences a non-technical user can act on
|
| 259 |
+
action: str # Single imperative sentence: what to do right now
|
| 260 |
+
severity: str # CRITICAL | HIGH | MEDIUM | LOW | SAFE
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
class AttackVector(BaseModel):
|
| 264 |
+
"""One technique an attacker can use via this app."""
|
| 265 |
+
name: str # e.g. "OTP Theft"
|
| 266 |
+
mitre_id: str # e.g. "T1636.004"
|
| 267 |
+
mitre_name: str # Full technique name
|
| 268 |
+
description: str # How the attack works in this context
|
| 269 |
+
enabled_by: List[str] # Which signals enable this vector
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
class AnalystSummary(BaseModel):
|
| 273 |
+
"""Technical output for security analysts and incident responders."""
|
| 274 |
+
threat_category: str # Primary class: BankingTrojan | RAT | OTPStealer | Impersonator | Adware | Unknown
|
| 275 |
+
attack_vectors: List[AttackVector]
|
| 276 |
+
ioc_summary: str # Indicators of compromise, one paragraph
|
| 277 |
+
confidence: str # HIGH | MEDIUM | LOW
|
| 278 |
+
recommended_actions: List[str] # Ordered investigation / remediation steps
|
| 279 |
+
engine_score_breakdown: dict # {"ml": 0.72, "yara": 1.0, ...}
|
| 280 |
+
analyst_notes: List[str] # Raw engine signals not captured elsewhere
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
class TechnicalDetails(BaseModel):
|
| 284 |
+
"""Full structured data for analyst dashboards and SIEM integration."""
|
| 285 |
+
# Verdict
|
| 286 |
+
verdict: str
|
| 287 |
+
fused_risk_score: float
|
| 288 |
+
malware_probability: float
|
| 289 |
+
|
| 290 |
+
# Permissions
|
| 291 |
+
dangerous_permissions: List[str]
|
| 292 |
+
permission_risk_map: dict # permission β plain-English risk
|
| 293 |
+
|
| 294 |
+
# ML
|
| 295 |
+
ml_confidence: float
|
| 296 |
+
active_ml_features: List[str]
|
| 297 |
+
ml_threat_family: Optional[str]
|
| 298 |
+
|
| 299 |
+
# YARA
|
| 300 |
+
yara_matched_rules: List[str]
|
| 301 |
+
yara_severities: dict # rule_name β severity
|
| 302 |
+
|
| 303 |
+
# APKiD
|
| 304 |
+
packers: List[str]
|
| 305 |
+
obfuscators: List[str]
|
| 306 |
+
anti_analysis_techniques: List[str]
|
| 307 |
+
compilers: List[str]
|
| 308 |
+
|
| 309 |
+
# Androguard static
|
| 310 |
+
is_obfuscated: bool
|
| 311 |
+
has_native_code: bool
|
| 312 |
+
has_dynamic_code_loading: bool
|
| 313 |
+
exported_component_count: int
|
| 314 |
+
suspicious_receivers: List[str]
|
| 315 |
+
component_counts: dict # {"activities": n, "services": n, ...}
|
| 316 |
+
|
| 317 |
+
# MobSF
|
| 318 |
+
mobsf_danger_score: int # -1 = not run
|
| 319 |
+
mobsf_trackers: List[str]
|
| 320 |
+
mobsf_malware_permissions: List[str]
|
| 321 |
+
mobsf_findings: List[str]
|
| 322 |
+
|
| 323 |
+
# Identity / impersonation
|
| 324 |
+
certificate_mismatch: bool
|
| 325 |
+
impersonated_bank: Optional[str]
|
| 326 |
+
impersonation_similarity: float
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
class ThreatExplanation(BaseModel):
|
| 330 |
+
"""
|
| 331 |
+
Complete explainable threat report.
|
| 332 |
+
|
| 333 |
+
Contains two audience-specific views:
|
| 334 |
+
user_message β plain English for end users
|
| 335 |
+
analyst_summary β technical detail for security teams
|
| 336 |
+
"""
|
| 337 |
+
# Core fields (match the user-requested schema)
|
| 338 |
+
summary: str
|
| 339 |
+
risk_breakdown: List[RiskFactor] # Sorted by impact descending
|
| 340 |
+
technical_details: TechnicalDetails
|
| 341 |
+
user_message: UserMessage
|
| 342 |
+
|
| 343 |
+
# Extended fields
|
| 344 |
+
verdict: str
|
| 345 |
+
overall_risk_score: int # 0-100
|
| 346 |
+
threat_family: Optional[str]
|
| 347 |
+
analyst_summary: AnalystSummary
|
| 348 |
+
generated_at: str # ISO-8601 UTC
|
app/services/apk_intelligence/threat_explainer.py
ADDED
|
@@ -0,0 +1,1009 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
threat_explainer.py β Explainable Threat Intelligence Engine.
|
| 3 |
+
|
| 4 |
+
Consumes raw outputs from all six analysis engines and produces a structured
|
| 5 |
+
ThreatExplanation containing:
|
| 6 |
+
|
| 7 |
+
1. user_message β plain English, non-technical, action-oriented
|
| 8 |
+
2. analyst_summary β technical detail with MITRE ATT&CK mappings,
|
| 9 |
+
IOC summary, attack vectors, recommended actions
|
| 10 |
+
|
| 11 |
+
Plus:
|
| 12 |
+
summary β one-sentence narrative of the primary threat
|
| 13 |
+
risk_breakdown β ordered list of contributing risk factors with impact
|
| 14 |
+
technical_details β full structured data for SIEM / dashboards
|
| 15 |
+
|
| 16 |
+
Architecture
|
| 17 |
+
ββββββββββββ
|
| 18 |
+
EngineResults + ThreatIntelligenceResponse
|
| 19 |
+
β
|
| 20 |
+
ThreatExplanationEngine.explain()
|
| 21 |
+
β
|
| 22 |
+
βββββββ΄βββββββββββββββββββββββββββ
|
| 23 |
+
β _extract_risk_factors() β one method per engine
|
| 24 |
+
β _normalize_impacts() β scale to 100-point total
|
| 25 |
+
β _generate_summary() β priority-chain narrative
|
| 26 |
+
β _build_technical_details() β structured data harvest
|
| 27 |
+
β _build_user_message() β severity-gated plain text
|
| 28 |
+
β _build_analyst_summary() β attack vectors + MITRE
|
| 29 |
+
ββββββββββββββββββββββββββββββββββ
|
| 30 |
+
β
|
| 31 |
+
ThreatExplanation
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
from __future__ import annotations
|
| 35 |
+
|
| 36 |
+
import logging
|
| 37 |
+
from datetime import datetime, timezone
|
| 38 |
+
from typing import Optional
|
| 39 |
+
|
| 40 |
+
from app.services.apk_intelligence.models import (
|
| 41 |
+
AnalystSummary,
|
| 42 |
+
APKidResult,
|
| 43 |
+
AndroguardResult,
|
| 44 |
+
AttackVector,
|
| 45 |
+
EngineResults,
|
| 46 |
+
ImpersonationResult,
|
| 47 |
+
MLResult,
|
| 48 |
+
MobSFResult,
|
| 49 |
+
RiskFactor,
|
| 50 |
+
TechnicalDetails,
|
| 51 |
+
ThreatExplanation,
|
| 52 |
+
ThreatIntelligenceResponse,
|
| 53 |
+
UserMessage,
|
| 54 |
+
Verdict,
|
| 55 |
+
YaraResult,
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
logger = logging.getLogger(__name__)
|
| 59 |
+
|
| 60 |
+
# ββ MITRE ATT&CK Mobile mappings βββββββββββββββββββββββββββββββββββββββββββββ
|
| 61 |
+
|
| 62 |
+
_MITRE = {
|
| 63 |
+
"sms_read": ("T1636.004", "Protected User Data: SMS Messages"),
|
| 64 |
+
"overlay": ("T1411", "Input Prompt"),
|
| 65 |
+
"accessibility": ("T1418", "Software Discovery"),
|
| 66 |
+
"dynamic_code": ("T1407", "Download New Code at Runtime"),
|
| 67 |
+
"packer": ("T1406", "Obfuscated Files or Information"),
|
| 68 |
+
"anti_analysis": ("T1523", "Evade Analysis Environment"),
|
| 69 |
+
"audio_capture": ("T1429", "Capture Audio"),
|
| 70 |
+
"location": ("T1430", "Location Tracking"),
|
| 71 |
+
"impersonation": ("T1517", "Access Notifications"),
|
| 72 |
+
"credential_theft": ("T1417", "Input Capture"),
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
# ββ Permission risk catalogue βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 76 |
+
|
| 77 |
+
_PERM_RISK: dict[str, str] = {
|
| 78 |
+
"android.permission.READ_SMS":
|
| 79 |
+
"Reads all SMS messages β allows OTP code interception",
|
| 80 |
+
"android.permission.RECEIVE_SMS":
|
| 81 |
+
"Intercepts incoming SMS before delivery β enables silent OTP theft",
|
| 82 |
+
"android.permission.SEND_SMS":
|
| 83 |
+
"Sends SMS without user interaction β can forward OTPs or incur charges",
|
| 84 |
+
"android.permission.SYSTEM_ALERT_WINDOW":
|
| 85 |
+
"Draws over other apps β enables phishing overlay on banking UI",
|
| 86 |
+
"android.permission.RECORD_AUDIO":
|
| 87 |
+
"Accesses microphone β can record calls and ambient audio",
|
| 88 |
+
"android.permission.ACCESS_FINE_LOCATION":
|
| 89 |
+
"Tracks precise GPS location continuously",
|
| 90 |
+
"android.permission.CAMERA":
|
| 91 |
+
"Accesses camera β can capture photos or video silently",
|
| 92 |
+
"android.permission.BIND_ACCESSIBILITY_SERVICE":
|
| 93 |
+
"Full UI control β reads screen content, simulates touches, keylogger risk",
|
| 94 |
+
"android.permission.BIND_DEVICE_ADMIN":
|
| 95 |
+
"Device administrator β prevents removal, can wipe device",
|
| 96 |
+
"android.permission.REQUEST_INSTALL_PACKAGES":
|
| 97 |
+
"Installs additional APKs silently β dropper capability",
|
| 98 |
+
"android.permission.READ_CALL_LOG":
|
| 99 |
+
"Reads call history β privacy violation",
|
| 100 |
+
"android.permission.PROCESS_OUTGOING_CALLS":
|
| 101 |
+
"Intercepts and redirects outgoing calls",
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
_HIGH_RISK_PERMS = frozenset(_PERM_RISK)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# ββ Main engine βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 108 |
+
|
| 109 |
+
class ThreatExplanationEngine:
|
| 110 |
+
"""
|
| 111 |
+
Generates a ThreatExplanation from raw engine results.
|
| 112 |
+
|
| 113 |
+
Stateless β safe to share as a module-level singleton.
|
| 114 |
+
"""
|
| 115 |
+
|
| 116 |
+
def explain(
|
| 117 |
+
self,
|
| 118 |
+
engine_results: EngineResults,
|
| 119 |
+
fused: ThreatIntelligenceResponse,
|
| 120 |
+
) -> ThreatExplanation:
|
| 121 |
+
factors = self._extract_risk_factors(engine_results, fused)
|
| 122 |
+
factors = self._normalize_impacts(factors)
|
| 123 |
+
|
| 124 |
+
summary = self._generate_summary(factors, fused, engine_results)
|
| 125 |
+
tech = self._build_technical_details(engine_results, fused)
|
| 126 |
+
user_msg = self._build_user_message(fused, factors)
|
| 127 |
+
analyst = self._build_analyst_summary(engine_results, fused, factors, tech)
|
| 128 |
+
|
| 129 |
+
return ThreatExplanation(
|
| 130 |
+
summary=summary,
|
| 131 |
+
risk_breakdown=sorted(factors, key=lambda f: f.impact, reverse=True),
|
| 132 |
+
technical_details=tech,
|
| 133 |
+
user_message=user_msg,
|
| 134 |
+
verdict=fused.verdict.value if hasattr(fused.verdict, "value") else str(fused.verdict),
|
| 135 |
+
overall_risk_score=min(100, int(round(fused.malware_probability * 100))),
|
| 136 |
+
threat_family=fused.threat_family,
|
| 137 |
+
analyst_summary=analyst,
|
| 138 |
+
generated_at=datetime.now(timezone.utc).isoformat(),
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
# ββ Risk factor extraction βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 142 |
+
|
| 143 |
+
def _extract_risk_factors(
|
| 144 |
+
self,
|
| 145 |
+
r: EngineResults,
|
| 146 |
+
fused: ThreatIntelligenceResponse,
|
| 147 |
+
) -> list[RiskFactor]:
|
| 148 |
+
factors: list[RiskFactor] = []
|
| 149 |
+
factors += self._factors_impersonation(r.impersonation)
|
| 150 |
+
factors += self._factors_yara(r.yara)
|
| 151 |
+
factors += self._factors_androguard(r.androguard)
|
| 152 |
+
factors += self._factors_apkid(r.apkid)
|
| 153 |
+
factors += self._factors_ml(r.ml)
|
| 154 |
+
factors += self._factors_mobsf(r.mobsf)
|
| 155 |
+
return factors
|
| 156 |
+
|
| 157 |
+
def _factors_impersonation(self, imp: ImpersonationResult) -> list[RiskFactor]:
|
| 158 |
+
if not imp.is_impersonating:
|
| 159 |
+
return []
|
| 160 |
+
bank = imp.impersonated_bank or "a trusted banking app"
|
| 161 |
+
sim = imp.similarity_score
|
| 162 |
+
factors = [
|
| 163 |
+
RiskFactor(
|
| 164 |
+
factor="Banking app impersonation",
|
| 165 |
+
impact=45 if sim >= 0.90 else 30,
|
| 166 |
+
evidence=f"Package / app name matches {bank} with {sim:.0%} similarity",
|
| 167 |
+
severity="CRITICAL" if sim >= 0.90 else "HIGH",
|
| 168 |
+
engine="identity",
|
| 169 |
+
signal_id="impersonation_detected",
|
| 170 |
+
),
|
| 171 |
+
]
|
| 172 |
+
if sim >= 0.80:
|
| 173 |
+
factors.append(RiskFactor(
|
| 174 |
+
factor="Certificate mismatch",
|
| 175 |
+
impact=40,
|
| 176 |
+
evidence=f"APK claims to be {bank} but signing certificate does not match the official app",
|
| 177 |
+
severity="CRITICAL",
|
| 178 |
+
engine="identity",
|
| 179 |
+
signal_id="cert_mismatch",
|
| 180 |
+
))
|
| 181 |
+
return factors
|
| 182 |
+
|
| 183 |
+
def _factors_yara(self, yara: YaraResult) -> list[RiskFactor]:
|
| 184 |
+
factors: list[RiskFactor] = []
|
| 185 |
+
rule_meta = {
|
| 186 |
+
"BANKING_TROJAN": (35, "CRITICAL", "Banking trojan signature matched in DEX/manifest (credential harvesting, overlay attack, or known family string)"),
|
| 187 |
+
"OTP_STEALER": (30, "CRITICAL", "OTP stealer signature matched β SMS interception API calls and permission strings detected"),
|
| 188 |
+
"REMOTE_ACCESS_TROJAN": (30, "CRITICAL", "Remote access trojan signature matched β accessibility abuse and/or screen-capture APIs detected"),
|
| 189 |
+
}
|
| 190 |
+
for match in yara.matches:
|
| 191 |
+
meta = rule_meta.get(match.rule)
|
| 192 |
+
if meta:
|
| 193 |
+
impact, severity, evidence = meta
|
| 194 |
+
else:
|
| 195 |
+
impact, severity, evidence = (20, match.severity, f"YARA rule {match.rule} matched")
|
| 196 |
+
factors.append(RiskFactor(
|
| 197 |
+
factor=f"Known malware signature: {match.rule}",
|
| 198 |
+
impact=impact,
|
| 199 |
+
evidence=evidence,
|
| 200 |
+
severity=severity,
|
| 201 |
+
engine="yara",
|
| 202 |
+
signal_id=f"yara_{match.rule.lower()}",
|
| 203 |
+
))
|
| 204 |
+
return factors
|
| 205 |
+
|
| 206 |
+
def _factors_androguard(self, ag: AndroguardResult) -> list[RiskFactor]:
|
| 207 |
+
if ag.error and not ag.permissions:
|
| 208 |
+
return []
|
| 209 |
+
factors: list[RiskFactor] = []
|
| 210 |
+
perm_set = frozenset(ag.permissions)
|
| 211 |
+
sf = ag.security_features
|
| 212 |
+
|
| 213 |
+
# OTP interception (READ + RECEIVE together = definitive theft chain)
|
| 214 |
+
sms_read = "android.permission.READ_SMS" in perm_set
|
| 215 |
+
sms_receive = "android.permission.RECEIVE_SMS" in perm_set
|
| 216 |
+
sms_send = "android.permission.SEND_SMS" in perm_set
|
| 217 |
+
|
| 218 |
+
if sms_read or sms_receive:
|
| 219 |
+
evidence_parts = []
|
| 220 |
+
if sms_read: evidence_parts.append("READ_SMS")
|
| 221 |
+
if sms_receive: evidence_parts.append("RECEIVE_SMS")
|
| 222 |
+
if sms_send: evidence_parts.append("SEND_SMS")
|
| 223 |
+
factors.append(RiskFactor(
|
| 224 |
+
factor="OTP interception capability",
|
| 225 |
+
impact=25 if (sms_read and sms_receive) else 15,
|
| 226 |
+
evidence=f"{' + '.join(evidence_parts)} permission(s) detected β can silently read incoming one-time passwords",
|
| 227 |
+
severity="HIGH",
|
| 228 |
+
engine="androguard",
|
| 229 |
+
signal_id="sms_access",
|
| 230 |
+
))
|
| 231 |
+
|
| 232 |
+
# Overlay attack
|
| 233 |
+
if "android.permission.SYSTEM_ALERT_WINDOW" in perm_set:
|
| 234 |
+
factors.append(RiskFactor(
|
| 235 |
+
factor="Overlay attack capability",
|
| 236 |
+
impact=20,
|
| 237 |
+
evidence="SYSTEM_ALERT_WINDOW allows drawing a fake login screen over the real banking app",
|
| 238 |
+
severity="HIGH",
|
| 239 |
+
engine="androguard",
|
| 240 |
+
signal_id="overlay_capability",
|
| 241 |
+
))
|
| 242 |
+
|
| 243 |
+
# Accessibility abuse
|
| 244 |
+
if "android.permission.BIND_ACCESSIBILITY_SERVICE" in perm_set:
|
| 245 |
+
factors.append(RiskFactor(
|
| 246 |
+
factor="Accessibility service abuse",
|
| 247 |
+
impact=25,
|
| 248 |
+
evidence="BIND_ACCESSIBILITY_SERVICE enables reading screen content, simulating taps, and keylogging without user awareness",
|
| 249 |
+
severity="HIGH",
|
| 250 |
+
engine="androguard",
|
| 251 |
+
signal_id="accessibility_abuse",
|
| 252 |
+
))
|
| 253 |
+
|
| 254 |
+
# Device admin (ransomware / stalkerware indicator)
|
| 255 |
+
if "android.permission.BIND_DEVICE_ADMIN" in perm_set:
|
| 256 |
+
factors.append(RiskFactor(
|
| 257 |
+
factor="Device administrator privilege",
|
| 258 |
+
impact=30,
|
| 259 |
+
evidence="BIND_DEVICE_ADMIN β app requests admin rights, enabling lock, wipe, and uninstall prevention",
|
| 260 |
+
severity="CRITICAL",
|
| 261 |
+
engine="androguard",
|
| 262 |
+
signal_id="device_admin",
|
| 263 |
+
))
|
| 264 |
+
|
| 265 |
+
# Dropper capability
|
| 266 |
+
if "android.permission.REQUEST_INSTALL_PACKAGES" in perm_set:
|
| 267 |
+
factors.append(RiskFactor(
|
| 268 |
+
factor="Silent APK installation",
|
| 269 |
+
impact=20,
|
| 270 |
+
evidence="REQUEST_INSTALL_PACKAGES enables downloading and installing additional malware without user prompts",
|
| 271 |
+
severity="HIGH",
|
| 272 |
+
engine="androguard",
|
| 273 |
+
signal_id="dropper_capability",
|
| 274 |
+
))
|
| 275 |
+
|
| 276 |
+
# Obfuscation from Androguard heuristic
|
| 277 |
+
if ag.is_obfuscated:
|
| 278 |
+
factors.append(RiskFactor(
|
| 279 |
+
factor="Code obfuscation",
|
| 280 |
+
impact=15,
|
| 281 |
+
evidence="Component class names are 1-2 characters β aggressive obfuscation to defeat static analysis",
|
| 282 |
+
severity="MEDIUM",
|
| 283 |
+
engine="androguard",
|
| 284 |
+
signal_id="obfuscated_names",
|
| 285 |
+
))
|
| 286 |
+
|
| 287 |
+
# Dynamic code loading
|
| 288 |
+
if ag.dynamic_code_loading:
|
| 289 |
+
factors.append(RiskFactor(
|
| 290 |
+
factor="Dynamic code loading",
|
| 291 |
+
impact=15,
|
| 292 |
+
evidence="DexClassLoader / multiple DEX files detected β downloads and executes code at runtime",
|
| 293 |
+
severity="HIGH",
|
| 294 |
+
engine="androguard",
|
| 295 |
+
signal_id="dynamic_code_loading",
|
| 296 |
+
))
|
| 297 |
+
|
| 298 |
+
# Suspicious broadcast receivers
|
| 299 |
+
if sf and sf.suspicious_receivers:
|
| 300 |
+
factors.append(RiskFactor(
|
| 301 |
+
factor="Suspicious broadcast receivers",
|
| 302 |
+
impact=10,
|
| 303 |
+
evidence=f"Receivers listening to high-risk actions: {', '.join(sf.suspicious_receivers[:3])}",
|
| 304 |
+
severity="MEDIUM",
|
| 305 |
+
engine="androguard",
|
| 306 |
+
signal_id="suspicious_receivers",
|
| 307 |
+
))
|
| 308 |
+
|
| 309 |
+
# High exported component count
|
| 310 |
+
if sf and sf.exported_count > 5:
|
| 311 |
+
factors.append(RiskFactor(
|
| 312 |
+
factor="Excessive exported components",
|
| 313 |
+
impact=8,
|
| 314 |
+
evidence=f"{sf.exported_count} exported components β unusually large attack surface",
|
| 315 |
+
severity="LOW",
|
| 316 |
+
engine="androguard",
|
| 317 |
+
signal_id="exported_components",
|
| 318 |
+
))
|
| 319 |
+
|
| 320 |
+
return factors
|
| 321 |
+
|
| 322 |
+
def _factors_apkid(self, apkid: APKidResult) -> list[RiskFactor]:
|
| 323 |
+
if apkid.error:
|
| 324 |
+
return []
|
| 325 |
+
factors: list[RiskFactor] = []
|
| 326 |
+
|
| 327 |
+
if apkid.packers:
|
| 328 |
+
names = ", ".join(apkid.packers)
|
| 329 |
+
factors.append(RiskFactor(
|
| 330 |
+
factor="Binary packing / runtime protection",
|
| 331 |
+
impact=20,
|
| 332 |
+
evidence=f"Packer detected: {names} β hides malicious code from static scanners",
|
| 333 |
+
severity="HIGH",
|
| 334 |
+
engine="apkid",
|
| 335 |
+
signal_id="packer_detected",
|
| 336 |
+
))
|
| 337 |
+
|
| 338 |
+
if apkid.anti_analysis:
|
| 339 |
+
techniques = ", ".join(apkid.anti_analysis[:4])
|
| 340 |
+
factors.append(RiskFactor(
|
| 341 |
+
factor="Anti-analysis techniques",
|
| 342 |
+
impact=15,
|
| 343 |
+
evidence=f"Evasion techniques: {techniques}",
|
| 344 |
+
severity="HIGH",
|
| 345 |
+
engine="apkid",
|
| 346 |
+
signal_id="anti_analysis",
|
| 347 |
+
))
|
| 348 |
+
|
| 349 |
+
if len(apkid.obfuscators) > 1:
|
| 350 |
+
names = ", ".join(apkid.obfuscators)
|
| 351 |
+
factors.append(RiskFactor(
|
| 352 |
+
factor="Multiple obfuscation layers",
|
| 353 |
+
impact=12,
|
| 354 |
+
evidence=f"Multiple obfuscators: {names} β layered obfuscation unusual in legitimate apps",
|
| 355 |
+
severity="MEDIUM",
|
| 356 |
+
engine="apkid",
|
| 357 |
+
signal_id="multi_obfuscator",
|
| 358 |
+
))
|
| 359 |
+
elif apkid.obfuscators:
|
| 360 |
+
factors.append(RiskFactor(
|
| 361 |
+
factor="Code obfuscation",
|
| 362 |
+
impact=5,
|
| 363 |
+
evidence=f"Obfuscator: {apkid.obfuscators[0]}",
|
| 364 |
+
severity="LOW",
|
| 365 |
+
engine="apkid",
|
| 366 |
+
signal_id="single_obfuscator",
|
| 367 |
+
))
|
| 368 |
+
|
| 369 |
+
return factors
|
| 370 |
+
|
| 371 |
+
def _factors_ml(self, ml: MLResult) -> list[RiskFactor]:
|
| 372 |
+
if ml.error or ml.malware_probability < 0.30:
|
| 373 |
+
return []
|
| 374 |
+
prob = ml.malware_probability
|
| 375 |
+
if prob >= 0.80:
|
| 376 |
+
severity, impact = "CRITICAL", 35
|
| 377 |
+
elif prob >= 0.60:
|
| 378 |
+
severity, impact = "HIGH", 25
|
| 379 |
+
else:
|
| 380 |
+
severity, impact = "MEDIUM", 15
|
| 381 |
+
|
| 382 |
+
family_str = f" ({ml.threat_family})" if ml.threat_family else ""
|
| 383 |
+
evidence = (
|
| 384 |
+
f"Trained classifier assigns {prob:.0%} malware probability{family_str} "
|
| 385 |
+
f"based on: {', '.join(ml.top_indicators[:3]) or 'feature vector analysis'}"
|
| 386 |
+
)
|
| 387 |
+
return [RiskFactor(
|
| 388 |
+
factor="ML malware classification",
|
| 389 |
+
impact=impact,
|
| 390 |
+
evidence=evidence,
|
| 391 |
+
severity=severity,
|
| 392 |
+
engine="ml",
|
| 393 |
+
signal_id="ml_malware_probability",
|
| 394 |
+
)]
|
| 395 |
+
|
| 396 |
+
def _factors_mobsf(self, mobsf: MobSFResult) -> list[RiskFactor]:
|
| 397 |
+
if not mobsf.available or mobsf.score < 0 or mobsf.error:
|
| 398 |
+
return []
|
| 399 |
+
factors: list[RiskFactor] = []
|
| 400 |
+
|
| 401 |
+
if mobsf.score > 70:
|
| 402 |
+
factors.append(RiskFactor(
|
| 403 |
+
factor="High static analysis risk score",
|
| 404 |
+
impact=_clamp(int((mobsf.score - 50) / 2), 5, 20),
|
| 405 |
+
evidence=f"MobSF danger score {mobsf.score}/100 β multiple static security issues",
|
| 406 |
+
severity="HIGH" if mobsf.score > 80 else "MEDIUM",
|
| 407 |
+
engine="mobsf",
|
| 408 |
+
signal_id="mobsf_high_score",
|
| 409 |
+
))
|
| 410 |
+
|
| 411 |
+
if mobsf.malware_permissions:
|
| 412 |
+
factors.append(RiskFactor(
|
| 413 |
+
factor="Malware-associated permissions",
|
| 414 |
+
impact=15,
|
| 415 |
+
evidence=f"MobSF flagged {len(mobsf.malware_permissions)} permission(s) as malware-associated: "
|
| 416 |
+
f"{', '.join(mobsf.malware_permissions[:3])}",
|
| 417 |
+
severity="HIGH",
|
| 418 |
+
engine="mobsf",
|
| 419 |
+
signal_id="mobsf_malware_permissions",
|
| 420 |
+
))
|
| 421 |
+
|
| 422 |
+
if len(mobsf.trackers) > 2:
|
| 423 |
+
factors.append(RiskFactor(
|
| 424 |
+
factor="Embedded privacy trackers",
|
| 425 |
+
impact=5 + min(len(mobsf.trackers), 3) * 2,
|
| 426 |
+
evidence=f"{len(mobsf.trackers)} advertising/analytics trackers: {', '.join(mobsf.trackers[:3])}",
|
| 427 |
+
severity="LOW",
|
| 428 |
+
engine="mobsf",
|
| 429 |
+
signal_id="privacy_trackers",
|
| 430 |
+
))
|
| 431 |
+
|
| 432 |
+
return factors
|
| 433 |
+
|
| 434 |
+
# ββ Impact normalisation βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 435 |
+
|
| 436 |
+
def _normalize_impacts(self, factors: list[RiskFactor]) -> list[RiskFactor]:
|
| 437 |
+
"""
|
| 438 |
+
Scale factor impacts so the top factor always reads clearly.
|
| 439 |
+
Raw impacts already use a 0-100 scale; we leave them as-is because
|
| 440 |
+
each factor is independent β their sum can exceed 100 legitimately.
|
| 441 |
+
"""
|
| 442 |
+
return factors
|
| 443 |
+
|
| 444 |
+
# ββ Narrative summary βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 445 |
+
|
| 446 |
+
def _generate_summary(
|
| 447 |
+
self,
|
| 448 |
+
factors: list[RiskFactor],
|
| 449 |
+
fused: ThreatIntelligenceResponse,
|
| 450 |
+
r: EngineResults,
|
| 451 |
+
) -> str:
|
| 452 |
+
imp = r.impersonation
|
| 453 |
+
yara = r.yara
|
| 454 |
+
ml = r.ml
|
| 455 |
+
ag = r.androguard
|
| 456 |
+
sf = ag.security_features
|
| 457 |
+
|
| 458 |
+
# 1. Impersonation β highest priority narrative
|
| 459 |
+
if imp.is_impersonating and imp.similarity_score >= 0.70:
|
| 460 |
+
bank = imp.impersonated_bank or "a legitimate banking app"
|
| 461 |
+
if imp.similarity_score >= 0.90:
|
| 462 |
+
return (
|
| 463 |
+
f"This application is almost certainly impersonating {bank}. "
|
| 464 |
+
f"It uses an identical package name or display name but is signed by an unknown developer, "
|
| 465 |
+
f"indicating a credential-phishing or session-hijacking attack."
|
| 466 |
+
)
|
| 467 |
+
return (
|
| 468 |
+
f"This application appears to impersonate {bank}. "
|
| 469 |
+
f"Its name and package are deceptively similar to the genuine app but the signing certificate does not match."
|
| 470 |
+
)
|
| 471 |
+
|
| 472 |
+
# 2. YARA hard matches β known malware family
|
| 473 |
+
if "BANKING_TROJAN" in yara.rule_names:
|
| 474 |
+
family = ml.threat_family or "banking trojan"
|
| 475 |
+
return (
|
| 476 |
+
f"This application contains signatures matching a known {family}. "
|
| 477 |
+
f"It is engineered to steal banking credentials through phishing overlays, "
|
| 478 |
+
f"credential harvesting forms, or known command-and-control infrastructure."
|
| 479 |
+
)
|
| 480 |
+
|
| 481 |
+
if "OTP_STEALER" in yara.rule_names:
|
| 482 |
+
return (
|
| 483 |
+
"This application is designed to intercept one-time passwords (OTPs). "
|
| 484 |
+
"It registers as an SMS receiver to silently read incoming verification codes "
|
| 485 |
+
"and forward them to a remote attacker, bypassing two-factor authentication."
|
| 486 |
+
)
|
| 487 |
+
|
| 488 |
+
if "REMOTE_ACCESS_TROJAN" in yara.rule_names:
|
| 489 |
+
return (
|
| 490 |
+
"This application contains a remote access trojan (RAT). "
|
| 491 |
+
"It abuses Android accessibility services or screen-capture APIs to give an attacker "
|
| 492 |
+
"full remote control of the device, including access to banking sessions."
|
| 493 |
+
)
|
| 494 |
+
|
| 495 |
+
# 3. Capability cluster β OTP theft without confirmed YARA match
|
| 496 |
+
has_sms = sf and sf.sms_access
|
| 497 |
+
has_overlay = sf and sf.overlay
|
| 498 |
+
has_acc = sf and sf.accessibility_service
|
| 499 |
+
if ml.malware_probability > 0.60:
|
| 500 |
+
if has_sms and has_overlay:
|
| 501 |
+
return (
|
| 502 |
+
"This application combines SMS interception with overlay attack capability β "
|
| 503 |
+
"the hallmark of banking malware. It can display fake login screens over your bank app "
|
| 504 |
+
"and steal entered credentials alongside incoming OTP codes."
|
| 505 |
+
)
|
| 506 |
+
if has_sms:
|
| 507 |
+
return (
|
| 508 |
+
"This application has a high probability of being banking malware. "
|
| 509 |
+
"It requests SMS read permissions to intercept OTP codes sent by your bank, "
|
| 510 |
+
"combined with other malicious indicators detected by multiple engines."
|
| 511 |
+
)
|
| 512 |
+
if has_acc:
|
| 513 |
+
return (
|
| 514 |
+
"This application abuses Android Accessibility Services β a common RAT technique. "
|
| 515 |
+
"It can read screen content, simulate touches, and log keystrokes "
|
| 516 |
+
"without any visible indication to the user."
|
| 517 |
+
)
|
| 518 |
+
|
| 519 |
+
# 4. Evasion-heavy but no confirmed payload
|
| 520 |
+
if r.apkid.is_packed and r.apkid.anti_analysis:
|
| 521 |
+
return (
|
| 522 |
+
"This application employs advanced evasion techniques including binary packing "
|
| 523 |
+
"and anti-analysis tricks. While no specific malware signature was matched, "
|
| 524 |
+
"this combination is characteristic of professionally developed malware."
|
| 525 |
+
)
|
| 526 |
+
|
| 527 |
+
# 5. Suspicious but no confirmed malware
|
| 528 |
+
if fused.verdict == Verdict.SUSPICIOUS or str(fused.verdict) == "SUSPICIOUS":
|
| 529 |
+
return (
|
| 530 |
+
"This application exhibits multiple suspicious characteristics. "
|
| 531 |
+
"No known malware signature was matched, but the combination of permissions, "
|
| 532 |
+
"code structure, and behaviour patterns warrants caution."
|
| 533 |
+
)
|
| 534 |
+
|
| 535 |
+
# 6. Clean
|
| 536 |
+
return (
|
| 537 |
+
"No significant malware indicators were detected in this application. "
|
| 538 |
+
"It does not appear to pose a direct threat to banking security."
|
| 539 |
+
)
|
| 540 |
+
|
| 541 |
+
# ββ Technical details βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 542 |
+
|
| 543 |
+
def _build_technical_details(
|
| 544 |
+
self,
|
| 545 |
+
r: EngineResults,
|
| 546 |
+
fused: ThreatIntelligenceResponse,
|
| 547 |
+
) -> TechnicalDetails:
|
| 548 |
+
ag = r.androguard
|
| 549 |
+
sf = ag.security_features
|
| 550 |
+
comp = ag.components
|
| 551 |
+
|
| 552 |
+
dangerous_perms = [p for p in ag.permissions if p in _HIGH_RISK_PERMS]
|
| 553 |
+
perm_risk_map = {p: _PERM_RISK[p] for p in dangerous_perms if p in _PERM_RISK}
|
| 554 |
+
|
| 555 |
+
yara_severities = {m.rule: m.severity for m in r.yara.matches}
|
| 556 |
+
|
| 557 |
+
verdict_str = fused.verdict.value if hasattr(fused.verdict, "value") else str(fused.verdict)
|
| 558 |
+
|
| 559 |
+
return TechnicalDetails(
|
| 560 |
+
verdict=verdict_str,
|
| 561 |
+
fused_risk_score=round(fused.malware_probability, 4),
|
| 562 |
+
malware_probability=round(r.ml.malware_probability, 4),
|
| 563 |
+
|
| 564 |
+
dangerous_permissions=dangerous_perms,
|
| 565 |
+
permission_risk_map=perm_risk_map,
|
| 566 |
+
|
| 567 |
+
ml_confidence=round(r.ml.confidence, 4),
|
| 568 |
+
active_ml_features=r.ml.features_used,
|
| 569 |
+
ml_threat_family=r.ml.threat_family,
|
| 570 |
+
|
| 571 |
+
yara_matched_rules=fused.yara_matches,
|
| 572 |
+
yara_severities=yara_severities,
|
| 573 |
+
|
| 574 |
+
packers=r.apkid.packers,
|
| 575 |
+
obfuscators=r.apkid.obfuscators,
|
| 576 |
+
anti_analysis_techniques=r.apkid.anti_analysis,
|
| 577 |
+
compilers=r.apkid.compilers,
|
| 578 |
+
|
| 579 |
+
is_obfuscated=ag.is_obfuscated,
|
| 580 |
+
has_native_code=ag.native_code,
|
| 581 |
+
has_dynamic_code_loading=ag.dynamic_code_loading,
|
| 582 |
+
exported_component_count=sf.exported_count if sf else 0,
|
| 583 |
+
suspicious_receivers=sf.suspicious_receivers if sf else [],
|
| 584 |
+
component_counts={
|
| 585 |
+
"activities": len(comp.activities) if comp else 0,
|
| 586 |
+
"services": len(comp.services) if comp else 0,
|
| 587 |
+
"receivers": len(comp.receivers) if comp else 0,
|
| 588 |
+
"providers": len(comp.providers) if comp else 0,
|
| 589 |
+
},
|
| 590 |
+
|
| 591 |
+
mobsf_danger_score=r.mobsf.score,
|
| 592 |
+
mobsf_trackers=r.mobsf.trackers,
|
| 593 |
+
mobsf_malware_permissions=r.mobsf.malware_permissions,
|
| 594 |
+
mobsf_findings=r.mobsf.findings[:5],
|
| 595 |
+
|
| 596 |
+
certificate_mismatch=r.impersonation.is_impersonating,
|
| 597 |
+
impersonated_bank=r.impersonation.impersonated_bank,
|
| 598 |
+
impersonation_similarity=round(r.impersonation.similarity_score, 4),
|
| 599 |
+
)
|
| 600 |
+
|
| 601 |
+
# ββ User-facing message βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 602 |
+
|
| 603 |
+
def _build_user_message(
|
| 604 |
+
self,
|
| 605 |
+
fused: ThreatIntelligenceResponse,
|
| 606 |
+
factors: list[RiskFactor],
|
| 607 |
+
) -> UserMessage:
|
| 608 |
+
verdict = fused.verdict.value if hasattr(fused.verdict, "value") else str(fused.verdict)
|
| 609 |
+
prob = fused.malware_probability
|
| 610 |
+
top = factors[0] if factors else None
|
| 611 |
+
|
| 612 |
+
if verdict == "MALICIOUS" or prob >= 0.75:
|
| 613 |
+
return UserMessage(
|
| 614 |
+
title="Dangerous App Detected",
|
| 615 |
+
body=(
|
| 616 |
+
"An app installed on your device has been identified as malware. "
|
| 617 |
+
"It may be attempting to steal your banking credentials, "
|
| 618 |
+
"intercept your OTP codes, or take control of your device."
|
| 619 |
+
),
|
| 620 |
+
action="Delete this app immediately and change your banking passwords.",
|
| 621 |
+
severity="CRITICAL",
|
| 622 |
+
)
|
| 623 |
+
|
| 624 |
+
if verdict == "SUSPICIOUS" or prob >= 0.45:
|
| 625 |
+
top_str = f" It has been flagged for: {top.factor.lower()}." if top else ""
|
| 626 |
+
return UserMessage(
|
| 627 |
+
title="Suspicious App Warning",
|
| 628 |
+
body=(
|
| 629 |
+
"An app on your device shows signs of suspicious behavior."
|
| 630 |
+
+ top_str +
|
| 631 |
+
" We recommend removing it before performing any banking transactions."
|
| 632 |
+
),
|
| 633 |
+
action="Remove this app and scan your device with a trusted security tool.",
|
| 634 |
+
severity="HIGH",
|
| 635 |
+
)
|
| 636 |
+
|
| 637 |
+
if prob >= 0.25:
|
| 638 |
+
return UserMessage(
|
| 639 |
+
title="App Flagged for Review",
|
| 640 |
+
body=(
|
| 641 |
+
"An app on your device has some unusual characteristics. "
|
| 642 |
+
"It may not be malicious, but we recommend caution when using banking services."
|
| 643 |
+
),
|
| 644 |
+
action="Avoid banking transactions while this app is installed. Consider removing it.",
|
| 645 |
+
severity="MEDIUM",
|
| 646 |
+
)
|
| 647 |
+
|
| 648 |
+
return UserMessage(
|
| 649 |
+
title="Device Appears Safe",
|
| 650 |
+
body=(
|
| 651 |
+
"This app passed all security checks. "
|
| 652 |
+
"No malware patterns, suspicious permissions, or known malware signatures were detected."
|
| 653 |
+
),
|
| 654 |
+
action="No action required. Continue using your banking app normally.",
|
| 655 |
+
severity="SAFE",
|
| 656 |
+
)
|
| 657 |
+
|
| 658 |
+
# ββ Analyst summary βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 659 |
+
|
| 660 |
+
def _build_analyst_summary(
|
| 661 |
+
self,
|
| 662 |
+
r: EngineResults,
|
| 663 |
+
fused: ThreatIntelligenceResponse,
|
| 664 |
+
factors: list[RiskFactor],
|
| 665 |
+
tech: TechnicalDetails,
|
| 666 |
+
) -> AnalystSummary:
|
| 667 |
+
threat_category = self._classify_threat(r, fused)
|
| 668 |
+
attack_vectors = self._map_attack_vectors(r, factors)
|
| 669 |
+
ioc_summary = self._build_ioc_summary(r, fused, tech)
|
| 670 |
+
confidence = self._assess_confidence(r, fused)
|
| 671 |
+
actions = self._recommended_actions(r, fused, threat_category)
|
| 672 |
+
notes = self._analyst_notes(r, fused, factors)
|
| 673 |
+
|
| 674 |
+
score_breakdown = {
|
| 675 |
+
"ml": round(r.ml.malware_probability, 4),
|
| 676 |
+
"yara": round(r.yara.risk_score, 4),
|
| 677 |
+
"apkid": round(r.apkid.risk_score, 4),
|
| 678 |
+
"mobsf": round(r.mobsf.risk_score, 4),
|
| 679 |
+
"impersonation": round(r.impersonation.similarity_score, 4),
|
| 680 |
+
"fused": round(fused.malware_probability, 4),
|
| 681 |
+
}
|
| 682 |
+
|
| 683 |
+
return AnalystSummary(
|
| 684 |
+
threat_category=threat_category,
|
| 685 |
+
attack_vectors=attack_vectors,
|
| 686 |
+
ioc_summary=ioc_summary,
|
| 687 |
+
confidence=confidence,
|
| 688 |
+
recommended_actions=actions,
|
| 689 |
+
engine_score_breakdown=score_breakdown,
|
| 690 |
+
analyst_notes=notes,
|
| 691 |
+
)
|
| 692 |
+
|
| 693 |
+
def _classify_threat(self, r: EngineResults, fused: ThreatIntelligenceResponse) -> str:
|
| 694 |
+
if r.impersonation.is_impersonating:
|
| 695 |
+
return "BankingImpersonator"
|
| 696 |
+
if "BANKING_TROJAN" in r.yara.rule_names or r.ml.threat_family == "BankingTrojan":
|
| 697 |
+
return "BankingTrojan"
|
| 698 |
+
if "OTP_STEALER" in r.yara.rule_names or r.ml.threat_family == "OTPStealer":
|
| 699 |
+
return "OTPStealer"
|
| 700 |
+
if "REMOTE_ACCESS_TROJAN" in r.yara.rule_names or r.ml.threat_family == "RAT":
|
| 701 |
+
return "RAT"
|
| 702 |
+
if fused.threat_family:
|
| 703 |
+
return fused.threat_family
|
| 704 |
+
sf = r.androguard.security_features
|
| 705 |
+
if sf and (sf.sms_access or sf.overlay):
|
| 706 |
+
return "SuspectedBankingMalware"
|
| 707 |
+
if r.apkid.is_packed or r.apkid.anti_analysis:
|
| 708 |
+
return "PackedMalware"
|
| 709 |
+
verdict = fused.verdict.value if hasattr(fused.verdict, "value") else str(fused.verdict)
|
| 710 |
+
if verdict in ("MALICIOUS", "SUSPICIOUS"):
|
| 711 |
+
return "UnclassifiedMalware"
|
| 712 |
+
return "Benign"
|
| 713 |
+
|
| 714 |
+
def _map_attack_vectors(
|
| 715 |
+
self,
|
| 716 |
+
r: EngineResults,
|
| 717 |
+
factors: list[RiskFactor],
|
| 718 |
+
) -> list[AttackVector]:
|
| 719 |
+
vectors: list[AttackVector] = []
|
| 720 |
+
sf = r.androguard.security_features
|
| 721 |
+
perm_set = frozenset(r.androguard.permissions)
|
| 722 |
+
|
| 723 |
+
if sf and sf.sms_access:
|
| 724 |
+
enabled = [p for p in ["android.permission.READ_SMS", "android.permission.RECEIVE_SMS"]
|
| 725 |
+
if p in perm_set]
|
| 726 |
+
mid, mname = _MITRE["sms_read"]
|
| 727 |
+
vectors.append(AttackVector(
|
| 728 |
+
name="OTP Theft via SMS",
|
| 729 |
+
mitre_id=mid,
|
| 730 |
+
mitre_name=mname,
|
| 731 |
+
description=(
|
| 732 |
+
"The app registers as an SMS broadcast receiver. Incoming OTP/2FA codes "
|
| 733 |
+
"sent by the bank via SMS are read silently and can be exfiltrated to a C2 server, "
|
| 734 |
+
"bypassing two-factor authentication."
|
| 735 |
+
),
|
| 736 |
+
enabled_by=enabled,
|
| 737 |
+
))
|
| 738 |
+
|
| 739 |
+
if sf and sf.overlay:
|
| 740 |
+
mid, mname = _MITRE["overlay"]
|
| 741 |
+
vectors.append(AttackVector(
|
| 742 |
+
name="Phishing Overlay Attack",
|
| 743 |
+
mitre_id=mid,
|
| 744 |
+
mitre_name=mname,
|
| 745 |
+
description=(
|
| 746 |
+
"SYSTEM_ALERT_WINDOW allows rendering a window above any other app. "
|
| 747 |
+
"The malware detects when the user opens a banking app and overlays a pixel-perfect "
|
| 748 |
+
"fake login screen to capture credentials."
|
| 749 |
+
),
|
| 750 |
+
enabled_by=["android.permission.SYSTEM_ALERT_WINDOW"],
|
| 751 |
+
))
|
| 752 |
+
|
| 753 |
+
if sf and sf.accessibility_service:
|
| 754 |
+
mid, mname = _MITRE["accessibility"]
|
| 755 |
+
vectors.append(AttackVector(
|
| 756 |
+
name="Accessibility-Based Keylogging",
|
| 757 |
+
mitre_id=mid,
|
| 758 |
+
mitre_name=mname,
|
| 759 |
+
description=(
|
| 760 |
+
"BIND_ACCESSIBILITY_SERVICE grants full UI tree access. "
|
| 761 |
+
"The malware can read all text fields (including password boxes), "
|
| 762 |
+
"simulate user input, and take actions on behalf of the user invisibly."
|
| 763 |
+
),
|
| 764 |
+
enabled_by=["android.permission.BIND_ACCESSIBILITY_SERVICE"],
|
| 765 |
+
))
|
| 766 |
+
|
| 767 |
+
if r.androguard.dynamic_code_loading:
|
| 768 |
+
mid, mname = _MITRE["dynamic_code"]
|
| 769 |
+
vectors.append(AttackVector(
|
| 770 |
+
name="Dynamic Payload Delivery",
|
| 771 |
+
mitre_id=mid,
|
| 772 |
+
mitre_name=mname,
|
| 773 |
+
description=(
|
| 774 |
+
"The app downloads and executes additional DEX code at runtime. "
|
| 775 |
+
"Initial APK may appear benign; the malicious payload is retrieved after installation, "
|
| 776 |
+
"evading static analysis at install time."
|
| 777 |
+
),
|
| 778 |
+
enabled_by=["DexClassLoader detected"],
|
| 779 |
+
))
|
| 780 |
+
|
| 781 |
+
if r.apkid.is_packed:
|
| 782 |
+
mid, mname = _MITRE["packer"]
|
| 783 |
+
vectors.append(AttackVector(
|
| 784 |
+
name="Evasion via Binary Packing",
|
| 785 |
+
mitre_id=mid,
|
| 786 |
+
mitre_name=mname,
|
| 787 |
+
description=(
|
| 788 |
+
f"Packer(s) detected: {', '.join(r.apkid.packers)}. "
|
| 789 |
+
"The DEX payload is encrypted and decrypted at runtime, hiding malicious code "
|
| 790 |
+
"from signature-based scanners and static analysis tools."
|
| 791 |
+
),
|
| 792 |
+
enabled_by=r.apkid.packers,
|
| 793 |
+
))
|
| 794 |
+
|
| 795 |
+
if r.impersonation.is_impersonating:
|
| 796 |
+
mid, mname = _MITRE["impersonation"]
|
| 797 |
+
bank = r.impersonation.impersonated_bank or "target bank"
|
| 798 |
+
vectors.append(AttackVector(
|
| 799 |
+
name="Banking App Impersonation",
|
| 800 |
+
mitre_id=mid,
|
| 801 |
+
mitre_name=mname,
|
| 802 |
+
description=(
|
| 803 |
+
f"App spoofs the identity of {bank} by mimicking its package name, "
|
| 804 |
+
f"display name, or icon. Users who install this app believing it to be genuine "
|
| 805 |
+
f"will hand credentials directly to the attacker."
|
| 806 |
+
),
|
| 807 |
+
enabled_by=[f"impersonation_similarity={r.impersonation.similarity_score:.0%}"],
|
| 808 |
+
))
|
| 809 |
+
|
| 810 |
+
return vectors
|
| 811 |
+
|
| 812 |
+
def _build_ioc_summary(
|
| 813 |
+
self,
|
| 814 |
+
r: EngineResults,
|
| 815 |
+
fused: ThreatIntelligenceResponse,
|
| 816 |
+
tech: TechnicalDetails,
|
| 817 |
+
) -> str:
|
| 818 |
+
parts: list[str] = []
|
| 819 |
+
|
| 820 |
+
if fused.yara_matches:
|
| 821 |
+
parts.append(f"YARA rules matched: {', '.join(fused.yara_matches)}.")
|
| 822 |
+
|
| 823 |
+
if r.impersonation.is_impersonating:
|
| 824 |
+
parts.append(
|
| 825 |
+
f"Identity: impersonating {r.impersonation.impersonated_bank or 'unknown bank'} "
|
| 826 |
+
f"(similarity {r.impersonation.similarity_score:.0%}, certificate mismatch confirmed)."
|
| 827 |
+
)
|
| 828 |
+
|
| 829 |
+
if tech.dangerous_permissions:
|
| 830 |
+
parts.append(
|
| 831 |
+
f"Dangerous permissions ({len(tech.dangerous_permissions)}): "
|
| 832 |
+
+ ", ".join(p.replace("android.permission.", "") for p in tech.dangerous_permissions[:5])
|
| 833 |
+
+ ("..." if len(tech.dangerous_permissions) > 5 else "") + "."
|
| 834 |
+
)
|
| 835 |
+
|
| 836 |
+
if r.apkid.packers:
|
| 837 |
+
parts.append(f"Packers: {', '.join(r.apkid.packers)}.")
|
| 838 |
+
|
| 839 |
+
if r.apkid.anti_analysis:
|
| 840 |
+
parts.append(f"Anti-analysis: {', '.join(r.apkid.anti_analysis[:4])}.")
|
| 841 |
+
|
| 842 |
+
if r.ml.threat_family:
|
| 843 |
+
parts.append(f"ML-inferred family: {r.ml.threat_family} (p={r.ml.malware_probability:.2f}).")
|
| 844 |
+
|
| 845 |
+
if r.mobsf.trackers:
|
| 846 |
+
parts.append(f"Embedded trackers: {', '.join(r.mobsf.trackers[:4])}.")
|
| 847 |
+
|
| 848 |
+
return " ".join(parts) if parts else "No definitive IOCs identified."
|
| 849 |
+
|
| 850 |
+
def _assess_confidence(self, r: EngineResults, fused: ThreatIntelligenceResponse) -> str:
|
| 851 |
+
signals = sum([
|
| 852 |
+
bool(r.yara.rule_names),
|
| 853 |
+
bool(r.impersonation.is_impersonating),
|
| 854 |
+
r.ml.malware_probability > 0.6,
|
| 855 |
+
bool(r.apkid.packers or r.apkid.anti_analysis),
|
| 856 |
+
bool(r.mobsf.available and r.mobsf.score > 70),
|
| 857 |
+
])
|
| 858 |
+
if signals >= 3:
|
| 859 |
+
return "HIGH"
|
| 860 |
+
if signals >= 2:
|
| 861 |
+
return "MEDIUM"
|
| 862 |
+
return "LOW"
|
| 863 |
+
|
| 864 |
+
def _recommended_actions(
|
| 865 |
+
self,
|
| 866 |
+
r: EngineResults,
|
| 867 |
+
fused: ThreatIntelligenceResponse,
|
| 868 |
+
threat_category: str,
|
| 869 |
+
) -> list[str]:
|
| 870 |
+
verdict = fused.verdict.value if hasattr(fused.verdict, "value") else str(fused.verdict)
|
| 871 |
+
actions: list[str] = []
|
| 872 |
+
|
| 873 |
+
if verdict == "MALICIOUS":
|
| 874 |
+
actions += [
|
| 875 |
+
"BLOCK: Immediately prevent the user from proceeding with banking transactions.",
|
| 876 |
+
"ALERT: Display critical threat notification with instructions to uninstall the app.",
|
| 877 |
+
"REPORT: Submit APK hash and package name to your threat intelligence platform.",
|
| 878 |
+
"ESCALATE: Flag the user account for security review if credentials may be compromised.",
|
| 879 |
+
]
|
| 880 |
+
elif verdict == "SUSPICIOUS":
|
| 881 |
+
actions += [
|
| 882 |
+
"WARN: Display prominent warning before allowing any banking transaction.",
|
| 883 |
+
"LOG: Record the package name, certificate hash, and analysis ID for SOC review.",
|
| 884 |
+
"MONITOR: Increase transaction monitoring for this user session.",
|
| 885 |
+
]
|
| 886 |
+
else:
|
| 887 |
+
actions += [
|
| 888 |
+
"ALLOW: No immediate action required.",
|
| 889 |
+
"LOG: Record clean verdict for audit trail.",
|
| 890 |
+
]
|
| 891 |
+
|
| 892 |
+
if r.impersonation.is_impersonating:
|
| 893 |
+
bank = r.impersonation.impersonated_bank or "the target bank"
|
| 894 |
+
actions.append(
|
| 895 |
+
f"NOTIFY: Coordinate with {bank} security team β active impersonation campaign detected."
|
| 896 |
+
)
|
| 897 |
+
|
| 898 |
+
if r.yara.rule_names:
|
| 899 |
+
actions.append(
|
| 900 |
+
"FORENSICS: Extract DEX files and submit to sandbox for dynamic analysis."
|
| 901 |
+
)
|
| 902 |
+
|
| 903 |
+
if r.apkid.is_packed:
|
| 904 |
+
actions.append(
|
| 905 |
+
"UNPACK: Use unpacking tools (e.g., DexDump, jadx) to recover the original DEX payload for deeper analysis."
|
| 906 |
+
)
|
| 907 |
+
|
| 908 |
+
return actions
|
| 909 |
+
|
| 910 |
+
def _analyst_notes(
|
| 911 |
+
self,
|
| 912 |
+
r: EngineResults,
|
| 913 |
+
fused: ThreatIntelligenceResponse,
|
| 914 |
+
factors: list[RiskFactor],
|
| 915 |
+
) -> list[str]:
|
| 916 |
+
notes: list[str] = []
|
| 917 |
+
|
| 918 |
+
if r.ml.error:
|
| 919 |
+
notes.append(f"ML engine error β ML signal unavailable: {r.ml.error}")
|
| 920 |
+
if r.androguard.error:
|
| 921 |
+
notes.append(f"Androguard error β static analysis partial: {r.androguard.error}")
|
| 922 |
+
if not r.mobsf.available:
|
| 923 |
+
notes.append("MobSF not available β static risk score absent from fusion.")
|
| 924 |
+
if r.apkid.error:
|
| 925 |
+
notes.append(f"APKiD error β packer detection limited: {r.apkid.error}")
|
| 926 |
+
|
| 927 |
+
if r.androguard.native_code:
|
| 928 |
+
notes.append(
|
| 929 |
+
"Native .so libraries present β dynamic analysis recommended; "
|
| 930 |
+
"static DEX inspection covers only part of the attack surface."
|
| 931 |
+
)
|
| 932 |
+
|
| 933 |
+
if not r.yara.rule_names and fused.malware_probability > 0.5:
|
| 934 |
+
notes.append(
|
| 935 |
+
"No YARA rule matched despite elevated ML probability β "
|
| 936 |
+
"may be a new or mutated variant not covered by current signatures; consider updating YARA rules."
|
| 937 |
+
)
|
| 938 |
+
|
| 939 |
+
if r.ml.confidence < 0.60:
|
| 940 |
+
notes.append(
|
| 941 |
+
f"ML confidence is low ({r.ml.confidence:.0%}) β "
|
| 942 |
+
"heuristic fallback active; retrain model with more samples of this category."
|
| 943 |
+
)
|
| 944 |
+
|
| 945 |
+
return notes
|
| 946 |
+
|
| 947 |
+
|
| 948 |
+
# ββ Helper βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 949 |
+
|
| 950 |
+
def _clamp(value: int, lo: int, hi: int) -> int:
|
| 951 |
+
return max(lo, min(hi, value))
|
| 952 |
+
|
| 953 |
+
|
| 954 |
+
# ββ Module singleton + entry point βββββββββββββββββββββββββββββββββββββββββββββ
|
| 955 |
+
|
| 956 |
+
_engine = ThreatExplanationEngine()
|
| 957 |
+
|
| 958 |
+
|
| 959 |
+
def run_threat_explanation(
|
| 960 |
+
engine_results: EngineResults,
|
| 961 |
+
fused: ThreatIntelligenceResponse,
|
| 962 |
+
) -> ThreatExplanation:
|
| 963 |
+
"""
|
| 964 |
+
Synchronous entry-point called by analyzer.py after risk fusion.
|
| 965 |
+
|
| 966 |
+
CPU-only (no I/O) β safe to call from the asyncio event loop directly.
|
| 967 |
+
"""
|
| 968 |
+
try:
|
| 969 |
+
return _engine.explain(engine_results, fused)
|
| 970 |
+
except Exception as exc:
|
| 971 |
+
logger.exception("ThreatExplanationEngine failed: %s", exc)
|
| 972 |
+
# Return a minimal explanation rather than breaking the response
|
| 973 |
+
return ThreatExplanation(
|
| 974 |
+
summary="Explanation generation failed β see raw engine results.",
|
| 975 |
+
risk_breakdown=[],
|
| 976 |
+
technical_details=TechnicalDetails(
|
| 977 |
+
verdict=str(fused.verdict),
|
| 978 |
+
fused_risk_score=fused.malware_probability,
|
| 979 |
+
malware_probability=0.0,
|
| 980 |
+
dangerous_permissions=[], permission_risk_map={},
|
| 981 |
+
ml_confidence=0.0, active_ml_features=[], ml_threat_family=None,
|
| 982 |
+
yara_matched_rules=fused.yara_matches, yara_severities={},
|
| 983 |
+
packers=[], obfuscators=[], anti_analysis_techniques=[], compilers=[],
|
| 984 |
+
is_obfuscated=False, has_native_code=False, has_dynamic_code_loading=False,
|
| 985 |
+
exported_component_count=0, suspicious_receivers=[], component_counts={},
|
| 986 |
+
mobsf_danger_score=-1, mobsf_trackers=[], mobsf_malware_permissions=[],
|
| 987 |
+
mobsf_findings=[], certificate_mismatch=False, impersonated_bank=None,
|
| 988 |
+
impersonation_similarity=0.0,
|
| 989 |
+
),
|
| 990 |
+
user_message=UserMessage(
|
| 991 |
+
title="Analysis Incomplete",
|
| 992 |
+
body="A partial analysis was completed. Please retry or contact support.",
|
| 993 |
+
action="Proceed with caution.",
|
| 994 |
+
severity="MEDIUM",
|
| 995 |
+
),
|
| 996 |
+
verdict=str(fused.verdict),
|
| 997 |
+
overall_risk_score=int(fused.malware_probability * 100),
|
| 998 |
+
threat_family=fused.threat_family,
|
| 999 |
+
analyst_summary=AnalystSummary(
|
| 1000 |
+
threat_category="Unknown",
|
| 1001 |
+
attack_vectors=[],
|
| 1002 |
+
ioc_summary=f"Explanation error: {exc}",
|
| 1003 |
+
confidence="LOW",
|
| 1004 |
+
recommended_actions=["Review raw engine results manually."],
|
| 1005 |
+
engine_score_breakdown={},
|
| 1006 |
+
analyst_notes=[f"Exception: {exc}"],
|
| 1007 |
+
),
|
| 1008 |
+
generated_at=datetime.now(timezone.utc).isoformat(),
|
| 1009 |
+
)
|
app/services/identity/__init__.py
ADDED
|
File without changes
|
app/services/identity/bank_registry.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
identity/bank_registry.py
|
| 3 |
+
β Registry of legitimate Indian bank apps and their signing certificates.
|
| 4 |
+
|
| 5 |
+
Used by the impersonation engine to detect fake banking apps
|
| 6 |
+
masquerading as legitimate ones.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from dataclasses import dataclass, field
|
| 12 |
+
from typing import Optional
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass(frozen=True)
|
| 16 |
+
class BankAppProfile:
|
| 17 |
+
"""Immutable profile for a legitimate Indian banking application."""
|
| 18 |
+
bank_name: str
|
| 19 |
+
official_package: str
|
| 20 |
+
cert_sha256: Optional[str] # hex, lowercase; None = not yet enrolled
|
| 21 |
+
alt_packages: tuple[str, ...] = field(default_factory=tuple)
|
| 22 |
+
display_names: tuple[str, ...] = field(default_factory=tuple)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ββ Indian Banking App Registry βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 26 |
+
# cert_sha256 values are placeholders β replace with real certs in production.
|
| 27 |
+
BANK_REGISTRY: list[BankAppProfile] = [
|
| 28 |
+
BankAppProfile(
|
| 29 |
+
bank_name="State Bank of India",
|
| 30 |
+
official_package="com.sbi.lotusintouch",
|
| 31 |
+
cert_sha256="a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2",
|
| 32 |
+
alt_packages=("com.sbi.mobile", "in.sbi.mobile"),
|
| 33 |
+
display_names=("YONO SBI", "SBI YONO", "State Bank of India", "SBI Anywhere"),
|
| 34 |
+
),
|
| 35 |
+
BankAppProfile(
|
| 36 |
+
bank_name="HDFC Bank",
|
| 37 |
+
official_package="com.snapwork.hdfc",
|
| 38 |
+
cert_sha256="b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3",
|
| 39 |
+
alt_packages=("com.hdfc.bank",),
|
| 40 |
+
display_names=("HDFC Bank MobileBanking", "HDFC Bank"),
|
| 41 |
+
),
|
| 42 |
+
BankAppProfile(
|
| 43 |
+
bank_name="ICICI Bank",
|
| 44 |
+
official_package="com.csam.icici.bank.imobile",
|
| 45 |
+
cert_sha256="c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4",
|
| 46 |
+
alt_packages=("com.icici.mbanking",),
|
| 47 |
+
display_names=("iMobile Pay", "ICICI Bank"),
|
| 48 |
+
),
|
| 49 |
+
BankAppProfile(
|
| 50 |
+
bank_name="Axis Bank",
|
| 51 |
+
official_package="com.axis.mobile",
|
| 52 |
+
cert_sha256="d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5",
|
| 53 |
+
alt_packages=(),
|
| 54 |
+
display_names=("Axis Mobile", "Axis Bank"),
|
| 55 |
+
),
|
| 56 |
+
BankAppProfile(
|
| 57 |
+
bank_name="Kotak Mahindra Bank",
|
| 58 |
+
official_package="com.msf.kbank.mobile",
|
| 59 |
+
cert_sha256="e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6",
|
| 60 |
+
alt_packages=(),
|
| 61 |
+
display_names=("Kotak Mobile Banking", "Kotak811"),
|
| 62 |
+
),
|
| 63 |
+
BankAppProfile(
|
| 64 |
+
bank_name="Punjab National Bank",
|
| 65 |
+
official_package="com.Version1.androPNBpr",
|
| 66 |
+
cert_sha256=None,
|
| 67 |
+
alt_packages=("com.pnb.mobile",),
|
| 68 |
+
display_names=("PNB ONE", "Punjab National Bank"),
|
| 69 |
+
),
|
| 70 |
+
BankAppProfile(
|
| 71 |
+
bank_name="Bank of Baroda",
|
| 72 |
+
official_package="com.baroda.mpassbook",
|
| 73 |
+
cert_sha256=None,
|
| 74 |
+
alt_packages=(),
|
| 75 |
+
display_names=("bob World", "Bank of Baroda"),
|
| 76 |
+
),
|
| 77 |
+
BankAppProfile(
|
| 78 |
+
bank_name="Canara Bank",
|
| 79 |
+
official_package="com.canarabank.mobility",
|
| 80 |
+
cert_sha256=None,
|
| 81 |
+
alt_packages=(),
|
| 82 |
+
display_names=("Canara ai1", "Canara Bank"),
|
| 83 |
+
),
|
| 84 |
+
BankAppProfile(
|
| 85 |
+
bank_name="Union Bank of India",
|
| 86 |
+
official_package="com.infrasoft.uboi",
|
| 87 |
+
cert_sha256=None,
|
| 88 |
+
alt_packages=(),
|
| 89 |
+
display_names=("Union Bank Mobile Banking",),
|
| 90 |
+
),
|
| 91 |
+
BankAppProfile(
|
| 92 |
+
bank_name="Indian Bank",
|
| 93 |
+
official_package="com.IndianBank.mPassbook",
|
| 94 |
+
cert_sha256=None,
|
| 95 |
+
alt_packages=(),
|
| 96 |
+
display_names=("IndOASIS", "Indian Bank"),
|
| 97 |
+
),
|
| 98 |
+
BankAppProfile(
|
| 99 |
+
bank_name="Google Pay",
|
| 100 |
+
official_package="com.google.android.apps.nbu.paisa.user",
|
| 101 |
+
cert_sha256="f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7",
|
| 102 |
+
alt_packages=(),
|
| 103 |
+
display_names=("Google Pay", "GPay"),
|
| 104 |
+
),
|
| 105 |
+
BankAppProfile(
|
| 106 |
+
bank_name="PhonePe",
|
| 107 |
+
official_package="com.phonepe.app",
|
| 108 |
+
cert_sha256=None,
|
| 109 |
+
alt_packages=(),
|
| 110 |
+
display_names=("PhonePe", "PhonePe - UPI, Payments"),
|
| 111 |
+
),
|
| 112 |
+
BankAppProfile(
|
| 113 |
+
bank_name="Paytm",
|
| 114 |
+
official_package="net.one97.paytm",
|
| 115 |
+
cert_sha256=None,
|
| 116 |
+
alt_packages=(),
|
| 117 |
+
display_names=("Paytm", "Paytm-UPI, Money Transfer"),
|
| 118 |
+
),
|
| 119 |
+
]
|
| 120 |
+
|
| 121 |
+
# ββ Lookup structures (built at import time) βββββββββββββββββββββββββββββββββββ
|
| 122 |
+
_PACKAGE_TO_BANK: dict[str, BankAppProfile] = {}
|
| 123 |
+
_CERT_TO_BANK: dict[str, BankAppProfile] = {}
|
| 124 |
+
_DISPLAYNAME_TO_BANK: dict[str, BankAppProfile] = {}
|
| 125 |
+
|
| 126 |
+
for _profile in BANK_REGISTRY:
|
| 127 |
+
_PACKAGE_TO_BANK[_profile.official_package.lower()] = _profile
|
| 128 |
+
for _alt in _profile.alt_packages:
|
| 129 |
+
_PACKAGE_TO_BANK[_alt.lower()] = _profile
|
| 130 |
+
if _profile.cert_sha256:
|
| 131 |
+
_CERT_TO_BANK[_profile.cert_sha256.lower()] = _profile
|
| 132 |
+
for _name in _profile.display_names:
|
| 133 |
+
_DISPLAYNAME_TO_BANK[_name.lower()] = _profile
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
# ββ Public API βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 137 |
+
|
| 138 |
+
def is_official_package(package_name: str) -> bool:
|
| 139 |
+
"""Return True if package_name is an official banking app package."""
|
| 140 |
+
return package_name.lower() in _PACKAGE_TO_BANK
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def get_bank_by_package(package_name: str) -> Optional[BankAppProfile]:
|
| 144 |
+
"""Return the BankAppProfile for a known official package, or None."""
|
| 145 |
+
return _PACKAGE_TO_BANK.get(package_name.lower())
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def get_bank_by_cert(cert_sha256: str) -> Optional[BankAppProfile]:
|
| 149 |
+
"""Return the BankAppProfile that owns a given signing certificate."""
|
| 150 |
+
return _CERT_TO_BANK.get(cert_sha256.lower())
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def find_bank_by_display_name(display_name: str) -> Optional[BankAppProfile]:
|
| 154 |
+
"""Return a BankAppProfile if the display name matches a known bank."""
|
| 155 |
+
return _DISPLAYNAME_TO_BANK.get(display_name.lower())
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def all_official_packages() -> set[str]:
|
| 159 |
+
"""Return the complete set of all known official bank package names."""
|
| 160 |
+
return set(_PACKAGE_TO_BANK.keys())
|
app/services/identity/impersonation_engine.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
identity/impersonation_engine.py
|
| 3 |
+
β Detects apps that impersonate legitimate Indian banking applications.
|
| 4 |
+
|
| 5 |
+
Detection strategy:
|
| 6 |
+
1. Certificate mismatch: Package matches official but certificate differs.
|
| 7 |
+
2. Name similarity: App name closely resembles a known bank app name.
|
| 8 |
+
3. Package squatting: Package name is a close variant of an official one.
|
| 9 |
+
4. Icon hash comparison: (stub β requires image processing in production)
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import difflib
|
| 15 |
+
import logging
|
| 16 |
+
import re
|
| 17 |
+
from typing import Optional
|
| 18 |
+
|
| 19 |
+
from app.services.apk_intelligence.models import ImpersonationResult
|
| 20 |
+
from app.services.identity.bank_registry import (
|
| 21 |
+
BANK_REGISTRY,
|
| 22 |
+
all_official_packages,
|
| 23 |
+
find_bank_by_display_name,
|
| 24 |
+
get_bank_by_cert,
|
| 25 |
+
get_bank_by_package,
|
| 26 |
+
is_official_package,
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
logger = logging.getLogger(__name__)
|
| 30 |
+
|
| 31 |
+
# Similarity threshold: above this β flag as impersonation attempt
|
| 32 |
+
_NAME_SIMILARITY_THRESHOLD = 0.72
|
| 33 |
+
_PACKAGE_SIMILARITY_THRESHOLD = 0.80
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
async def run_impersonation_check(
|
| 37 |
+
package_name: str,
|
| 38 |
+
app_name: Optional[str],
|
| 39 |
+
certificate_hash: Optional[str],
|
| 40 |
+
) -> ImpersonationResult:
|
| 41 |
+
"""
|
| 42 |
+
Check whether an APK is impersonating a legitimate banking app.
|
| 43 |
+
|
| 44 |
+
Returns ImpersonationResult with impersonation flag, target bank, and similarity score.
|
| 45 |
+
"""
|
| 46 |
+
try:
|
| 47 |
+
# 1. Official package + wrong certificate (clone attack)
|
| 48 |
+
cert_result = _check_cert_mismatch(package_name, certificate_hash)
|
| 49 |
+
if cert_result.is_impersonating:
|
| 50 |
+
return cert_result
|
| 51 |
+
|
| 52 |
+
# 2. Display name similarity attack
|
| 53 |
+
if app_name:
|
| 54 |
+
name_result = _check_name_similarity(app_name)
|
| 55 |
+
if name_result.is_impersonating:
|
| 56 |
+
return name_result
|
| 57 |
+
|
| 58 |
+
# 3. Package name squatting
|
| 59 |
+
pkg_result = _check_package_squatting(package_name)
|
| 60 |
+
if pkg_result.is_impersonating:
|
| 61 |
+
return pkg_result
|
| 62 |
+
|
| 63 |
+
return ImpersonationResult(is_impersonating=False)
|
| 64 |
+
|
| 65 |
+
except Exception as exc:
|
| 66 |
+
logger.exception("Impersonation check failed for %s", package_name)
|
| 67 |
+
return ImpersonationResult(is_impersonating=False)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _check_cert_mismatch(
|
| 71 |
+
package_name: str,
|
| 72 |
+
certificate_hash: Optional[str],
|
| 73 |
+
) -> ImpersonationResult:
|
| 74 |
+
"""
|
| 75 |
+
If the package name matches an official bank app but the certificate
|
| 76 |
+
does NOT match the registered cert β very high confidence impersonation.
|
| 77 |
+
"""
|
| 78 |
+
if not is_official_package(package_name):
|
| 79 |
+
return ImpersonationResult(is_impersonating=False)
|
| 80 |
+
|
| 81 |
+
profile = get_bank_by_package(package_name)
|
| 82 |
+
if profile is None:
|
| 83 |
+
return ImpersonationResult(is_impersonating=False)
|
| 84 |
+
|
| 85 |
+
# If we have a registered cert and it doesn't match β clone
|
| 86 |
+
if profile.cert_sha256 and certificate_hash:
|
| 87 |
+
if profile.cert_sha256.lower() != certificate_hash.lower():
|
| 88 |
+
logger.warning(
|
| 89 |
+
"Certificate mismatch for %s: expected %s, got %s",
|
| 90 |
+
package_name, profile.cert_sha256[:16] + "...", certificate_hash[:16] + "...",
|
| 91 |
+
)
|
| 92 |
+
return ImpersonationResult(
|
| 93 |
+
is_impersonating=True,
|
| 94 |
+
impersonated_bank=profile.bank_name,
|
| 95 |
+
similarity_score=0.97, # Near-certain clone
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
# Package matches but no cert registered β suspicious but not confirmed
|
| 99 |
+
if not profile.cert_sha256:
|
| 100 |
+
return ImpersonationResult(
|
| 101 |
+
is_impersonating=False,
|
| 102 |
+
similarity_score=0.50,
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
return ImpersonationResult(is_impersonating=False)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _check_name_similarity(app_name: str) -> ImpersonationResult:
|
| 109 |
+
"""
|
| 110 |
+
Use sequence matching to detect display names that closely resemble
|
| 111 |
+
known banking app names (e.g. "SBlMobile" vs "SBIAnywhere").
|
| 112 |
+
"""
|
| 113 |
+
# Collect all official display names for comparison
|
| 114 |
+
all_display_names: list[tuple[str, str]] = [] # (bank_name, display_name)
|
| 115 |
+
for profile in BANK_REGISTRY:
|
| 116 |
+
for dname in profile.display_names:
|
| 117 |
+
all_display_names.append((profile.bank_name, dname))
|
| 118 |
+
|
| 119 |
+
best_score = 0.0
|
| 120 |
+
best_bank: Optional[str] = None
|
| 121 |
+
|
| 122 |
+
for bank_name, official_name in all_display_names:
|
| 123 |
+
score = difflib.SequenceMatcher(
|
| 124 |
+
None,
|
| 125 |
+
_normalise(app_name),
|
| 126 |
+
_normalise(official_name),
|
| 127 |
+
).ratio()
|
| 128 |
+
|
| 129 |
+
if score > best_score:
|
| 130 |
+
best_score = score
|
| 131 |
+
best_bank = bank_name
|
| 132 |
+
|
| 133 |
+
if best_score >= _NAME_SIMILARITY_THRESHOLD:
|
| 134 |
+
# Extra check: if the name matches but the package wasn't flagged,
|
| 135 |
+
# this is a display-name spoofing attack
|
| 136 |
+
logger.info(
|
| 137 |
+
"Display name '%s' is %.0f%% similar to %s apps",
|
| 138 |
+
app_name, best_score * 100, best_bank,
|
| 139 |
+
)
|
| 140 |
+
return ImpersonationResult(
|
| 141 |
+
is_impersonating=True,
|
| 142 |
+
impersonated_bank=best_bank,
|
| 143 |
+
similarity_score=round(best_score, 3),
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
return ImpersonationResult(is_impersonating=False)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _check_package_squatting(package_name: str) -> ImpersonationResult:
|
| 150 |
+
"""
|
| 151 |
+
Detect typosquatting / package squatting: package names that are
|
| 152 |
+
near-identical to official ones (e.g. com.sbi.yono vs com.sbi.lotusintouch).
|
| 153 |
+
"""
|
| 154 |
+
official_packages = all_official_packages()
|
| 155 |
+
best_score = 0.0
|
| 156 |
+
best_pkg: Optional[str] = None
|
| 157 |
+
|
| 158 |
+
normed = _normalise_pkg(package_name)
|
| 159 |
+
|
| 160 |
+
for official_pkg in official_packages:
|
| 161 |
+
score = difflib.SequenceMatcher(
|
| 162 |
+
None, normed, _normalise_pkg(official_pkg)
|
| 163 |
+
).ratio()
|
| 164 |
+
if score > best_score:
|
| 165 |
+
best_score = score
|
| 166 |
+
best_pkg = official_pkg
|
| 167 |
+
|
| 168 |
+
if best_score >= _PACKAGE_SIMILARITY_THRESHOLD and best_pkg:
|
| 169 |
+
profile = get_bank_by_package(best_pkg)
|
| 170 |
+
return ImpersonationResult(
|
| 171 |
+
is_impersonating=True,
|
| 172 |
+
impersonated_bank=profile.bank_name if profile else best_pkg,
|
| 173 |
+
similarity_score=round(best_score, 3),
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
return ImpersonationResult(is_impersonating=False)
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def _normalise(text: str) -> str:
|
| 180 |
+
"""Lower-case, strip non-alphanumeric for string comparison."""
|
| 181 |
+
return re.sub(r"[^a-z0-9]", "", text.lower())
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def _normalise_pkg(pkg: str) -> str:
|
| 185 |
+
"""Normalise package name by removing dots and lower-casing."""
|
| 186 |
+
return pkg.lower().replace(".", "")
|
app/utils.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""app/utils.py β Shared async helpers."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import asyncio
|
| 5 |
+
import logging
|
| 6 |
+
from typing import Coroutine, Any
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def fire_and_forget(coro: Coroutine[Any, Any, Any]) -> None:
|
| 12 |
+
"""Schedule a coroutine as a non-blocking background task.
|
| 13 |
+
|
| 14 |
+
Exceptions inside the coroutine are logged but never propagate β
|
| 15 |
+
a storage failure must never break a security response.
|
| 16 |
+
"""
|
| 17 |
+
async def _guarded() -> None:
|
| 18 |
+
try:
|
| 19 |
+
await coro
|
| 20 |
+
except Exception as exc:
|
| 21 |
+
logger.warning("Background DB write failed (non-critical): %s", exc)
|
| 22 |
+
|
| 23 |
+
try:
|
| 24 |
+
asyncio.get_running_loop().create_task(_guarded())
|
| 25 |
+
except RuntimeError:
|
| 26 |
+
# No running event loop (e.g., pure-sync unit tests) β skip silently.
|
| 27 |
+
pass
|
main.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
APK Scanner Microservice
|
| 3 |
+
Stateless β no DB, no Redis. Receives APK β analyzes β returns JSON.
|
| 4 |
+
Deploys to Hugging Face Spaces (port 7860) or any container host.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import logging
|
| 10 |
+
import uuid
|
| 11 |
+
from typing import List, Optional
|
| 12 |
+
|
| 13 |
+
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
| 14 |
+
from fastapi.middleware.gzip import GZipMiddleware
|
| 15 |
+
|
| 16 |
+
from app.config import get_settings
|
| 17 |
+
from app.services.apk_intelligence.analyzer import APKAnalyzer
|
| 18 |
+
from app.services.apk_intelligence.models import (
|
| 19 |
+
ApkAnalysisRequest,
|
| 20 |
+
ThreatIntelligenceResponse,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
logging.basicConfig(
|
| 24 |
+
level=logging.INFO,
|
| 25 |
+
format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
| 26 |
+
)
|
| 27 |
+
logger = logging.getLogger(__name__)
|
| 28 |
+
settings = get_settings()
|
| 29 |
+
|
| 30 |
+
app = FastAPI(title="ShieldYONO APK Scanner", version="1.0.0")
|
| 31 |
+
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
| 32 |
+
|
| 33 |
+
_analyzer = APKAnalyzer()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _parse_permissions(raw: str | None) -> List[str]:
|
| 37 |
+
if not raw:
|
| 38 |
+
return []
|
| 39 |
+
try:
|
| 40 |
+
parsed = json.loads(raw)
|
| 41 |
+
if isinstance(parsed, list):
|
| 42 |
+
return parsed
|
| 43 |
+
except (json.JSONDecodeError, TypeError):
|
| 44 |
+
pass
|
| 45 |
+
return [p.strip() for p in raw.split(",") if p.strip()]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@app.get("/health")
|
| 49 |
+
async def health() -> dict:
|
| 50 |
+
return {
|
| 51 |
+
"status": "healthy",
|
| 52 |
+
"service": "apk-scanner",
|
| 53 |
+
"engines": {
|
| 54 |
+
"androguard": True,
|
| 55 |
+
"yara": settings.YARA_ENABLED,
|
| 56 |
+
"ml": settings.ML_ENABLED,
|
| 57 |
+
"impersonation": True,
|
| 58 |
+
},
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@app.post("/analyze", response_model=ThreatIntelligenceResponse)
|
| 63 |
+
async def analyze(
|
| 64 |
+
file: UploadFile = File(...),
|
| 65 |
+
package_name: str = Form(...),
|
| 66 |
+
app_label: Optional[str] = Form(None),
|
| 67 |
+
cert_hash: Optional[str] = Form(None),
|
| 68 |
+
permissions: Optional[str] = Form(None),
|
| 69 |
+
local_risk_score: float = Form(0.0),
|
| 70 |
+
) -> ThreatIntelligenceResponse:
|
| 71 |
+
analysis_id = str(uuid.uuid4())
|
| 72 |
+
|
| 73 |
+
if not file.filename or not file.filename.lower().endswith(".apk"):
|
| 74 |
+
raise HTTPException(status_code=400, detail="File must have .apk extension.")
|
| 75 |
+
|
| 76 |
+
apk_bytes = await file.read()
|
| 77 |
+
max_bytes = settings.MAX_APK_SIZE_MB * 1024 * 1024
|
| 78 |
+
if len(apk_bytes) > max_bytes:
|
| 79 |
+
raise HTTPException(status_code=413, detail=f"APK exceeds {settings.MAX_APK_SIZE_MB} MB limit.")
|
| 80 |
+
if len(apk_bytes) == 0:
|
| 81 |
+
raise HTTPException(status_code=400, detail="Empty APK file.")
|
| 82 |
+
|
| 83 |
+
metadata = ApkAnalysisRequest(
|
| 84 |
+
packageName=package_name,
|
| 85 |
+
appName=app_label,
|
| 86 |
+
certificateHash=cert_hash,
|
| 87 |
+
permissions=_parse_permissions(permissions),
|
| 88 |
+
localRisk=local_risk_score,
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
logger.info("[%s] Analyzing %s (%d bytes)", analysis_id, package_name, len(apk_bytes))
|
| 92 |
+
return await _analyzer.analyze(
|
| 93 |
+
apk_bytes=apk_bytes,
|
| 94 |
+
metadata=metadata,
|
| 95 |
+
analysis_id=analysis_id,
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
if __name__ == "__main__":
|
| 100 |
+
import uvicorn
|
| 101 |
+
uvicorn.run(app, host="0.0.0.0", port=7860)
|
models/apk_classifier.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:61fdabe2dad75047eba99ead79b66e0a3d1d0ee1f332668ea59adfc8e49556d0
|
| 3 |
+
size 907450
|
requirements.txt
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.111.0
|
| 2 |
+
uvicorn[standard]>=0.29.0
|
| 3 |
+
pydantic>=2.7.0
|
| 4 |
+
pydantic-settings>=2.3.0
|
| 5 |
+
python-multipart>=0.0.9
|
| 6 |
+
httpx>=0.27.0
|
| 7 |
+
aiofiles>=23.2.1
|
| 8 |
+
cryptography>=42.0.0
|
| 9 |
+
|
| 10 |
+
# ββ Heavy analysis engines ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 11 |
+
androguard>=4.1.4
|
| 12 |
+
yara-python>=4.5.0
|
| 13 |
+
# apkid>=2.1.5 # uncomment when available
|
| 14 |
+
|
| 15 |
+
# ββ ML classifier (uncomment when model is ready) βββββββββββββββββββββββββββββ
|
| 16 |
+
# joblib>=1.4.0
|
| 17 |
+
# scikit-learn>=1.4.0
|
| 18 |
+
# numpy>=1.26.0
|
rules/android_banker.yar
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
* android_banker.yar β ShieldYONO Android Malware Signatures
|
| 3 |
+
*
|
| 4 |
+
* Detects banking trojans: credential harvesting, overlay attacks,
|
| 5 |
+
* and known malware family byte-strings found in DEX or asset files.
|
| 6 |
+
*
|
| 7 |
+
* Severity: CRITICAL
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
rule BANKING_TROJAN
|
| 11 |
+
{
|
| 12 |
+
meta:
|
| 13 |
+
severity = "CRITICAL"
|
| 14 |
+
description = "Banking trojan β credential harvesting, overlay attack, or known family"
|
| 15 |
+
author = "ShieldYONO Security"
|
| 16 |
+
reference = "Android.Banker"
|
| 17 |
+
date = "2024-01-01"
|
| 18 |
+
|
| 19 |
+
strings:
|
| 20 |
+
// ββ Known banking trojan family identifiers ββββββββββββββββββββββββββ
|
| 21 |
+
// These strings appear in DEX string pools or embedded asset paths
|
| 22 |
+
$fam_bankbot = "bankbot" ascii nocase
|
| 23 |
+
$fam_cerberus = "cerberus" ascii nocase
|
| 24 |
+
$fam_flubot = "flubot" ascii nocase
|
| 25 |
+
$fam_sharkbot = "sharkbot" ascii nocase
|
| 26 |
+
$fam_eventbot = "eventbot" ascii nocase
|
| 27 |
+
$fam_anubis = "anubis_c2" ascii nocase
|
| 28 |
+
$fam_godfather = "godfather" ascii nocase
|
| 29 |
+
$fam_octo = "octo.panel" ascii nocase
|
| 30 |
+
$fam_joker = "joker.payload" ascii nocase
|
| 31 |
+
|
| 32 |
+
// ββ Credential harvesting field names in layout XML or DEX βββββββββββ
|
| 33 |
+
$cred_card = "cardNumber" ascii
|
| 34 |
+
$cred_cvv = "inputCvv" ascii
|
| 35 |
+
$cred_expiry = "expiryDate" ascii
|
| 36 |
+
$cred_pin = "enterPin" ascii
|
| 37 |
+
$cred_pass_et = "etPassword" ascii
|
| 38 |
+
$cred_pass_edit = "editPassword" ascii
|
| 39 |
+
$cred_pass_hint = "passwordHint" ascii
|
| 40 |
+
|
| 41 |
+
// ββ Overlay / screen-draw permission ββββββββββββββββββββββββββββββββ
|
| 42 |
+
// wide catches UTF-16 in binary AXML manifest; ascii catches DEX string pool
|
| 43 |
+
$overlay_perm = "SYSTEM_ALERT_WINDOW" ascii wide
|
| 44 |
+
$overlay_type = "TYPE_APPLICATION_OVERLAY" ascii
|
| 45 |
+
$overlay_draw = "drawOverApps" ascii
|
| 46 |
+
|
| 47 |
+
// ββ Fake login UI indicators βββββββββββββββββββββββββββββββββββββββββ
|
| 48 |
+
$fake_login1 = "fakeLoginActivity" ascii nocase
|
| 49 |
+
$fake_login2 = "overlayActivity" ascii nocase
|
| 50 |
+
$fake_login3 = "phishingWebView" ascii nocase
|
| 51 |
+
|
| 52 |
+
condition:
|
| 53 |
+
// Definitive family string hit
|
| 54 |
+
1 of ($fam_*)
|
| 55 |
+
or
|
| 56 |
+
// Credential field cluster β genuine banking trojans harvest many fields
|
| 57 |
+
3 of ($cred_*)
|
| 58 |
+
or
|
| 59 |
+
// Overlay permission paired with any credential field
|
| 60 |
+
(1 of ($overlay_*) and 1 of ($cred_*))
|
| 61 |
+
or
|
| 62 |
+
// Explicit fake-login string
|
| 63 |
+
1 of ($fake_login*)
|
| 64 |
+
}
|
rules/otp_stealer.yar
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
* otp_stealer.yar β ShieldYONO Android Malware Signatures
|
| 3 |
+
*
|
| 4 |
+
* Detects APKs that read, intercept, or forward SMS messages,
|
| 5 |
+
* a hallmark of OTP-stealing banking malware.
|
| 6 |
+
*
|
| 7 |
+
* Severity: HIGH
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
rule OTP_STEALER
|
| 11 |
+
{
|
| 12 |
+
meta:
|
| 13 |
+
severity = "HIGH"
|
| 14 |
+
description = "SMS interception and OTP theft β reads or forwards one-time passwords"
|
| 15 |
+
author = "ShieldYONO Security"
|
| 16 |
+
reference = "Android.Banker.OTPStealer"
|
| 17 |
+
date = "2024-01-01"
|
| 18 |
+
|
| 19 |
+
strings:
|
| 20 |
+
// Permission strings β stored as UTF-16 LE in binary AXML manifest,
|
| 21 |
+
// so match both ascii (DEX string pool) and wide (AXML binary format)
|
| 22 |
+
$p_read = "READ_SMS" ascii wide
|
| 23 |
+
$p_receive = "RECEIVE_SMS" ascii wide
|
| 24 |
+
$p_send = "SEND_SMS" ascii wide
|
| 25 |
+
|
| 26 |
+
// Android SMS API class and content-provider URI (DEX string pool, ASCII)
|
| 27 |
+
$api_class = "SmsMessage" ascii
|
| 28 |
+
$api_body = "getMessageBody" ascii
|
| 29 |
+
$api_addr = "getOriginatingAddress" ascii
|
| 30 |
+
$api_uri = "content://sms" ascii
|
| 31 |
+
|
| 32 |
+
// Smali class descriptor for SMS broadcast receiver (DEX)
|
| 33 |
+
$smali_sms = "Landroid/telephony/SmsMessage;" ascii
|
| 34 |
+
|
| 35 |
+
// OTP-related keyword literals often present in stealer DEX string pools
|
| 36 |
+
$kw_otp1 = "otp" ascii nocase
|
| 37 |
+
$kw_otp2 = "one-time" ascii nocase
|
| 38 |
+
$kw_otp3 = "otpCode" ascii
|
| 39 |
+
$kw_otp4 = "verificationCode" ascii
|
| 40 |
+
|
| 41 |
+
condition:
|
| 42 |
+
// Two or more SMS permission strings (wide catches binary AXML)
|
| 43 |
+
(2 of ($p_*))
|
| 44 |
+
or
|
| 45 |
+
// SMS API usage combined with any permission string
|
| 46 |
+
(1 of ($api_*) and 1 of ($p_*))
|
| 47 |
+
or
|
| 48 |
+
// Direct OTP keyword combined with SMS permission
|
| 49 |
+
(1 of ($kw_*) and 1 of ($p_*))
|
| 50 |
+
or
|
| 51 |
+
// Pure DEX: two SMS API strings β sufficient without permission match
|
| 52 |
+
(2 of ($api_*, $smali_sms))
|
| 53 |
+
}
|
rules/rat.yar
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
* rat.yar β ShieldYONO Android Malware Signatures
|
| 3 |
+
*
|
| 4 |
+
* Detects Remote Access Trojans (RATs): accessibility service abuse,
|
| 5 |
+
* screen capture, and remote shell / command-and-control patterns.
|
| 6 |
+
*
|
| 7 |
+
* Severity: HIGH
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
rule REMOTE_ACCESS_TROJAN
|
| 11 |
+
{
|
| 12 |
+
meta:
|
| 13 |
+
severity = "HIGH"
|
| 14 |
+
description = "Remote Access Trojan β accessibility abuse, screen capture, or remote commands"
|
| 15 |
+
author = "ShieldYONO Security"
|
| 16 |
+
reference = "Android.RAT"
|
| 17 |
+
date = "2024-01-01"
|
| 18 |
+
|
| 19 |
+
strings:
|
| 20 |
+
// ββ Accessibility service abuse ββββββββββββββββββββββββββββββββββββββ
|
| 21 |
+
// Permission stored as UTF-16 in binary AXML; wide catches that encoding
|
| 22 |
+
$acc_perm = "BIND_ACCESSIBILITY_SERVICE" ascii wide
|
| 23 |
+
// Class and callback method names found in DEX
|
| 24 |
+
$acc_service = "AccessibilityService" ascii
|
| 25 |
+
$acc_event = "onAccessibilityEvent" ascii
|
| 26 |
+
$acc_action = "performGlobalAction" ascii
|
| 27 |
+
$acc_node = "AccessibilityNodeInfo" ascii
|
| 28 |
+
|
| 29 |
+
// ββ Screen capture / remote viewing βββββββββββββββββββββββββββββββββ
|
| 30 |
+
$screen_proj = "MediaProjection" ascii
|
| 31 |
+
$screen_vdisp = "createVirtualDisplay" ascii
|
| 32 |
+
$screen_cap = "screencap" ascii nocase
|
| 33 |
+
|
| 34 |
+
// ββ Remote shell / command execution ββββββββββββββββββββββββββββββββ
|
| 35 |
+
// Use exact Smali descriptor to avoid matching RuntimeException
|
| 36 |
+
$cmd_runtime = "Ljava/lang/Runtime;" ascii
|
| 37 |
+
$cmd_exec = "getRuntime" ascii
|
| 38 |
+
$cmd_shell = "/system/bin/sh" ascii
|
| 39 |
+
$cmd_builder = "ProcessBuilder" ascii
|
| 40 |
+
|
| 41 |
+
// ββ C2 / remote command channel indicators βββββββββββββββββββββββββββ
|
| 42 |
+
$c2_cmd = "sendCommand" ascii
|
| 43 |
+
$c2_exec = "executeCmd" ascii
|
| 44 |
+
$c2_socket = "connectToC2" ascii
|
| 45 |
+
|
| 46 |
+
condition:
|
| 47 |
+
// BIND_ACCESSIBILITY_SERVICE permission (wide = binary AXML, ascii = DEX string pool)
|
| 48 |
+
$acc_perm
|
| 49 |
+
or
|
| 50 |
+
// Accessibility callback method + service class β rules out compat-lib stub matches
|
| 51 |
+
($acc_event and 1 of ($acc_service, $acc_action, $acc_node))
|
| 52 |
+
or
|
| 53 |
+
// Screen capture combined with accessibility or shell access
|
| 54 |
+
(1 of ($screen_*) and (1 of ($acc_*) or 1 of ($cmd_*)))
|
| 55 |
+
or
|
| 56 |
+
// Remote shell with C2 indicator
|
| 57 |
+
(2 of ($cmd_*) and 1 of ($c2_*))
|
| 58 |
+
}
|