Add Android forensics, IOC threat-intel DB, Compose companion; scrub secrets from configs

This commit is contained in:
SsSnake
2026-07-13 15:45:47 -07:00
parent 925216290f
commit e48d577bd5
387 changed files with 211976 additions and 921 deletions

View File

@@ -80,7 +80,7 @@ PARAMS: {{"question": "your question"}}
self,
llm: LLM = None,
tools: ToolRegistry = None,
max_steps: int = 20,
max_steps: int = 200,
verbose: bool = True
):
"""Initialize the agent.

871
core/android_forensics.py Normal file
View File

@@ -0,0 +1,871 @@
"""
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}

View File

@@ -405,10 +405,18 @@ class AutonomyDaemon:
return
try:
# Read step limit from config based on the active LLM backend
from core.config import Config
cfg = Config()
backend = cfg.get('autarch', 'llm_backend', fallback='local')
step_key = f'{backend}_max_steps'
agent_settings = cfg.get_agent_settings()
max_steps = agent_settings.get(step_key, agent_settings.get('local_max_steps', 200))
agent = Agent(
llm=llm_inst,
tools=get_tool_registry(),
max_steps=15,
max_steps=max_steps,
verbose=False,
)
result = agent.run(task)

View File

@@ -140,17 +140,17 @@ class Config:
},
'agents': {
'backend': 'local',
'local_max_steps': '20',
'local_max_steps': '200',
'local_verbose': 'true',
'claude_enabled': 'false',
'claude_model': 'claude-sonnet-4-6',
'claude_max_tokens': '16384',
'claude_max_steps': '30',
'claude_max_steps': '200',
'openai_enabled': 'false',
'openai_model': 'gpt-4o',
'openai_base_url': 'https://api.openai.com/v1',
'openai_max_tokens': '16384',
'openai_max_steps': '30',
'openai_max_steps': '200',
},
'hal_memory': {
'max_bytes': str(4 * 1024 * 1024 * 1024), # 4GB default
@@ -539,17 +539,17 @@ class Config:
"""Get agent configuration settings."""
return {
'backend': self.get('agents', 'backend', 'local'),
'local_max_steps': self.get_int('agents', 'local_max_steps', 20),
'local_max_steps': self.get_int('agents', 'local_max_steps', 200),
'local_verbose': self.get_bool('agents', 'local_verbose', True),
'claude_enabled': self.get_bool('agents', 'claude_enabled', False),
'claude_model': self.get('agents', 'claude_model', 'claude-sonnet-4-6'),
'claude_max_tokens': self.get_int('agents', 'claude_max_tokens', 16384),
'claude_max_steps': self.get_int('agents', 'claude_max_steps', 30),
'claude_max_steps': self.get_int('agents', 'claude_max_steps', 200),
'openai_enabled': self.get_bool('agents', 'openai_enabled', False),
'openai_model': self.get('agents', 'openai_model', 'gpt-4o'),
'openai_base_url': self.get('agents', 'openai_base_url', 'https://api.openai.com/v1'),
'openai_max_tokens': self.get_int('agents', 'openai_max_tokens', 16384),
'openai_max_steps': self.get_int('agents', 'openai_max_steps', 30),
'openai_max_steps': self.get_int('agents', 'openai_max_steps', 200),
}
@staticmethod

258
core/cve_db.py Normal file
View File

@@ -0,0 +1,258 @@
"""
Autarch CVE Database — CVE lookup for Android packages via OSV.dev.
by: SsSnake
"""
import json
import os
import threading
import urllib.request
import urllib.error
from datetime import datetime, timezone
from pathlib import Path
_db_instance = None
_db_lock = threading.Lock()
def get_cve_db():
global _db_instance
if _db_instance is None:
with _db_lock:
if _db_instance is None:
_db_instance = CveDatabase()
return _db_instance
class CveDatabase:
"""CVE lookup for Android packages. Uses OSV.dev as primary data source."""
DATA_DIR = Path(__file__).parent.parent / 'data' / 'ioc' / 'cve'
OSV_QUERY_URL = 'https://api.osv.dev/v1/query'
OSV_BATCH_URL = 'https://api.osv.dev/v1/querybatch'
def __init__(self):
self.DATA_DIR.mkdir(parents=True, exist_ok=True)
self._cache = {} # package_name -> [vuln_dicts]
self._stats_cache = None
self._lock = threading.Lock()
self._last_query = None
def get_cve_for_package(self, package_name, version=None) -> list:
"""Query OSV.dev for CVEs affecting an Android package."""
with self._lock:
if package_name in self._cache:
return self._cache[package_name]
try:
body = {
"package": {
"name": package_name,
"ecosystem": "Android"
}
}
if version is not None:
body["version"] = version
response = self._query_osv(body)
vulns = response.get("vulns", [])
results = []
for vuln in vulns:
parsed = self._parse_vuln(vuln, package_name)
results.append(parsed)
with self._lock:
self._cache[package_name] = results
self._last_query = datetime.now(timezone.utc).isoformat()
return results
except Exception:
return []
def get_cve_for_android_sdk(self, sdk_version) -> list:
"""Query OSV.dev for CVEs affecting a specific Android SDK version."""
package_name = f"android:{sdk_version}"
try:
body = {
"package": {
"name": package_name,
"ecosystem": "Android"
}
}
response = self._query_osv(body)
vulns = response.get("vulns", [])
results = []
for vuln in vulns:
parsed = self._parse_vuln(vuln, package_name)
results.append(parsed)
with self._lock:
self._cache[package_name] = results
self._last_query = datetime.now(timezone.utc).isoformat()
return results
except Exception:
return []
def check_packages_batch(self, packages: list) -> dict:
"""Query OSV.dev for CVEs affecting multiple Android packages at once."""
if not packages:
return {}
try:
results = {}
# Split into chunks of 50
chunk_size = 50
for i in range(0, len(packages), chunk_size):
chunk = packages[i:i + chunk_size]
queries = [
{"package": {"name": p["name"], "ecosystem": "Android"}}
for p in chunk
]
body = {"queries": queries}
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(
self.OSV_BATCH_URL,
data=data,
headers={
"Content-Type": "application/json",
"User-Agent": "Autarch/1.0"
},
method="POST"
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
response = json.loads(resp.read().decode("utf-8"))
except Exception:
continue
batch_results = response.get("results", [])
for idx, result in enumerate(batch_results):
if idx >= len(chunk):
break
pkg_name = chunk[idx]["name"]
vulns = result.get("vulns", [])
parsed_vulns = [self._parse_vuln(v, pkg_name) for v in vulns]
results[pkg_name] = parsed_vulns
with self._lock:
self._cache[pkg_name] = parsed_vulns
with self._lock:
if results:
self._last_query = datetime.now(timezone.utc).isoformat()
return results
except Exception:
return {}
def get_stats(self) -> dict:
"""Return basic stats about the CVE database cache."""
with self._lock:
return {
"total_cached": len(self._cache),
"last_query": self._last_query,
"data_dir": str(self.DATA_DIR)
}
def _query_osv(self, body: dict) -> dict:
"""POST a JSON body to the OSV.dev query endpoint. Returns parsed JSON or {}."""
try:
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(
self.OSV_QUERY_URL,
data=data,
headers={
"Content-Type": "application/json",
"User-Agent": "Autarch/1.0"
},
method="POST"
)
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read().decode("utf-8"))
except Exception:
return {}
def _parse_vuln(self, vuln: dict, package_name: str) -> dict:
"""Extract standardised CVE fields from an OSV vulnerability object."""
# cve_id: prefer alias starting with "CVE-", else use vuln id
cve_id = vuln.get("id", "UNKNOWN")
for alias in vuln.get("aliases", []):
if alias.startswith("CVE-"):
cve_id = alias
break
# severity: try database_specific first, then severity array score, normalise
raw_severity = (
vuln.get("database_specific", {}).get("severity")
or vuln.get("severity", [{}])[0].get("score", "UNKNOWN")
)
severity = self._normalise_severity(raw_severity)
# description: summary or details, capped at 500 chars
description = vuln.get("summary", vuln.get("details", ""))[:500]
# patched_version: look for a "fixed" event in affected ranges
patched_version = "unknown"
affected_list = vuln.get("affected", [{}])
if affected_list:
ranges = affected_list[0].get("ranges", [])
for r in ranges:
for event in r.get("events", []):
if "fixed" in event:
patched_version = event["fixed"]
break
if patched_version != "unknown":
break
return {
"package": package_name,
"cve_id": cve_id,
"severity": severity,
"description": description,
"patched_version": patched_version,
"source": "osv.dev"
}
def _normalise_severity(self, raw) -> str:
"""Map a raw severity string or CVSS score to critical/high/medium/low."""
if raw is None or raw == "UNKNOWN":
return "medium"
raw_str = str(raw).strip().upper()
# Direct label match
label_map = {
"CRITICAL": "critical",
"HIGH": "high",
"MEDIUM": "medium",
"MODERATE": "medium",
"LOW": "low",
"NONE": "low"
}
if raw_str in label_map:
return label_map[raw_str]
# Try numeric CVSS score
try:
score = float(raw_str)
if score >= 9.0:
return "critical"
elif score >= 7.0:
return "high"
elif score >= 4.0:
return "medium"
else:
return "low"
except ValueError:
pass
# CVSS vector strings often start with digits after the last colon
# e.g. "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"
# Fall back to medium if we can't parse it
return "medium"

259
core/keystore.py Normal file
View File

@@ -0,0 +1,259 @@
"""
AUTARCH Encrypted Keystore
Per-user encrypted storage for API keys, passwords, and credentials.
Stores secrets in keystore.xml in the user data directory using AES-256-CBC
encryption from the existing vault module. Secrets are separated by user
account so multi-user deployments don't leak across accounts.
The keystore file format is XML for human-inspectability of the structure
(though all values are encrypted). Each entry has:
- A unique key name
- The encrypted value (base64-encoded)
- The user who owns it
- A timestamp
- An optional category (api_key, password, token, certificate, etc.)
Usage:
from core.keystore import get_keystore
ks = get_keystore()
# Store a secret for the current user
ks.set('claude_api_key', 'sk-ant-...', category='api_key')
# Retrieve it
key = ks.get('claude_api_key') # Returns '' if not set
# List all keys for the current user
ks.keys() # ['claude_api_key', 'openai_api_key']
# Store for a specific user
ks.set('shodan_key', 'abc123', user='admin', category='api_key')
# Delete
ks.delete('old_key')
"""
import base64
import hashlib
import json
import logging
import os
import time
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Dict, List, Optional
from xml.dom import minidom
_log = logging.getLogger('autarch.keystore')
# Import encryption functions from the existing vault
from core.vault import _derive_key, _encrypt, _decrypt
def _get_default_user() -> str:
"""Get the current user identity for key ownership."""
from core.paths import get_user_profile_path
profile_path = get_user_profile_path()
if profile_path.exists():
try:
with open(profile_path) as f:
profile = json.load(f)
return profile.get('username', 'default')
except Exception:
pass
return 'default'
class Keystore:
"""Encrypted per-user secrets keystore backed by XML."""
def __init__(self, keystore_path: Path = None, master_password: str = ''):
from core.paths import get_keystore_path
self._path = keystore_path or get_keystore_path()
self._master = master_password
self._salt = None
self._entries: Dict[str, Dict] = {} # key -> {value, user, category, timestamp, salt}
self._load()
def _load(self):
"""Load and decrypt the keystore from disk."""
if not self._path.exists():
_log.info(f'No keystore found at {self._path}, starting fresh')
return
try:
tree = ET.parse(str(self._path))
root = tree.getroot()
self._salt = base64.b64decode(root.get('salt', ''))
for entry in root.findall('entry'):
key_name = entry.get('name', '')
if not key_name:
continue
self._entries[key_name] = {
'encrypted_value': entry.get('value', ''),
'user': entry.get('user', 'default'),
'category': entry.get('category', ''),
'timestamp': entry.get('timestamp', ''),
'iv': entry.get('iv', ''),
}
_log.info(f'Loaded keystore with {len(self._entries)} entries')
except Exception as e:
_log.error(f'Failed to load keystore: {e}')
def _save(self):
"""Encrypt and save the keystore to disk."""
try:
root = ET.Element('keystore')
root.set('version', '1')
if self._salt is None:
self._salt = os.urandom(32)
root.set('salt', base64.b64encode(self._salt).decode())
root.set('updated', str(int(time.time())))
for key_name, data in sorted(self._entries.items()):
entry = ET.SubElement(root, 'entry')
entry.set('name', key_name)
entry.set('value', data.get('encrypted_value', ''))
entry.set('iv', data.get('iv', ''))
entry.set('user', data.get('user', 'default'))
entry.set('category', data.get('category', ''))
entry.set('timestamp', data.get('timestamp', ''))
# Pretty-print the XML
rough = ET.tostring(root, encoding='unicode')
parsed = minidom.parseString(rough)
pretty = parsed.toprettyxml(indent=' ', encoding='utf-8')
self._path.parent.mkdir(parents=True, exist_ok=True)
with open(self._path, 'wb') as f:
f.write(pretty)
_log.info(f'Saved keystore with {len(self._entries)} entries')
except Exception as e:
_log.error(f'Failed to save keystore: {e}')
def set(self, key: str, value: str, user: str = None, category: str = ''):
"""Store an encrypted secret."""
if user is None:
user = _get_default_user()
if self._salt is None:
self._salt = os.urandom(32)
# Derive encryption key from salt + master password
enc_key = _derive_key(self._salt, self._master)
# Encrypt the value
iv, ciphertext = _encrypt(value.encode('utf-8'), enc_key)
self._entries[key] = {
'encrypted_value': base64.b64encode(ciphertext).decode(),
'iv': base64.b64encode(iv).decode(),
'user': user,
'category': category,
'timestamp': str(int(time.time())),
}
self._save()
_log.info(f'Stored secret: {key} (user={user}, category={category})')
def get(self, key: str, user: str = None) -> str:
"""Retrieve a decrypted secret. Returns '' if not found."""
if user is None:
user = _get_default_user()
entry = self._entries.get(key)
if not entry:
return ''
# Check user ownership
if entry['user'] != user and entry['user'] != 'shared':
_log.warning(f'Access denied: {key} belongs to {entry["user"]}, not {user}')
return ''
try:
enc_key = _derive_key(self._salt, self._master)
iv = base64.b64decode(entry['iv'])
ciphertext = base64.b64decode(entry['encrypted_value'])
plaintext = _decrypt(iv, ciphertext, enc_key)
return plaintext.decode('utf-8')
except Exception as e:
_log.error(f'Failed to decrypt {key}: {e}')
return ''
def delete(self, key: str, user: str = None):
"""Delete a secret."""
if user is None:
user = _get_default_user()
entry = self._entries.get(key)
if entry and (entry['user'] == user or entry['user'] == 'shared'):
del self._entries[key]
self._save()
_log.info(f'Deleted secret: {key}')
def keys(self, user: str = None, category: str = None) -> List[str]:
"""List key names for a user, optionally filtered by category."""
if user is None:
user = _get_default_user()
result = []
for key, data in self._entries.items():
if data['user'] != user and data['user'] != 'shared':
continue
if category and data.get('category', '') != category:
continue
result.append(key)
return sorted(result)
def get_all_for_user(self, user: str = None) -> Dict[str, str]:
"""Get all decrypted secrets for a user as a dict."""
if user is None:
user = _get_default_user()
return {k: self.get(k, user) for k in self.keys(user)}
def get_metadata(self, key: str) -> Optional[Dict]:
"""Get metadata (user, category, timestamp) for a key without decrypting."""
entry = self._entries.get(key)
if not entry:
return None
return {
'user': entry['user'],
'category': entry.get('category', ''),
'timestamp': entry.get('timestamp', ''),
}
def migrate_from_vault(self):
"""Import secrets from the old vault.enc into the new keystore."""
from core.vault import get_vault
vault = get_vault()
migrated = 0
for key in vault.keys():
value = vault.get(key)
if value and key not in self._entries:
# Guess category from key name
category = 'api_key' if 'api_key' in key else 'credential'
self.set(key, value, user='default', category=category)
migrated += 1
if migrated:
_log.info(f'Migrated {migrated} secrets from vault to keystore')
return migrated
# Singleton
_keystore_instance: Optional[Keystore] = None
def get_keystore(master_password: str = '') -> Keystore:
"""Get the global keystore instance."""
global _keystore_instance
if _keystore_instance is None:
_keystore_instance = Keystore(master_password=master_password)
return _keystore_instance

280
core/local_agent.py Normal file
View File

@@ -0,0 +1,280 @@
"""
AUTARCH Local Agent
Lightweight client that runs on the user's machine and executes
commands locally, streaming results back to the AUTARCH server.
This makes the web UI feel like AUTARCH is running on the user's
machine — scans, tool output, file operations all happen locally.
Usage:
python3 autarch-agent.py --server https://10.0.0.1:8181
The agent:
1. Connects to the AUTARCH server via WebSocket
2. Authenticates with the user's token
3. Receives command requests from the server
4. Executes them locally (subprocess)
5. Streams stdout/stderr back in real-time
6. Reports system info (OS, arch, hostname, IP) on connect
Protocol:
Server -> Agent: {"type": "exec", "id": "xxx", "command": "nmap -sV 10.0.0.1"}
Agent -> Server: {"type": "output", "id": "xxx", "stream": "stdout", "data": "..."}
Agent -> Server: {"type": "output", "id": "xxx", "stream": "stderr", "data": "..."}
Agent -> Server: {"type": "done", "id": "xxx", "exit_code": 0}
Agent -> Server: {"type": "sysinfo", "hostname": "...", "os": "...", ...}
"""
import argparse
import asyncio
import json
import os
import platform
import shutil
import socket
import subprocess
import ssl
import sys
import time
from pathlib import Path
try:
import websockets
except ImportError:
print("Installing websockets...")
subprocess.check_call([sys.executable, "-m", "pip", "install", "websockets", "-q"])
import websockets
def get_system_info():
"""Collect local system information to send on connect."""
info = {
"hostname": socket.gethostname(),
"os": platform.system(),
"os_version": platform.version(),
"arch": platform.machine(),
"python": platform.python_version(),
"username": os.getenv("USER", os.getenv("USERNAME", "unknown")),
"home": str(Path.home()),
"cwd": os.getcwd(),
"pid": os.getpid(),
}
# Network interfaces
try:
info["ip"] = socket.gethostbyname(socket.gethostname())
except Exception:
info["ip"] = "unknown"
# Available tools
tools = {}
for tool in ["nmap", "tcpdump", "wireshark", "python3", "git", "curl",
"wget", "ssh", "netstat", "ss", "ip", "ifconfig", "arp",
"dig", "nslookup", "traceroute", "ping", "whois",
"openssl", "nikto", "sqlmap", "hydra", "john", "hashcat"]:
path = shutil.which(tool)
if path:
tools[tool] = path
info["tools"] = tools
# Disk usage
try:
usage = shutil.disk_usage("/")
info["disk_total"] = usage.total
info["disk_free"] = usage.free
except Exception:
pass
return info
async def execute_command(command, cmd_id, websocket):
"""Execute a command locally and stream output back."""
try:
process = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
# Stream stdout
async def read_stream(stream, name):
while True:
line = await stream.readline()
if not line:
break
await websocket.send(json.dumps({
"type": "output",
"id": cmd_id,
"stream": name,
"data": line.decode("utf-8", errors="replace"),
}))
# Run both streams concurrently
await asyncio.gather(
read_stream(process.stdout, "stdout"),
read_stream(process.stderr, "stderr"),
)
exit_code = await process.wait()
await websocket.send(json.dumps({
"type": "done",
"id": cmd_id,
"exit_code": exit_code,
}))
except Exception as e:
await websocket.send(json.dumps({
"type": "error",
"id": cmd_id,
"message": str(e),
}))
async def read_file(path, cmd_id, websocket):
"""Read a file and send its contents back."""
try:
p = Path(path).expanduser()
if not p.exists():
await websocket.send(json.dumps({
"type": "error", "id": cmd_id,
"message": f"File not found: {path}"
}))
return
if p.is_dir():
# List directory
entries = []
for entry in sorted(p.iterdir()):
stat = entry.stat()
entries.append({
"name": entry.name,
"is_dir": entry.is_dir(),
"size": stat.st_size,
"modified": stat.st_mtime,
})
await websocket.send(json.dumps({
"type": "dirlist", "id": cmd_id,
"path": str(p), "entries": entries,
}))
else:
# Read file (limit to 10MB)
size = p.stat().st_size
if size > 10 * 1024 * 1024:
await websocket.send(json.dumps({
"type": "error", "id": cmd_id,
"message": f"File too large: {size} bytes"
}))
return
content = p.read_text(encoding="utf-8", errors="replace")
await websocket.send(json.dumps({
"type": "file", "id": cmd_id,
"path": str(p), "content": content,
"size": size,
}))
except Exception as e:
await websocket.send(json.dumps({
"type": "error", "id": cmd_id,
"message": str(e),
}))
async def agent_loop(server_url, token):
"""Main agent loop - connect to server and process commands."""
# Allow self-signed certs
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
ws_url = server_url.replace("https://", "wss://").replace("http://", "ws://")
ws_url = f"{ws_url}/ws/agent"
print(f"Connecting to {ws_url}...")
while True:
try:
async with websockets.connect(ws_url, ssl=ssl_context,
extra_headers={"Authorization": f"Bearer {token}"}) as ws:
print("Connected to AUTARCH server")
# Send system info on connect
sysinfo = get_system_info()
sysinfo["type"] = "sysinfo"
await ws.send(json.dumps(sysinfo))
# Send heartbeat periodically
async def heartbeat():
while True:
await asyncio.sleep(30)
try:
await ws.send(json.dumps({"type": "heartbeat", "time": time.time()}))
except Exception:
break
heartbeat_task = asyncio.create_task(heartbeat())
# Process commands
try:
async for message in ws:
try:
msg = json.loads(message)
msg_type = msg.get("type", "")
cmd_id = msg.get("id", "")
if msg_type == "exec":
command = msg.get("command", "")
if command:
print(f" Executing: {command[:80]}")
asyncio.create_task(execute_command(command, cmd_id, ws))
elif msg_type == "read":
path = msg.get("path", "")
if path:
asyncio.create_task(read_file(path, cmd_id, ws))
elif msg_type == "ping":
await ws.send(json.dumps({"type": "pong", "id": cmd_id}))
elif msg_type == "kill":
# Server wants us to disconnect
print("Server requested disconnect")
break
except json.JSONDecodeError:
pass
finally:
heartbeat_task.cancel()
except (websockets.exceptions.ConnectionClosed,
ConnectionRefusedError, OSError) as e:
print(f"Connection lost: {e}. Reconnecting in 5s...")
await asyncio.sleep(5)
except KeyboardInterrupt:
print("\nAgent stopped")
break
def main():
parser = argparse.ArgumentParser(description="AUTARCH Local Agent")
parser.add_argument("--server", "-s", required=True,
help="AUTARCH server URL (e.g. https://10.0.0.1:8181)")
parser.add_argument("--token", "-t", default="",
help="Authentication token")
args = parser.parse_args()
print("AUTARCH Local Agent")
print(f"Server: {args.server}")
print(f"System: {platform.system()} {platform.machine()}")
print(f"Host: {socket.gethostname()}")
print()
asyncio.run(agent_loop(args.server, args.token))
if __name__ == "__main__":
main()

View File

@@ -76,6 +76,49 @@ def get_data_dir() -> Path:
return d
def get_user_data_dir() -> Path:
"""Return the platform-specific user data directory.
Created as a hidden folder on first call.
Windows: %APPDATA%/Autarch/
Linux: ~/.autarch/
macOS: ~/Library/Application Support/Autarch/
"""
system = platform.system()
if system == 'Windows':
base = os.environ.get('APPDATA', '')
if base:
d = Path(base) / 'Autarch'
else:
d = Path.home() / 'AppData' / 'Roaming' / 'Autarch'
elif system == 'Darwin':
d = Path.home() / 'Library' / 'Application Support' / 'Autarch'
else:
# Linux and others - hidden dotfolder in home
d = Path.home() / '.autarch'
d.mkdir(parents=True, exist_ok=True)
return d
def get_keystore_path() -> Path:
"""Return the path to the encrypted keystore file.
Stored in the user data directory, separate from the app install."""
return get_user_data_dir() / 'keystore.xml'
def get_user_profile_path() -> Path:
"""Return the path to the user profile file."""
return get_user_data_dir() / 'profile.json'
def get_user_sessions_dir() -> Path:
"""Return the directory for user session data (chat history, agent memory)."""
d = get_user_data_dir() / 'sessions'
d.mkdir(parents=True, exist_ok=True)
return d
def get_config_path() -> Path:
"""Return config path. Writable copy lives next to the exe;
falls back to the bundled default if the writable copy doesn't exist yet."""

View File

@@ -49,6 +49,12 @@ class SitesDatabase:
self._conn = None
self._lock = threading.Lock()
# Ensure the schema exists, then seed from the master dh.json the first
# time (or whenever the cache is empty). dh.json is the source of truth;
# dh_sites.db is a fast local query cache rebuilt from it automatically.
self._ensure_schema()
self._auto_populate()
def _get_connection(self) -> sqlite3.Connection:
"""Get thread-safe database connection."""
if self._conn is None:
@@ -56,6 +62,48 @@ class SitesDatabase:
self._conn.row_factory = sqlite3.Row
return self._conn
def _ensure_schema(self) -> None:
"""Create the sites table and indexes if they don't already exist."""
with self._lock:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS sites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
url_template TEXT NOT NULL,
category TEXT DEFAULT 'other',
source TEXT DEFAULT 'dh',
nsfw INTEGER DEFAULT 0,
enabled INTEGER DEFAULT 1,
error_type TEXT,
error_code INTEGER,
error_string TEXT,
match_code INTEGER,
match_string TEXT
)
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_sites_category ON sites(category)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_sites_source ON sites(source)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_sites_enabled ON sites(enabled)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_sites_nsfw ON sites(nsfw)")
conn.commit()
def _auto_populate(self) -> None:
"""Seed the cache from the master dh.json when the sites table is empty."""
with self._lock:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM sites")
count = cursor.fetchone()[0]
if count > 0:
return
json_path = self.data_dir / "dh.json"
if json_path.exists():
self.load_from_json(json_path)
def get_stats(self) -> Dict[str, Any]:
"""Get database statistics."""
with self._lock:
@@ -390,6 +438,142 @@ class SitesDatabase:
except Exception:
return False
def add_sites_bulk(self, sites: List[Dict], overwrite: bool = False) -> Dict[str, int]:
"""Bulk-insert sites in a single transaction.
By default only sites with a NOT-yet-seen name are added; existing
names are left untouched (INSERT OR IGNORE). Pass overwrite=True to
replace existing rows instead.
Args:
sites: List of site dicts. Recognised keys: name, url_template
(or url), category, source, nsfw, enabled, error_type,
error_code, error_string, match_code, match_string.
overwrite: Replace existing rows sharing the same name.
Returns:
Stats dict: {'total', 'added', 'skipped', 'errors'}.
"""
stats = {'total': len(sites), 'added': 0, 'skipped': 0, 'errors': 0}
verb = "INSERT OR REPLACE" if overwrite else "INSERT OR IGNORE"
with self._lock:
conn = self._get_connection()
cursor = conn.cursor()
for site in sites:
name = site.get('name')
url = site.get('url_template') or site.get('url')
if not name or not url:
stats['errors'] += 1
continue
try:
cursor.execute(f"""
{verb} INTO sites
(name, url_template, category, source, nsfw, enabled,
error_type, error_code, error_string, match_code, match_string)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
name,
url,
site.get('category', 'other'),
site.get('source', 'custom'),
1 if site.get('nsfw') else 0,
0 if site.get('enabled', 1) in (0, False) else 1,
site.get('error_type'),
site.get('error_code'),
site.get('error_string'),
site.get('match_code'),
site.get('match_string'),
))
if cursor.rowcount > 0:
stats['added'] += 1
else:
stats['skipped'] += 1
except Exception:
stats['errors'] += 1
conn.commit()
return stats
@staticmethod
def _normalize_url(url: str) -> str:
"""Normalise a URL template for duplicate detection.
Strips scheme, leading www., case, and trailing slash so that
http/https, www/non-www and case variants of the same profile URL
collapse to one key.
"""
if not url:
return ''
u = url.strip().lower()
for scheme in ('https://', 'http://'):
if u.startswith(scheme):
u = u[len(scheme):]
break
if u.startswith('www.'):
u = u[4:]
return u.rstrip('/')
def deduplicate(self) -> Dict[str, int]:
"""Remove duplicate sites that share the same (normalised) URL template.
Names are already unique via the schema constraint; this collapses rows
whose URL templates point at the same profile page. For each duplicate
group the best row is kept (prefer enabled, then having a detection
pattern, then the oldest/lowest id) and the rest are deleted.
Returns:
Stats dict: {'duplicate_groups', 'removed', 'remaining'}.
"""
with self._lock:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute(
"SELECT id, url_template, error_type, error_string, match_string, enabled FROM sites"
)
rows = cursor.fetchall()
groups: Dict[str, list] = {}
for r in rows:
key = self._normalize_url(r['url_template'])
if not key:
continue
groups.setdefault(key, []).append(r)
def score(r):
has_detection = bool(r['error_string'] or r['match_string'] or r['error_type'])
# Higher tuple wins; -id keeps the lowest (oldest) id.
return (1 if r['enabled'] else 0, 1 if has_detection else 0, -r['id'])
to_delete = []
dup_groups = 0
for members in groups.values():
if len(members) < 2:
continue
dup_groups += 1
members_sorted = sorted(members, key=score, reverse=True)
to_delete.extend(r['id'] for r in members_sorted[1:])
for i in range(0, len(to_delete), 500):
chunk = to_delete[i:i + 500]
placeholders = ','.join('?' * len(chunk))
cursor.execute(f"DELETE FROM sites WHERE id IN ({placeholders})", chunk)
conn.commit()
cursor.execute("SELECT COUNT(*) FROM sites")
remaining = cursor.fetchone()[0]
return {
'duplicate_groups': dup_groups,
'removed': len(to_delete),
'remaining': remaining,
}
def update_detection(
self,
name: str,

159
core/user_profile.py Normal file
View File

@@ -0,0 +1,159 @@
"""
AUTARCH User Profile Manager
Handles user identity, preferences, and session management.
On first launch, creates a default profile in the user data directory.
Supports multiple users for shared deployments.
The profile stores non-sensitive user info (username, display name, role, etc.)
Sensitive data (API keys, passwords) goes in the keystore.
"""
import json
import logging
import os
import time
from pathlib import Path
from typing import Dict, Optional
_log = logging.getLogger('autarch.user_profile')
class UserProfile:
"""User profile and preferences."""
def __init__(self, profile_path: Path = None):
from core.paths import get_user_profile_path
self._path = profile_path or get_user_profile_path()
self._data: Dict = {}
self._load()
def _load(self):
"""Load profile from disk, or create default."""
if self._path.exists():
try:
with open(self._path) as f:
self._data = json.load(f)
_log.info(f'Loaded profile for {self._data.get("username", "unknown")}')
return
except Exception as e:
_log.error(f'Failed to load profile: {e}')
# Create default profile
self._data = self._create_default()
self._save()
_log.info('Created default user profile')
def _create_default(self) -> Dict:
"""Create the default user profile."""
username = os.getenv('USER', os.getenv('USERNAME', 'operator'))
return {
'username': username,
'display_name': username.capitalize(),
'role': 'admin',
'created': int(time.time()),
'last_login': int(time.time()),
'preferences': {
'theme': 'dark',
'llm_backend': 'local',
'hal_auto_analyze': True,
'hal_max_steps': 200,
'verbose': False,
'default_scan_target': 'local', # 'local' scans user device, 'server' scans server
'chat_memory_enabled': True,
},
'session': {
'last_module': '',
'last_target': '',
'last_scan_type': '',
},
}
def _save(self):
"""Save profile to disk."""
try:
self._path.parent.mkdir(parents=True, exist_ok=True)
with open(self._path, 'w') as f:
json.dump(self._data, f, indent=2)
except Exception as e:
_log.error(f'Failed to save profile: {e}')
@property
def username(self) -> str:
return self._data.get('username', 'default')
@property
def display_name(self) -> str:
return self._data.get('display_name', self.username)
@property
def role(self) -> str:
return self._data.get('role', 'user')
def get_preference(self, key: str, default=None):
"""Get a user preference value."""
return self._data.get('preferences', {}).get(key, default)
def set_preference(self, key: str, value):
"""Set a user preference and save."""
if 'preferences' not in self._data:
self._data['preferences'] = {}
self._data['preferences'][key] = value
self._save()
def get_session(self, key: str, default='') -> str:
"""Get a session value (non-persistent state hints)."""
return self._data.get('session', {}).get(key, default)
def set_session(self, key: str, value: str):
"""Set a session value."""
if 'session' not in self._data:
self._data['session'] = {}
self._data['session'][key] = value
self._save()
def update_last_login(self):
"""Record login timestamp."""
self._data['last_login'] = int(time.time())
self._save()
def to_dict(self) -> Dict:
"""Export profile as a dict (for API/display)."""
return {
'username': self.username,
'display_name': self.display_name,
'role': self.role,
'created': self._data.get('created', 0),
'last_login': self._data.get('last_login', 0),
'preferences': self._data.get('preferences', {}),
}
# Singleton
_profile_instance: Optional[UserProfile] = None
def get_profile() -> UserProfile:
"""Get the global user profile instance."""
global _profile_instance
if _profile_instance is None:
_profile_instance = UserProfile()
return _profile_instance
def init_profile_on_launch():
"""Called at app startup to ensure profile exists and update login time."""
profile = get_profile()
profile.update_last_login()
# Migrate old vault secrets to new keystore if needed
from core.paths import get_keystore_path
ks_path = get_keystore_path()
if not ks_path.exists():
from core.keystore import get_keystore
ks = get_keystore()
migrated = ks.migrate_from_vault()
if migrated:
_log.info(f'Migrated {migrated} secrets from vault to keystore on first run')
return profile