Aniket2006 commited on
Commit
a249ee1
Β·
verified Β·
1 Parent(s): a797c9b

Initial APK scanner microservice deployment

Browse files
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: Apk Scanner
3
- emoji: 🐨
4
- colorFrom: purple
5
- colorTo: indigo
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
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
+ }