Aniket2006 commited on
Commit
9bce975
Β·
1 Parent(s): 95f6f5f

sync: update apk intelligence, identity, risk fusion logic, and YARA rules from backend

Browse files
app/services/apk_intelligence/analyzer.py CHANGED
@@ -252,6 +252,9 @@ class APKAnalyzer:
252
  engine_results=engine_results,
253
  local_risk=metadata.localRisk,
254
  package_name=metadata.packageName,
 
 
 
255
  )
256
 
257
  response.explanation = run_threat_explanation(engine_results, response)
 
252
  engine_results=engine_results,
253
  local_risk=metadata.localRisk,
254
  package_name=metadata.packageName,
255
+ certificate_hash=metadata.certificateHash,
256
+ app_name=metadata.appName,
257
+ installer_package_name=metadata.installerPackageName,
258
  )
259
 
260
  response.explanation = run_threat_explanation(engine_results, response)
app/services/apk_intelligence/engines/androguard_engine.py CHANGED
@@ -51,6 +51,35 @@ _SUSPICIOUS_RECEIVER_ACTIONS = frozenset({
51
 
52
  _ANDROID_NS = "http://schemas.android.com/apk/res/android"
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
 
55
  class AndroguardEngine:
56
  """
@@ -108,6 +137,9 @@ class AndroguardEngine:
108
 
109
  try:
110
  apk = APK(str(apk_path))
 
 
 
111
  except Exception as exc:
112
  logger.warning("Androguard: cannot parse %s β€” %s", apk_path, exc)
113
  return AndroguardResult(error=f"Invalid APK: {exc}")
@@ -123,10 +155,14 @@ class AndroguardEngine:
123
  perm_set = set(permissions)
124
  is_obfuscated = _has_obfuscated_names(components)
125
  native_code = bool(list(apk.get_libraries()))
126
- dynamic_code_loading = (
127
- "android.permission.REQUEST_INSTALL_PACKAGES" in perm_set
128
- or sum(1 for _ in apk.get_dex_names()) > 1
129
- )
 
 
 
 
130
 
131
  logger.info(
132
  "Androguard: pkg=%s perms=%d activities=%d services=%d receivers=%d "
@@ -166,6 +202,9 @@ class AndroguardEngine:
166
  self._cert_sha256_from_der(cert_der) if cert_der else self._extract_cert_sha256(apk)
167
  ),
168
  certificate_intel=self._build_certificate_intel(cert_der),
 
 
 
169
  )
170
 
171
  def _get_cert_der(self, apk) -> Optional[bytes]:
@@ -394,6 +433,13 @@ def _safe_str(val: object) -> str:
394
  return str(val) if val is not None else ""
395
 
396
 
 
 
 
 
 
 
 
397
  def _has_obfuscated_names(components: ApkComponents) -> bool:
398
  """True if any component name ends with a 1-2 character class segment."""
399
  all_names = components.activities + components.services + components.receivers
@@ -404,6 +450,24 @@ def _has_obfuscated_names(components: ApkComponents) -> bool:
404
  return False
405
 
406
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
407
  def _build_ml_features(
408
  permissions: list[str],
409
  components: ApkComponents,
 
51
 
52
  _ANDROID_NS = "http://schemas.android.com/apk/res/android"
53
 
54
+ # ---------------------------------------------------------------------------
55
+ # Patch androguard's DIMENSION_UNITS to cover all 16 unit indices that
56
+ # COMPLEX_UNIT_MASK (0x0F) can produce. Upstream only defines 6 entries
57
+ # (px/dip/sp/pt/in/mm). Packed or obfuscated APKs often have resource
58
+ # entries with unit indices 6–15, which causes an IndexError inside
59
+ # get_resource_dimen β†’ a DEBUG log storm. Extending the list with
60
+ # placeholder names makes the lookup always succeed.
61
+ # ---------------------------------------------------------------------------
62
+ def _patch_androguard_dimension_units() -> None:
63
+ try:
64
+ import androguard.core.axml as _axml # type: ignore[import-untyped]
65
+
66
+ _UNIT_MASK = 0x0F # COMPLEX_UNIT_MASK β€” max possible index is 15
67
+ _needed = _UNIT_MASK + 1 # 16 entries
68
+ _shortfall = _needed - len(_axml.DIMENSION_UNITS)
69
+ if _shortfall > 0:
70
+ for _i in range(len(_axml.DIMENSION_UNITS), _needed):
71
+ _axml.DIMENSION_UNITS.append(f"unknown_unit{_i}")
72
+ logger.debug(
73
+ "Patched androguard DIMENSION_UNITS: added %d placeholder unit(s) "
74
+ "to prevent IndexError on non-standard APK resource tables.",
75
+ _shortfall,
76
+ )
77
+ except Exception:
78
+ pass # androguard not installed β€” nothing to patch
79
+
80
+
81
+ _patch_androguard_dimension_units()
82
+
83
 
84
  class AndroguardEngine:
85
  """
 
137
 
138
  try:
139
  apk = APK(str(apk_path))
140
+ if not apk.is_valid_APK():
141
+ logger.warning("Androguard: invalid APK or cannot parse manifest for %s", apk_path)
142
+ return AndroguardResult(error="Invalid APK: Failed to parse AndroidManifest.xml")
143
  except Exception as exc:
144
  logger.warning("Androguard: cannot parse %s β€” %s", apk_path, exc)
145
  return AndroguardResult(error=f"Invalid APK: {exc}")
 
155
  perm_set = set(permissions)
156
  is_obfuscated = _has_obfuscated_names(components)
157
  native_code = bool(list(apk.get_libraries()))
158
+ # Actual dynamic code loading β€” DexClassLoader fetches and executes code
159
+ # from an arbitrary file/byte array at runtime. This used to be approximated
160
+ # by the REQUEST_INSTALL_PACKAGES permission, which is wrong: that permission
161
+ # is for self-update/in-app-install flows (Spotify, Instagram both hold it)
162
+ # and has nothing to do with loading code dynamically. PathClassLoader is
163
+ # deliberately excluded β€” it's the default loader for every app's own code
164
+ # since Android 5.0, so its presence is universal and not a signal at all.
165
+ dynamic_code_loading = _has_dex_class_loader(apk)
166
 
167
  logger.info(
168
  "Androguard: pkg=%s perms=%d activities=%d services=%d receivers=%d "
 
202
  self._cert_sha256_from_der(cert_der) if cert_der else self._extract_cert_sha256(apk)
203
  ),
204
  certificate_intel=self._build_certificate_intel(cert_der),
205
+ min_sdk_version=_safe_int(_safe_call(apk.get_min_sdk_version)),
206
+ target_sdk_version=_safe_int(_safe_call(apk.get_target_sdk_version)),
207
+ main_activity=_safe_call(apk.get_main_activity) or None,
208
  )
209
 
210
  def _get_cert_der(self, apk) -> Optional[bytes]:
 
433
  return str(val) if val is not None else ""
434
 
435
 
436
+ def _safe_int(val: object) -> Optional[int]:
437
+ try:
438
+ return int(val) if val is not None else None
439
+ except (TypeError, ValueError):
440
+ return None
441
+
442
+
443
  def _has_obfuscated_names(components: ApkComponents) -> bool:
444
  """True if any component name ends with a 1-2 character class segment."""
445
  all_names = components.activities + components.services + components.receivers
 
450
  return False
451
 
452
 
453
+ def _has_dex_class_loader(apk: object) -> bool:
454
+ """True if the APK's DEX bytecode references DexClassLoader/InMemoryDexClassLoader
455
+ β€” the loaders apps use to fetch and execute code from an arbitrary source at
456
+ runtime. PathClassLoader is intentionally not checked here (see caller comment)."""
457
+ try:
458
+ get_all_dex = getattr(apk, "get_all_dex", None)
459
+ dex_blobs = list(get_all_dex()) if callable(get_all_dex) else [apk.get_dex()]
460
+ except Exception:
461
+ return False
462
+
463
+ for blob in dex_blobs:
464
+ if not blob:
465
+ continue
466
+ if b"DexClassLoader" in blob:
467
+ return True
468
+ return False
469
+
470
+
471
  def _build_ml_features(
472
  permissions: list[str],
473
  components: ApkComponents,
app/services/apk_intelligence/engines/apkid_engine.py CHANGED
@@ -59,24 +59,22 @@ _OBFUSCATOR_SIGS: list[tuple[str, bytes]] = [
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
 
@@ -220,14 +218,23 @@ def _build_result(
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
 
 
59
  ]
60
 
61
  _ANTI_ANALYSIS_SIGS: list[tuple[str, bytes]] = [
62
+ # "ro.product.model", "ro.hardware", and "android.os.build"/"android.os.debug"
63
+ # were dropped β€” they're just the Build/Debug API surface used by virtually
64
+ # every app for device-info logging and analytics, not evidence of deliberate
65
+ # evasion. Keep only strings that specifically name an evasion technique or
66
+ # a specific emulator/VM/instrumentation product.
67
  ("emulator-check", b"isemulator"),
 
 
68
  ("emulator-check", b"goldfish"),
69
  ("emulator-check", b"vbox86"),
 
70
  ("debugger-check", b"isdebuggerconnected"),
 
71
  ("debugger-check", b"jdwp"),
72
  ("anti-vm", b"vmware"),
73
  ("anti-vm", b"virtualbox"),
74
  ("anti-vm", b"bluestacks"),
75
  ("anti-vm", b"noxplayer"),
 
76
  ("anti-frida", b"frida-gadget"),
77
  ("anti-frida", b"gum-js-loop"),
 
 
78
  ("root-check", b"/system/xbin/su"),
79
  ]
80
 
 
218
  if packers:
219
  score += 0.4
220
 
221
+ # A single anti-analysis category (e.g. just a debugger-state check used for
222
+ # crash-reporting telemetry) is common in legitimate hardened release builds.
223
+ # An evasion *suite* β€” multiple distinct categories together (anti-emulator +
224
+ # anti-debug + anti-frida etc.) β€” is the real signal genuine malware exhibits
225
+ # to survive sandbox/dynamic analysis.
226
+ if len(anti_analysis) > 1:
227
  score += 0.3
228
+ elif anti_analysis:
229
+ score += 0.05
230
 
231
+ # ProGuard/R8 is the default release-hardening toolchain for virtually all
232
+ # production Android apps β€” "heavy" obfuscation (multiple distinct tools
233
+ # stacked together) is the unusual, more suspicious case.
234
  if len(obfuscators) > 1:
235
  score += 0.2
236
  elif obfuscators:
237
+ score += 0.02
238
 
239
  risk_score = round(min(score, 1.0), 4)
240
 
app/services/apk_intelligence/engines/ml_engine.py CHANGED
@@ -114,9 +114,13 @@ class MLFeatureExtractor:
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
 
114
  perm_location = 1.0 if _PERM_LOCATION in perm_set else 0.0
115
 
116
  # ── Component features ─────────────────────────────────────────────────
117
+ # Clamp raw counts before weighting β€” large legitimate apps routinely
118
+ # ship dozens of activities/services/receivers, which would otherwise
119
+ # rack up score purely from being feature-rich rather than malicious.
120
+ _COMPONENT_COUNT_CAP = 5.0
121
+ receiver_count = min(float(len(comp.receivers) if comp else 0), _COMPONENT_COUNT_CAP)
122
+ service_count = min(float(len(comp.services) if comp else 0), _COMPONENT_COUNT_CAP)
123
+ exported_count = min(float(sf.exported_count if sf else 0), _COMPONENT_COUNT_CAP)
124
 
125
  # ── Behaviour features ─────────────────────────────────────────────────
126
  sms_access = 1.0 if sf and sf.sms_access else 0.0
app/services/apk_intelligence/engines/reverse_engineering_engine.py CHANGED
@@ -28,6 +28,7 @@ from app.services.apk_intelligence.engines.apkid_engine import _PACKER_SIGS
28
  from app.services.apk_intelligence.models import (
29
  AndroguardResult,
30
  CapabilityFinding,
 
31
  NetworkIndicator,
32
  ReverseEngineeringResult,
33
  )
@@ -119,6 +120,22 @@ _IGNORED_IPS = frozenset({"0.0.0.0", "127.0.0.1", "255.255.255.255"})
119
  _PASTEBIN_RAW_RE = re.compile(rb"pastebin\.com/raw/", re.IGNORECASE)
120
  _TELEGRAM_BOT_RE = re.compile(rb"api\.telegram\.org/bot", re.IGNORECASE)
121
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
 
123
  def _confidence(n_hits: int, has_corroboration: bool) -> Optional[str]:
124
  """HIGH = >=2 signature hits AND a corroborating permission/flag.
@@ -132,10 +149,12 @@ def _confidence(n_hits: int, has_corroboration: bool) -> Optional[str]:
132
 
133
  def _confidence_no_corroboration(n_hits: int) -> Optional[str]:
134
  """For capabilities with no manifest-level corroborating signal:
135
- HIGH = >=3 hits, MEDIUM = >=1 hit."""
136
- if n_hits >= 3:
 
 
137
  return "HIGH"
138
- if n_hits >= 1:
139
  return "MEDIUM"
140
  return None
141
 
@@ -185,7 +204,7 @@ class ReverseEngineeringEngine:
185
  self._find_overlay_phishing(buf_lower, perms),
186
  self._find_remote_access(buf_lower),
187
  self._find_accessibility_abuse(buf_lower, perms),
188
- self._find_credential_harvesting(buf_lower),
189
  self._find_dynamic_code_loading(buf_lower, androguard),
190
  self._find_persistence(buf_lower, perms),
191
  ]
@@ -199,12 +218,14 @@ class ReverseEngineeringEngine:
199
  capabilities = [f for f in findings if f is not None]
200
 
201
  certificate = androguard.identity.certificate_intel if androguard.identity else None
 
202
 
203
  return ReverseEngineeringResult(
204
  capabilities=capabilities,
205
  native_libraries=native_libraries,
206
  network_indicators=network_indicators,
207
  certificate=certificate,
 
208
  )
209
 
210
  # ── Scan buffer (mirrors yara_engine._extract_scan_buffer) ────────────────────
@@ -339,11 +360,20 @@ class ReverseEngineeringEngine:
339
  mitre_id="T1418",
340
  )
341
 
342
- def _find_credential_harvesting(self, buf: bytes) -> Optional[CapabilityFinding]:
343
  n, hits = self._count_hits(buf, _CREDENTIAL_HARVESTING_SIGS)
344
- confidence = _confidence_no_corroboration(n)
 
 
 
 
 
 
 
 
345
  if confidence is None:
346
  return None
 
347
  return CapabilityFinding(
348
  capability_id="credential_harvesting",
349
  name="Credential Harvesting via WebView",
@@ -354,7 +384,7 @@ class ReverseEngineeringEngine:
354
  "usernames and passwords."
355
  ),
356
  confidence=confidence,
357
- evidence=hits[:5],
358
  mitre_id="T1417",
359
  )
360
 
@@ -498,6 +528,38 @@ class ReverseEngineeringEngine:
498
 
499
  return indicators
500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
501
  def _find_suspicious_network(self, indicators: list[NetworkIndicator]) -> Optional[CapabilityFinding]:
502
  flagged = [i for i in indicators if i.context]
503
  if len(flagged) >= 2:
 
28
  from app.services.apk_intelligence.models import (
29
  AndroguardResult,
30
  CapabilityFinding,
31
+ IOCSet,
32
  NetworkIndicator,
33
  ReverseEngineeringResult,
34
  )
 
120
  _PASTEBIN_RAW_RE = re.compile(rb"pastebin\.com/raw/", re.IGNORECASE)
121
  _TELEGRAM_BOT_RE = re.compile(rb"api\.telegram\.org/bot", re.IGNORECASE)
122
 
123
+ # ── IOC extraction (emails, crypto constants, Firebase) ────────────────────────
124
+
125
+ _MAX_IOCS_PER_CATEGORY = 10
126
+
127
+ _EMAIL_RE = re.compile(rb"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")
128
+ _CRYPTO_RE = re.compile(
129
+ rb"\b(AES|DES|3DES|TripleDES|RSA|Blowfish|RC4|ChaCha20|HMAC-?SHA(?:1|256)?)\b",
130
+ re.IGNORECASE,
131
+ )
132
+ _FIREBASE_MARKERS = (
133
+ b".firebaseio.com",
134
+ b".firebaseapp.com",
135
+ b"firebasestorage.googleapis.com",
136
+ b"firebase-settings.crashlytics.com",
137
+ )
138
+
139
 
140
  def _confidence(n_hits: int, has_corroboration: bool) -> Optional[str]:
141
  """HIGH = >=2 signature hits AND a corroborating permission/flag.
 
149
 
150
  def _confidence_no_corroboration(n_hits: int) -> Optional[str]:
151
  """For capabilities with no manifest-level corroborating signal:
152
+ HIGH = >=4 hits, MEDIUM = >=2 hits. A single common API string (e.g.
153
+ "loadurl", "jobscheduler") is too common in ordinary apps to mean
154
+ anything on its own."""
155
+ if n_hits >= 4:
156
  return "HIGH"
157
+ if n_hits >= 2:
158
  return "MEDIUM"
159
  return None
160
 
 
204
  self._find_overlay_phishing(buf_lower, perms),
205
  self._find_remote_access(buf_lower),
206
  self._find_accessibility_abuse(buf_lower, perms),
207
+ self._find_credential_harvesting(buf_lower, perms),
208
  self._find_dynamic_code_loading(buf_lower, androguard),
209
  self._find_persistence(buf_lower, perms),
210
  ]
 
218
  capabilities = [f for f in findings if f is not None]
219
 
220
  certificate = androguard.identity.certificate_intel if androguard.identity else None
221
+ iocs = self._extract_iocs(buf, network_indicators)
222
 
223
  return ReverseEngineeringResult(
224
  capabilities=capabilities,
225
  native_libraries=native_libraries,
226
  network_indicators=network_indicators,
227
  certificate=certificate,
228
+ iocs=iocs,
229
  )
230
 
231
  # ── Scan buffer (mirrors yara_engine._extract_scan_buffer) ────────────────────
 
360
  mitre_id="T1418",
361
  )
362
 
363
+ def _find_credential_harvesting(self, buf: bytes, perms: FrozenSet[str]) -> Optional[CapabilityFinding]:
364
  n, hits = self._count_hits(buf, _CREDENTIAL_HARVESTING_SIGS)
365
+ # All 4 signatures here (addJavascriptInterface, setJavascriptEnabled,
366
+ # passwordTransformationMethod, loadUrl) are bog-standard APIs for any
367
+ # WebView-based OAuth/payment login screen β€” confirmed false-positive HIGH
368
+ # on Google Docs, Instagram, Spotify, and an unrelated Flutter hackathon app,
369
+ # none of which harvest credentials. Genuine overlay-phishing credential theft
370
+ # specifically needs to draw its fake form *over* another app, so require the
371
+ # overlay permission as corroboration rather than trusting the bare hit count.
372
+ has_perm = _OVERLAY_PERM in perms
373
+ confidence = _confidence(n, has_perm)
374
  if confidence is None:
375
  return None
376
+ evidence = hits[:5] + ([_OVERLAY_PERM] if has_perm else [])
377
  return CapabilityFinding(
378
  capability_id="credential_harvesting",
379
  name="Credential Harvesting via WebView",
 
384
  "usernames and passwords."
385
  ),
386
  confidence=confidence,
387
+ evidence=evidence[:6],
388
  mitre_id="T1417",
389
  )
390
 
 
528
 
529
  return indicators
530
 
531
+ def _extract_iocs(self, buf: bytes, network_indicators: list[NetworkIndicator]) -> IOCSet:
532
+ """Categorize already-extracted network indicators plus independently
533
+ regex emails/crypto constants/Firebase markers β€” reuses the
534
+ domain/url/ip pass already done by _extract_network_indicators."""
535
+ domains = [i.value for i in network_indicators if i.type == "domain"][:_MAX_IOCS_PER_CATEGORY]
536
+ urls = [i.value for i in network_indicators if i.type == "url"][:_MAX_IOCS_PER_CATEGORY]
537
+ ips = [i.value for i in network_indicators if i.type == "ip"][:_MAX_IOCS_PER_CATEGORY]
538
+
539
+ emails = sorted({
540
+ m.group().decode("ascii", errors="ignore")
541
+ for m in _EMAIL_RE.finditer(buf)
542
+ })[:_MAX_IOCS_PER_CATEGORY]
543
+
544
+ crypto_constants = sorted({
545
+ m.group().decode("ascii", errors="ignore").upper()
546
+ for m in _CRYPTO_RE.finditer(buf)
547
+ })[:_MAX_IOCS_PER_CATEGORY]
548
+
549
+ firebase_urls = sorted({
550
+ url for url in (i.value for i in network_indicators if i.type == "url")
551
+ if any(marker in url.lower().encode() for marker in _FIREBASE_MARKERS)
552
+ })[:_MAX_IOCS_PER_CATEGORY]
553
+
554
+ return IOCSet(
555
+ domains=domains,
556
+ urls=urls,
557
+ ips=ips,
558
+ emails=emails,
559
+ firebase_urls=firebase_urls,
560
+ crypto_constants=crypto_constants,
561
+ )
562
+
563
  def _find_suspicious_network(self, indicators: list[NetworkIndicator]) -> Optional[CapabilityFinding]:
564
  flagged = [i for i in indicators if i.context]
565
  if len(flagged) >= 2:
app/services/apk_intelligence/models.py CHANGED
@@ -49,6 +49,13 @@ class ApkAnalysisRequest(BaseModel):
49
  certificateHash: Optional[str] = Field(None, description="SHA-256 of the signing certificate (hex)")
50
  permissions: List[str] = Field(default_factory=list, description="Permissions declared in the APK manifest")
51
  localRisk: float = Field(0.0, ge=0.0, le=100.0, description="Local risk score (0–100) from the SDK")
 
 
 
 
 
 
 
52
 
53
  @field_validator("permissions", mode="before")
54
  @classmethod
 
49
  certificateHash: Optional[str] = Field(None, description="SHA-256 of the signing certificate (hex)")
50
  permissions: List[str] = Field(default_factory=list, description="Permissions declared in the APK manifest")
51
  localRisk: float = Field(0.0, ge=0.0, le=100.0, description="Local risk score (0–100) from the SDK")
52
+ installerPackageName: Optional[str] = Field(
53
+ None,
54
+ description="Package name of the app that installed this APK (e.g. com.android.vending "
55
+ "for Play Store), via PackageManager.getInstallSourceInfo/getInstallerPackageName. "
56
+ "Null for not-yet-installed files β€” Android only records install-source "
57
+ "attribution for already-installed packages.",
58
+ )
59
 
60
  @field_validator("permissions", mode="before")
61
  @classmethod
app/services/apk_intelligence/threat_explainer.py CHANGED
@@ -910,8 +910,15 @@ class ThreatExplanationEngine:
910
 
911
  re_result = r.reverse_engineering
912
 
 
 
 
 
 
 
 
913
  remote_access = self._find_capability(re_result, "remote_access")
914
- if remote_access:
915
  mid, mname = _MITRE["remote_access"]
916
  vectors.append(AttackVector(
917
  name="Remote Access / Screen Streaming",
@@ -922,7 +929,7 @@ class ThreatExplanationEngine:
922
  ))
923
 
924
  persistence = self._find_capability(re_result, "persistence")
925
- if persistence:
926
  mid, mname = _MITRE["persistence"]
927
  vectors.append(AttackVector(
928
  name="Persistence via Boot Receiver / Scheduled Tasks",
@@ -933,7 +940,7 @@ class ThreatExplanationEngine:
933
  ))
934
 
935
  suspicious_network = self._find_capability(re_result, "suspicious_network")
936
- if suspicious_network:
937
  mid, mname = _MITRE["suspicious_network"]
938
  vectors.append(AttackVector(
939
  name="Command-and-Control Communication",
@@ -944,7 +951,7 @@ class ThreatExplanationEngine:
944
  ))
945
 
946
  credential_harvesting = self._find_capability(re_result, "credential_harvesting")
947
- if credential_harvesting:
948
  mid, mname = _MITRE["credential_theft"]
949
  vectors.append(AttackVector(
950
  name="Credential Harvesting via WebView Injection",
@@ -955,7 +962,7 @@ class ThreatExplanationEngine:
955
  ))
956
 
957
  native_code_hiding = self._find_capability(re_result, "native_code_hiding")
958
- if native_code_hiding:
959
  mid, mname = _MITRE["packer"]
960
  vectors.append(AttackVector(
961
  name="Native Code Obfuscation",
 
910
 
911
  re_result = r.reverse_engineering
912
 
913
+ # Only escalate HIGH-confidence reverse-engineering findings into named
914
+ # attack-pattern vectors (and therefore the STIX/UI bundle). MEDIUM
915
+ # findings stay available on the raw scan result, but a single common
916
+ # API string (e.g. "loadurl", "jobscheduler") isn't enough corroboration
917
+ # to label an app with a MITRE attack pattern β€” this mirrors the
918
+ # HIGH-only gating already used in risk/fusion.py's _compute_confidence
919
+ # and in _classify_threat above.
920
  remote_access = self._find_capability(re_result, "remote_access")
921
+ if remote_access and remote_access.confidence == "HIGH":
922
  mid, mname = _MITRE["remote_access"]
923
  vectors.append(AttackVector(
924
  name="Remote Access / Screen Streaming",
 
929
  ))
930
 
931
  persistence = self._find_capability(re_result, "persistence")
932
+ if persistence and persistence.confidence == "HIGH":
933
  mid, mname = _MITRE["persistence"]
934
  vectors.append(AttackVector(
935
  name="Persistence via Boot Receiver / Scheduled Tasks",
 
940
  ))
941
 
942
  suspicious_network = self._find_capability(re_result, "suspicious_network")
943
+ if suspicious_network and suspicious_network.confidence == "HIGH":
944
  mid, mname = _MITRE["suspicious_network"]
945
  vectors.append(AttackVector(
946
  name="Command-and-Control Communication",
 
951
  ))
952
 
953
  credential_harvesting = self._find_capability(re_result, "credential_harvesting")
954
+ if credential_harvesting and credential_harvesting.confidence == "HIGH":
955
  mid, mname = _MITRE["credential_theft"]
956
  vectors.append(AttackVector(
957
  name="Credential Harvesting via WebView Injection",
 
962
  ))
963
 
964
  native_code_hiding = self._find_capability(re_result, "native_code_hiding")
965
+ if native_code_hiding and native_code_hiding.confidence == "HIGH":
966
  mid, mname = _MITRE["packer"]
967
  vectors.append(AttackVector(
968
  name="Native Code Obfuscation",
app/services/identity/bank_registry.py CHANGED
@@ -158,3 +158,23 @@ def find_bank_by_display_name(display_name: str) -> Optional[BankAppProfile]:
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())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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())
161
+
162
+
163
+ # ── Brand-keyword detection (coarser net than name-similarity/squatting) ───────
164
+ # Catches cases like "SBI Quick Pay Helper" that don't score high enough on
165
+ # difflib similarity against "YONO SBI" but still trade on a banking brand
166
+ # without being a recognized official banking app.
167
+ _BRAND_KEYWORDS: tuple[str, ...] = (
168
+ "sbi", "yono", "hdfc", "icici", "axis bank", "kotak",
169
+ "pnb", "bank of baroda", "canara bank", "union bank",
170
+ "indian bank", "kyc update", "kyc verification",
171
+ )
172
+
173
+
174
+ def find_brand_keyword(text: str) -> Optional[str]:
175
+ """Return the matched banking-brand keyword if `text` contains one, else None."""
176
+ normalised = text.lower()
177
+ for keyword in _BRAND_KEYWORDS:
178
+ if keyword in normalised:
179
+ return keyword
180
+ return None
app/services/identity/trusted_publisher_registry.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ identity/trusted_publisher_registry.py
3
+ β€” Registry of well-known, non-banking app publishers and their signing certificates.
4
+
5
+ Used by risk/fusion.py to apply a trust discount when an APK's package name AND
6
+ signing certificate both match a known-good publisher. This is what stops a
7
+ signed Instagram/Spotify/Google Docs build β€” which legitimately holds SMS,
8
+ camera, contacts, and self-update capabilities β€” from being scored the same
9
+ way as an unsigned/unknown APK that holds the exact same capabilities.
10
+
11
+ This is a capability allowlist by *identity*, not by *behaviour*: a package+cert
12
+ match means "this exact build is the real, signed app", which is a much
13
+ stronger signal than any static capability heuristic can produce on its own.
14
+
15
+ cert_sha256 values below were captured directly from APKMirror-distributed
16
+ builds during testing β€” replace/expand with certs sourced from Play Console
17
+ or Play Integrity API attestation in production, the same caveat that already
18
+ applies to bank_registry.BANK_REGISTRY.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass, field
24
+ from enum import Enum
25
+ from typing import Optional
26
+
27
+
28
+ class TrustTier(str, Enum):
29
+ TRUSTED = "TRUSTED" # package + certificate both match a known-good publisher
30
+ NEUTRAL = "NEUTRAL" # no registry entry β€” neither vouched for nor flagged
31
+ UNKNOWN = "UNKNOWN" # package matches a known publisher but cert does not (possible clone)
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class TrustedPublisherProfile:
36
+ """Immutable profile for a well-known, legitimate (non-banking) app publisher."""
37
+ vendor: str
38
+ package: str
39
+ cert_sha256: tuple[str, ...] = field(default_factory=tuple) # hex, lowercase; multiple = signing rotation
40
+
41
+
42
+ TRUSTED_PUBLISHERS: list[TrustedPublisherProfile] = [
43
+ TrustedPublisherProfile(
44
+ vendor="Meta",
45
+ package="com.instagram.android",
46
+ cert_sha256=("5f3e50f435583c9ae626302a71f7340044087a7e2c60adacfc254205a993e305",),
47
+ ),
48
+ TrustedPublisherProfile(
49
+ vendor="Meta",
50
+ package="com.whatsapp",
51
+ cert_sha256=("3987d043d10aefaf5a8710b3671418fe57e0e19b653c9df82558feb5ffce5d44",),
52
+ ),
53
+ TrustedPublisherProfile(
54
+ vendor="Spotify",
55
+ package="com.spotify.music",
56
+ cert_sha256=("6505b181933344f93893d586e399b94616183f04349cb572a9e81a3335e28ffd",),
57
+ ),
58
+ TrustedPublisherProfile(
59
+ vendor="Google",
60
+ package="com.google.android.apps.docs.editors.docs",
61
+ cert_sha256=("3d7a1223019aa39d9ea0e3436ab7c0896bfb4fb679f4de5fe7c23f326c8f994a",),
62
+ ),
63
+ ]
64
+
65
+ _PACKAGE_TO_PUBLISHER: dict[str, TrustedPublisherProfile] = {}
66
+ _CERT_TO_PUBLISHER: dict[str, TrustedPublisherProfile] = {}
67
+
68
+ for _profile in TRUSTED_PUBLISHERS:
69
+ _PACKAGE_TO_PUBLISHER[_profile.package.lower()] = _profile
70
+ for _cert in _profile.cert_sha256:
71
+ _CERT_TO_PUBLISHER[_cert.lower()] = _profile
72
+
73
+
74
+ def get_trust_tier(package_name: str, certificate_hash: Optional[str]) -> tuple[TrustTier, Optional[str]]:
75
+ """
76
+ Resolve the trust tier for a package + certificate pair.
77
+
78
+ Returns:
79
+ (tier, vendor) β€” vendor is the matched publisher name, or None if no match.
80
+ """
81
+ profile = _PACKAGE_TO_PUBLISHER.get(package_name.lower())
82
+ if profile is None:
83
+ return TrustTier.NEUTRAL, None
84
+
85
+ if not certificate_hash:
86
+ # Known package, but we have no cert to verify against β€” can't vouch for it
87
+ return TrustTier.NEUTRAL, None
88
+
89
+ if certificate_hash.lower() in (c.lower() for c in profile.cert_sha256):
90
+ return TrustTier.TRUSTED, profile.vendor
91
+
92
+ # Package name matches a known publisher but the certificate doesn't β€”
93
+ # this is exactly the clone-attack pattern bank_registry watches for.
94
+ return TrustTier.UNKNOWN, profile.vendor
95
+
96
+
97
+ # ── Installer-source signal ─────────────────────────────────────────────────
98
+ # A package+cert allowlist only ever covers the handful of apps someone has
99
+ # manually verified β€” it can't scale to "any unknown legitimate app". The
100
+ # installer attribution (PackageManager.getInstallSourceInfo /
101
+ # getInstallerPackageName on the Android side) is identity-agnostic: it tells
102
+ # you the app went through a store's review process without needing to know
103
+ # which app it is in advance. It's deliberately a *weaker* discount than a
104
+ # verified cert match below β€” store review isn't airtight (malware has
105
+ # reached the Play Store before), so this should never outrank a confirmed
106
+ # bank-impersonation/brand-keyword/cert-mismatch finding. See risk/fusion.py
107
+ # for how the two are layered.
108
+ KNOWN_APP_STORE_INSTALLERS: frozenset[str] = frozenset({
109
+ "com.android.vending", # Google Play Store
110
+ "com.google.android.packageinstaller", # Google Play, some OEM/AOSP paths
111
+ "com.android.packageinstaller", # AOSP package installer
112
+ "com.sec.android.app.samsungapps", # Samsung Galaxy Store
113
+ "com.amazon.venezia", # Amazon Appstore
114
+ })
115
+
116
+
117
+ def is_known_app_store(installer_package_name: Optional[str]) -> bool:
118
+ """True if `installer_package_name` is a recognized official app store.
119
+
120
+ Note: this is almost always unset for the "scan a freshly downloaded APK
121
+ before the user installs it" flow β€” Android only records install-source
122
+ attribution for already-installed packages, so this signal only becomes
123
+ available on a later re-scan or when re-checking an existing app's update.
124
+ """
125
+ return bool(installer_package_name) and installer_package_name in KNOWN_APP_STORE_INSTALLERS
app/services/risk/fusion.py CHANGED
@@ -31,10 +31,17 @@ from app.services.apk_intelligence.models import (
31
  EngineResults,
32
  ImpersonationResult,
33
  MLResult,
 
34
  ThreatIntelligenceResponse,
35
  Verdict,
36
  YaraResult,
37
  )
 
 
 
 
 
 
38
  from app.services.risk.escalation import EscalationResult, detect_escalation
39
 
40
  logger = logging.getLogger(__name__)
@@ -45,6 +52,23 @@ _WEIGHT_YARA = 0.25
45
  _WEIGHT_ANDROGUARD = 0.15
46
  _WEIGHT_IMPERSONATION = 0.10
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  # ── Confidence model constants ───────────────────────────────────────────────
49
  _CONFIDENCE_BASE = 0.5
50
  _CONFIDENCE_PER_CATEGORY = 0.08
@@ -62,14 +86,23 @@ def fuse_results(
62
  engine_results: EngineResults,
63
  local_risk: float,
64
  package_name: str,
 
 
 
65
  ) -> ThreatIntelligenceResponse:
66
  """
67
  Fuse all engine results into a final ThreatIntelligenceResponse.
68
 
69
  Args:
70
- engine_results: Aggregated outputs from all analysis engines.
71
- local_risk: Local SDK risk score (0–100).
72
- package_name: APK package name (for logging).
 
 
 
 
 
 
73
 
74
  Returns:
75
  ThreatIntelligenceResponse matching the Android SDK wire format.
@@ -78,8 +111,23 @@ def fuse_results(
78
  fused_score = 0.0
79
 
80
  # ── 1. ML contribution ────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
81
  ml = engine_results.ml
82
  ml_score = _score_ml(ml, explanations)
 
 
 
 
 
 
 
83
  fused_score += ml_score * _WEIGHT_ML
84
 
85
  # ── 2. YARA contribution ──────────────────────────────────────────────────
@@ -108,12 +156,43 @@ def fuse_results(
108
  explanations.append(f"Android SDK local risk score is high ({local_risk:.0f}/100).")
109
  fused_score = min(fused_score + local_boost, 1.0)
110
 
111
- # ── 7. Hard overrides ─────────────────────────────────────────────────────
112
- # YARA match β†’ always at least SUSPICIOUS
113
- if yara.rule_names:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  fused_score = max(fused_score, 0.45)
115
 
116
- # Confirmed impersonation β†’ always MALICIOUS
117
  if imp.is_impersonating and imp.similarity_score >= 0.90:
118
  fused_score = max(fused_score, 0.80)
119
  explanations.append(
@@ -121,7 +200,29 @@ def fuse_results(
121
  f"(target: {imp.impersonated_bank}, score: {imp.similarity_score:.0%})."
122
  )
123
 
124
- # ── 8. Verdict determination ──────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  from app.config import get_settings
126
  settings = get_settings()
127
 
@@ -138,34 +239,72 @@ def fuse_results(
138
  explanations.insert(0, f"Fused risk score: {fused_score:.3f} (verdict: {verdict.value}).")
139
 
140
  logger.info(
141
- "Risk fusion for %s β†’ fused=%.3f, verdict=%s, yara=%s, ml_prob=%.3f",
142
  package_name,
143
  fused_score,
144
  verdict.value,
 
145
  yara.rule_names,
146
  ml.malware_probability,
147
  )
148
 
149
- # ── 9. Escalation flag ────────────────────────────────────────────────────
150
  escalation = detect_escalation(engine_results)
151
 
152
- # ── 10. Numeric confidence score ──────────────────────────────────────────
153
  confidence_score = _compute_confidence(engine_results, fused_score, verdict, escalation)
154
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
  return ThreatIntelligenceResponse(
156
  verdict=verdict,
157
  malware_probability=round(fused_score, 4),
158
  yara_matches=yara.rule_names,
159
- threat_family=ml.threat_family or _infer_threat_family(engine_results),
160
  explanations=explanations,
161
  confidence_score=confidence_score,
162
  escalation=escalation.escalate,
163
  escalation_reasons=escalation.reasons,
 
164
  )
165
 
166
 
167
  # ── Per-engine scorers ────────────────────────────────────────────────────────
168
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  def _score_ml(ml: MLResult, explanations: list[str]) -> float:
170
  if ml.error:
171
  explanations.append(f"ML engine error: {ml.error}")
@@ -195,10 +334,17 @@ def _score_androguard(a: AndroguardResult, explanations: list[str]) -> float:
195
  return 0.0
196
  score = 0.0
197
  if a.is_obfuscated:
198
- score += 0.30
 
 
199
  explanations.append("APK appears to be obfuscated.")
200
  if a.dynamic_code_loading:
201
- score += 0.25
 
 
 
 
 
202
  explanations.append("Dynamic code loading detected (DexClassLoader pattern).")
203
  for act in a.suspicious_activities:
204
  score += 0.15
@@ -226,10 +372,17 @@ def _score_apkid(apkid: APKidResult, explanations: list[str]) -> float:
226
  names = ", ".join(apkid.packers)
227
  explanations.append(f"APKiD: packer(s) detected β€” {names}.")
228
  boost += 0.08
229
- if apkid.anti_analysis:
 
 
 
 
230
  techniques = ", ".join(apkid.anti_analysis[:4])
231
- explanations.append(f"APKiD: anti-analysis techniques β€” {techniques}.")
232
  boost += 0.05
 
 
 
233
  if len(apkid.obfuscators) > 1:
234
  explanations.append(f"APKiD: multiple obfuscators detected ({len(apkid.obfuscators)}).")
235
  boost += 0.02
 
31
  EngineResults,
32
  ImpersonationResult,
33
  MLResult,
34
+ ReputationInfo,
35
  ThreatIntelligenceResponse,
36
  Verdict,
37
  YaraResult,
38
  )
39
+ from app.services.identity.bank_registry import find_brand_keyword, is_official_package
40
+ from app.services.identity.trusted_publisher_registry import (
41
+ TrustTier,
42
+ get_trust_tier,
43
+ is_known_app_store,
44
+ )
45
  from app.services.risk.escalation import EscalationResult, detect_escalation
46
 
47
  logger = logging.getLogger(__name__)
 
52
  _WEIGHT_ANDROGUARD = 0.15
53
  _WEIGHT_IMPERSONATION = 0.10
54
 
55
+ # ── Identity-layer constants ─────────────────────────────────────────────────
56
+ # A verified package+cert match against a known-good publisher is a much
57
+ # stronger signal than any static capability heuristic β€” capabilities like
58
+ # SMS access, camera+location+contacts, or a self-update permission are
59
+ # *expected* in a signed build of a real app and shouldn't be scored the same
60
+ # way they would be for an unsigned/unknown APK exhibiting the same behaviour.
61
+ _TRUSTED_PUBLISHER_DISCOUNT = 0.25 # multiply fused_score by this when TRUSTED
62
+ # Weaker than the cert-match discount above: passing app-store review is real
63
+ # but not airtight identity verification (malware has reached the Play Store
64
+ # before), so this must never be allowed to outrank a confirmed
65
+ # impersonation/brand-keyword/cert-mismatch finding β€” see ordering in
66
+ # fuse_results, where hard floors are applied via max() *after* this discount.
67
+ _KNOWN_STORE_INSTALL_DISCOUNT = 0.6
68
+ _PUBLISHER_CERT_MISMATCH_FLOOR = 0.55 # known publisher package, wrong cert β†’ likely clone
69
+ _BRAND_KEYWORD_WITHOUT_MATCH_FLOOR = 0.75 # banking brand name, not an official bank app
70
+ _UNCORROBORATED_ML_CAP = 0.35 # see "1. ML contribution" below
71
+
72
  # ── Confidence model constants ───────────────────────────────────────────────
73
  _CONFIDENCE_BASE = 0.5
74
  _CONFIDENCE_PER_CATEGORY = 0.08
 
86
  engine_results: EngineResults,
87
  local_risk: float,
88
  package_name: str,
89
+ certificate_hash: Optional[str] = None,
90
+ app_name: Optional[str] = None,
91
+ installer_package_name: Optional[str] = None,
92
  ) -> ThreatIntelligenceResponse:
93
  """
94
  Fuse all engine results into a final ThreatIntelligenceResponse.
95
 
96
  Args:
97
+ engine_results: Aggregated outputs from all analysis engines.
98
+ local_risk: Local SDK risk score (0–100).
99
+ package_name: APK package name (for logging and identity checks).
100
+ certificate_hash: SHA-256 of the signing certificate, used to resolve
101
+ publisher/bank identity trust (see identity/ registries).
102
+ app_name: Human-readable app label, used for brand-keyword checks.
103
+ installer_package_name: Package name of the app that installed this APK (e.g.
104
+ com.android.vending for Play Store). Usually None for
105
+ not-yet-installed files β€” see trusted_publisher_registry.
106
 
107
  Returns:
108
  ThreatIntelligenceResponse matching the Android SDK wire format.
 
111
  fused_score = 0.0
112
 
113
  # ── 1. ML contribution ────────────────────────────────────────────────────
114
+ # Interim mitigation for a known model-calibration issue: apk_classifier.pkl
115
+ # was trained on a heavily imbalanced dataset (10,110 malware vs. 1,795 benign
116
+ # samples) and outputs 90%+ malware probability even on near-empty feature
117
+ # vectors for ordinary unsigned apps with no real risk signal β€” confirmed on
118
+ # two known-benign hackathon-project APKs with no SMS/overlay/accessibility
119
+ # permissions and no YARA matches. Until the model is retrained on a better-
120
+ # balanced dataset, an uncorroborated ML score (no other engine agrees
121
+ # there's anything to see) gets capped rather than trusted at full weight.
122
  ml = engine_results.ml
123
  ml_score = _score_ml(ml, explanations)
124
+ if not _has_corroborating_signal(engine_results) and ml_score > _UNCORROBORATED_ML_CAP:
125
+ explanations.append(
126
+ f"ML model probability ({ml_score:.0%}) is not corroborated by any other "
127
+ f"engine β€” capping its contribution in the fused score (known model "
128
+ f"calibration issue, see fusion.py)."
129
+ )
130
+ ml_score = _UNCORROBORATED_ML_CAP
131
  fused_score += ml_score * _WEIGHT_ML
132
 
133
  # ── 2. YARA contribution ──────────────────────────────────────────────────
 
156
  explanations.append(f"Android SDK local risk score is high ({local_risk:.0f}/100).")
157
  fused_score = min(fused_score + local_boost, 1.0)
158
 
159
+ # ── 7. Identity layer β€” certificate/publisher/installer trust ─────────────
160
+ # Resolved before the hard overrides below. A verified package+cert match
161
+ # means this exact build is the real, signed app; a known-app-store
162
+ # installer is weaker (review isn't airtight) but identity-agnostic β€” it
163
+ # scales to any app, not just ones manually added to the publisher registry.
164
+ trust_tier, trusted_vendor = get_trust_tier(package_name, certificate_hash)
165
+ store_installed = trust_tier != TrustTier.TRUSTED and is_known_app_store(installer_package_name)
166
+
167
+ # Apply identity-based discounts to the capability-weighted score itself β€”
168
+ # deliberately *before* the hard floors below, so neither discount can ever
169
+ # dilute a confirmed clone/impersonation/brand-keyword finding (those are
170
+ # applied via max() afterward and always win, regardless of any discount
171
+ # applied here).
172
+ if trust_tier == TrustTier.TRUSTED:
173
+ pre_discount_score = fused_score
174
+ fused_score = round(fused_score * _TRUSTED_PUBLISHER_DISCOUNT, 4)
175
+ explanations.append(
176
+ f"Certificate matches verified publisher '{trusted_vendor}' β€” "
177
+ f"applying trust discount (raw score {pre_discount_score:.3f} β†’ {fused_score:.3f})."
178
+ )
179
+ elif store_installed:
180
+ pre_discount_score = fused_score
181
+ fused_score = round(fused_score * _KNOWN_STORE_INSTALL_DISCOUNT, 4)
182
+ explanations.append(
183
+ f"Installed via a recognized app store (installer={installer_package_name}) β€” "
184
+ f"applying a moderate trust discount (raw score {pre_discount_score:.3f} β†’ {fused_score:.3f})."
185
+ )
186
+
187
+ # ── 8. Hard overrides (always win β€” applied via max() after any discount) ─
188
+ # YARA match β†’ at least SUSPICIOUS, *unless* the publisher is verified trusted
189
+ # or the app came through a known store (a signed Instagram/Spotify build, or
190
+ # any Play-Store-installed app, matching a capability-only YARA rule is
191
+ # expected; an unknown, unattributed APK doing the same is not).
192
+ if yara.rule_names and trust_tier != TrustTier.TRUSTED and not store_installed:
193
  fused_score = max(fused_score, 0.45)
194
 
195
+ # Confirmed banking-app impersonation β†’ always MALICIOUS
196
  if imp.is_impersonating and imp.similarity_score >= 0.90:
197
  fused_score = max(fused_score, 0.80)
198
  explanations.append(
 
200
  f"(target: {imp.impersonated_bank}, score: {imp.similarity_score:.0%})."
201
  )
202
 
203
+ # Package name matches a known publisher (banking or otherwise) but the
204
+ # certificate doesn't β€” classic clone/repackage pattern, independent of
205
+ # whether the impersonation engine's name/package-similarity checks fired.
206
+ if trust_tier == TrustTier.UNKNOWN:
207
+ fused_score = max(fused_score, _PUBLISHER_CERT_MISMATCH_FLOOR)
208
+ explanations.append(
209
+ f"Package name matches known publisher '{trusted_vendor}' but the signing "
210
+ f"certificate does not β€” possible clone or repackaged app."
211
+ )
212
+
213
+ # Banking-brand keyword in the package/app name without being a recognized
214
+ # official banking app β€” catches near-miss fakes that don't score high
215
+ # enough on name-similarity to trip the dedicated impersonation check.
216
+ if not imp.is_impersonating and not is_official_package(package_name):
217
+ keyword = find_brand_keyword(f"{package_name} {app_name or ''}")
218
+ if keyword:
219
+ fused_score = max(fused_score, _BRAND_KEYWORD_WITHOUT_MATCH_FLOOR)
220
+ explanations.append(
221
+ f"App name/package references banking brand '{keyword}' but is not a "
222
+ f"recognized official banking app β€” high risk of being a fake banking clone."
223
+ )
224
+
225
+ # ── 9. Verdict determination ──────────────────────────────────────────────
226
  from app.config import get_settings
227
  settings = get_settings()
228
 
 
239
  explanations.insert(0, f"Fused risk score: {fused_score:.3f} (verdict: {verdict.value}).")
240
 
241
  logger.info(
242
+ "Risk fusion for %s β†’ fused=%.3f, verdict=%s, trust=%s, yara=%s, ml_prob=%.3f",
243
  package_name,
244
  fused_score,
245
  verdict.value,
246
+ trust_tier.value,
247
  yara.rule_names,
248
  ml.malware_probability,
249
  )
250
 
251
+ # ── 10. Escalation flag ───────────────────────────────────────────────────
252
  escalation = detect_escalation(engine_results)
253
 
254
+ # ── 11. Numeric confidence score ──────────────────────────────────────────
255
  confidence_score = _compute_confidence(engine_results, fused_score, verdict, escalation)
256
 
257
+ # ── 12. Reputation summary (wire field) ───────────────────────────────────
258
+ reputation = ReputationInfo(
259
+ known_samples=0,
260
+ certificate_trust=(
261
+ "TRUSTED" if trust_tier == TrustTier.TRUSTED
262
+ else "SUSPICIOUS" if trust_tier == TrustTier.UNKNOWN
263
+ else "NEUTRAL"
264
+ ),
265
+ )
266
+
267
+ # A CLEAN verdict shouldn't carry a threat family label β€” once the trust
268
+ # discount (or any other override) has cleared the app, naming a "family"
269
+ # from suppressed static findings would contradict the verdict shown.
270
+ threat_family = (
271
+ None if verdict == Verdict.CLEAN
272
+ else ml.threat_family or _infer_threat_family(engine_results)
273
+ )
274
+
275
  return ThreatIntelligenceResponse(
276
  verdict=verdict,
277
  malware_probability=round(fused_score, 4),
278
  yara_matches=yara.rule_names,
279
+ threat_family=threat_family,
280
  explanations=explanations,
281
  confidence_score=confidence_score,
282
  escalation=escalation.escalate,
283
  escalation_reasons=escalation.reasons,
284
+ reputation=reputation,
285
  )
286
 
287
 
288
  # ── Per-engine scorers ────────────────────────────────────────────────────────
289
 
290
+ def _has_corroborating_signal(r: EngineResults) -> bool:
291
+ """True if at least one *reliable* engine independently flags risk β€”
292
+ manifest-confirmed security features, an actual YARA rule match, confirmed
293
+ impersonation, a real evasion suite, or a high-confidence capability finding.
294
+ Used to gate the ML cap above: these are all signals derived from real
295
+ parsed manifest data or multi-signature corroboration, not single bare
296
+ string hits, so they're trustworthy enough to vouch for an ML score."""
297
+ sf = r.androguard.security_features
298
+ return bool(
299
+ r.yara.rule_names
300
+ or r.impersonation.is_impersonating
301
+ or (sf and (sf.sms_access or sf.overlay or sf.accessibility_service))
302
+ or r.apkid.packers
303
+ or len(r.apkid.anti_analysis) > 1
304
+ or any(c.confidence == "HIGH" for c in r.reverse_engineering.capabilities)
305
+ )
306
+
307
+
308
  def _score_ml(ml: MLResult, explanations: list[str]) -> float:
309
  if ml.error:
310
  explanations.append(f"ML engine error: {ml.error}")
 
334
  return 0.0
335
  score = 0.0
336
  if a.is_obfuscated:
337
+ # R8/ProGuard minification is the default for virtually all production
338
+ # release builds β€” name mangling alone isn't a meaningful malice signal.
339
+ score += 0.10
340
  explanations.append("APK appears to be obfuscated.")
341
  if a.dynamic_code_loading:
342
+ # DexClassLoader usage on its own is a feature-delivery/plugin pattern used
343
+ # by many large legitimate apps (Play Feature Delivery, A/B experiments) β€”
344
+ # not inherently malicious. It's a mild signal here; the stronger escalation
345
+ # path is YARA's TROJAN_DROPPER rule, which requires it paired with an
346
+ # actual silent-install permission before treating it as a dropper chain.
347
+ score += 0.10
348
  explanations.append("Dynamic code loading detected (DexClassLoader pattern).")
349
  for act in a.suspicious_activities:
350
  score += 0.15
 
372
  names = ", ".join(apkid.packers)
373
  explanations.append(f"APKiD: packer(s) detected β€” {names}.")
374
  boost += 0.08
375
+ if len(apkid.anti_analysis) > 1:
376
+ # Multiple distinct evasion categories together (anti-emulator + anti-debug +
377
+ # anti-frida etc.) is the real evasion-suite signal. A single category alone
378
+ # (e.g. one debugger-state check used for crash-reporting telemetry) is common
379
+ # in legitimate hardened release builds and gets a much smaller bump below.
380
  techniques = ", ".join(apkid.anti_analysis[:4])
381
+ explanations.append(f"APKiD: anti-analysis evasion suite detected β€” {techniques}.")
382
  boost += 0.05
383
+ elif apkid.anti_analysis:
384
+ explanations.append(f"APKiD: anti-analysis technique detected β€” {apkid.anti_analysis[0]}.")
385
+ boost += 0.01
386
  if len(apkid.obfuscators) > 1:
387
  explanations.append(f"APKiD: multiple obfuscators detected ({len(apkid.obfuscators)}).")
388
  boost += 0.02
rules/android_banker.yar CHANGED
@@ -18,16 +18,20 @@ rule BANKING_TROJAN
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
@@ -38,9 +42,7 @@ rule BANKING_TROJAN
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
 
@@ -53,11 +55,17 @@ rule BANKING_TROJAN
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*)
 
18
 
19
  strings:
20
  // ── Known banking trojan family identifiers ──────────────────────────
21
+ // These strings appear in DEX string pools or embedded asset paths.
22
+ // Bounded with a non-letter lookalike on both sides β€” plain substring
23
+ // matching false-positived on unrelated identifiers that merely contain
24
+ // the token mid-word, e.g. "eventbot" inside Instagram/Spotify's
25
+ // "...DirectEventBottomSheetFragment..." UI class names.
26
+ $fam_bankbot = /[^a-zA-Z]bankbot[^a-zA-Z]/ ascii nocase
27
+ $fam_cerberus = /[^a-zA-Z]cerberus[^a-zA-Z]/ ascii nocase
28
+ $fam_flubot = /[^a-zA-Z]flubot[^a-zA-Z]/ ascii nocase
29
+ $fam_sharkbot = /[^a-zA-Z]sharkbot[^a-zA-Z]/ ascii nocase
30
+ $fam_eventbot = /[^a-zA-Z]eventbot[^a-zA-Z]/ ascii nocase
31
+ $fam_anubis = /[^a-zA-Z]anubis_c2[^a-zA-Z]/ ascii nocase
32
+ $fam_godfather = /[^a-zA-Z]godfather[^a-zA-Z]/ ascii nocase
33
+ $fam_octo = /[^a-zA-Z]octo\.panel[^a-zA-Z]/ ascii nocase
34
+ $fam_joker = /[^a-zA-Z]joker\.payload[^a-zA-Z]/ ascii nocase
35
 
36
  // ── Credential harvesting field names in layout XML or DEX ───────────
37
  $cred_card = "cardNumber" ascii
 
42
  $cred_pass_edit = "editPassword" ascii
43
  $cred_pass_hint = "passwordHint" ascii
44
 
45
+ // ── Overlay / screen-draw behavior ────────────────────────────────
 
 
46
  $overlay_type = "TYPE_APPLICATION_OVERLAY" ascii
47
  $overlay_draw = "drawOverApps" ascii
48
 
 
55
  // Definitive family string hit
56
  1 of ($fam_*)
57
  or
58
+ // Credential field cluster β€” genuine banking trojans harvest many fields in one
59
+ // fake-login form. 3 distinct field names was satisfiable by an app that simply
60
+ // has a checkout form (cardNumber, expiryDate) *and*, completely unrelated, a
61
+ // separate login form (etPassword) elsewhere β€” not necessarily one coordinated
62
+ // overlay. Require 4 to make incidental cross-feature overlap far less likely.
63
+ 4 of ($cred_*)
64
  or
65
+ // Actual overlay-window creation (not just the bare SYSTEM_ALERT_WINDOW
66
+ // permission, which any chat-head/floating-widget/PiP app legitimately
67
+ // declares) paired with a credential field
68
+ (1 of ($overlay_type, $overlay_draw) and 1 of ($cred_*))
69
  or
70
  // Explicit fake-login string
71
  1 of ($fake_login*)
rules/otp_stealer.yar CHANGED
@@ -17,11 +17,17 @@ rule OTP_STEALER
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
@@ -39,15 +45,20 @@ rule OTP_STEALER
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
  }
 
17
  date = "2024-01-01"
18
 
19
  strings:
20
+ // Manifest-DECLARED permissions only β€” <uses-permission> entries are stored
21
+ // as UTF-16 LE in the binary AXML manifest, so wide-only reliably means "the
22
+ // app's manifest actually requests this." Deliberately *not* ascii: an ascii
23
+ // hit for "READ_SMS" is just as likely a bundled cross-platform permission
24
+ // library (e.g. Flutter's permission_handler ships every Android permission
25
+ // name as a constant) referencing the string without the app ever declaring
26
+ // or using it β€” confirmed false-positive on a real hackathon Flutter app
27
+ // with zero SMS permissions in its actual manifest.
28
+ $p_read = "READ_SMS" wide
29
+ $p_receive = "RECEIVE_SMS" wide
30
+ $p_send = "SEND_SMS" wide
31
 
32
  // Android SMS API class and content-provider URI (DEX string pool, ASCII)
33
  $api_class = "SmsMessage" ascii
 
45
  $kw_otp4 = "verificationCode" ascii
46
 
47
  condition:
48
+ // Two or more manifest-declared SMS permissions
49
  (2 of ($p_*))
50
  or
51
+ // SMS API usage combined with an actual manifest-declared permission
52
  (1 of ($api_*) and 1 of ($p_*))
53
  or
54
+ // OTP keyword combined with actual SMS API usage, not just a declared
55
+ // permission β€” a bare "otp" substring plus a single SMS permission is
56
+ // satisfied by any ordinary app implementing standard OTP/2FA login.
57
+ (1 of ($kw_*) and 1 of ($api_*, $smali_sms))
58
  or
59
+ // Pure DEX: two SMS API strings β€” sufficient without permission match.
60
+ // This is the fallback for cases where the manifest's permission
61
+ // declaration is itself obfuscated/stripped but the SMS-reading code
62
+ // is genuinely present.
63
  (2 of ($api_*, $smali_sms))
64
  }
rules/rat.yar CHANGED
@@ -24,7 +24,6 @@ rule REMOTE_ACCESS_TROJAN
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
@@ -32,9 +31,6 @@ rule REMOTE_ACCESS_TROJAN
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
 
@@ -47,12 +43,21 @@ rule REMOTE_ACCESS_TROJAN
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
  }
 
24
  $acc_service = "AccessibilityService" ascii
25
  $acc_event = "onAccessibilityEvent" ascii
26
  $acc_action = "performGlobalAction" ascii
 
27
 
28
  // ── Screen capture / remote viewing ─────────────────────────────────
29
  $screen_proj = "MediaProjection" ascii
 
31
  $screen_cap = "screencap" ascii nocase
32
 
33
  // ── Remote shell / command execution ────────────────────────────────
 
 
 
34
  $cmd_shell = "/system/bin/sh" ascii
35
  $cmd_builder = "ProcessBuilder" ascii
36
 
 
43
  // BIND_ACCESSIBILITY_SERVICE permission (wide = binary AXML, ascii = DEX string pool)
44
  $acc_perm
45
  or
46
+ // Accessibility callback method + service class β€” rules out compat-lib stub matches.
47
+ // $acc_node is deliberately excluded here: AccessibilityNodeInfo is referenced by
48
+ // the AndroidX/support-lib accessibility compliance code bundled in almost every app.
49
+ ($acc_event and 1 of ($acc_service, $acc_action))
50
  or
51
+ // Screen capture combined with actual accessibility *automation* (event callback or
52
+ // synthesized global action) β€” not just service/node presence, which legitimate
53
+ // screen-cast/Cast-receiver code pulls in alongside MediaProjection/createVirtualDisplay.
54
+ (1 of ($screen_*) and 1 of ($acc_event, $acc_action))
55
  or
56
+ // Remote shell execution β€” explicit shell binary path, not just generic
57
+ // Runtime/getRuntime/ProcessBuilder references used by many legitimate
58
+ // native-lib loaders and crash reporters β€” paired with a C2 indicator
59
+ ($cmd_shell and 1 of ($c2_*))
60
+ or
61
+ // Shell path built and executed via ProcessBuilder
62
+ ($cmd_shell and $cmd_builder)
63
  }
rules/spyware_collector.yar CHANGED
@@ -29,21 +29,34 @@ rule SPYWARE_COLLECTOR
29
  $call_log = "READ_CALL_LOG" ascii wide
30
  $outgoing = "PROCESS_OUTGOING_CALLS" ascii wide
31
  $calllog_api = "Landroid/provider/CallLog" ascii
 
32
 
33
  // ── Audio / camera recording ─────────────────────────────────────────────
34
- $audio = "RECORD_AUDIO" ascii wide
35
- $cam = "android.permission.CAMERA" ascii wide
 
 
36
 
37
  // ── Persistence ───────────────────────────────────────────────────────────
38
  $boot = "RECEIVE_BOOT_COMPLETED" ascii wide
39
 
40
  condition:
41
- // Location + (contacts / call log / call interception) + (audio or camera)
42
- (1 of ($loc_fine, $loc_coarse) and 1 of ($contacts, $call_log, $outgoing) and 1 of ($audio, $cam))
 
 
 
 
 
 
43
  or
44
- // Contact/call-log harvesting + microphone recording + boot persistence
45
- (1 of ($contacts, $call_log, $outgoing) and $audio and $boot)
 
46
  or
47
- // Location-manager API paired with call-log provider access in DEX
48
- ($loc_api and $calllog_api)
 
 
 
49
  }
 
29
  $call_log = "READ_CALL_LOG" ascii wide
30
  $outgoing = "PROCESS_OUTGOING_CALLS" ascii wide
31
  $calllog_api = "Landroid/provider/CallLog" ascii
32
+ $contacts_api = "Landroid/provider/ContactsContract;" ascii
33
 
34
  // ── Audio / camera recording ─────────────────────────────────────────────
35
+ $audio = "RECORD_AUDIO" ascii wide
36
+ $cam = "android.permission.CAMERA" ascii wide
37
+ $audio_api = "MediaRecorder" ascii
38
+ $cam_api = "CameraManager" ascii
39
 
40
  // ── Persistence ───────────────────────────────────────────────────────────
41
  $boot = "RECEIVE_BOOT_COMPLETED" ascii wide
42
 
43
  condition:
44
+ // Location + contacts/call-log + audio/camera *API usage*, not just the bare
45
+ // permission declarations β€” large legitimate apps (Docs: Drive/voice-typing/
46
+ // camera-scan; Instagram: geotagging/stories) hold all of these permissions for
47
+ // unrelated single-purpose features without ever invoking the other domains'
48
+ // APIs in combination. Actual class-level usage across all three domains
49
+ // together is a much stronger spyware signal.
50
+ ($loc_api and 1 of ($calllog_api, $contacts_api) and 1 of ($audio_api, $cam_api)
51
+ and 1 of ($loc_fine, $loc_coarse) and 1 of ($contacts, $call_log, $outgoing) and 1 of ($audio, $cam))
52
  or
53
+ // Contact/call-log API usage + microphone recording API + boot persistence
54
+ (1 of ($calllog_api, $contacts_api) and $audio_api and $boot
55
+ and 1 of ($contacts, $call_log, $outgoing) and $audio)
56
  or
57
+ // Location-manager API paired with call-log provider access in DEX,
58
+ // corroborated by the matching dangerous permissions β€” the bare class-name
59
+ // strings alone are present in any app bundling Play Services/GMS, even
60
+ // when the location/call-log capability is never invoked.
61
+ ($loc_api and $calllog_api and 1 of ($loc_fine, $loc_coarse) and 1 of ($contacts, $call_log))
62
  }
rules/trojan_dropper.yar CHANGED
@@ -34,16 +34,23 @@ rule TROJAN_DROPPER
34
  $write_secure = "WRITE_SECURE_SETTINGS" ascii wide
35
 
36
  // ── Dynamic code loading β€” fetches/executes secondary payloads ─────────
37
- $dex_loader = "DexClassLoader" ascii
38
- $pkg_installer = "Landroid/content/pm/PackageInstaller;" ascii
39
 
40
  condition:
41
- // Boot persistence + any silent-install or device-takeover permission
42
- ($boot and 1 of ($req_install, $install_pkgs, $del_pkgs, $device_admin, $force_lock, $write_secure))
 
43
  or
44
- // Two or more silent package-management / takeover permissions on their own
 
 
45
  (2 of ($req_install, $install_pkgs, $del_pkgs, $device_admin, $force_lock, $write_secure))
46
  or
47
- // Silent-install permission paired with dynamic code loading
48
- (1 of ($req_install, $install_pkgs, $del_pkgs) and 1 of ($dex_loader, $pkg_installer))
 
 
 
 
 
49
  }
 
34
  $write_secure = "WRITE_SECURE_SETTINGS" ascii wide
35
 
36
  // ── Dynamic code loading β€” fetches/executes secondary payloads ─────────
37
+ $dex_loader = "DexClassLoader" ascii
 
38
 
39
  condition:
40
+ // Boot persistence + device-admin/lockscreen takeover β€” rarely legitimate
41
+ // together, unlike a bare silent-install permission (see below)
42
+ ($boot and 1 of ($device_admin, $force_lock, $write_secure))
43
  or
44
+ // Two or more silent package-management / takeover permissions together β€”
45
+ // a single one (e.g. REQUEST_INSTALL_PACKAGES for a self-update flow) is
46
+ // common in legitimate apps and isn't enough on its own
47
  (2 of ($req_install, $install_pkgs, $del_pkgs, $device_admin, $force_lock, $write_secure))
48
  or
49
+ // Silent-install permission paired with actual dynamic code loading β€” the
50
+ // dangerous combo a ghostpush-style dropper needs to fetch and run a payload.
51
+ // PackageInstaller class references alone were dropped from this branch: that's
52
+ // the standard Android self-update/in-app-update API surface, present in any
53
+ // legitimate app with an update flow (e.g. Spotify, which has REQUEST_INSTALL_PACKAGES
54
+ // + a PackageInstaller reference but no dynamic code loading at all).
55
+ (1 of ($req_install, $install_pkgs, $del_pkgs) and $dex_loader)
56
  }