""" 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}