Files
autarch/core/android_forensics.py

872 lines
39 KiB
Python
Raw Normal View History

"""
Autarch Android Forensics forensic acquisition and IOC scanning for Android devices.
by: SsSnake
"""
import json
import os
import re
from datetime import datetime
from pathlib import Path
_manager = None
def get_android_forensics_manager():
global _manager
if _manager is None:
_manager = AutarchAndroidManager()
return _manager
class AutarchAndroidManager:
"""Forensic acquisition and IOC scanning for Android devices."""
IOC_DATA_DIR = Path(__file__).parent.parent / 'data' / 'ioc'
ACQ_DATA_DIR = Path(__file__).parent.parent / 'data' / 'android_forensics'
def __init__(self):
self._ioc_db = None # lazy-loaded
# ── Helpers ─────────────────────────────────────────────────────
def _hw(self):
from core.hardware import get_hardware_manager
return get_hardware_manager()
def _adb(self, serial, cmd, timeout=60):
"""Run an ADB shell command, return stdout string."""
result = self._hw().adb_shell_raw(serial, cmd, timeout=timeout)
if isinstance(result, dict):
return result.get('output', '') or ''
return str(result or '')
def _acq_dir(self, serial, acq_id):
d = self.ACQ_DATA_DIR / serial / acq_id
d.mkdir(parents=True, exist_ok=True)
return d
def _new_acq_id(self):
return datetime.now().strftime('%Y%m%d_%H%M%S')
# ── IOC Database ────────────────────────────────────────────────
def load_ioc_database(self, force=False):
"""Lazy-load and merge all IOC data from data/ioc/. Returns merged dict."""
if self._ioc_db is not None and not force:
return self._ioc_db
db = {
'packages': {}, # pkg_name -> {name, source, severity}
'certs': {}, # sha1/sha256 -> {name, source}
'domains': {}, # domain -> {name, source}
'ips': set(),
'file_paths': {}, # path -> {name, source}
'processes': {}, # proc_name -> {name, source}
'hashes': {}, # sha256 -> {name, source}
}
# 1. Stalkerware ioc.yaml + watchware.yaml
for fname in ('ioc.yaml', 'watchware.yaml'):
fpath = self.IOC_DATA_DIR / 'stalkerware' / fname
if fpath.exists():
self._parse_stalkerware_yaml(fpath, db)
# 2. Amnesty investigations
amnesty_dir = self.IOC_DATA_DIR / 'spyware' / 'amnesty'
if amnesty_dir.exists():
for campaign_dir in sorted(amnesty_dir.iterdir()):
if campaign_dir.is_dir():
self._parse_investigation(campaign_dir, db)
# 3. MVT indicators
mvt_dir = self.IOC_DATA_DIR / 'spyware' / 'mvt'
if mvt_dir.exists():
for campaign_dir in sorted(mvt_dir.iterdir()):
if campaign_dir.is_dir():
self._parse_investigation(campaign_dir, db)
# 4. Citizen Lab campaigns
cl_dir = self.IOC_DATA_DIR / 'spyware' / 'citizen_lab'
if cl_dir.exists():
for campaign_dir in sorted(cl_dir.iterdir()):
if campaign_dir.is_dir():
self._parse_investigation(campaign_dir, db)
# 5. iMazing indicators (recursive)
imazing_dir = self.IOC_DATA_DIR / 'spyware' / 'imazing'
if imazing_dir.exists():
for item in imazing_dir.rglob('*'):
if item.is_dir():
self._parse_investigation(item, db)
# 6. Existing stalkerware_signatures.json
sigs_path = Path(__file__).parent.parent / 'data' / 'stalkerware_signatures.json'
if sigs_path.exists():
self._parse_sigs_json(sigs_path, db)
# Convert ips set to list for JSON serialisation
db['ips'] = list(db['ips'])
self._ioc_db = db
self._save_index(db)
return db
def _parse_stalkerware_yaml(self, path, db):
"""Parse stalkerware/watchware ioc.yaml into db."""
try:
import yaml
except ImportError:
return
try:
with open(path, encoding='utf-8', errors='ignore') as f:
data = yaml.safe_load(f)
if not isinstance(data, dict):
return
for vendor, info in data.items():
if not isinstance(info, dict):
continue
source = f'stalkerware/{path.name}:{vendor}'
for pkg in info.get('packages', []) or []:
if pkg:
db['packages'][pkg] = {'name': vendor, 'source': source, 'severity': 'high'}
for cert in info.get('certificates', []) or []:
if cert:
db['certs'][cert.lower()] = {'name': vendor, 'source': source}
for domain in (info.get('domains', []) or []) + (info.get('c2', []) or []) + (info.get('website', []) or []):
if domain:
db['domains'][domain.lower()] = {'name': vendor, 'source': source}
except Exception:
pass
def _parse_investigation(self, campaign_dir, db):
"""Parse a campaign directory for IOC text files and STIX2."""
name = campaign_dir.name
source = f'spyware/{campaign_dir.parent.name}/{name}'
for f in campaign_dir.iterdir():
if not f.is_file():
continue
fname = f.name.lower()
if fname in ('domains.txt', 'domains'):
self._load_txt_lines(f, db['domains'], name, source)
elif fname in ('package_names.txt', 'package_names'):
self._load_txt_lines(f, db['packages'], name, source, extra={'severity': 'critical'})
elif fname in ('processes.txt', 'processes'):
self._load_txt_lines(f, db['processes'], name, source)
elif fname in ('file_paths.txt', 'file_paths'):
self._load_txt_lines(f, db['file_paths'], name, source)
elif fname in ('ips.txt', 'ip_addresses.txt', 'ip-addresses.txt'):
self._load_txt_set(f, db['ips'])
elif fname.endswith('.stix2') or fname.endswith('_stix.json'):
self._parse_stix(f, db, name, source)
elif fname in ('sha256.txt', 'sha256.csv'):
self._load_hashes(f, db['hashes'], name, source)
elif fname == 'package_cert_hashes.txt':
self._load_txt_lines(f, db['certs'], name, source)
elif fname.endswith('.json') and 'ioc' in fname:
self._parse_ioc_json(f, db, name, source)
elif fname.endswith('.csv') and 'ioc' in fname:
self._parse_ioc_csv(f, db, name, source)
def _load_txt_lines(self, path, target_dict, name, source, extra=None):
"""Load one-indicator-per-line text file into a dict."""
try:
with open(path, encoding='utf-8', errors='ignore') as f:
for line in f:
line = line.strip().lower()
if line and not line.startswith('#'):
entry = {'name': name, 'source': source}
if extra:
entry.update(extra)
target_dict[line] = entry
except Exception:
pass
def _load_txt_set(self, path, target_set):
try:
with open(path, encoding='utf-8', errors='ignore') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#'):
target_set.add(line)
except Exception:
pass
def _load_hashes(self, path, target_dict, name, source):
try:
with open(path, encoding='utf-8', errors='ignore') as f:
for line in f:
parts = line.strip().split(',')
h = parts[0].strip().lower()
if h and len(h) in (32, 40, 64):
target_dict[h] = {'name': name, 'source': source}
except Exception:
pass
def _parse_stix(self, path, db, name, source):
"""Parse a STIX2 JSON file for indicators."""
try:
with open(path, encoding='utf-8', errors='ignore') as f:
data = json.load(f)
objects = data.get('objects', []) if isinstance(data, dict) else []
for obj in objects:
if obj.get('type') != 'indicator':
continue
pattern = obj.get('pattern', '')
for m in re.findall(r"domain-name:value\s*=\s*'([^']+)'", pattern):
db['domains'][m.lower()] = {'name': name, 'source': source}
for m in re.findall(r"file:hashes\.'[^']+'\s*=\s*'([a-fA-F0-9]+)'", pattern):
db['hashes'][m.lower()] = {'name': name, 'source': source}
for m in re.findall(r"process:name\s*=\s*'([^']+)'", pattern):
db['processes'][m.lower()] = {'name': name, 'source': source}
for m in re.findall(r"(?:file:name|directory:path)\s*=\s*'([^']+)'", pattern):
db['file_paths'][m] = {'name': name, 'source': source}
for m in re.findall(r"ipv[46]-addr:value\s*=\s*'([^']+)'", pattern):
db['ips'].add(m)
except Exception:
pass
def _parse_ioc_json(self, path, db, name, source):
try:
with open(path, encoding='utf-8', errors='ignore') as f:
data = json.load(f)
items = data.get('items', data.get('iocs', [])) if isinstance(data, dict) else data
if not isinstance(items, list):
return
for item in items:
if not isinstance(item, dict):
continue
itype = item.get('type', '').lower()
val = str(item.get('value', item.get('indicator', ''))).strip().lower()
if not val:
continue
if 'domain' in itype:
db['domains'][val] = {'name': name, 'source': source}
elif 'ip' in itype:
db['ips'].add(val)
elif 'hash' in itype or 'sha' in itype or 'md5' in itype:
db['hashes'][val] = {'name': name, 'source': source}
except Exception:
pass
def _parse_ioc_csv(self, path, db, name, source):
try:
import csv
with open(path, encoding='utf-8', errors='ignore', newline='') as f:
reader = csv.DictReader(f)
for row in reader:
val = (row.get('value') or row.get('indicator') or row.get('domain') or '').strip().lower()
itype = (row.get('type') or row.get('indicator_type') or '').lower()
if not val:
continue
if 'domain' in itype:
db['domains'][val] = {'name': name, 'source': source}
elif 'hash' in itype or 'sha256' in itype:
db['hashes'][val] = {'name': name, 'source': source}
except Exception:
pass
def _parse_sigs_json(self, path, db):
"""Merge existing stalkerware_signatures.json into db."""
try:
with open(path, encoding='utf-8') as f:
data = json.load(f)
for vendor, info in data.get('stalkerware', {}).items():
source = f'stalkerware_signatures.json:{vendor}'
sev = info.get('severity', 'high')
for pkg in info.get('packages', []):
if pkg and pkg not in db['packages']:
db['packages'][pkg] = {'name': vendor, 'source': source, 'severity': sev}
for spyware in data.get('government_spyware', []):
name = spyware.get('name', 'unknown')
source = f'stalkerware_signatures.json:{name}'
for ind in spyware.get('indicators', []):
itype = ind.get('type', '')
val = ind.get('value', '')
if not val:
continue
if itype == 'process':
db['processes'][val.lower()] = {'name': name, 'source': source}
elif itype == 'domain':
db['domains'][val.lower()] = {'name': name, 'source': source, 'severity': 'critical'}
elif itype == 'file_path':
db['file_paths'][val] = {'name': name, 'source': source}
except Exception:
pass
def _save_index(self, db):
try:
def _count_dirs(d):
return sum(1 for p in d.iterdir() if p.is_dir()) if d.exists() else 0
ips = db.get('ips', [])
index = {
'generated': datetime.now().isoformat(),
'counts': {
'packages': len(db.get('packages', {})),
'certs': len(db.get('certs', {})),
'domains': len(db.get('domains', {})),
'ips': len(ips) if not isinstance(ips, set) else len(ips),
'file_paths': len(db.get('file_paths', {})),
'processes': len(db.get('processes', {})),
'hashes': len(db.get('hashes', {})),
},
'sources': {
'stalkerware_ioc_yaml': (self.IOC_DATA_DIR / 'stalkerware' / 'ioc.yaml').exists(),
'amnesty_investigations': _count_dirs(self.IOC_DATA_DIR / 'spyware' / 'amnesty'),
'mvt_campaigns': _count_dirs(self.IOC_DATA_DIR / 'spyware' / 'mvt'),
'citizen_lab_campaigns': _count_dirs(self.IOC_DATA_DIR / 'spyware' / 'citizen_lab'),
'yara_rules': sum(1 for _ in (self.IOC_DATA_DIR / 'yara').rglob('*.yar*'))
if (self.IOC_DATA_DIR / 'yara').exists() else 0,
},
}
self.IOC_DATA_DIR.mkdir(parents=True, exist_ok=True)
with open(self.IOC_DATA_DIR / 'index.json', 'w', encoding='utf-8') as f:
json.dump(index, f, indent=2)
except Exception:
pass
def get_ioc_stats(self):
"""Return IOC database statistics (from index.json if available)."""
idx_path = self.IOC_DATA_DIR / 'index.json'
if idx_path.exists():
try:
with open(idx_path, encoding='utf-8') as f:
return json.load(f)
except Exception:
pass
db = self.load_ioc_database()
return {'counts': {k: len(v) for k, v in db.items() if isinstance(v, (dict, list))}}
def reload_ioc_database(self):
"""Force reload all IOC data from disk."""
self._ioc_db = None
return self.load_ioc_database(force=True)
# ── Acquisition Methods ─────────────────────────────────────────
def acquire_packages(self, serial):
"""Get list of installed packages with paths and system/user classification."""
try:
all_out = self._adb(serial, 'pm list packages -f', timeout=30)
sys_out = self._adb(serial, 'pm list packages -s', timeout=30)
# Build system package set from -s output (format: "package:com.pkg.name")
sys_pkgs = set()
for line in sys_out.splitlines():
line = line.strip()
if line.startswith('package:'):
sys_pkgs.add(line[8:].strip())
# Parse -f output (format: "package:/path/to/base.apk=com.pkg.name")
packages = []
for line in all_out.splitlines():
line = line.strip()
if not line.startswith('package:'):
continue
line = line[8:] # strip "package:"
# Split on last '=' to handle paths that may contain '='
eq_pos = line.rfind('=')
if eq_pos == -1:
pkg_name = line
apk_path = ''
else:
apk_path = line[:eq_pos]
pkg_name = line[eq_pos + 1:]
pkg_name = pkg_name.strip()
if pkg_name:
packages.append({
'package': pkg_name,
'path': apk_path.strip(),
'system': pkg_name in sys_pkgs,
})
return {'ok': True, 'packages': packages, 'total': len(packages)}
except Exception as e:
return {'ok': False, 'error': str(e), 'packages': []}
def acquire_processes(self, serial):
"""Get list of running processes."""
try:
out = self._adb(serial, 'ps -A', timeout=30)
lines = [l for l in out.splitlines() if l.strip()]
return {'ok': True, 'output': out, 'count': max(0, len(lines) - 1)}
except Exception as e:
return {'ok': False, 'error': str(e), 'output': ''}
def acquire_getprop(self, serial):
"""Get all system properties via getprop."""
try:
out = self._adb(serial, 'getprop', timeout=30)
props = {}
for line in out.splitlines():
m = re.match(r'\[(.+)\]:\s*\[(.*)\]\s*$', line)
if m:
props[m.group(1)] = m.group(2)
return {'ok': True, 'props': props, 'output': out}
except Exception as e:
return {'ok': False, 'error': str(e), 'props': {}}
def acquire_settings(self, serial):
"""Get global, secure, and system settings."""
try:
result = {}
for ns in ('global', 'secure', 'system'):
out = self._adb(serial, f'settings list {ns}', timeout=30)
settings = {}
for line in out.splitlines():
if '=' in line:
k, _, v = line.partition('=')
settings[k.strip()] = v.strip()
result[ns] = settings
return {'ok': True, 'settings': result}
except Exception as e:
return {'ok': False, 'error': str(e), 'settings': {}}
def acquire_services(self, serial):
"""List all running Android services."""
try:
out = self._adb(serial, 'service list', timeout=30)
return {'ok': True, 'output': out, 'count': len(out.splitlines())}
except Exception as e:
return {'ok': False, 'error': str(e), 'output': ''}
def acquire_dumpsys(self, serial):
"""Full dumpsys output (may be large)."""
try:
out = self._adb(serial, 'dumpsys', timeout=180)
return {'ok': True, 'output': out, 'size': len(out)}
except Exception as e:
return {'ok': False, 'error': str(e), 'output': ''}
def acquire_logcat(self, serial):
"""Recent logcat entries (last 1000 lines)."""
try:
out = self._adb(serial, 'logcat -d -t 1000', timeout=60)
return {'ok': True, 'output': out, 'lines': len(out.splitlines())}
except Exception as e:
return {'ok': False, 'error': str(e), 'output': ''}
def acquire_tmp_files(self, serial):
"""List files in /data/local/tmp/."""
try:
out = self._adb(serial, 'ls -la /data/local/tmp/ 2>/dev/null', timeout=15)
return {'ok': True, 'output': out, 'lines': len(out.splitlines())}
except Exception as e:
return {'ok': False, 'error': str(e), 'output': ''}
def acquire_files(self, serial, max_depth=5):
"""Recursive file listing from common paths."""
try:
paths = ['/data/local/tmp', '/sdcard/Android/data', '/data/data',
'/system/app', '/data/app']
results = []
for base in paths:
out = self._adb(
serial,
f'find {base} -maxdepth {max_depth} -type f 2>/dev/null | head -500',
timeout=30,
)
for line in out.splitlines():
line = line.strip()
if line:
results.append({'path': line})
return {'ok': True, 'files': results, 'total': len(results)}
except Exception as e:
return {'ok': False, 'error': str(e), 'files': []}
def acquire_logs(self, serial):
"""Full logcat with thread-time format (last 2000 lines)."""
try:
out = self._adb(serial, 'logcat -d -v threadtime -t 2000', timeout=90)
return {'ok': True, 'output': out, 'lines': len(out.splitlines())}
except Exception as e:
return {'ok': False, 'error': str(e), 'output': ''}
def acquire_bugreport_info(self, serial):
"""Collect bugreport header info (device state, battery, memory) without full zip."""
try:
sections = {}
for name, cmd in [
('memory', 'cat /proc/meminfo'),
('cpuinfo', 'cat /proc/cpuinfo | head -40'),
('uptime', 'cat /proc/uptime'),
('mounts', 'cat /proc/mounts'),
]:
sections[name] = self._adb(serial, cmd, timeout=15)
return {'ok': True, 'sections': sections}
except Exception as e:
return {'ok': False, 'error': str(e), 'sections': {}}
# ── IOC Scanning ────────────────────────────────────────────────
def scan_packages(self, serial, packages_data=None):
"""Match installed packages against IOC package names."""
db = self.load_ioc_database()
if packages_data is None:
result = self.acquire_packages(serial)
if not result.get('ok'):
return {'ok': False, 'error': result.get('error'), 'findings': []}
packages_data = result['packages']
findings = []
for pkg_info in packages_data:
pkg = pkg_info.get('package', '').strip()
if pkg and pkg in db['packages']:
match = db['packages'][pkg]
findings.append({
'package': pkg,
'path': pkg_info.get('path', ''),
'system': pkg_info.get('system', False),
'threat_name': match['name'],
'source': match['source'],
'severity': match.get('severity', 'high'),
'type': 'package_match',
})
return {
'ok': True,
'total_scanned': len(packages_data),
'findings': findings,
'clean': len(findings) == 0,
}
def scan_certificates(self, serial):
"""Check APK signing cert hashes against known-bad certs in IOC database."""
db = self.load_ioc_database()
findings = []
if not db.get('certs'):
return {'ok': True, 'findings': [], 'note': 'No cert IOCs loaded'}
try:
pkg_result = self.acquire_packages(serial)
# Only check user-installed (non-system) packages; limit to 100 for speed
packages = [p for p in pkg_result.get('packages', []) if not p.get('system')][:100]
for pkg_info in packages:
pkg = pkg_info.get('package', '')
if not pkg:
continue
out = self._adb(serial, f'pm dump {pkg} 2>/dev/null | grep -A2 "Signing"', timeout=15)
for line in out.splitlines():
m = re.search(r'([a-fA-F0-9]{40,64})', line)
if m:
cert_hash = m.group(1).lower()
if cert_hash in db['certs']:
match = db['certs'][cert_hash]
findings.append({
'package': pkg,
'cert_hash': cert_hash,
'threat_name': match['name'],
'source': match['source'],
'severity': 'critical',
'type': 'cert_match',
})
except Exception as e:
return {'ok': False, 'error': str(e), 'findings': findings}
return {'ok': True, 'findings': findings, 'clean': len(findings) == 0}
def scan_network_indicators(self, serial):
"""Check DNS/proxy settings and active TCP connections against IOC domains/IPs."""
db = self.load_ioc_database()
findings = []
try:
props = self.acquire_getprop(serial).get('props', {})
for key, val in props.items():
if 'dns' in key.lower() and val and val.lower() in db['domains']:
match = db['domains'][val.lower()]
findings.append({
'type': 'dns_property', 'key': key, 'value': val,
'threat_name': match['name'], 'source': match['source'], 'severity': 'high',
})
ioc_ips = set(db.get('ips', []))
if ioc_ips:
tcp_out = self._adb(serial, 'cat /proc/net/tcp 2>/dev/null', timeout=15)
for line in tcp_out.splitlines()[1:]:
parts = line.strip().split()
if len(parts) < 3:
continue
try:
remote_hex = parts[2]
ip_hex, port_hex = remote_hex.split(':')
# /proc/net/tcp stores IPs in little-endian hex
ip = '.'.join(str(int(ip_hex[i:i+2], 16)) for i in (6, 4, 2, 0))
port = int(port_hex, 16)
if ip not in ('0.0.0.0', '127.0.0.1') and ip in ioc_ips:
findings.append({
'type': 'tcp_connection', 'remote_ip': ip, 'remote_port': port,
'severity': 'critical', 'source': 'network_scan',
'threat_name': 'Known malicious IP',
})
except Exception:
pass
except Exception as e:
return {'ok': False, 'error': str(e), 'findings': findings}
return {'ok': True, 'findings': findings, 'clean': len(findings) == 0}
def scan_file_indicators(self, serial):
"""Check device for known spyware artifact file paths."""
db = self.load_ioc_database()
if not db.get('file_paths'):
return {'ok': True, 'findings': [], 'note': 'No file path IOCs loaded'}
findings = []
try:
for fpath, match in list(db['file_paths'].items())[:200]:
out = self._adb(serial, f'ls "{fpath}" 2>/dev/null', timeout=5)
if out.strip() and 'No such file' not in out:
findings.append({
'type': 'file_path',
'path': fpath,
'threat_name': match['name'],
'source': match['source'],
'severity': 'critical',
})
except Exception as e:
return {'ok': False, 'error': str(e), 'findings': findings}
return {'ok': True, 'findings': findings, 'clean': len(findings) == 0}
def scan_process_indicators(self, serial):
"""Cross-reference running processes against known spyware process names."""
db = self.load_ioc_database()
if not db.get('processes'):
return {'ok': True, 'findings': [], 'note': 'No process IOCs loaded'}
findings = []
try:
proc_output = self.acquire_processes(serial).get('output', '').lower()
for proc_name, match in db['processes'].items():
if proc_name and proc_name in proc_output:
findings.append({
'type': 'process_match',
'process': proc_name,
'threat_name': match['name'],
'source': match['source'],
'severity': 'critical',
})
except Exception as e:
return {'ok': False, 'error': str(e), 'findings': findings}
return {'ok': True, 'findings': findings, 'clean': len(findings) == 0}
# ── SLM Heuristic Scanner ────────────────────────────────────────
def _heuristic_scan(self, data_type, items, threshold=0.65):
"""Run SLM heuristic pass on a list of items via the local LLM.
data_type: 'packages' | 'processes' | 'network' | 'files'
Returns list of {item, score, reason, category} for items scoring >= threshold."""
if not items:
return []
try:
from core.llm import get_llm, LLMError
llm = get_llm()
if not llm or not llm.is_loaded:
return []
except Exception:
return []
prompts = {
'packages': (
'Classify each Android package name for stalkerware/spyware/adware risk. '
'Score 0.0-1.0. High risk: names impersonating system apps, random strings, '
'unknown publishers with device-admin-sounding names. '
'Return only a JSON array: [{"item":"...","score":0.0,"reason":"...","category":"stalkerware|adware|suspicious|clean"}]'
),
'processes': (
'Classify each Android process name for malware risk. Score 0.0-1.0. '
'Suspicious: random hex strings, names mimicking system processes with slight misspellings. '
'Return only a JSON array: [{"item":"...","score":0.0,"reason":"...","category":"malware|suspicious|clean"}]'
),
'network': (
'Classify each domain or IP for C2/malware beacon risk. Score 0.0-1.0. '
'High risk: DGA-looking names, dynamic DNS (duckdns, no-ip, ddns), unusual TLDs, high-entropy subdomains. '
'Return only a JSON array: [{"item":"...","score":0.0,"reason":"...","category":"c2_beacon|dga_domain|suspicious|clean"}]'
),
'files': (
'Classify each Android file path for spyware/stalkerware artifact risk. Score 0.0-1.0. '
'High risk: hidden directories, paths in /data/local/tmp, surveillance-sounding names. '
'Return only a JSON array: [{"item":"...","score":0.0,"reason":"...","category":"spyware|suspicious|clean"}]'
),
}
base_prompt = prompts.get(data_type, prompts['packages'])
findings = []
# Process in chunks of 50 (on-device SLM token budget)
for i in range(0, len(items), 50):
chunk = items[i:i + 50]
prompt = f'{base_prompt}\n\nItems:\n' + '\n'.join(str(x) for x in chunk)
try:
raw = get_llm().generate(prompt, max_tokens=800)
# Extract JSON array from response
m = re.search(r'\[.*\]', raw, re.DOTALL)
if not m:
continue
parsed = json.loads(m.group(0))
for entry in parsed:
if isinstance(entry, dict) and float(entry.get('score', 0)) >= threshold:
findings.append({
'item': str(entry.get('item', '')),
'score': float(entry.get('score', 0)),
'reason': str(entry.get('reason', '')),
'category': str(entry.get('category', 'suspicious')),
'confidence': float(entry.get('score', 0)),
'heuristic': True,
'data_type': data_type,
})
except Exception:
continue
return findings
# ── Composite Operations ─────────────────────────────────────────
def full_acquisition(self, serial):
"""Run all acquisition modules and all IOC scans. Save results to disk."""
acq_id = self._new_acq_id()
acq_dir = self._acq_dir(serial, acq_id)
report = {
'acq_id': acq_id,
'serial': serial,
'timestamp': datetime.now().isoformat(),
'modules': {},
'ioc_findings': [],
}
module_map = {
'packages': self.acquire_packages,
'processes': self.acquire_processes,
'getprop': self.acquire_getprop,
'settings': self.acquire_settings,
'services': self.acquire_services,
'logcat': self.acquire_logcat,
'tmp_files': self.acquire_tmp_files,
'files': self.acquire_files,
'logs': self.acquire_logs,
'bugreport': self.acquire_bugreport_info,
}
for mod_name, func in module_map.items():
try:
result = func(serial)
report['modules'][mod_name] = result
with open(acq_dir / f'{mod_name}.json', 'w', encoding='utf-8') as f:
json.dump(result, f, indent=2, default=str)
except Exception as e:
report['modules'][mod_name] = {'ok': False, 'error': str(e)}
# IOC scans — reuse already-acquired package list
packages_data = report['modules'].get('packages', {}).get('packages', [])
all_findings = []
for scan_name, scan_func in [
('packages', lambda s: self.scan_packages(s, packages_data)),
('network', self.scan_network_indicators),
('files', self.scan_file_indicators),
('processes', self.scan_process_indicators),
]:
try:
scan_result = scan_func(serial)
for finding in scan_result.get('findings', []):
finding['scan_type'] = scan_name
all_findings.append(finding)
except Exception:
pass
report['ioc_findings'] = all_findings
report['scan_summary'] = {
'total_findings': len(all_findings),
'critical': sum(1 for f in all_findings if f.get('severity') == 'critical'),
'high': sum(1 for f in all_findings if f.get('severity') == 'high'),
'clean': len(all_findings) == 0,
}
with open(acq_dir / 'report.json', 'w', encoding='utf-8') as f:
json.dump(report, f, indent=2, default=str)
return report
def get_acquisition_list(self, serial):
"""List past acquisitions for a device."""
device_dir = self.ACQ_DATA_DIR / serial
if not device_dir.exists():
return []
acquisitions = []
for acq_dir in sorted(device_dir.iterdir(), reverse=True):
if not acq_dir.is_dir():
continue
report_path = acq_dir / 'report.json'
entry = {'acq_id': acq_dir.name, 'has_report': report_path.exists()}
if report_path.exists():
try:
with open(report_path, encoding='utf-8') as f:
r = json.load(f)
entry['timestamp'] = r.get('timestamp', acq_dir.name)
entry['modules'] = list(r.get('modules', {}).keys())
entry['findings'] = r.get('scan_summary', {}).get('total_findings', 0)
except Exception:
pass
acquisitions.append(entry)
return acquisitions
def export_report(self, serial, acq_id):
"""Load a saved acquisition report from disk."""
report_path = self.ACQ_DATA_DIR / serial / acq_id / 'report.json'
if not report_path.exists():
return {'ok': False, 'error': 'Report not found'}
try:
with open(report_path, encoding='utf-8') as f:
return {'ok': True, 'report': json.load(f), 'path': str(report_path)}
except Exception as e:
return {'ok': False, 'error': str(e)}
# ── Direct (WebUSB) Mode ─────────────────────────────────────────
def get_direct_commands(self, operation):
"""Return ADB shell commands for browser-side WebUSB execution."""
commands = {
'packages': 'pm list packages -f',
'packages_sys': 'pm list packages -s',
'processes': 'ps -A',
'getprop': 'getprop',
'settings_global': 'settings list global',
'settings_secure': 'settings list secure',
'settings_system': 'settings list system',
'services': 'service list',
'logcat': 'logcat -d -t 1000',
'tmp_files': 'ls -la /data/local/tmp/ 2>/dev/null',
'tcp_connections': 'cat /proc/net/tcp 2>/dev/null',
}
if operation == 'full':
return commands
cmd = commands.get(operation)
return {operation: cmd} if cmd else {}
def parse_direct_output(self, operation, raw_output):
"""Parse raw ADB output returned from a WebUSB browser client."""
if operation == 'packages':
packages = []
for line in raw_output.splitlines():
line = line.strip()
if not line.startswith('package:'):
continue
line = line[8:]
eq_pos = line.rfind('=')
if eq_pos == -1:
pkg_name, apk_path = line, ''
else:
apk_path, pkg_name = line[:eq_pos], line[eq_pos + 1:]
pkg_name = pkg_name.strip()
if pkg_name:
packages.append({'package': pkg_name, 'path': apk_path.strip(), 'system': False})
return {'ok': True, 'packages': packages, 'total': len(packages)}
elif operation == 'processes':
lines = [l for l in raw_output.splitlines() if l.strip()]
return {'ok': True, 'output': raw_output, 'count': max(0, len(lines) - 1)}
elif operation == 'getprop':
props = {}
for line in raw_output.splitlines():
m = re.match(r'\[(.+)\]:\s*\[(.*)\]\s*$', line)
if m:
props[m.group(1)] = m.group(2)
return {'ok': True, 'props': props, 'output': raw_output}
else:
return {'ok': True, 'output': raw_output}