- scanner.py: replace heuristic binary manifest parser with aapt/aapt2 (fixes #2, #7) - main.py: add ZIP magic bytes validation on APK upload (fixes #8) - main.py: sanitize screenshot filename with os.path.basename + resolve() check (fixes #6) - main.py: add threading.Lock + TTL to in-memory cache (fixes #4, #9) - SettingsManager.kt: default port 9800 -> 8080 to match config.yaml (fixes #3) - main.py: parse_manifest_bytes for in-memory APK validation on upload
282 lines
9.0 KiB
Python
282 lines
9.0 KiB
Python
import os
|
|
import re
|
|
import subprocess
|
|
import zipfile
|
|
import xml.etree.ElementTree as ET
|
|
from typing import Dict, List, Optional
|
|
from pathlib import Path
|
|
|
|
|
|
def parse_manifest(apk_path: str) -> Dict:
|
|
"""Extract metadata from an APK by reading AndroidManifest.xml directly."""
|
|
metadata = {
|
|
"package_name": None,
|
|
"version_name": None,
|
|
"version_code": None,
|
|
"label": None,
|
|
"icon": None,
|
|
"min_sdk": None,
|
|
"target_sdk": None,
|
|
"permissions": [],
|
|
}
|
|
|
|
try:
|
|
with zipfile.ZipFile(apk_path) as z:
|
|
# Check for compiled manifest (binary XML)
|
|
if "AndroidManifest.xml" in z.namelist():
|
|
# Binary XML - use aapt for reliable parsing
|
|
metadata = _parse_binary_manifest(apk_path, metadata)
|
|
else:
|
|
# No manifest found
|
|
return metadata
|
|
|
|
# Extract package name from filename if not found in manifest
|
|
if not metadata["package_name"]:
|
|
apk_name = Path(apk_path).stem
|
|
metadata["package_name"] = apk_name
|
|
|
|
# Look for icon resource (fallback if aapt didn't find one)
|
|
if not metadata.get("icon"):
|
|
icon_res = _find_icon_resource(apk_path)
|
|
if icon_res:
|
|
metadata["icon"] = icon_res
|
|
|
|
except Exception as e:
|
|
print(f"Error parsing {apk_path}: {e}")
|
|
|
|
return metadata
|
|
|
|
|
|
def parse_manifest_bytes(data: bytes) -> Dict:
|
|
"""Extract metadata from APK bytes (for in-memory validation)."""
|
|
metadata = {
|
|
"package_name": None,
|
|
"version_name": None,
|
|
"version_code": None,
|
|
"label": None,
|
|
"icon": None,
|
|
"min_sdk": None,
|
|
"target_sdk": None,
|
|
"permissions": [],
|
|
}
|
|
|
|
try:
|
|
import tempfile
|
|
with tempfile.NamedTemporaryFile(suffix=".apk", delete=False) as tmp:
|
|
tmp.write(data)
|
|
tmp_path = tmp.name
|
|
try:
|
|
with zipfile.ZipFile(tmp_path) as z:
|
|
if "AndroidManifest.xml" in z.namelist():
|
|
metadata = _parse_binary_manifest(tmp_path, metadata)
|
|
finally:
|
|
os.unlink(tmp_path)
|
|
except Exception as e:
|
|
print(f"Error parsing APK bytes: {e}")
|
|
|
|
return metadata
|
|
|
|
|
|
def _parse_binary_manifest(data_or_path, metadata: Dict) -> Dict:
|
|
"""Parse binary XML manifest using aapt for reliable extraction.
|
|
|
|
Accepts either a file path (str) or raw APK bytes.
|
|
"""
|
|
import tempfile
|
|
|
|
if isinstance(data_or_path, str):
|
|
# Already a file path, use directly with aapt
|
|
result = _parse_with_aapt(data_or_path)
|
|
if result:
|
|
metadata.update(result)
|
|
return metadata
|
|
|
|
# Raw bytes - write temp APK for aapt
|
|
try:
|
|
with tempfile.NamedTemporaryFile(suffix=".apk", delete=False) as tmp:
|
|
tmp.write(data_or_path)
|
|
tmp_path = tmp.name
|
|
|
|
result = _parse_with_aapt(tmp_path)
|
|
os.unlink(tmp_path)
|
|
|
|
if result:
|
|
metadata.update(result)
|
|
except Exception as e:
|
|
print(f"Error parsing binary manifest with aapt: {e}")
|
|
# Fallback: basic string extraction if aapt fails
|
|
_parse_fallback(data_or_path, metadata)
|
|
|
|
return metadata
|
|
|
|
|
|
def _parse_with_aapt(apk_path: str) -> Optional[Dict]:
|
|
"""Use aapt or aapt2 to extract manifest info reliably."""
|
|
for tool in ["aapt2", "aapt"]:
|
|
try:
|
|
result = subprocess.run(
|
|
[tool, "dump", "badging", apk_path],
|
|
capture_output=True, text=True, timeout=30
|
|
)
|
|
if result.returncode == 0:
|
|
return _parse_aapt_output(result.stdout)
|
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
continue
|
|
return None
|
|
|
|
|
|
def _parse_aapt_output(output: str) -> Dict:
|
|
"""Parse aapt dump badging output."""
|
|
result = {
|
|
"package_name": None,
|
|
"version_name": None,
|
|
"version_code": None,
|
|
"label": None,
|
|
"icon": None,
|
|
"min_sdk": None,
|
|
"target_sdk": None,
|
|
"permissions": [],
|
|
}
|
|
|
|
for line in output.splitlines():
|
|
# Package: name='com.example.app'
|
|
m = re.search(r"package: name='([^']+)'", line)
|
|
if m:
|
|
result["package_name"] = m.group(1)
|
|
# versionName='1.0.0'
|
|
m = re.search(r"versionName='([^']+)'", line)
|
|
if m:
|
|
result["version_name"] = m.group(1)
|
|
# versionCode='1'
|
|
m = re.search(r"versionCode='([^']+)'", line)
|
|
if m:
|
|
result["version_code"] = m.group(1)
|
|
# application-label:'My App'
|
|
m = re.search(r"application-label:'([^']*)'", line)
|
|
if m:
|
|
result["label"] = m.group(1)
|
|
# application-icon-128:'res/path'
|
|
m = re.search(r"application-icon-\d+:'([^']+)'", line)
|
|
if m:
|
|
result["icon"] = m.group(1)
|
|
# sdkVersion:'21' / minSdkVersion:'21' / targetSdkVersion:'33'
|
|
m = re.search(r"minSdkVersion='([^']+)'", line)
|
|
if m:
|
|
result["min_sdk"] = m.group(1)
|
|
m = re.search(r"targetSdkVersion='([^']+)'", line)
|
|
if m:
|
|
result["target_sdk"] = m.group(1)
|
|
# uses-permission: name='android.permission.INTERNET'
|
|
m = re.search(r"uses-permission: name='([^']+)'", line)
|
|
if m:
|
|
result["permissions"].append(m.group(1))
|
|
|
|
return result
|
|
|
|
|
|
def _parse_fallback(data: bytes, metadata: Dict) -> None:
|
|
"""Fallback string-based extraction when aapt is unavailable."""
|
|
try:
|
|
if len(data) < 20:
|
|
return
|
|
|
|
if data[0:4] != b'\x03\x00\x08\x00' and data[0:4] != b'\x00\x08\x00\x03':
|
|
return
|
|
|
|
strings = _extract_strings_fallback(data)
|
|
|
|
for s in strings:
|
|
if s and "." in s and not s.startswith("android"):
|
|
if re.match(r'^[a-zA-Z][a-zA-Z0-9_.]*$', s) and len(s.split('.')) >= 2:
|
|
if metadata["package_name"] is None:
|
|
metadata["package_name"] = s
|
|
break
|
|
|
|
for s in strings:
|
|
if s and re.match(r'^\d+\.\d+', s):
|
|
if metadata["version_name"] is None:
|
|
metadata["version_name"] = s
|
|
break
|
|
|
|
except Exception as e:
|
|
print(f"Error in fallback manifest parsing: {e}")
|
|
|
|
|
|
def _extract_strings_fallback(data: bytes) -> List[str]:
|
|
"""Extract printable strings >= 4 chars from binary data."""
|
|
strings = []
|
|
current = []
|
|
for byte in data:
|
|
if 32 <= byte < 127:
|
|
current.append(chr(byte))
|
|
else:
|
|
if len(current) >= 4:
|
|
strings.append(''.join(current))
|
|
current = []
|
|
if len(current) >= 4:
|
|
strings.append(''.join(current))
|
|
return strings
|
|
|
|
|
|
def _find_icon_resource(apk_path: str) -> Optional[str]:
|
|
"""Find icon resource path in APK."""
|
|
try:
|
|
with zipfile.ZipFile(apk_path) as z:
|
|
names = z.namelist()
|
|
# Look for icon in res/drawable-* folders
|
|
for name in names:
|
|
if "ic_launcher" in name and name.endswith(".png"):
|
|
return name
|
|
if "icon" in name and name.endswith(".png"):
|
|
return name
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def scan_repository(repo_path: str) -> List[Dict]:
|
|
"""Scan repository directory for APKs and return metadata."""
|
|
apps = []
|
|
repo_dir = Path(repo_path)
|
|
|
|
if not repo_dir.exists():
|
|
return apps
|
|
|
|
for apk_file in sorted(repo_dir.glob("*.apk")):
|
|
metadata = parse_manifest(str(apk_file))
|
|
|
|
if not metadata["package_name"]:
|
|
continue
|
|
|
|
app = {
|
|
"id": apk_file.stem,
|
|
"package_name": metadata["package_name"],
|
|
"version_name": str(metadata["version_name"]) if metadata["version_name"] else "unknown",
|
|
"version_code": str(metadata["version_code"]) if metadata["version_code"] else "0",
|
|
"name": metadata["label"] or apk_file.stem,
|
|
"description": "",
|
|
"icon": metadata["icon"],
|
|
"size": apk_file.stat().st_size,
|
|
"file_path": str(apk_file),
|
|
"min_sdk": str(metadata["min_sdk"]) if metadata["min_sdk"] else None,
|
|
"target_sdk": str(metadata["target_sdk"]) if metadata["target_sdk"] else None,
|
|
"permissions": metadata["permissions"],
|
|
}
|
|
|
|
# Check for README or info file alongside APK
|
|
info_file = repo_dir / f"{apk_file.stem}.json"
|
|
if info_file.exists():
|
|
import json
|
|
with open(info_file) as f:
|
|
info = json.load(f)
|
|
for key in ("name", "description", "icon", "package_name",
|
|
"version_name", "version_code", "min_sdk",
|
|
"target_sdk", "screenshots"):
|
|
if key in info:
|
|
app[key] = str(info[key]) if key in ("version_name", "version_code", "min_sdk", "target_sdk") else info[key]
|
|
if "permissions" in info:
|
|
app["permissions"] = info["permissions"]
|
|
|
|
apps.append(app)
|
|
|
|
return apps |