From 567134c847470313724de3cd2cd2485d3fd57f3b Mon Sep 17 00:00:00 2001 From: drneox Date: Sat, 2 May 2026 23:04:45 -0500 Subject: [PATCH 01/30] i18n(core): localize all hardcoded strings in apk_tools, runtime + restore .gitignore - apk_tools.py: replace 25 hardcoded Spanish strings with t() calls (apk_* keys) Covers: patch_split_apk (no apksigner/keytool, patching, zipalign, signing) and install_apk (all install paths, split/bundle, signature mismatch, ABI) - runtime.py: replace 3 hardcoded strings in simulate_app_navigation (rt_ui_*) - i18n.py: add 26 new apk_* keys (EN + ES) and rt_ui_stopped (EN + ES) - .gitignore: restore entries for AI tools, plugin outputs, backups and logs --- .gitignore | 11 +++++-- nutcracker_core/apk_tools.py | 60 ++++++++++++++++-------------------- nutcracker_core/i18n.py | 60 ++++++++++++++++++++++++++++++++++-- nutcracker_core/runtime.py | 6 ++-- 4 files changed, 96 insertions(+), 41 deletions(-) diff --git a/.gitignore b/.gitignore index 3bf8a95..1e227ca 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ venv/ decompiled/ downloads/ reports/ +logs/ *.apk *.apks /tmp/ @@ -23,7 +24,8 @@ reports/ .semgrep/ frida_scripts/ config.yaml -# Added by code-review-graph + +# AI / IDE tool files .code-review-graph/ .claude .kiro @@ -34,7 +36,10 @@ config.yaml CLAUDE.md AGENTS.md GEMINI.md -logs/ + +# Plugin outputs / memory plugins/ aipwn_memory/ -*.bk \ No newline at end of file + +# Backup files +*.bk diff --git a/nutcracker_core/apk_tools.py b/nutcracker_core/apk_tools.py index 8fb5fb0..f5aaf2f 100644 --- a/nutcracker_core/apk_tools.py +++ b/nutcracker_core/apk_tools.py @@ -10,6 +10,8 @@ from pathlib import Path from typing import Callable +from .i18n import t + # Directorio caché para debug keystore _CACHE_DIR = Path.home() / ".cache" / "nutcracker" @@ -281,15 +283,15 @@ def patch_split_apk( sdk = find_sdk_root() apksigner = find_apksigner(sdk) if not apksigner: - cb("apksigner no encontrado en Android SDK — no se puede parchear el APK") + cb(t("apk_no_apksigner")) return None keystore = ensure_debug_keystore() if not keystore: - cb("keytool no disponible — no se puede generar la clave de firma debug") + cb(t("apk_no_keytool")) return None - cb("Parcheando APK — anulando atributos de splits requeridos...") + cb(t("apk_patching_splits")) unsigned = apk_path.parent / f"{apk_path.stem}_unsigned.apk" aligned = apk_path.parent / f"{apk_path.stem}_aligned.apk" @@ -302,7 +304,7 @@ def patch_split_apk( _zipfile.ZipFile(str(unsigned), "w") as zout: for item in zin.infolist(): if item.filename in REMOVE: - cb(f" Eliminado: {item.filename}") + cb(t("apk_removed_item", filename=item.filename)) continue if item.filename.startswith("META-INF/"): continue @@ -312,30 +314,30 @@ def patch_split_apk( if item.filename == "AndroidManifest.xml": patched_raw = strip_required_splits_from_manifest(raw) if patched_raw != raw: - cb(" Parcheado: AndroidManifest.xml (requiredSplitTypes anulado)") + cb(t("apk_patched_manifest")) raw = patched_raw zout.writestr(item, raw, compress_type=item.compress_type) except Exception as exc: - cb(f"Error al reempaquetar APK: {exc}") + cb(t("apk_repack_error", exc=exc)) unsigned.unlink(missing_ok=True) return None zipalign = str(Path(apksigner).parent / "zipalign") if Path(zipalign).exists(): - cb("Alineando APK con zipalign...") + cb(t("apk_aligning")) za_result = subprocess.run( [zipalign, "-f", "4", str(unsigned), str(aligned)], capture_output=True, text=True, timeout=60, ) unsigned.unlink(missing_ok=True) if za_result.returncode != 0: - cb(f"Advertencia — zipalign falló: {za_result.stderr[:200]}") + cb(t("apk_zipalign_warning", err=za_result.stderr[:200])) aligned.rename(unsigned) else: aligned = unsigned - cb("Firmando APK parcheada con clave debug...") + cb(t("apk_signing")) sign_result = subprocess.run( [ apksigner, "sign", @@ -351,11 +353,11 @@ def patch_split_apk( aligned.unlink(missing_ok=True) if sign_result.returncode != 0: - cb(f"Error firmando APK: {sign_result.stderr[:300]}") + cb(t("apk_sign_error", err=sign_result.stderr[:300])) patched.unlink(missing_ok=True) return None - cb(f"APK parcheada lista: {patched.name}") + cb(t("apk_patched_ready", name=patched.name)) return patched @@ -403,63 +405,55 @@ def _run_install(apks: list[Path]) -> tuple[bool, str]: return True, "" return False, combined[:300].strip() - cb(f"Instalando APK en emulador {serial}...") + cb(t("apk_installing", serial=serial)) ok, err = _run_install([apk_path]) if ok: - cb("APK instalada correctamente") + cb(t("apk_installed_ok")) return True - cb(f"Fallo inicial: {err}") + cb(t("apk_install_initial_fail", err=err)) # ── Splits requeridos ───────────────────────────────────────────────────── if "MISSING_SPLIT" in err: splits = find_split_apks(apk_path) if len(splits) > 1: - cb(f"Bundle detectado — instalando {len(splits)} splits con install-multiple...") + cb(t("apk_bundle_detected", count=len(splits))) ok, err = _run_install(splits) if ok: - cb(f"APK instalada correctamente ({len(splits)} splits)") + cb(t("apk_installed_splits_ok", count=len(splits))) return True - cb(f"Fallo con splits: {err}") + cb(t("apk_install_splits_fail", err=err)) - cb("No se encontraron splits locales — intentando parcheado del APK...") + cb(t("apk_no_splits_patching")) patched = patch_split_apk(apk_path, tools, cb) if patched: ok, err = _run_install([patched]) if ok: - cb("APK parcheada instalada correctamente") + cb(t("apk_patched_installed_ok")) return True - cb(f"Fallo después de parcheado: {err}") + cb(t("apk_install_patched_fail", err=err)) - cb( - "INSTALL_FAILED_MISSING_SPLIT: no se pudo instalar el APK.\n" - "La app se distribuye como App Bundle y apkeep solo descargó el split base.\n" - "Prueba obtener el APK completo (todos los splits) de APKMirror u otra fuente." - ) + cb(t("apk_install_missing_split")) return False # ── Firma incompatible: desinstalar primero ─────────────────────────────── if "UPDATE_INCOMPATIBLE" in err or "SIGNATURES_DO_NOT_MATCH" in err: pkg_to_uninstall = package_name or apk_path.stem - cb("Firma incompatible — desinstalando versión anterior...") + cb(t("apk_incompatible_sig")) subprocess.run( [adb, "-s", serial, "uninstall", pkg_to_uninstall], capture_output=True, text=True, timeout=30, ) ok, err = _run_install([apk_path]) if ok: - cb("APK instalada correctamente (tras desinstalación)") + cb(t("apk_installed_after_uninstall")) return True # ── Mismatch de ABI ─────────────────────────────────────────────────────── if "NO_MATCHING_ABIS" in err: - cb( - "ERROR: La APK no contiene libs nativas para la arquitectura del AVD. " - "Usa un AVD arm64 con una APK arm64, o un AVD x86_64. " - "Prueba el AVD Resizable o elige otro AVD." - ) + cb(t("apk_no_matching_abis")) return False - cb(f"Error instalando APK: {err}") + cb(t("apk_install_error", err=err)) return False diff --git a/nutcracker_core/i18n.py b/nutcracker_core/i18n.py index 2673a2b..b1dfb20 100644 --- a/nutcracker_core/i18n.py +++ b/nutcracker_core/i18n.py @@ -781,6 +781,7 @@ def register(strings: dict[str, dict[str, str]]) -> None: "rt_downloaded_valid": "Downloaded: {count} valid .dex files ({discarded} discarded)", "rt_ui_waiting_startup": "UI: waiting {delay:.0f}s for app startup...", "rt_ui_starting": "UI: starting screen automation", + "rt_ui_stopped": "UI: automation stopped", "rt_dex_empty": "\u26a0 {name}: empty, discarding", "rt_dex_invalid_magic": "\u26a0 {name}: invalid magic (dump captured before decryption in RAM)", "rt_dexdump_via_host": "Dumping DEX via frida-dexdump -H {host} -f {package}...", @@ -818,6 +819,33 @@ def register(strings: dict[str, dict[str, str]]) -> None: "dev_frida_waiting": "Waiting for frida-server to start...", "dev_frida_timeout": "Timeout: frida-server did not start", + # ── apk_tools.py messages ───────────────────────────────────────────── + "apk_no_apksigner": "apksigner not found in Android SDK — cannot patch APK", + "apk_no_keytool": "keytool not available — cannot generate debug signing key", + "apk_patching_splits": "Patching APK — nullifying required split attributes...", + "apk_removed_item": " Removed: {filename}", + "apk_patched_manifest": " Patched: AndroidManifest.xml (requiredSplitTypes nullified)", + "apk_repack_error": "Error repacking APK: {exc}", + "apk_aligning": "Aligning APK with zipalign...", + "apk_zipalign_warning": "Warning — zipalign failed: {err}", + "apk_signing": "Signing patched APK with debug key...", + "apk_sign_error": "Error signing APK: {err}", + "apk_patched_ready": "Patched APK ready: {name}", + "apk_installing": "Installing APK on emulator {serial}...", + "apk_installed_ok": "APK installed successfully", + "apk_install_initial_fail": "Initial failure: {err}", + "apk_bundle_detected": "Bundle detected — installing {count} splits with install-multiple...", + "apk_installed_splits_ok": "APK installed successfully ({count} splits)", + "apk_install_splits_fail": "Failure with splits: {err}", + "apk_no_splits_patching": "No local splits found — attempting APK patching...", + "apk_patched_installed_ok": "Patched APK installed successfully", + "apk_install_patched_fail": "Failure after patching: {err}", + "apk_install_missing_split": "INSTALL_FAILED_MISSING_SPLIT: could not install APK.\nThe app is distributed as an App Bundle and apkeep only downloaded the base split.\nTry getting the full APK (all splits) from APKMirror or another source.", + "apk_incompatible_sig": "Incompatible signature — uninstalling previous version...", + "apk_installed_after_uninstall": "APK installed successfully (after uninstall)", + "apk_no_matching_abis": "ERROR: The APK does not contain native libs for this AVD architecture. Use an arm64 AVD with an arm64 APK, or an x86_64 AVD. Try the Resizable AVD or choose another.", + "apk_install_error": "Error installing APK: {err}", + # ── String extractor messages ───────────────────────────────────────── "se_extracting": "Extracting strings... {current}/{total} files", "se_extraction_complete": "String extraction complete.", @@ -1617,8 +1645,9 @@ def register(strings: dict[str, dict[str, str]]) -> None: "rt_downloading_dumps": "Descargando volcados desde {serial}:{dir}...", "rt_downloaded_valid": "Descargados: {count} archivos .dex v\u00e1lidos ({discarded} descartados)", "rt_ui_waiting_startup": "UI: esperando {delay:.0f}s para el arranque de la app...", - "rt_ui_starting": "UI: iniciando automatizaci\u00f3n de pantalla", - "rt_dex_empty": "\u26a0 {name}: vac\u00edo, descartando", + "rt_ui_starting": "UI: iniciando automatización de pantalla", + "rt_ui_stopped": "UI: automatización detenida", + "rt_dex_empty": "\u26a0 {name}: vacío, descartando", "rt_dex_invalid_magic": "\u26a0 {name}: magic inv\u00e1lido (dump capturado antes del descifrado en RAM)", "rt_dexdump_via_host": "Volcando DEX v\u00eda frida-dexdump -H {host} -f {package}...", "rt_dex_dumped_in": "{count} DEX volcados en {output_dir}", @@ -1655,6 +1684,33 @@ def register(strings: dict[str, dict[str, str]]) -> None: "dev_frida_waiting": "Esperando a que frida-server arranque...", "dev_frida_timeout": "Timeout: frida-server no arrancó", + # ── apk_tools.py messages ───────────────────────────────────────────── + "apk_no_apksigner": "apksigner no encontrado en Android SDK — no se puede parchear el APK", + "apk_no_keytool": "keytool no disponible — no se puede generar la clave de firma debug", + "apk_patching_splits": "Parcheando APK — anulando atributos de splits requeridos...", + "apk_removed_item": " Eliminado: {filename}", + "apk_patched_manifest": " Parcheado: AndroidManifest.xml (requiredSplitTypes anulado)", + "apk_repack_error": "Error al reempaquetar APK: {exc}", + "apk_aligning": "Alineando APK con zipalign...", + "apk_zipalign_warning": "Advertencia — zipalign falló: {err}", + "apk_signing": "Firmando APK parcheada con clave debug...", + "apk_sign_error": "Error firmando APK: {err}", + "apk_patched_ready": "APK parcheada lista: {name}", + "apk_installing": "Instalando APK en emulador {serial}...", + "apk_installed_ok": "APK instalada correctamente", + "apk_install_initial_fail": "Fallo inicial: {err}", + "apk_bundle_detected": "Bundle detectado — instalando {count} splits con install-multiple...", + "apk_installed_splits_ok": "APK instalada correctamente ({count} splits)", + "apk_install_splits_fail": "Fallo con splits: {err}", + "apk_no_splits_patching": "No se encontraron splits locales — intentando parcheado del APK...", + "apk_patched_installed_ok": "APK parcheada instalada correctamente", + "apk_install_patched_fail": "Fallo después de parcheado: {err}", + "apk_install_missing_split": "INSTALL_FAILED_MISSING_SPLIT: no se pudo instalar el APK.\nLa app se distribuye como App Bundle y apkeep solo descargó el split base.\nPrueba obtener el APK completo (todos los splits) de APKMirror u otra fuente.", + "apk_incompatible_sig": "Firma incompatible — desinstalando versión anterior...", + "apk_installed_after_uninstall": "APK instalada correctamente (tras desinstalación)", + "apk_no_matching_abis": "ERROR: La APK no contiene libs nativas para la arquitectura del AVD. Usa un AVD arm64 con una APK arm64, o un AVD x86_64. Prueba el AVD Resizable o elige otro AVD.", + "apk_install_error": "Error instalando APK: {err}", + # ── String extractor messages ───────────────────────────────────────── "se_extracting": "Extrayendo strings... {current}/{total} archivos", "se_extraction_complete": "Extracci\u00f3n de strings completada.", diff --git a/nutcracker_core/runtime.py b/nutcracker_core/runtime.py index eacbfcd..398133a 100644 --- a/nutcracker_core/runtime.py +++ b/nutcracker_core/runtime.py @@ -486,12 +486,12 @@ def simulate_app_navigation( (W + 200, H, W - 200, H, 300), ] - cb(f"UI: esperando {startup_delay:.0f}s para el arranque de la app...") + cb(t("rt_ui_waiting_startup", delay=startup_delay)) stop_event.wait(timeout=startup_delay) if stop_event.is_set(): return - cb("UI: iniciando automatización de pantalla") + cb(t("rt_ui_starting")) cycle = 0 while not stop_event.is_set(): try: @@ -532,7 +532,7 @@ def simulate_app_navigation( except Exception: # noqa: BLE001 stop_event.wait(timeout=3.0) - cb("UI: automatización detenida") + cb(t("rt_ui_stopped")) # ── Cierre de proceso Frida ─────────────────────────────────────────────────── From 981bcc1f7f2726b8d9425d7c0b885f930ba0fe88 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 3 May 2026 07:13:42 +0000 Subject: [PATCH 02/30] Add Demo section with YouTube video to README Agent-Logs-Url: https://github.com/drneox/nutcracker/sessions/854364d9-1441-4a3e-a0fc-6c797bc4e22e Co-authored-by: drneox <1235287+drneox@users.noreply.github.com> --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index d25da63..7011a82 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,12 @@ domains, endpoints and extracted secrets (subdomains via crt.sh, public leaks on Findings go through an optional LLM-powered false positive filter (`ai-review`). All results are consolidated into a technical PDF report ready for reporting. +--- + +## Demo + +[![Demo video](https://img.youtube.com/vi/nMcFy7ibgp0/maxresdefault.jpg)](https://youtu.be/nMcFy7ibgp0?si=jpTBApvVqix5pKd9) + --- ## ⚠️ Legal Disclaimer From 6a42859a85f528da5bba7e979a9931c79f5ee984 Mon Sep 17 00:00:00 2001 From: drneox Date: Sun, 3 May 2026 19:39:58 -0500 Subject: [PATCH 03/30] fix: corrected bugs in device, report and pipeline --- Dockerfile | 1 + README.md | 8 ++- nutcracker.py | 45 ++++++++-------- nutcracker_core/device.py | 33 +++++++----- nutcracker_core/i18n.py | 4 ++ nutcracker_core/pdf_reporter.py | 2 +- nutcracker_core/pipeline.py | 40 ++++++++++----- nutcracker_core/plugins/aireview/__init__.py | 54 ++++++++++++-------- setup.sh | 14 +++++ 9 files changed, 131 insertions(+), 70 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1e8b25b..9ddc3e2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,7 @@ ENV DEBIAN_FRONTEND=noninteractive \ RUN apt-get update && apt-get install -y --no-install-recommends \ adb \ apktool \ + binutils-aarch64-linux-gnu \ curl \ default-jre \ git \ diff --git a/README.md b/README.md index d25da63..63df338 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ brew install android-platform-tools # adb ```bash # Base tools sudo apt update -sudo apt install -y openjdk-21-jre-headless jadx apktool adb curl +sudo apt install -y openjdk-21-jre-headless jadx apktool adb curl binutils-aarch64-linux-gnu # semgrep (via pipx recommended) python3 -m pip install --user pipx @@ -518,6 +518,12 @@ git clone https://github.com// nutcracker_core/plugins/ | `aipwn` | `nutcracker aipwn ` | Autonomous LLM-powered Frida bypass agent | | `aireview` | `nutcracker ai-review ` | LLM-powered false positive filter | +> **`aipwn` native library analysis** — The agent can disassemble and patch `.so` files +> (native RASP checks). This requires a cross-compiler `objdump` for ARM64: +> - **macOS**: system `objdump` (LLVM) supports ELF ARM64 — no extra install needed. +> - **Linux**: `sudo apt install binutils-aarch64-linux-gnu` (provides `aarch64-linux-gnu-objdump`). Already included in the Docker image. +> - **Optional**: `radare2` — if present, preferred over objdump for richer output. + --- ## Building a Plugin diff --git a/nutcracker.py b/nutcracker.py index 49162b3..da584fa 100644 --- a/nutcracker.py +++ b/nutcracker.py @@ -1628,11 +1628,7 @@ def _is_leak_finding(f) -> bool: print_vuln_report(scan_result, source_dir) pkg_name = package_hint or source_dir.name - canonical_report = Path("./decompiled") / f"vuln_{pkg_name}.json" - _save_vuln_json(scan_result, canonical_report) - legacy_report = source_dir.parent / f"vuln_{source_dir.name}.json" - if legacy_report.resolve() != canonical_report.resolve(): - _save_vuln_json(scan_result, legacy_report) + _save_vuln_json(scan_result, pkg_name) return scan_result try: @@ -1721,16 +1717,9 @@ def on_progress(msg: str) -> None: print_vuln_report(scan_result, source_dir) - # Guardar JSON con nombre canónico por paquete para que el PDF siempre - # cargue el último scan correcto (incluyendo flujos DexGuard/FART). + # Guardar JSON con nombre canónico por paquete. pkg_name = package_hint or source_dir.name - canonical_report = Path("./decompiled") / f"vuln_{pkg_name}.json" - _save_vuln_json(scan_result, canonical_report) - - # Compatibilidad: mantener también el path histórico local al source_dir. - legacy_report = source_dir.parent / f"vuln_{source_dir.name}.json" - if legacy_report.resolve() != canonical_report.resolve(): - _save_vuln_json(scan_result, legacy_report) + _save_vuln_json(scan_result, pkg_name) return scan_result @@ -1739,7 +1728,11 @@ def _load_vuln_json(package: str): import json from nutcracker_core.vuln_scanner import ScanResult, VulnFinding - json_path = Path("./decompiled") / f"vuln_{package}.json" + # Primario: reports//vuln.json + # Fallback: decompiled/vuln_.json (legacy) + json_path = Path("./reports") / package / "vuln.json" + if not json_path.exists(): + json_path = Path("./decompiled") / f"vuln_{package}.json" if not json_path.exists(): return None try: @@ -1844,10 +1837,14 @@ def _generate_pdf(result, vuln_scan=None, vuln_scan_enabled: bool = True) -> Non console.print(f"[red]{t('cli_error_pdf')}[/red] {exc}") -def _save_vuln_json(scan_result, output_path: Path) -> None: - """Guarda los hallazgos de vulnerabilidades en JSON.""" +def _save_vuln_json(scan_result, package: str) -> None: + """Guarda los hallazgos de vulnerabilidades en JSON. + + Escribe en dos ubicaciones: + - reports//vuln.json (primario, junto al AnalysisResult) + - decompiled/vuln_.json (legacy, usado por aireview) + """ import json - output_path.parent.mkdir(parents=True, exist_ok=True) data = { "files_scanned": scan_result.files_scanned, "total_findings": len(scan_result.findings), @@ -1866,9 +1863,15 @@ def _save_vuln_json(scan_result, output_path: Path) -> None: for f in scan_result.findings ], } - with output_path.open("w", encoding="utf-8") as fh: - json.dump(data, fh, ensure_ascii=False, indent=2) - console.print(f"[dim]{t('cli_vuln_json_saved')}[/dim] [bold]{output_path}[/bold]") + payload = json.dumps(data, ensure_ascii=False, indent=2) + primary = Path("./reports") / package / "vuln.json" + primary.parent.mkdir(parents=True, exist_ok=True) + primary.write_text(payload, encoding="utf-8") + # Legacy copy para aireview y otros usos directos de decompiled/ + legacy = Path("./decompiled") / f"vuln_{package}.json" + legacy.parent.mkdir(parents=True, exist_ok=True) + legacy.write_text(payload, encoding="utf-8") + console.print(f"[dim]{t('cli_vuln_json_saved')}[/dim] [bold]{primary}[/bold]") # ── Comando: batch ─────────────────────────────────────────────────────────── diff --git a/nutcracker_core/device.py b/nutcracker_core/device.py index b154b83..b7f1725 100644 --- a/nutcracker_core/device.py +++ b/nutcracker_core/device.py @@ -230,13 +230,16 @@ def _adb_shell( timeout: int = 15, ) -> str: """Ejecuta un comando en el shell del dispositivo y devuelve stdout.""" - result = subprocess.run( - [adb, "-s", serial, "shell", command], - capture_output=True, - text=True, - timeout=timeout, - ) - return result.stdout.strip() + try: + result = subprocess.run( + [adb, "-s", serial, "shell", command], + capture_output=True, + text=True, + timeout=timeout, + ) + return result.stdout.strip() + except subprocess.TimeoutExpired: + return "" # ── Arrancar emulador ───────────────────────────────────────────────────────── @@ -422,12 +425,16 @@ def setup_frida_server( # Push del binario cb(t("dev_frida_pushing")) - result = subprocess.run( - [adb, "-s", serial, "push", str(server_binary), remote_path], - capture_output=True, - text=True, - timeout=60, - ) + try: + result = subprocess.run( + [adb, "-s", serial, "push", str(server_binary), remote_path], + capture_output=True, + text=True, + timeout=60, + ) + except subprocess.TimeoutExpired: + cb(t("dev_frida_push_timeout")) + return False if result.returncode != 0: cb(t("dev_frida_push_error", err=result.stderr[:200])) return False diff --git a/nutcracker_core/i18n.py b/nutcracker_core/i18n.py index b1dfb20..4211ce0 100644 --- a/nutcracker_core/i18n.py +++ b/nutcracker_core/i18n.py @@ -750,6 +750,7 @@ def register(strings: dict[str, dict[str, str]]) -> None: "pipe_fart_manual_hint": "Continue with the manual flow shown above.", "pipe_waiting_dex_dumps": "Waiting for DEX dumps...", "pipe_fart_timeout_device": "FART timeout on device. Trying next method...", + "pipe_adb_uninstall_timeout": "adb uninstall timed out \u2014 continuing anyway", "pipe_downloading_dex_dumps": "Downloading DEX dumps...", # ── Runtime messages ────────────────────────────────────────────────── @@ -806,6 +807,7 @@ def register(strings: dict[str, dict[str, str]]) -> None: "dev_frida_restarting": "Restarting frida-server (force_restart)...", "dev_frida_pushing": "Uploading frida-server to device...", "dev_frida_push_error": "Error uploading frida-server: {err}", + "dev_frida_push_timeout": "Timeout uploading frida-server — adb push took too long", "dev_adb_rooting": "Elevating permissions with adb root...", "dev_selinux_enforcing": "SELinux enforcing detected — switching to permissive...", "dev_selinux_root_popup": " (if a root popup appears on the device, accept it)", @@ -1615,6 +1617,7 @@ def register(strings: dict[str, dict[str, str]]) -> None: "pipe_fart_manual_hint": "Contin\u00faa con el flujo manual mostrado arriba.", "pipe_waiting_dex_dumps": "Esperando volcados DEX...", "pipe_fart_timeout_device": "FART timeout en device. Probando siguiente m\u00e9todo...", + "pipe_adb_uninstall_timeout": "adb uninstall agot\u00f3 el tiempo de espera \u2014 continuando", "pipe_downloading_dex_dumps": "Descargando DEX volcados...", # ── Runtime messages ────────────────────────────────────────────────── @@ -1671,6 +1674,7 @@ def register(strings: dict[str, dict[str, str]]) -> None: "dev_frida_restarting": "Reiniciando frida-server (force_restart)...", "dev_frida_pushing": "Subiendo frida-server al device...", "dev_frida_push_error": "Error subiendo frida-server: {err}", + "dev_frida_push_timeout": "Timeout subiendo frida-server — adb push tardó demasiado", "dev_adb_rooting": "Elevando permisos con adb root...", "dev_selinux_enforcing": "SELinux enforcing detectado — poniendo en permissive...", "dev_selinux_root_popup": " (si aparece un popup de root en el dispositivo, acéptalo)", diff --git a/nutcracker_core/pdf_reporter.py b/nutcracker_core/pdf_reporter.py index 8255ef3..835ad60 100644 --- a/nutcracker_core/pdf_reporter.py +++ b/nutcracker_core/pdf_reporter.py @@ -1035,7 +1035,7 @@ def _findings_section( # Pills de resumen por severidad by_sev: dict[str, list["VulnFinding"]] = {"critical": [], "high": [], "medium": [], "low": [], "info": []} for f in findings: - by_sev.setdefault(f.severity, []).append(f) + by_sev.setdefault((f.severity or "info").lower(), []).append(f) sev_order = ["critical", "high", "medium", "low", "info"] pdf.set_font("Helvetica", "B", 8) diff --git a/nutcracker_core/pipeline.py b/nutcracker_core/pipeline.py index 0a7f6a6..3d614bd 100644 --- a/nutcracker_core/pipeline.py +++ b/nutcracker_core/pipeline.py @@ -385,10 +385,13 @@ def _ensure_installed(target_apk: Path, pkg: str) -> bool: # Gadget puede haber reinstalado un APK parcheado en el emulador. # Desinstalar el parcheado para que el siguiente método pueda # instalar el original sin UPDATE_INCOMPATIBLE (firma debug ≠ Play Store). - subprocess.run( - [sdk_tools["adb"], "-s", serial, "uninstall", package], - capture_output=True, text=True, timeout=30, - ) + try: + subprocess.run( + [sdk_tools["adb"], "-s", serial, "uninstall", package], + capture_output=True, text=True, timeout=30, + ) + except subprocess.TimeoutExpired: + console.print(f"[yellow]⚠[/yellow] {t('pipe_adb_uninstall_timeout')}") app_installed = False continue @@ -398,10 +401,13 @@ def _ensure_installed(target_apk: Path, pkg: str) -> bool: if not _ensure_frida_server(force_restart=True): continue # Reinstalar APK original: gadget puede haber dejado APK parcheado. - subprocess.run( - [sdk_tools["adb"], "-s", serial, "uninstall", package], - capture_output=True, text=True, timeout=30, - ) + try: + subprocess.run( + [sdk_tools["adb"], "-s", serial, "uninstall", package], + capture_output=True, text=True, timeout=30, + ) + except subprocess.TimeoutExpired: + console.print(f"[yellow]⚠[/yellow] {t('pipe_adb_uninstall_timeout')}") app_installed = False if not _ensure_installed(apk_path, package): continue @@ -436,10 +442,13 @@ def _ensure_installed(target_apk: Path, pkg: str) -> bool: # previo de gadget tuvo éxito dejó un APK parcheado con # libfrida-gadget.so. Hacer spawn sobre ese APK causa doble # inyección → DeadSystemException en el system_server. - subprocess.run( - [sdk_tools["adb"], "-s", serial, "uninstall", package], - capture_output=True, text=True, timeout=30, - ) + try: + subprocess.run( + [sdk_tools["adb"], "-s", serial, "uninstall", package], + capture_output=True, text=True, timeout=30, + ) + except subprocess.TimeoutExpired: + console.print(f"[yellow]⚠[/yellow] {t('pipe_adb_uninstall_timeout')}") app_installed = False if not _ensure_installed(apk_path, package): continue @@ -793,8 +802,11 @@ def try_gadget_inject( ) console.print(f" {t('pipe_installing_splits_gadget', count=len(patched_splits))}") # Desinstalar primero (firma cambió) - subprocess.run([sdk_tools["adb"], "-s", serial, "uninstall", package], - capture_output=True, text=True, timeout=30) + try: + subprocess.run([sdk_tools["adb"], "-s", serial, "uninstall", package], + capture_output=True, text=True, timeout=30) + except subprocess.TimeoutExpired: + console.print(f"[yellow]⚠[/yellow] {t('pipe_adb_uninstall_timeout')}") r = subprocess.run( [sdk_tools["adb"], "-s", serial, "install-multiple", "-r", "-t", "-d", *[str(p) for p in patched_splits]], diff --git a/nutcracker_core/plugins/aireview/__init__.py b/nutcracker_core/plugins/aireview/__init__.py index 74205f3..ca460d3 100644 --- a/nutcracker_core/plugins/aireview/__init__.py +++ b/nutcracker_core/plugins/aireview/__init__.py @@ -34,6 +34,7 @@ # ── Constants ──────────────────────────────────────────────────────────────── _DECOMPILED_DIR = Path("./decompiled") +_REPORTS_DIR = Path("./reports") _DEFAULT_BATCH_SIZE = 8 _DEFAULT_CONTEXT_LINES = 4 _VALID_VERDICTS = {"TRUE_POSITIVE", "FALSE_POSITIVE", "DOWNGRADE"} @@ -86,10 +87,13 @@ def _load_findings(package: str) -> tuple[Path, dict]: """Load the vulnerability JSON for a package. Raises click.ClickException.""" - path = _DECOMPILED_DIR / f"vuln_{package}.json" + # Primario: reports//vuln.json; fallback: decompiled/vuln_.json + path = _REPORTS_DIR / package / "vuln.json" + if not path.exists(): + path = _DECOMPILED_DIR / f"vuln_{package}.json" if not path.exists(): raise click.ClickException( - f"No vuln scan found at {path}. Run a full analysis first." + f"No vuln scan found for {package}. Run a full analysis first." ) try: data = json.loads(path.read_text(encoding="utf-8")) @@ -203,7 +207,7 @@ def _parse_verdicts(raw: str, expected: int) -> list[dict] | None: if verdict == "DOWNGRADE": sev = str(v.get("suggested_severity", "")).upper().strip() if sev in {"CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"}: - entry["suggested_severity"] = sev + entry["suggested_severity"] = sev.lower() cat = str(v.get("suggested_category", "")).strip() if cat: entry["suggested_category"] = cat @@ -371,8 +375,12 @@ def _after_analysis_hook( f"dropped {fp_count} FPs, downgraded {dg_count} for {package}" ) - # Save audit trail - review_path = _DECOMPILED_DIR / f"vuln_{package}_review.json" + # Save audit trail — primario en reports//review.json + pkg_reports_dir = _REPORTS_DIR / package + pkg_reports_dir.mkdir(parents=True, exist_ok=True) + review_path = pkg_reports_dir / "review.json" + # Legacy copy en decompiled/ para compatibilidad + legacy_review_path = _DECOMPILED_DIR / f"vuln_{package}_review.json" review_payload = { "package": package, "reviewed_at": time.strftime("%Y-%m-%dT%H:%M:%S"), @@ -384,9 +392,9 @@ def _after_analysis_hook( "downgraded": dg_count, "verdicts": verdicts, } - review_path.write_text( - json.dumps(review_payload, ensure_ascii=False, indent=2), encoding="utf-8" - ) + review_json = json.dumps(review_payload, ensure_ascii=False, indent=2) + review_path.write_text(review_json, encoding="utf-8") + legacy_review_path.write_text(review_json, encoding="utf-8") if fp_count == 0 and dg_count == 0: return @@ -406,8 +414,12 @@ def _after_analysis_hook( "dropped": fp_count, "downgraded": dg_count, } - path.write_text(json.dumps(new_data, ensure_ascii=False, indent=2), encoding="utf-8") - console.print(f"[dim] {path} rewritten in place.[/dim]") + new_json = json.dumps(new_data, ensure_ascii=False, indent=2) + # Escribir en primario (reports/) y en legacy (decompiled/) + primary_vuln = pkg_reports_dir / "vuln.json" + primary_vuln.write_text(new_json, encoding="utf-8") + path.write_text(new_json, encoding="utf-8") + console.print(f"[dim] vuln.json rewritten in {pkg_reports_dir} and {path.parent}[/dim]") if regen_pdf: try: @@ -603,7 +615,10 @@ def ai_review_cmd( console.print(f"[dim]... and {len(downgraded) - 50} more[/dim]") # ── Persist results ────────────────────────────────────────────── - review_path = _DECOMPILED_DIR / f"vuln_{package}_review.json" + pkg_reports_dir = _REPORTS_DIR / package + pkg_reports_dir.mkdir(parents=True, exist_ok=True) + review_path = pkg_reports_dir / "review.json" + legacy_review_path = _DECOMPILED_DIR / f"vuln_{package}_review.json" review_payload = { "package": package, "reviewed_at": time.strftime("%Y-%m-%dT%H:%M:%S"), @@ -623,10 +638,9 @@ def ai_review_cmd( ) return - review_path.write_text( - json.dumps(review_payload, ensure_ascii=False, indent=2), - encoding="utf-8", - ) + review_json = json.dumps(review_payload, ensure_ascii=False, indent=2) + review_path.write_text(review_json, encoding="utf-8") + legacy_review_path.write_text(review_json, encoding="utf-8") console.print(f"[dim]Audit trail saved to {review_path}[/dim]") if fp_count == 0 and dg_count == 0: @@ -653,11 +667,11 @@ def ai_review_cmd( "dropped": fp_count, "downgraded": dg_count, } - path.write_text( - json.dumps(new_data, ensure_ascii=False, indent=2), - encoding="utf-8", - ) - console.print(f"[green]✔[/green] {path} updated in place.") + new_json = json.dumps(new_data, ensure_ascii=False, indent=2) + # Escribir en primario (reports/) y en legacy (decompiled/) + (pkg_reports_dir / "vuln.json").write_text(new_json, encoding="utf-8") + path.write_text(new_json, encoding="utf-8") + console.print(f"[green]✔[/green] vuln.json updated in {pkg_reports_dir} and {path.parent}.") # ── Optionally regenerate the PDF ──────────────────────────────── if no_regen_pdf: diff --git a/setup.sh b/setup.sh index c45e4ef..8805889 100755 --- a/setup.sh +++ b/setup.sh @@ -34,6 +34,20 @@ else fi echo "" +echo "" +echo "==> Verificando desensamblador para libs nativas (.so ARM64)..." +if command -v objdump &>/dev/null || command -v aarch64-linux-gnu-objdump &>/dev/null || command -v r2 &>/dev/null; then + echo " OK: desensamblador disponible" +else + if [[ "$(uname)" == "Linux" ]]; then + echo " AVISO: no se encontró objdump ARM64. Instalando binutils-aarch64-linux-gnu..." + sudo apt-get install -y binutils-aarch64-linux-gnu 2>/dev/null || \ + echo " No se pudo instalar automáticamente. Ejecuta: sudo apt install binutils-aarch64-linux-gnu" + else + echo " INFO (macOS): objdump del sistema soporta ELF ARM64. OK" + fi +fi + echo "==> ¡Configuración completada!" echo "" echo "Activa el entorno virtual con:" From 244b3b40da16169880b593b7f33bc7c595cef0c2 Mon Sep 17 00:00:00 2001 From: drneox Date: Tue, 5 May 2026 04:30:35 -0500 Subject: [PATCH 04/30] feat(i18n): add er_* strings for ExploitAgent PDF report (EN + ES) Add 24 keys (er_header, er_title, er_description, er_confirmed, er_badge_*, er_field_*, er_section_bypass, er_stats_line, etc.) for both 'en' and 'es' locales. Used by exploit_report_pdf.py. --- nutcracker_core/i18n.py | 48 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/nutcracker_core/i18n.py b/nutcracker_core/i18n.py index 4211ce0..2ade002 100644 --- a/nutcracker_core/i18n.py +++ b/nutcracker_core/i18n.py @@ -930,6 +930,30 @@ def register(strings: dict[str, dict[str, str]]) -> None: "et_verify_apkeep": "Verify that apkeep is installed and the device has a valid session.", "et_aas_saved": "\nAAS token saved to {path}", "et_ready": "You can now use Google Play in nutcracker.", + # ── Exploit Report PDF ───────────────────────────────────────────────── + "er_header": "nutcracker - Exploit Report | {package}", + "er_title": "EXPLOIT REPORT", + "er_pdf_title": "Exploit Report - {package}", + "er_description": "AiPWN Agents (bypass + exploit) results: dynamic vulnerability confirmation via ADB and Frida on a real device. Tests were executed post-bypass.", + "er_confirmed": "CONFIRMED", + "er_not_confirmed": "NOT CONFIRMED", + "er_omitted": "OMITTED", + "er_section_bypass": "Frida Bypass", + "er_field_explanation": "Explanation / Diagnosis", + "er_field_stats": "Statistics", + "er_field_script": "Bypass script", + "er_field_script_body": "Script contents", + "er_screenshot_bypass": "SCREENSHOT (visual confirmation post-bypass)", + "er_badge_confirmed": "CONFIRMED", + "er_badge_not_conf": "NOT CONFIRMED", + "er_field_evidence": "Evidence", + "er_field_poc_cmd": "PoC Command", + "er_field_poc_frida": "PoC Frida", + "er_field_impact": "Impact", + "er_screenshot": "SCREENSHOT", + "er_omitted_title": "Omitted (no dynamic test) ({count})", + "er_skip_reason": " Reason: {reason}", + "er_stats_line": "Frida runs: {frida_runs} | LLM iterations: {iterations} | Timestamp: {timestamp}", }, # ══════════════════════════════════════════════════════════════════════════ @@ -1797,5 +1821,29 @@ def register(strings: dict[str, dict[str, str]]) -> None: "et_verify_apkeep": "Verifica que apkeep est\u00e9 instalado y que el dispositivo tenga sesi\u00f3n v\u00e1lida.", "et_aas_saved": "\nAAS token guardado en {path}", "et_ready": "Ya puedes usar Google Play en nutcracker.", + # ── Exploit Report PDF ───────────────────────────────────────────────── + "er_header": "nutcracker - Informe de Explotacion | {package}", + "er_title": "INFORME DE EXPLOTACION", + "er_pdf_title": "Informe de Explotacion - {package}", + "er_description": "Resultados de AiPWN Agents (bypass + exploit): confirmacion dinamica de vulnerabilidades mediante ADB y Frida sobre el dispositivo real. Las pruebas se ejecutaron post-bypass.", + "er_confirmed": "CONFIRMADAS", + "er_not_confirmed": "NO CONFIRMADAS", + "er_omitted": "OMITIDAS", + "er_section_bypass": "Frida Bypass", + "er_field_explanation": "Explicacion / Diagnostico", + "er_field_stats": "Estadisticas", + "er_field_script": "Script de bypass", + "er_field_script_body": "Contenido del script", + "er_screenshot_bypass": "CAPTURA DE PANTALLA (confirmacion visual post-bypass)", + "er_badge_confirmed": "CONFIRMADO", + "er_badge_not_conf": "NO CONFIRMADO", + "er_field_evidence": "Evidencia", + "er_field_poc_cmd": "Comando PoC", + "er_field_poc_frida": "Script Frida", + "er_field_impact": "Impacto", + "er_screenshot": "CAPTURA DE PANTALLA", + "er_omitted_title": "Omitidas sin prueba dinamica ({count})", + "er_skip_reason": " Motivo: {reason}", + "er_stats_line": "Ejecuciones Frida: {frida_runs} | Iteraciones LLM: {iterations} | Fecha: {timestamp}", }, } From b6962b7e7a7d94f0282ea1d3741db35f25483559 Mon Sep 17 00:00:00 2001 From: drneox Date: Tue, 5 May 2026 04:33:36 -0500 Subject: [PATCH 05/30] refactor(i18n): remove er_* strings from global i18n (moved to aipwn plugin) --- nutcracker_core/i18n.py | 48 ----------------------------------------- 1 file changed, 48 deletions(-) diff --git a/nutcracker_core/i18n.py b/nutcracker_core/i18n.py index 2ade002..4211ce0 100644 --- a/nutcracker_core/i18n.py +++ b/nutcracker_core/i18n.py @@ -930,30 +930,6 @@ def register(strings: dict[str, dict[str, str]]) -> None: "et_verify_apkeep": "Verify that apkeep is installed and the device has a valid session.", "et_aas_saved": "\nAAS token saved to {path}", "et_ready": "You can now use Google Play in nutcracker.", - # ── Exploit Report PDF ───────────────────────────────────────────────── - "er_header": "nutcracker - Exploit Report | {package}", - "er_title": "EXPLOIT REPORT", - "er_pdf_title": "Exploit Report - {package}", - "er_description": "AiPWN Agents (bypass + exploit) results: dynamic vulnerability confirmation via ADB and Frida on a real device. Tests were executed post-bypass.", - "er_confirmed": "CONFIRMED", - "er_not_confirmed": "NOT CONFIRMED", - "er_omitted": "OMITTED", - "er_section_bypass": "Frida Bypass", - "er_field_explanation": "Explanation / Diagnosis", - "er_field_stats": "Statistics", - "er_field_script": "Bypass script", - "er_field_script_body": "Script contents", - "er_screenshot_bypass": "SCREENSHOT (visual confirmation post-bypass)", - "er_badge_confirmed": "CONFIRMED", - "er_badge_not_conf": "NOT CONFIRMED", - "er_field_evidence": "Evidence", - "er_field_poc_cmd": "PoC Command", - "er_field_poc_frida": "PoC Frida", - "er_field_impact": "Impact", - "er_screenshot": "SCREENSHOT", - "er_omitted_title": "Omitted (no dynamic test) ({count})", - "er_skip_reason": " Reason: {reason}", - "er_stats_line": "Frida runs: {frida_runs} | LLM iterations: {iterations} | Timestamp: {timestamp}", }, # ══════════════════════════════════════════════════════════════════════════ @@ -1821,29 +1797,5 @@ def register(strings: dict[str, dict[str, str]]) -> None: "et_verify_apkeep": "Verifica que apkeep est\u00e9 instalado y que el dispositivo tenga sesi\u00f3n v\u00e1lida.", "et_aas_saved": "\nAAS token guardado en {path}", "et_ready": "Ya puedes usar Google Play en nutcracker.", - # ── Exploit Report PDF ───────────────────────────────────────────────── - "er_header": "nutcracker - Informe de Explotacion | {package}", - "er_title": "INFORME DE EXPLOTACION", - "er_pdf_title": "Informe de Explotacion - {package}", - "er_description": "Resultados de AiPWN Agents (bypass + exploit): confirmacion dinamica de vulnerabilidades mediante ADB y Frida sobre el dispositivo real. Las pruebas se ejecutaron post-bypass.", - "er_confirmed": "CONFIRMADAS", - "er_not_confirmed": "NO CONFIRMADAS", - "er_omitted": "OMITIDAS", - "er_section_bypass": "Frida Bypass", - "er_field_explanation": "Explicacion / Diagnostico", - "er_field_stats": "Estadisticas", - "er_field_script": "Script de bypass", - "er_field_script_body": "Contenido del script", - "er_screenshot_bypass": "CAPTURA DE PANTALLA (confirmacion visual post-bypass)", - "er_badge_confirmed": "CONFIRMADO", - "er_badge_not_conf": "NO CONFIRMADO", - "er_field_evidence": "Evidencia", - "er_field_poc_cmd": "Comando PoC", - "er_field_poc_frida": "Script Frida", - "er_field_impact": "Impacto", - "er_screenshot": "CAPTURA DE PANTALLA", - "er_omitted_title": "Omitidas sin prueba dinamica ({count})", - "er_skip_reason": " Motivo: {reason}", - "er_stats_line": "Ejecuciones Frida: {frida_runs} | Iteraciones LLM: {iterations} | Fecha: {timestamp}", }, } From d13edf264e95cd838af498118c9dd3df4d4251ce Mon Sep 17 00:00:00 2001 From: drneox Date: Tue, 5 May 2026 13:51:32 -0500 Subject: [PATCH 06/30] fix: manifest misconfigs en vuln.json, misconfig_titles en MASVS, COMP008, PDF a reports/{pkg}/ --- nutcracker.py | 33 ++++++--- nutcracker_core/masvs.py | 12 ++++ nutcracker_core/vuln_scanner.py | 122 ++++++++++++++++++++++++++++++++ 3 files changed, 157 insertions(+), 10 deletions(-) diff --git a/nutcracker.py b/nutcracker.py index da584fa..e5111eb 100644 --- a/nutcracker.py +++ b/nutcracker.py @@ -772,12 +772,12 @@ def on_progress(msg: str) -> None: result.elapsed_seconds = elapsed_seconds # Guardar JSON una vez que todos los datos están completos - save_analysis_json(result, scan_result=vuln_scan) + save_analysis_json(result, scan_result=vuln_scan, manifest=_MANIFEST_ANALYSIS) # ── Resumen MASVS v2 ───────────────────────────────────────────── try: from nutcracker_core.masvs import build_masvs_report - _masvs = build_masvs_report(result, vuln_scan) + _masvs = build_masvs_report(result, vuln_scan, _MANIFEST_ANALYSIS) print_masvs_summary(_masvs) except Exception: pass @@ -1628,7 +1628,7 @@ def _is_leak_finding(f) -> bool: print_vuln_report(scan_result, source_dir) pkg_name = package_hint or source_dir.name - _save_vuln_json(scan_result, pkg_name) + _save_vuln_json(scan_result, pkg_name, manifest=_MANIFEST_ANALYSIS) return scan_result try: @@ -1719,7 +1719,7 @@ def on_progress(msg: str) -> None: # Guardar JSON con nombre canónico por paquete. pkg_name = package_hint or source_dir.name - _save_vuln_json(scan_result, pkg_name) + _save_vuln_json(scan_result, pkg_name, manifest=_MANIFEST_ANALYSIS) return scan_result @@ -1823,7 +1823,7 @@ def _generate_pdf(result, vuln_scan=None, vuln_scan_enabled: bool = True) -> Non f"[dim] {t('cli_loading_prev_findings', count=len(vuln_scan.findings))}[/dim]" ) - reports_dir = Path("./reports") + reports_dir = Path("./reports") / result.package reports_dir.mkdir(parents=True, exist_ok=True) pdf_path = reports_dir / f"nutcracker_{result.package}_report.pdf" try: @@ -1837,7 +1837,7 @@ def _generate_pdf(result, vuln_scan=None, vuln_scan_enabled: bool = True) -> Non console.print(f"[red]{t('cli_error_pdf')}[/red] {exc}") -def _save_vuln_json(scan_result, package: str) -> None: +def _save_vuln_json(scan_result, package: str, manifest=None) -> None: """Guarda los hallazgos de vulnerabilidades en JSON. Escribe en dos ubicaciones: @@ -1863,6 +1863,18 @@ def _save_vuln_json(scan_result, package: str) -> None: for f in scan_result.findings ], } + if manifest is not None and manifest.misconfigurations: + data["manifest_misconfigs"] = [ + { + "severity": m.severity, + "category": m.category, + "title": m.title, + "description": m.description, + "location": m.location, + "recommendation": m.recommendation, + } + for m in manifest.misconfigurations + ] payload = json.dumps(data, ensure_ascii=False, indent=2) primary = Path("./reports") / package / "vuln.json" primary.parent.mkdir(parents=True, exist_ok=True) @@ -2016,18 +2028,19 @@ def on_prog(msg: str) -> None: pkg = result.package pdf_path: Path | None = None if save_pdf: - Path(reports_dir).mkdir(parents=True, exist_ok=True) - pdf_dest = Path(reports_dir) / f"nutcracker_{pkg}_report.pdf" + pkg_dir = Path(reports_dir) / pkg + pkg_dir.mkdir(parents=True, exist_ok=True) + pdf_dest = pkg_dir / f"nutcracker_{pkg}_report.pdf" scan_result = _post_analysis_flow(result, apk_path) # Guardar JSON una vez que todos los datos están completos - save_analysis_json(result, scan_result=scan_result) + save_analysis_json(result, scan_result=scan_result, manifest=_MANIFEST_ANALYSIS) from nutcracker_core.pdf_reporter import generate_pdf_report pdf_path = generate_pdf_report(result, pdf_dest, scan=scan_result, manifest=_MANIFEST_ANALYSIS) console.print(f" [green]✔[/green] PDF: [bold]{pdf_path}[/bold]") else: scan_result = _post_analysis_flow(result, apk_path) # Guardar JSON una vez que todos los datos están completos - save_analysis_json(result, scan_result=scan_result) + save_analysis_json(result, scan_result=scan_result, manifest=_MANIFEST_ANALYSIS) status = "protected_broken" if result.protection_broken \ else ("protected" if result.protected else "unprotected") diff --git a/nutcracker_core/masvs.py b/nutcracker_core/masvs.py index 62b06b2..cee3c1d 100644 --- a/nutcracker_core/masvs.py +++ b/nutcracker_core/masvs.py @@ -96,6 +96,9 @@ "COMP003": ["MASVS-PLATFORM-2"], "COMP004": ["MASVS-PLATFORM-1"], "COMP005": ["MASVS-PLATFORM-2"], + "COMP006": ["MASVS-PLATFORM-1"], # Activity exported sin permission + "COMP007": ["MASVS-PLATFORM-1"], # Service exported sin permission + "INFO001": ["MASVS-CODE-2"], # debuggable=true "INJ001": ["MASVS-CODE-4"], "INJ002": ["MASVS-CODE-4"], "INJ003": ["MASVS-CODE-4"], @@ -106,6 +109,15 @@ "OBF001": ["MASVS-RESILIENCE-3"], "DESER001": ["MASVS-CODE-4"], "EXTRA001": ["MASVS-PLATFORM-1"], + # ── Reglas nativas (native_scanner) ────────────────────────────────────── + "NAT001": ["MASVS-CODE-4"], + "NAT002": ["MASVS-CODE-4"], + "NAT003": ["MASVS-RESILIENCE-2"], + "NAT004": ["MASVS-STORAGE-2"], + "NAT005": ["MASVS-NETWORK-1"], + "NAT006": ["MASVS-RESILIENCE-1"], + "NAT007": ["MASVS-NETWORK-1", "MASVS-NETWORK-2"], + "NAT008": ["MASVS-CRYPTO-1"], } # ── Mapeo detectores → MASVS ────────────────────────────────────────────────── diff --git a/nutcracker_core/vuln_scanner.py b/nutcracker_core/vuln_scanner.py index 46a6c12..c0bc09d 100644 --- a/nutcracker_core/vuln_scanner.py +++ b/nutcracker_core/vuln_scanner.py @@ -1175,9 +1175,131 @@ def _apply_apkleaks(scan_result: ScanResult) -> None: new_xml = [f for f in xml_findings if (str(f.file), f.line, f.rule_id) not in existing_keys] result.findings.extend(new_xml) result.files_scanned += len({f.file for f in new_xml}) + # ── Escaneo de librerías nativas (.so) si hay APK disponible ───────────── + if apk_path and apk_path.exists(): + try: + from .native_scanner import scan_native_libs + _nat_work = source_dir.parent / "_native_scan_work" + nat_findings = scan_native_libs( + apk_path=apk_path, + work_dir=_nat_work, + progress_callback=progress_callback, + ) + if nat_findings: + result.findings.extend(nat_findings) + if progress_callback: + progress_callback(f"Librerías nativas: {len(nat_findings)} hallazgo(s) NAT") + except Exception as _nat_err: + if progress_callback: + progress_callback(f"native_scanner omitido: {_nat_err}") return result +def scan_manifest_components(source_dir: Path) -> list[VulnFinding]: + """ + Parsea AndroidManifest.xml buscando: + - Activities/Services/Receivers con android:exported="true" sin android:permission (COMP004/COMP006) + - ContentProviders con android:exported="true" sin android:permission (COMP008) + - android:usesCleartextTraffic="true" (NET001) + - android:debuggable="true" (INFO001) + """ + import xml.etree.ElementTree as ET + + findings: list[VulnFinding] = [] + + # Buscar AndroidManifest.xml + candidates = [ + source_dir / "resources" / "AndroidManifest.xml", + source_dir / "AndroidManifest.xml", + ] + manifest_path = next((p for p in candidates if p.exists()), None) + if manifest_path is None: + return findings + + try: + tree = ET.parse(manifest_path) + except ET.ParseError: + return findings + + root = tree.getroot() + ns = "http://schemas.android.com/apk/res/android" + + # ── usesCleartextTraffic ────────────────────────────────────────────────── + app_el = root.find("application") + if app_el is not None: + if app_el.get(f"{{{ns}}}usesCleartextTraffic") == "true": + findings.append(VulnFinding( + rule_id="NET001", + title="usesCleartextTraffic habilitado", + severity="high", + category="M3 - Comunicación insegura", + file=manifest_path, + line=0, + matched_text='android:usesCleartextTraffic="true"', + description="La app permite tráfico HTTP sin cifrar a cualquier destino.", + recommendation="Eliminar usesCleartextTraffic o restringir con un Network Security Config.", + )) + # ── debuggable ─────────────────────────────────────────────────────── + if app_el.get(f"{{{ns}}}debuggable") == "true": + findings.append(VulnFinding( + rule_id="INFO001", + title="android:debuggable=true en producción", + severity="high", + category="M7 - Calidad del código", + file=manifest_path, + line=0, + matched_text='android:debuggable="true"', + description="La app tiene depuración habilitada. Permite attach con adb/jdb y extracción de datos.", + recommendation="Nunca distribuir con android:debuggable=\"true\". Usar BuildConfig.DEBUG.", + )) + + # ── Componentes exported sin permission ────────────────────────────────── + component_tags = { + "activity": ("COMP006", "Activity exported sin permission", "critical", "M6 - Componentes inseguros"), + "service": ("COMP007", "Service exported sin permission", "high", "M6 - Componentes inseguros"), + "receiver": ("COMP004", "BroadcastReceiver exported sin permission", "high", "M6 - Componentes inseguros"), + "provider": ("COMP008", "ContentProvider exported sin permission", "critical", "M6 - Componentes inseguros"), + } + + if app_el is not None: + for tag, (rule_id, title, severity, category) in component_tags.items(): + for el in app_el.findall(tag): + exported = el.get(f"{{{ns}}}exported") + permission = el.get(f"{{{ns}}}permission") + name = el.get(f"{{{ns}}}name", "") + + # Los Launchers tienen exported=true intencionalmente + is_launcher = any( + action.get(f"{{{ns}}}name") == "android.intent.action.MAIN" + for action in el.findall(".//action") + ) + if is_launcher: + continue + + if exported == "true" and not permission: + desc = ( + f"{tag.capitalize()} `{name}` tiene android:exported=\"true\" sin " + f"android:permission. Cualquier app o comando ADB puede invocarlo directamente." + ) + rec = ( + f"Añadir android:exported=\"false\" o proteger con " + f"android:permission=\"\"." + ) + findings.append(VulnFinding( + rule_id=rule_id, + title=f"{title}: {name.split('.')[-1]}", + severity=severity, + category=category, + file=manifest_path, + line=0, + matched_text=f'<{tag} android:name="{name}" android:exported="true">', + description=desc, + recommendation=rec, + )) + + return findings + + # Reglas solo para secretos en XML de recursos Android _XML_SECRET_RULES: list[VulnRule] = [ r for r in RULES From d525dc8c3be8169a1fd86500fd44b334cdbfb979 Mon Sep 17 00:00:00 2001 From: drneox Date: Tue, 5 May 2026 13:56:58 -0500 Subject: [PATCH 07/30] =?UTF-8?q?feat:=20native=5Fscanner=20=E2=80=94=20an?= =?UTF-8?q?=C3=A1lisis=20de=20librer=C3=ADas=20.so=20(NAT001-NAT007)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nutcracker_core/native_scanner.py | 475 ++++++++++++++++++++++++++++++ 1 file changed, 475 insertions(+) create mode 100644 nutcracker_core/native_scanner.py diff --git a/nutcracker_core/native_scanner.py b/nutcracker_core/native_scanner.py new file mode 100644 index 0000000..24d8ebb --- /dev/null +++ b/nutcracker_core/native_scanner.py @@ -0,0 +1,475 @@ +""" +Scanner de vulnerabilidades en librerías nativas (.so) extraídas de un APK. + +Complementa vuln_scanner.py (Java/Kotlin) con análisis de código nativo: + - Símbolos importados peligrosos (buffer overflow, command injection) + - Strings hardcodeadas sospechosas en .rodata (secretos, TLS bypass) + - Patrones anti-debug nativos + - Cripto débil a nivel de símbolos (MD5, DES, RC4) + +Uso: + from nutcracker_core.native_scanner import scan_native_libs + findings = scan_native_libs(apk_path=Path("app.apk"), work_dir=Path("/tmp/nat")) + # devuelve list[VulnFinding] compatible con ScanResult +""" + +from __future__ import annotations + +import math +import shutil +import subprocess +import zipfile +from collections import defaultdict +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable + +from .vuln_scanner import VulnFinding + + +# ── Definición de reglas nativas ────────────────────────────────────────────── + +@dataclass +class _NativeRule: + rule_id: str + title: str + severity: str # critical / high / medium / low / info + category: str + description: str + recommendation: str + # Símbolos importados (nm -D) que activan esta regla + imported_symbols: list[str] = field(default_factory=list) + # Strings (strings -n 8) que activan esta regla — comparación case-insensitive + string_patterns: list[str] = field(default_factory=list) + # Si True, solo reportar si la cadena tiene entropía Shannon ≥ entropy_threshold + require_high_entropy: bool = False + entropy_threshold: float = 3.5 + + +_NATIVE_RULES: list[_NativeRule] = [ + # ── NAT001: Funciones de manejo de buffer inseguras ─────────────────────── + _NativeRule( + rule_id="NAT001", + title="Funciones inseguras de manejo de buffer importadas", + severity="high", + category="M7 - Inyección / Buffer overflow", + imported_symbols=[ + "strcpy", "strcat", "gets", "sprintf", "vsprintf", + "strncpy", # no null-termina garantizado + "strncat", + "scanf", "sscanf", "fscanf", + "memcpy", # solo si no verifica longitud — lo reportamos como aviso + "memmove", + ], + description=( + "La librería importa funciones C inseguras que no verifican límites de buffer " + "(strcpy, gets, sprintf, etc.). Si los argumentos provienen de fuentes no confiables, " + "pueden derivar en buffer overflow, stack smashing o corrupción de memoria." + ), + recommendation=( + "Reemplazar con equivalentes seguros: strncpy→strlcpy, sprintf→snprintf, " + "gets→fgets. Activar flags de compilación: -D_FORTIFY_SOURCE=2 -fstack-protector-all." + ), + ), + + # ── NAT002: Ejecución de comandos del sistema ───────────────────────────── + _NativeRule( + rule_id="NAT002", + title="Ejecución de comandos del sistema en código nativo", + severity="critical", + category="M7 - Inyección", + imported_symbols=["system", "popen", "execve", "execl", "execlp", "execvp", "execvpe"], + description=( + "La librería importa funciones de ejecución de comandos del sistema " + "(system, popen, execve, etc.). Si el input no está sanitizado, permite " + "inyección de comandos del SO." + ), + recommendation=( + "Evitar system() y popen(). Si es imprescindible, sanitizar y validar " + "todo argumento externo. Nunca construir comandos por concatenación de strings." + ), + ), + + # ── NAT003: Anti-debug nativo ───────────────────────────────────────────── + _NativeRule( + rule_id="NAT003", + title="Strings de anti-debug / detección de análisis", + severity="info", + category="M9 - Ingeniería inversa", + string_patterns=[ + "/proc/self/status", + "TracerPid", + "ptrace", + "/proc/self/maps", + "frida", + "xposed", + "substrate", + "gadget", + "/proc/self/mem", + "android_server", + ], + description=( + "La librería contiene strings relacionados con detección de debuggers, " + "Frida, Xposed o técnicas anti-análisis. Esto indica que implementa " + "protecciones RASP a nivel nativo." + ), + recommendation=( + "Documentar para el report. Estos strings son candidatos para patch con " + "Memory.patchCode() o patch_native_lib durante el análisis dinámico." + ), + ), + + # ── NAT004: Secretos hardcodeados con alta entropía ─────────────────────── + _NativeRule( + rule_id="NAT004", + title="Posibles secretos hardcodeados en código nativo (alta entropía)", + severity="high", + category="M2 - Almacenamiento inseguro", + string_patterns=[], # detectado por entropía, no por patrón fijo + require_high_entropy=True, + entropy_threshold=4.2, + description=( + "Se encontraron strings de alta entropía en la sección .rodata de la " + "librería. Pueden ser API keys, tokens, claves de cifrado o secretos " + "hardcodeados incrustados en el binario nativo." + ), + recommendation=( + "Auditar manualmente cada string de alta entropía. Mover secretos a " + "almacenamiento seguro (Android Keystore, servidor). No incrustar " + "credenciales en binarios distribuidos." + ), + ), + + # ── NAT005: TLS bypass en código nativo ─────────────────────────────────── + _NativeRule( + rule_id="NAT005", + title="Strings de bypass TLS / desactivación de verificación SSL", + severity="high", + category="M3 - Comunicación insegura", + string_patterns=[ + "CURLOPT_SSL_VERIFYPEER", + "CURLOPT_SSL_VERIFYHOST", + "ssl_verify_none", + "SSL_VERIFY_NONE", + "SSL_CTX_set_verify", + "setHostnameVerifier", + "ALLOW_ALL_HOSTNAME", + ], + description=( + "La librería contiene strings asociados con la desactivación de " + "verificación TLS/SSL (CURLOPT_SSL_VERIFYPEER=0, SSL_VERIFY_NONE, etc.). " + "Indica que las conexiones HTTPS pueden no verificar el certificado del servidor." + ), + recommendation=( + "Verificar el contexto exacto con disassemble_native_lib. " + "SSL_CTX_set_verify debe llamarse con SSL_VERIFY_PEER, nunca con SSL_VERIFY_NONE." + ), + ), + + # ── NAT006: Root / entorno comprometido ─────────────────────────────────── + _NativeRule( + rule_id="NAT006", + title="Strings de detección de root en código nativo", + severity="medium", + category="M8 - Manipulación de código", + string_patterns=[ + "/data/local/tmp", + "/sbin/su", + "/system/bin/su", + "/system/xbin/su", + "magisk", + "supersu", + "superuser", + "busybox", + "/proc/net/unix", + ], + description=( + "La librería contiene strings de rutas y binarios típicos de dispositivos " + "rooteados. Implementa detección de root a nivel nativo." + ), + recommendation=( + "Documentar. Durante bypass dinámico, estos strings son candidatos a parchear " + "con Memory.patchCode() para que las comprobaciones devuelvan 'no root'." + ), + ), + + # ── NAT007: Carga dinámica de SSL (dlopen + dlsym) ──────────────────────── + _NativeRule( + rule_id="NAT007", + title="Carga dinámica de libssl / pinning nativo custom", + severity="medium", + category="M3 - Comunicación insegura", + imported_symbols=["dlopen", "dlsym"], + string_patterns=["libssl", "libcrypto", "SSL_CTX_new", "SSL_new", "certificate"], + description=( + "La librería usa dlopen/dlsym junto con strings de SSL. Indica pinning " + "implementado nativamente cargando libssl de forma dinámica, lo que evita " + "los hooks estándar de Interceptor.attach sobre símbolos exportados." + ), + recommendation=( + "Usar Memory.scanSync para encontrar la función SSL en memoria en lugar de " + "resolver por símbolo exportado. Ver Phase 2 del sistema de bypass." + ), + ), + + # ── NAT008: Cripto débil a nivel de símbolo nativo ──────────────────────── + _NativeRule( + rule_id="NAT008", + title="Algoritmos criptográficos débiles en código nativo (MD5, DES, RC4)", + severity="high", + category="M5 - Criptografía insuficiente", + imported_symbols=[ + "MD5_Init", "MD5_Update", "MD5_Final", + "MD2_Init", + "DES_key_sched", "DES_ecb_encrypt", "DES_cbc_encrypt", + "RC4_set_key", "RC4", + "SHA1_Init", # SHA1 solo para signing es riesgo + ], + description=( + "La librería importa funciones de algoritmos criptográficos débiles o rotos: " + "MD5, MD2, DES, RC4 y/o SHA1. Estos algoritmos no son seguros para uso " + "criptográfico (hashing de contraseñas, cifrado de datos sensibles)." + ), + recommendation=( + "Migrar a AES-256-GCM para cifrado, SHA-256/SHA-3 para hashing. " + "Nunca usar MD5 o SHA1 para verificar integridad de datos sensibles." + ), + ), +] + + +# ── Utilidades ──────────────────────────────────────────────────────────────── + +def _shannon_entropy(s: str) -> float: + """Entropía de Shannon de un string (0-8 bits/char para ASCII).""" + if not s: + return 0.0 + freq: dict[str, int] = defaultdict(int) + for c in s: + freq[c] += 1 + length = len(s) + return -sum((count / length) * math.log2(count / length) for count in freq.values()) + + +def _extract_so_files(apk_path: Path, work_dir: Path) -> list[Path]: + """Extrae todos los .so del APK en work_dir/native_libs/. Retorna lista de paths.""" + out_dir = work_dir / "native_libs" + out_dir.mkdir(parents=True, exist_ok=True) + + extracted: list[Path] = [] + try: + with zipfile.ZipFile(apk_path, "r") as zf: + for entry in zf.namelist(): + if entry.endswith(".so") and not entry.endswith("/"): + # Aplanar la ruta para evitar colisiones de ABI: lib/arm64-v8a/foo.so → arm64-v8a_foo.so + parts = Path(entry).parts + flat_name = "_".join(parts[1:]) if len(parts) > 1 else parts[0] + dest = out_dir / flat_name + if not dest.exists(): + dest.write_bytes(zf.read(entry)) + extracted.append(dest) + except (zipfile.BadZipFile, OSError): + pass + + return extracted + + +def _get_imported_symbols(so_path: Path) -> list[str]: + """ + Devuelve la lista de símbolos importados (undefined) del .so usando nm o objdump. + Prefiere nm (más universal), fallback a objdump. + """ + nm = shutil.which("nm") or shutil.which("arm-linux-androideabi-nm") + if nm: + try: + result = subprocess.run( + [nm, "-D", "--undefined-only", str(so_path)], + capture_output=True, text=True, timeout=15, + ) + symbols: list[str] = [] + for line in result.stdout.splitlines(): + parts = line.strip().split() + if parts: + symbols.append(parts[-1].lstrip("_")) + return symbols + except (subprocess.TimeoutExpired, OSError): + pass + + objdump = shutil.which("objdump") or shutil.which("llvm-objdump") + if objdump: + try: + result = subprocess.run( + [objdump, "-T", str(so_path)], + capture_output=True, text=True, timeout=15, + ) + symbols = [] + for line in result.stdout.splitlines(): + if "*UND*" in line or "UND" in line: + parts = line.strip().split() + if parts: + symbols.append(parts[-1].lstrip("_")) + return symbols + except (subprocess.TimeoutExpired, OSError): + pass + + return [] + + +def _get_strings(so_path: Path, min_len: int = 8) -> list[str]: + """Extrae strings imprimibles del .so. Usa el comando `strings` si está disponible.""" + strings_bin = shutil.which("strings") + if strings_bin: + try: + result = subprocess.run( + [strings_bin, "-n", str(min_len), str(so_path)], + capture_output=True, text=True, timeout=20, + ) + return result.stdout.splitlines() + except (subprocess.TimeoutExpired, OSError): + pass + + # Fallback Python puro: extraer secuencias ASCII imprimibles + found: list[str] = [] + try: + data = so_path.read_bytes() + current: list[int] = [] + for byte in data: + if 0x20 <= byte <= 0x7E: + current.append(byte) + else: + if len(current) >= min_len: + found.append(bytes(current).decode("ascii", errors="replace")) + current = [] + if len(current) >= min_len: + found.append(bytes(current).decode("ascii", errors="replace")) + except OSError: + pass + return found + + +# ── Scanner principal ───────────────────────────────────────────────────────── + +def scan_native_libs( + apk_path: Path, + work_dir: Path, + progress_callback: Callable[[str], None] | None = None, + abi_filter: str | None = "arm64-v8a", +) -> list[VulnFinding]: + """ + Extrae los .so del APK y aplica reglas NAT001-NAT008. + + Args: + apk_path: Path al APK (o split APK). + work_dir: Directorio temporal donde se extraerán los .so. + progress_callback: Función(str) para mensajes de progreso. + abi_filter: Si se especifica, solo escanear .so de esa ABI. + None = escanear todas las ABIs. + + Returns: + Lista de VulnFinding compatible con ScanResult.findings. + """ + + def _cb(msg: str) -> None: + if progress_callback: + progress_callback(msg) + + apk_path = Path(apk_path) + work_dir = Path(work_dir) + + if not apk_path.exists(): + _cb(f"native_scanner: APK no encontrado en {apk_path}") + return [] + + _cb("Extrayendo librerías nativas del APK…") + all_so = _extract_so_files(apk_path, work_dir) + + if not all_so: + _cb("native_scanner: no se encontraron .so en el APK") + return [] + + # Filtrar por ABI si se especificó + if abi_filter: + filtered = [p for p in all_so if abi_filter in p.name] + # Si no hay .so para esa ABI usar todos (e.g. APK universal) + all_so = filtered if filtered else all_so + + _cb(f"Escaneando {len(all_so)} librería(s) nativa(s)…") + + findings: list[VulnFinding] = [] + + for so_path in all_so: + lib_name = so_path.name + _cb(f" → {lib_name}") + + imported_syms = _get_imported_symbols(so_path) + imported_set = {s.lower() for s in imported_syms} + + all_strings = _get_strings(so_path) + strings_lower = [s.lower() for s in all_strings] + + # Agrupar hallazgos por regla para este .so (evitar duplicados masivos) + # Solo reportamos el primer match por (rule_id, .so) — suficiente para el report + matched_rules: set[str] = set() + + for rule in _NATIVE_RULES: + if rule.rule_id in matched_rules: + continue + + matched_text = "" + matched_line = 0 # .so no tiene líneas — usamos 0 + + # Comprobar símbolos importados + for sym in rule.imported_symbols: + sym_lower = sym.lower() + # Buscar coincidencia exacta o con prefijo __ + if sym_lower in imported_set or f"__{sym_lower}" in imported_set: + matched_text = sym + break + + # Comprobar strings (si no hubo match por símbolo) + if not matched_text: + for pattern in rule.string_patterns: + pat_lower = pattern.lower() + for s in strings_lower: + if pat_lower in s: + matched_text = s[:120] + break + if matched_text: + break + + # Regla NAT004: alta entropía (sin pattern fijo) + if not matched_text and rule.require_high_entropy: + for raw_str in all_strings: + if len(raw_str) >= 20 and _shannon_entropy(raw_str) >= rule.entropy_threshold: + # Filtrar strings que claramente son rutas o nombres de función + if not raw_str.startswith("/") and "." not in raw_str[:4]: + matched_text = raw_str[:80] + break + + if not matched_text: + continue + + # Regla NAT007: requiere AMBAS condiciones (dlopen/dlsym + strings ssl) + if rule.rule_id == "NAT007": + has_dlopen = "dlopen" in imported_set or "dlsym" in imported_set + has_ssl_str = any( + p.lower() in s for p in rule.string_patterns for s in strings_lower + ) + if not (has_dlopen and has_ssl_str): + continue + + matched_rules.add(rule.rule_id) + findings.append(VulnFinding( + rule_id=rule.rule_id, + title=rule.title, + severity=rule.severity, + category=rule.category, + file=so_path, + line=matched_line, + matched_text=matched_text, + description=rule.description, + recommendation=rule.recommendation, + )) + + _cb(f"native_scanner: {len(findings)} hallazgo(s) en librerías nativas") + return findings From d0bcb4b557321b7f0e3d2232655bfa7689b4f533 Mon Sep 17 00:00:00 2001 From: drneox Date: Tue, 5 May 2026 23:53:18 -0500 Subject: [PATCH 08/30] =?UTF-8?q?fix:=20a=C3=B1adir=20scan=5Fmanifest=5Fco?= =?UTF-8?q?mponents=20a=20auto=5Fscan=20para=20persistir=20COMP=20findings?= =?UTF-8?q?=20en=20vuln.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nutcracker_core/vuln_scanner.py | 40 +++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/nutcracker_core/vuln_scanner.py b/nutcracker_core/vuln_scanner.py index c0bc09d..0df0a06 100644 --- a/nutcracker_core/vuln_scanner.py +++ b/nutcracker_core/vuln_scanner.py @@ -1159,6 +1159,25 @@ def _apply_apkleaks(scan_result: ScanResult) -> None: new_xml = [f for f in xml_findings if (str(f.file), f.line, f.rule_id) not in existing_keys] semgrep_result.findings.extend(new_xml) semgrep_result.files_scanned += len({f.file for f in new_xml}) + # ── Componentes del manifest exportados (COMP006/COMP007/COMP008) ───── + try: + comp_findings = scan_manifest_components(source_dir) + if comp_findings: + existing_comp = { + (str(f.file), f.rule_id, f.matched_text) + for f in semgrep_result.findings + if f.rule_id.startswith("COMP") + } + new_comp = [ + f for f in comp_findings + if (str(f.file), f.rule_id, f.matched_text) not in existing_comp + ] + semgrep_result.findings.extend(new_comp) + if progress_callback and new_comp: + progress_callback(f"Componentes exportados: {len(new_comp)} hallazgo(s) COMP") + except Exception as _comp_err: + if progress_callback: + progress_callback(f"scan_manifest_components omitido: {_comp_err}") return semgrep_result scan_rules = RULES if include_code_leak_rules else [ @@ -1192,6 +1211,27 @@ def _apply_apkleaks(scan_result: ScanResult) -> None: except Exception as _nat_err: if progress_callback: progress_callback(f"native_scanner omitido: {_nat_err}") + + # ── Componentes del manifest exportados (COMP006/COMP007/COMP008) ───────── + try: + comp_findings = scan_manifest_components(source_dir) + if comp_findings: + existing_comp = { + (str(f.file), f.rule_id, f.matched_text) + for f in result.findings + if f.rule_id.startswith("COMP") + } + new_comp = [ + f for f in comp_findings + if (str(f.file), f.rule_id, f.matched_text) not in existing_comp + ] + result.findings.extend(new_comp) + if progress_callback and new_comp: + progress_callback(f"Componentes exportados: {len(new_comp)} hallazgo(s) COMP") + except Exception as _comp_err: + if progress_callback: + progress_callback(f"scan_manifest_components omitido: {_comp_err}") + return result From 4ab976f150088cc78b6d4e7004a397328b964122 Mon Sep 17 00:00:00 2001 From: drneox Date: Tue, 5 May 2026 23:57:08 -0500 Subject: [PATCH 09/30] fix: COMP findings desde directorio apktool en _do_vuln_scan, revertir auto_scan --- nutcracker.py | 19 ++++++++++++++++ nutcracker_core/vuln_scanner.py | 39 --------------------------------- 2 files changed, 19 insertions(+), 39 deletions(-) diff --git a/nutcracker.py b/nutcracker.py index e5111eb..481e4d7 100644 --- a/nutcracker.py +++ b/nutcracker.py @@ -1715,6 +1715,25 @@ def on_progress(msg: str) -> None: f" [dim]{t('cli_scanner_used_dim', engine=engine_used, files=scan_result.files_scanned, findings=len(scan_result.findings))}[/dim]" ) + # ── Componentes del manifest exportados (COMP006/COMP007/COMP008) ───────── + # auto_scan trabaja sobre el directorio jadx (sin manifest XML). + # El manifest decodificado está en _manifest_apktool_*/AndroidManifest.xml. + if scan_result is not None: + try: + from nutcracker_core.vuln_scanner import scan_manifest_components + # Buscar directorio apktool: padre del source_dir + _apktool_candidates = sorted(source_dir.parent.glob("_manifest_apktool_*")) + if _apktool_candidates: + _comp_findings = scan_manifest_components(_apktool_candidates[0]) + if _comp_findings: + existing_comp = {(f.rule_id, f.matched_text) for f in scan_result.findings} + new_comp = [f for f in _comp_findings if (f.rule_id, f.matched_text) not in existing_comp] + scan_result.findings.extend(new_comp) + if new_comp: + console.print(f" [dim]Componentes exportados (manifest): {len(new_comp)} hallazgo(s) COMP[/dim]") + except Exception: + pass + print_vuln_report(scan_result, source_dir) # Guardar JSON con nombre canónico por paquete. diff --git a/nutcracker_core/vuln_scanner.py b/nutcracker_core/vuln_scanner.py index 0df0a06..4558023 100644 --- a/nutcracker_core/vuln_scanner.py +++ b/nutcracker_core/vuln_scanner.py @@ -1159,25 +1159,6 @@ def _apply_apkleaks(scan_result: ScanResult) -> None: new_xml = [f for f in xml_findings if (str(f.file), f.line, f.rule_id) not in existing_keys] semgrep_result.findings.extend(new_xml) semgrep_result.files_scanned += len({f.file for f in new_xml}) - # ── Componentes del manifest exportados (COMP006/COMP007/COMP008) ───── - try: - comp_findings = scan_manifest_components(source_dir) - if comp_findings: - existing_comp = { - (str(f.file), f.rule_id, f.matched_text) - for f in semgrep_result.findings - if f.rule_id.startswith("COMP") - } - new_comp = [ - f for f in comp_findings - if (str(f.file), f.rule_id, f.matched_text) not in existing_comp - ] - semgrep_result.findings.extend(new_comp) - if progress_callback and new_comp: - progress_callback(f"Componentes exportados: {len(new_comp)} hallazgo(s) COMP") - except Exception as _comp_err: - if progress_callback: - progress_callback(f"scan_manifest_components omitido: {_comp_err}") return semgrep_result scan_rules = RULES if include_code_leak_rules else [ @@ -1212,26 +1193,6 @@ def _apply_apkleaks(scan_result: ScanResult) -> None: if progress_callback: progress_callback(f"native_scanner omitido: {_nat_err}") - # ── Componentes del manifest exportados (COMP006/COMP007/COMP008) ───────── - try: - comp_findings = scan_manifest_components(source_dir) - if comp_findings: - existing_comp = { - (str(f.file), f.rule_id, f.matched_text) - for f in result.findings - if f.rule_id.startswith("COMP") - } - new_comp = [ - f for f in comp_findings - if (str(f.file), f.rule_id, f.matched_text) not in existing_comp - ] - result.findings.extend(new_comp) - if progress_callback and new_comp: - progress_callback(f"Componentes exportados: {len(new_comp)} hallazgo(s) COMP") - except Exception as _comp_err: - if progress_callback: - progress_callback(f"scan_manifest_components omitido: {_comp_err}") - return result From 20f5b4d0d69d670bb4d045be97ff721787185ee0 Mon Sep 17 00:00:00 2001 From: drneox Date: Wed, 6 May 2026 00:20:27 -0500 Subject: [PATCH 10/30] fix: COMP findings desde _MANIFEST_ANALYSIS.exported_components en _do_vuln_scan --- nutcracker.py | 55 ++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/nutcracker.py b/nutcracker.py index 481e4d7..b68e525 100644 --- a/nutcracker.py +++ b/nutcracker.py @@ -1716,21 +1716,48 @@ def on_progress(msg: str) -> None: ) # ── Componentes del manifest exportados (COMP006/COMP007/COMP008) ───────── - # auto_scan trabaja sobre el directorio jadx (sin manifest XML). - # El manifest decodificado está en _manifest_apktool_*/AndroidManifest.xml. - if scan_result is not None: + # _MANIFEST_ANALYSIS ya tiene exported_components del manifest extraído. + if scan_result is not None and _MANIFEST_ANALYSIS and getattr(_MANIFEST_ANALYSIS, "exported_components", None): try: - from nutcracker_core.vuln_scanner import scan_manifest_components - # Buscar directorio apktool: padre del source_dir - _apktool_candidates = sorted(source_dir.parent.glob("_manifest_apktool_*")) - if _apktool_candidates: - _comp_findings = scan_manifest_components(_apktool_candidates[0]) - if _comp_findings: - existing_comp = {(f.rule_id, f.matched_text) for f in scan_result.findings} - new_comp = [f for f in _comp_findings if (f.rule_id, f.matched_text) not in existing_comp] - scan_result.findings.extend(new_comp) - if new_comp: - console.print(f" [dim]Componentes exportados (manifest): {len(new_comp)} hallazgo(s) COMP[/dim]") + from nutcracker_core.vuln_scanner import VulnFinding + _COMP_MAP = { + "activity": ("COMP006", "Activity exported sin permission", "critical", "M6 - Componentes inseguros"), + "service": ("COMP007", "Service exported sin permission", "high", "M6 - Componentes inseguros"), + "receiver": ("COMP004", "BroadcastReceiver exported sin permission", "high", "M6 - Componentes inseguros"), + "provider": ("COMP008", "ContentProvider exported sin permission", "critical", "M6 - Componentes inseguros"), + } + existing_comp = {(f.rule_id, f.matched_text) for f in scan_result.findings if f.rule_id.startswith("COMP")} + new_comp = [] + for ec in _MANIFEST_ANALYSIS.exported_components: + tag = ec.get("tag", "").lower() + name = ec.get("name", "") + if tag not in _COMP_MAP: + continue + rule_id, title, severity, category = _COMP_MAP[tag] + matched = f'<{tag} android:name="{name}" android:exported="true">' + if (rule_id, matched) in existing_comp: + continue + new_comp.append(VulnFinding( + rule_id=rule_id, + title=f"{title}: {name.split('.')[-1]}", + severity=severity, + category=category, + file=None, + line=0, + matched_text=matched, + description=( + f"{tag.capitalize()} `{name}` tiene android:exported=\"true\" sin " + f"android:permission. Cualquier app o comando ADB puede invocarlo directamente." + ), + recommendation=( + f"Añadir android:exported=\"false\" o proteger con " + f"android:permission=\"\"." + ), + )) + existing_comp.add((rule_id, matched)) + if new_comp: + scan_result.findings.extend(new_comp) + console.print(f" [dim]Componentes exportados (manifest): {len(new_comp)} hallazgo(s) COMP[/dim]") except Exception: pass From 5f9dbfb8dfbbd6f9c9516d3081f82cb54b1ca40b Mon Sep 17 00:00:00 2001 From: drneox Date: Thu, 7 May 2026 01:36:54 -0500 Subject: [PATCH 11/30] feat(i18n): localize VulnRule/NativeRule titles, descs and recs; fix MASVS summary layout; i18n vuln_scanner + native_scanner --- nutcracker_core/i18n.py | 350 +++++++++++++ nutcracker_core/native_scanner.py | 22 +- nutcracker_core/plugins/aireview/__init__.py | 499 ++++++++++--------- nutcracker_core/reporter.py | 6 +- nutcracker_core/vuln_scanner.py | 60 ++- 5 files changed, 661 insertions(+), 276 deletions(-) diff --git a/nutcracker_core/i18n.py b/nutcracker_core/i18n.py index 4211ce0..db237f0 100644 --- a/nutcracker_core/i18n.py +++ b/nutcracker_core/i18n.py @@ -930,6 +930,181 @@ def register(strings: dict[str, dict[str, str]]) -> None: "et_verify_apkeep": "Verify that apkeep is installed and the device has a valid session.", "et_aas_saved": "\nAAS token saved to {path}", "et_ready": "You can now use Google Play in nutcracker.", + + # ── VulnRule / NativeRule finding titles, descriptions, recommendations ─ + # HC: Hardcoded credentials / secrets + "rule_hc001_title": "Hardcoded API key", + "rule_hc001_desc": "API key embedded directly in the source code.", + "rule_hc001_rec": "Store credentials server-side or use Android Keystore.", + "rule_hc002_title": "Hardcoded password", + "rule_hc002_desc": "Password or secret embedded in the code.", + "rule_hc002_rec": "Never store credentials in code. Use a secrets manager.", + "rule_hc003_title": "Embedded private key / certificate", + "rule_hc003_desc": "RSA/EC private key embedded in the code.", + "rule_hc003_rec": "Private keys must never be included in the APK.", + "rule_hc004_title": "Hardcoded Firebase / Google credentials", + "rule_hc004_desc": "Google API key (AIza...) or FCM server key found in code.", + "rule_hc004_rec": "Restrict the API key in Google Cloud Console and do not embed it in the APK.", + "rule_hc005_title": "Hardcoded AWS credentials", + "rule_hc005_desc": "AWS Access Key ID found in code.", + "rule_hc005_rec": "Revoke the key immediately and never include AWS credentials in the APK.", + "rule_hc006_title": "Hardcoded cryptographic key/IV", + "rule_hc006_desc": "Cryptographic material (key/iv/aes) embedded as a constant in the code.", + "rule_hc006_rec": "Generate and manage keys/IVs securely (Keystore/KMS) and do not hardcode them.", + "rule_hc007_title": "Hardcoded production/API URL", + "rule_hc007_desc": "Production URL or API endpoint exposed as a constant in the code.", + "rule_hc007_rec": "Centralize URLs in remote configuration. Exposed production URLs reveal the API attack surface.", + "rule_hc008_title": "Hardcoded third-party service URL (KYC/Auth/Payments)", + "rule_hc008_desc": "Critical service URL (KYC, authentication or payments) hardcoded. Combined with exposed API keys, allows direct interaction with the service.", + "rule_hc008_rec": "Never expose identity, payment or authentication service URLs in the APK. Proxy all calls through your own backend.", + # ST: Insecure storage + "rule_st001_title": "Unencrypted SharedPreferences", + "rule_st001_desc": "Use of SharedPreferences without encryption. Data is accessible on rooted devices.", + "rule_st001_rec": "Use EncryptedSharedPreferences from androidx.security.crypto.", + "rule_st002_title": "World-readable / world-writable file write", + "rule_st002_desc": "File created with permissions accessible by other apps.", + "rule_st002_rec": "Use MODE_PRIVATE for application files.", + "rule_st003_title": "Sensitive data in external storage", + "rule_st003_desc": "Writing data to external storage (SD card), accessible without permissions.", + "rule_st003_rec": "Store sensitive data only in internal storage.", + "rule_st004_title": "Unencrypted SQLite database", + "rule_st004_desc": "Unencrypted SQLite database, accessible on rooted devices.", + "rule_st004_rec": "Use SQLCipher or Room with EncryptedDatabase for sensitive data.", + "rule_st005_title": "Sensitive data in Clipboard", + "rule_st005_desc": "The app copies data to the clipboard. Other apps can read the clipboard on Android versions below 10.", + "rule_st005_rec": "Avoid copying sensitive data to the clipboard. On Android \u226510, clear the clipboard after use.", + "rule_st006_title": "Firebase Realtime Database open reference", + "rule_st006_desc": "The app uses Firebase Realtime Database. If security rules allow public read/write, data is exposed.", + "rule_st006_rec": "Review Firebase Security Rules. Never use '.read: true' or '.write: true' in production.", + # NET: Network security + "rule_net001_title": "HTTP URL (no TLS)", + "rule_net001_desc": "Cleartext HTTP communication. Susceptible to MITM attacks.", + "rule_net001_rec": "Use HTTPS for all network communications.", + "rule_net002_title": "SSL validation disabled (permissive TrustManager)", + "rule_net002_desc": "TrustManager that accepts any SSL certificate. Fully vulnerable to MITM.", + "rule_net002_rec": "Implement Certificate Pinning or use the system TrustManager.", + "rule_net003_title": "Permissive HostnameVerifier (accepts any hostname)", + "rule_net003_desc": "HostnameVerifier that accepts any hostname without verification. Allows MITM attacks even if TLS is active.", + "rule_net003_rec": "Remove the permissive HostnameVerifier. The system verifier is correct by default; if flexibility is needed, validate the hostname explicitly.", + "rule_net004_title": "Explicit SSLv3 / TLSv1.0", + "rule_net004_desc": "Explicit use of SSL 3.0 or TLS 1.0, protocols with known vulnerabilities (POODLE, BEAST).", + "rule_net004_rec": "Use TLSv1.2 or TLSv1.3 only.", + "rule_net005_title": "OkHttp without Certificate Pinner", + "rule_net005_desc": "OkHttp client built without CertificatePinner. No additional defense against MITM attacks if the server certificate changes.", + "rule_net005_rec": "Configure CertificatePinner with the SHA-256 hashes of the server certificate.", + "rule_net006_title": "WebView accepts any SSL certificate", + "rule_net006_desc": "WebViewClient with onReceivedSslError that calls handler.proceed() without verifying the error. Accepts any invalid, expired or self-signed certificate \u2014 equivalent to disabling TLS for WebViews.", + "rule_net006_rec": "Do not call handler.proceed() in onReceivedSslError except in debug builds. In production, call handler.cancel() and show an error to the user.", + # AUTH + "rule_auth001_title": "Token/session in logs", + "rule_auth001_desc": "Possible authentication token sent to system logs.", + "rule_auth001_rec": "Never log tokens, sessions or authentication data.", + # CRYPTO + "rule_crypto001_title": "MD5 algorithm in use", + "rule_crypto001_desc": "MD5 is cryptographically broken. Not suitable for password hashing or integrity.", + "rule_crypto001_rec": "Use SHA-256 or higher. For passwords, use BCrypt/Argon2.", + "rule_crypto002_title": "SHA-1 algorithm in use", + "rule_crypto002_desc": "SHA-1 is deprecated for cryptographic use.", + "rule_crypto002_rec": "Use SHA-256 or higher.", + "rule_crypto003_title": "DES / 3DES in use", + "rule_crypto003_desc": "DES and 3DES are weak algorithms with short keys.", + "rule_crypto003_rec": "Use AES-256-GCM.", + "rule_crypto004_title": "AES in ECB mode", + "rule_crypto004_desc": "AES in ECB mode or without specified mode (Android defaults to ECB). ECB does not hide patterns in encrypted data and is insecure.", + "rule_crypto004_rec": "Use AES/GCM/NoPadding with a random IV generated by SecureRandom.", + "rule_crypto005_title": "Static or hardcoded IV", + "rule_crypto005_desc": "Static initialization vector (IV). Reusing IV nullifies the security of the cipher.", + "rule_crypto005_rec": "Generate a random IV with SecureRandom for each encryption operation.", + "rule_crypto006_title": "java.util.Random instead of SecureRandom", + "rule_crypto006_desc": "java.util.Random is predictable. Not suitable for cryptographic use.", + "rule_crypto006_rec": "Use java.security.SecureRandom for cryptographic values.", + # COMP: Insecure component configuration + "rule_comp001_title": "WebView with JavaScript enabled", + "rule_comp001_desc": "JavaScript enabled in WebView. If the app loads third-party URLs or untrusted content, it allows arbitrary code execution (universal XSS).", + "rule_comp001_rec": "Disable JavaScript unless strictly necessary. If enabled, only load own URLs and verify the origin.", + "rule_comp002_title": "WebView with file or local content access", + "rule_comp002_desc": "WebView configured to access the file system or ContentProviders. setAllowUniversalAccessFromFileURLs allows JS in file:// to read any file on the device.", + "rule_comp002_rec": "Keep all these flags to false (they are the default since API 30). Never enable AllowUniversalAccessFromFileURLs in production.", + "rule_comp003_title": "addJavascriptInterface exposed to WebView", + "rule_comp003_desc": "Java/Kotlin interface directly exposed to JavaScript in WebView. In APIs < 17 allows RCE. In modern APIs, every public method annotated with @JavascriptInterface is invocable from any loaded page.", + "rule_comp003_rec": "Expose only strictly necessary methods with @JavascriptInterface. Validate the page origin before allowing calls. Never use in WebViews that load external URLs.", + "rule_comp004_title": "Broadcast receiver without permissions", + "rule_comp004_desc": "BroadcastReceiver registered dynamically without permission restrictions.", + "rule_comp004_rec": "Use LocalBroadcastManager or specify permissions in the registration.", + "rule_comp005_title": "WebView.loadUrl with javascript: URI", + "rule_comp005_desc": "The app injects JavaScript into a WebView via loadUrl('javascript:...') or evaluateJavascript() with untrusted data. Allows XSS if content comes from an Intent or external source.", + "rule_comp005_rec": "Do not build javascript: URIs with external data. If using evaluateJavascript(), escape and validate all input.", + # INJ: Injection + "rule_inj001_title": "Potential SQL injection (concatenated rawQuery)", + "rule_inj001_desc": "SQL query built with string concatenation. Susceptible to SQL injection.", + "rule_inj001_rec": "Use parameterized queries with selectionArgs or PreparedStatement.", + "rule_inj002_title": "Potential path traversal", + "rule_inj002_desc": "File path built with user input or from an Intent.", + "rule_inj002_rec": "Validate and canonicalize file paths before using them.", + "rule_inj003_title": "System command execution (Runtime.exec)", + "rule_inj003_desc": "The app executes OS commands. If input is not validated, it can lead to command injection.", + "rule_inj003_rec": "Avoid Runtime.exec(). If necessary, validate and sanitize all input before including it in the command.", + "rule_inj004_title": "Deep link / Intent without source validation", + "rule_inj004_desc": "Data extracted from an Intent without source validation. A malicious deep link could inject unexpected values.", + "rule_inj004_rec": "Validate the scheme, host and Intent parameters before using them. Restrict exported activities with android:exported=false.", + # DBG: Debug / logging + "rule_dbg001_title": "Debug mode enabled in code", + "rule_dbg001_desc": "Code blocks that only run in debug may include insecure logic.", + "rule_dbg001_rec": "Review what code runs only in debug and make sure sensitive data is not exposed.", + "rule_dbg002_title": "Log with sensitive data", + "rule_dbg002_desc": "Possible sensitive data sent to Logcat.", + "rule_dbg002_rec": "Remove logs with sensitive information before publishing.", + "rule_dbg003_title": "printStackTrace() in production", + "rule_dbg003_desc": "printStackTrace() exposes internal implementation details in logs.", + "rule_dbg003_rec": "Use a logger with level control (Timber, SLF4J) that can be disabled.", + # OBF / DESER / EXTRA + "rule_obf001_title": "Dynamic reflection (possible analysis evasion)", + "rule_obf001_desc": "Use of Java reflection. Can be used to dynamically load code.", + "rule_obf001_rec": "Audit the use of reflection to ensure it does not load external code.", + "rule_deser001_title": "Native Java deserialization (ObjectInputStream)", + "rule_deser001_desc": "Native Java deserialization. If the stream comes from an untrusted source, it can lead to arbitrary code execution.", + "rule_deser001_rec": "Avoid native Java deserialization. Use JSON/Protobuf with schema validation. If unavoidable, implement an ObjectInputFilter.", + "rule_extra001_title": "Dangerous permissions in use", + "rule_extra001_desc": "Use of permissions considered dangerous by Android. Verify that each requested permission is strictly necessary.", + "rule_extra001_rec": "Follow the principle of least privilege: request only essential permissions and justify each one in the Play Store listing.", + # NAT: Native code rules + "rule_nat001_title": "Unsafe buffer handling functions imported", + "rule_nat001_desc": "The library imports unsafe C functions that do not check buffer bounds (strcpy, gets, sprintf, etc.). If arguments come from untrusted sources, they can lead to buffer overflow, stack smashing or memory corruption.", + "rule_nat001_rec": "Replace with safe equivalents: strncpy\u2192strlcpy, sprintf\u2192snprintf, gets\u2192fgets. Enable compiler flags: -D_FORTIFY_SOURCE=2 -fstack-protector-all.", + "rule_nat002_title": "System command execution in native code", + "rule_nat002_desc": "The library imports system command execution functions (system, popen, execve, etc.). If input is not sanitized, it allows OS command injection.", + "rule_nat002_rec": "Avoid system() and popen(). If unavoidable, sanitize and validate all external arguments. Never build commands by string concatenation.", + "rule_nat003_title": "Anti-debug / analysis detection strings", + "rule_nat003_desc": "The library contains strings related to debugger detection, Frida, Xposed or anti-analysis techniques. This indicates it implements RASP protections at the native level.", + "rule_nat003_rec": "Document for the report. These strings are candidates for patching with Memory.patchCode() or patch_native_lib during dynamic analysis.", + "rule_nat004_title": "Possible hardcoded secrets in native code (high entropy)", + "rule_nat004_desc": "High-entropy strings were found in the .rodata section of the library. They may be API keys, tokens, encryption keys or hardcoded secrets embedded in the native binary.", + "rule_nat004_rec": "Manually audit each high-entropy string. Move secrets to secure storage (Android Keystore, server). Do not embed credentials in distributed binaries.", + "rule_nat005_title": "TLS bypass / SSL verification disable strings", + "rule_nat005_desc": "The library contains strings associated with disabling TLS/SSL verification (CURLOPT_SSL_VERIFYPEER=0, SSL_VERIFY_NONE, etc.). Indicates that HTTPS connections may not verify the server certificate.", + "rule_nat005_rec": "Verify the exact context with disassemble_native_lib. SSL_CTX_set_verify must be called with SSL_VERIFY_PEER, never with SSL_VERIFY_NONE.", + "rule_nat006_title": "Root detection strings in native code", + "rule_nat006_desc": "The library contains strings of paths and binaries typical of rooted devices. Implements root detection at the native level.", + "rule_nat006_rec": "Document. During dynamic bypass, these strings are candidates to patch with Memory.patchCode() so checks return 'no root'.", + "rule_nat007_title": "Dynamic libssl loading / custom native pinning", + "rule_nat007_desc": "The library uses dlopen/dlsym along with SSL strings. Indicates pinning implemented natively by loading libssl dynamically, which bypasses standard Interceptor.attach hooks on exported symbols.", + "rule_nat007_rec": "Use Memory.scanSync to find the SSL function in memory instead of resolving by exported symbol. See Phase 2 of the bypass system.", + "rule_nat008_title": "Weak cryptographic algorithms in native code (MD5, DES, RC4)", + "rule_nat008_desc": "The library imports functions of weak or broken cryptographic algorithms: MD5, MD2, DES, RC4 and/or SHA1. These algorithms are not secure for cryptographic use.", + "rule_nat008_rec": "Migrate to AES-256-GCM for encryption, SHA-256/SHA-3 for hashing. Never use MD5 or SHA1 to verify the integrity of sensitive data.", + # Manifest-specific findings + "rule_net001_manifest_title": "usesCleartextTraffic enabled", + "rule_net001_manifest_desc": "The app allows unencrypted HTTP traffic to any destination.", + "rule_net001_manifest_rec": "Remove usesCleartextTraffic or restrict with a Network Security Config.", + "rule_info001_title": "android:debuggable=true in production", + "rule_info001_desc": "The app has debugging enabled. Allows attaching with adb/jdb and data extraction.", + "rule_info001_rec": 'Never distribute with android:debuggable="true". Use BuildConfig.DEBUG.', + "rule_comp006_title_prefix": "Activity exported without permission", + "rule_comp007_title_prefix": "Service exported without permission", + "rule_comp004_title_prefix": "BroadcastReceiver exported without permission", + "rule_comp008_title_prefix": "ContentProvider exported without permission", + "rule_exported_comp_desc": '{tag} `{name}` has android:exported="true" without android:permission. Any app or ADB command can invoke it directly.', + "rule_exported_comp_rec": 'Add android:exported="false" or protect with android:permission="".', }, # ══════════════════════════════════════════════════════════════════════════ @@ -1797,5 +1972,180 @@ def register(strings: dict[str, dict[str, str]]) -> None: "et_verify_apkeep": "Verifica que apkeep est\u00e9 instalado y que el dispositivo tenga sesi\u00f3n v\u00e1lida.", "et_aas_saved": "\nAAS token guardado en {path}", "et_ready": "Ya puedes usar Google Play en nutcracker.", + + # ── VulnRule / NativeRule finding titles, descriptions, recommendations ─ + # HC: Credenciales / secretos hardcodeados + "rule_hc001_title": "API key hardcodeada", + "rule_hc001_desc": "Clave de API embebida directamente en el c\u00f3digo fuente.", + "rule_hc001_rec": "Almacenar credenciales en el servidor o usar Android Keystore.", + "rule_hc002_title": "Contrase\u00f1a hardcodeada", + "rule_hc002_desc": "Contrase\u00f1a o secreto embebido en el c\u00f3digo.", + "rule_hc002_rec": "Nunca almacenar credenciales en el c\u00f3digo. Usar un gestor de secretos.", + "rule_hc003_title": "Clave privada / certificado embebido", + "rule_hc003_desc": "Clave privada RSA/EC embebida en el c\u00f3digo.", + "rule_hc003_rec": "Las claves privadas nunca deben incluirse en el APK.", + "rule_hc004_title": "Firebase / Google credentials hardcodeadas", + "rule_hc004_desc": "Google API key (AIza...) o FCM server key encontrada en el c\u00f3digo.", + "rule_hc004_rec": "Restringir la API key en Google Cloud Console y no embebir en el APK.", + "rule_hc005_title": "AWS credentials hardcodeadas", + "rule_hc005_desc": "AWS Access Key ID encontrada en el c\u00f3digo.", + "rule_hc005_rec": "Revocar la clave inmediatamente y nunca incluir credenciales AWS en el APK.", + "rule_hc006_title": "Clave/IV criptogr\u00e1fica hardcodeada", + "rule_hc006_desc": "Material criptogr\u00e1fico (key/iv/aes) embebido como constante en el c\u00f3digo.", + "rule_hc006_rec": "Generar y gestionar claves/IV de forma segura (Keystore/KMS) y no hardcodearlas.", + "rule_hc007_title": "URL de producci\u00f3n/API hardcodeada", + "rule_hc007_desc": "URL de producci\u00f3n o endpoint de API expuesta como constante en el c\u00f3digo.", + "rule_hc007_rec": "Centralizar URLs en configuraci\u00f3n remota. URLs de producci\u00f3n expuestas revelan la superficie de ataque de la API.", + "rule_hc008_title": "URL de servicio tercero (KYC/Auth/Pagos) hardcodeada", + "rule_hc008_desc": "URL de servicio cr\u00edtico (KYC, autenticaci\u00f3n o pagos) hardcodeada. Combinada con API keys expuestas, permite interacci\u00f3n directa con el servicio.", + "rule_hc008_rec": "Nunca exponer URLs de servicios de identidad, pagos o autenticaci\u00f3n en el APK. Proxy todas las llamadas a trav\u00e9s del backend propio.", + # ST: Almacenamiento inseguro + "rule_st001_title": "SharedPreferences sin cifrado", + "rule_st001_desc": "Uso de SharedPreferences sin cifrado. Los datos son accesibles en dispositivos rooteados.", + "rule_st001_rec": "Usar EncryptedSharedPreferences de androidx.security.crypto.", + "rule_st002_title": "Escritura de archivo world-readable/world-writable", + "rule_st002_desc": "Archivo creado con permisos accesibles por otras apps.", + "rule_st002_rec": "Usar MODE_PRIVATE para archivos de la aplicaci\u00f3n.", + "rule_st003_title": "Datos sensibles en almacenamiento externo", + "rule_st003_desc": "Escritura de datos en almacenamiento externo (SD card), accesible sin permisos.", + "rule_st003_rec": "Almacenar datos sensibles solo en almacenamiento interno.", + "rule_st004_title": "Base de datos SQLite sin cifrado", + "rule_st004_desc": "Base de datos SQLite sin cifrado, accesible en dispositivos rooteados.", + "rule_st004_rec": "Usar SQLCipher o Room con EncryptedDatabase para datos sensibles.", + "rule_st005_title": "Datos sensibles en el Clipboard", + "rule_st005_desc": "La app copia datos al portapapeles. Otras apps pueden leer el clipboard en versiones de Android anteriores a 10.", + "rule_st005_rec": "Evitar copiar datos sensibles al clipboard. En Android \u226510, limpiar el clipboard tras el uso.", + "rule_st006_title": "Firebase Realtime Database referencia abierta", + "rule_st006_desc": "La app usa Firebase Realtime Database. Si las reglas de seguridad permiten lectura/escritura p\u00fablica, los datos quedan expuestos.", + "rule_st006_rec": "Revisar las Firebase Security Rules. Nunca usar '`.read`: true' o '`.write`: true' en producci\u00f3n.", + # NET: Seguridad de red + "rule_net001_title": "URL HTTP (sin TLS)", + "rule_net001_desc": "Comunicaci\u00f3n HTTP en texto claro. Susceptible a ataques MITM.", + "rule_net001_rec": "Usar HTTPS en todas las comunicaciones de red.", + "rule_net002_title": "Validaci\u00f3n SSL deshabilitada (TrustManager permisivo)", + "rule_net002_desc": "TrustManager que acepta cualquier certificado SSL. Vulnerable a MITM total.", + "rule_net002_rec": "Implementar Certificate Pinning o usar el TrustManager del sistema.", + "rule_net003_title": "HostnameVerifier permisivo (acepta cualquier hostname)", + "rule_net003_desc": "HostnameVerifier que acepta cualquier hostname sin verificaci\u00f3n. Permite ataques MITM aunque TLS est\u00e9 activo.", + "rule_net003_rec": "Eliminar el HostnameVerifier permisivo. El verificador del sistema es correcto por defecto; si se necesita flexibilidad, validar el hostname expl\u00edcitamente.", + "rule_net004_title": "SSLv3 / TLSv1.0 expl\u00edcito", + "rule_net004_desc": "Uso expl\u00edcito de SSL 3.0 o TLS 1.0, protocolos con vulnerabilidades conocidas (POODLE, BEAST).", + "rule_net004_rec": "Usar TLSv1.2 o TLSv1.3 \u00fanicamente.", + "rule_net005_title": "OkHttp sin Certificate Pinner", + "rule_net005_desc": "Cliente OkHttp construido sin CertificatePinner. No hay defensa adicional contra ataques MITM si el certificado del servidor cambia.", + "rule_net005_rec": "Configurar CertificatePinner con los hashes SHA-256 del certificado del servidor.", + "rule_net006_title": "WebView acepta cualquier certificado SSL", + "rule_net006_desc": "WebViewClient con onReceivedSslError que llama handler.proceed() sin verificar el error. Acepta cualquier certificado inv\u00e1lido, caducado o autofirmado \u2014 equivalente a deshabilitar TLS para WebViews.", + "rule_net006_rec": "No llamar handler.proceed() en onReceivedSslError salvo en builds de debug. En producci\u00f3n, llamar handler.cancel() y mostrar un error al usuario.", + # AUTH + "rule_auth001_title": "Token/sesi\u00f3n en logs", + "rule_auth001_desc": "Posible token de autenticaci\u00f3n enviado a los logs del sistema.", + "rule_auth001_rec": "Nunca loguear tokens, sesiones o datos de autenticaci\u00f3n.", + # CRYPTO + "rule_crypto001_title": "Algoritmo MD5 en uso", + "rule_crypto001_desc": "MD5 es criptogr\u00e1ficamente roto. No apto para hashing de contrase\u00f1as o integridad.", + "rule_crypto001_rec": "Usar SHA-256 o superior. Para contrase\u00f1as, usar BCrypt/Argon2.", + "rule_crypto002_title": "Algoritmo SHA-1 en uso", + "rule_crypto002_desc": "SHA-1 est\u00e1 deprecado para uso criptogr\u00e1fico.", + "rule_crypto002_rec": "Usar SHA-256 o superior.", + "rule_crypto003_title": "DES / 3DES en uso", + "rule_crypto003_desc": "DES y 3DES son algoritmos d\u00e9biles con claves cortas.", + "rule_crypto003_rec": "Usar AES-256-GCM.", + "rule_crypto004_title": "AES en modo ECB", + "rule_crypto004_desc": "AES en modo ECB o sin modo especificado (Android defaultea a ECB). ECB no oculta patrones en los datos cifrados y es inseguro.", + "rule_crypto004_rec": "Usar AES/GCM/NoPadding con IV aleatorio generado por SecureRandom.", + "rule_crypto005_title": "IV est\u00e1tico o hardcodeado", + "rule_crypto005_desc": "Vector de inicializaci\u00f3n (IV) est\u00e1tico. Reutilizar IV anula la seguridad del cifrado.", + "rule_crypto005_rec": "Generar un IV aleatorio con SecureRandom para cada operaci\u00f3n de cifrado.", + "rule_crypto006_title": "java.util.Random en lugar de SecureRandom", + "rule_crypto006_desc": "java.util.Random es predecible. No apto para uso criptogr\u00e1fico.", + "rule_crypto006_rec": "Usar java.security.SecureRandom para valores criptogr\u00e1ficos.", + # COMP: Componentes inseguros + "rule_comp001_title": "WebView con JavaScript habilitado", + "rule_comp001_desc": "JavaScript habilitado en WebView. Si la app carga URLs de terceros o contenido no confiable, permite ejecuci\u00f3n de c\u00f3digo arbitrario (XSS universal).", + "rule_comp001_rec": "Deshabilitar JavaScript salvo que sea imprescindible. Si se habilita, cargar solo URLs propias y verificar el origen.", + "rule_comp002_title": "WebView con acceso a archivos o contenido local", + "rule_comp002_desc": "WebView configurado para acceder al sistema de archivos o a ContentProviders. setAllowUniversalAccessFromFileURLs permite que JS en file:// lea cualquier archivo del dispositivo.", + "rule_comp002_rec": "Mantener todos estos flags a false (son el default desde API 30). Nunca habilitar AllowUniversalAccessFromFileURLs en producci\u00f3n.", + "rule_comp003_title": "addJavascriptInterface expuesto a WebView", + "rule_comp003_desc": "Interfaz Java/Kotlin expuesta directamente a JavaScript en WebView. En APIs < 17 permite RCE. En APIs modernas, cada m\u00e9todo p\u00fablico anotado con @JavascriptInterface es invocable desde cualquier p\u00e1gina cargada.", + "rule_comp003_rec": "Exponer solo los m\u00e9todos estrictamente necesarios con @JavascriptInterface. Validar el origen de la p\u00e1gina antes de permitir llamadas. Nunca usar en WebViews que carguen URLs externas.", + "rule_comp004_title": "Broadcast receiver sin permisos", + "rule_comp004_desc": "BroadcastReceiver registrado din\u00e1micamente sin restricci\u00f3n de permisos.", + "rule_comp004_rec": "Usar LocalBroadcastManager o especificar permisos en el registro.", + "rule_comp005_title": "WebView.loadUrl con URI javascript:", + "rule_comp005_desc": "La app inyecta JavaScript en un WebView mediante loadUrl('javascript:...') o evaluateJavascript() con datos no confiables. Permite XSS si el contenido proviene de un Intent o fuente externa.", + "rule_comp005_rec": "No construir URIs javascript: con datos externos. Si se usa evaluateJavascript(), escapar y validar toda entrada.", + # INJ: Inyecci\u00f3n + "rule_inj001_title": "SQL injection potencial (rawQuery concatenado)", + "rule_inj001_desc": "Consulta SQL construida con concatenaci\u00f3n de strings. Susceptible a SQL injection.", + "rule_inj001_rec": "Usar queries parametrizadas con selectionArgs o PreparedStatement.", + "rule_inj002_title": "Path traversal potencial", + "rule_inj002_desc": "Ruta de archivo construida con input del usuario o de un Intent.", + "rule_inj002_rec": "Validar y canonicalizar rutas de archivo antes de usarlas.", + "rule_inj003_title": "Ejecuci\u00f3n de comandos del sistema (Runtime.exec)", + "rule_inj003_desc": "La app ejecuta comandos del sistema operativo. Si el input no est\u00e1 validado, puede derivar en inyecci\u00f3n de comandos.", + "rule_inj003_rec": "Evitar Runtime.exec(). Si es necesario, validar y sanitizar toda entrada antes de incluirla en el comando.", + "rule_inj004_title": "Deep link / Intent sin validaci\u00f3n de origen", + "rule_inj004_desc": "Datos extra\u00eddos de un Intent sin validaci\u00f3n del origen. Un deep link malicioso podr\u00eda inyectar valores inesperados.", + "rule_inj004_rec": "Validar el scheme, host y par\u00e1metros del Intent antes de usarlos. Restringir las actividades exportadas con android:exported=false.", + # DBG: Debug / logging + "rule_dbg001_title": "Modo debug habilitado en c\u00f3digo", + "rule_dbg001_desc": "Bloques de c\u00f3digo que solo se ejecutan en debug pueden incluir l\u00f3gica insegura.", + "rule_dbg001_rec": "Revisar qu\u00e9 c\u00f3digo se ejecuta solo en debug y asegurarse de no exponer datos sensibles.", + "rule_dbg002_title": "Log con datos sensibles", + "rule_dbg002_desc": "Posibles datos sensibles enviados a Logcat.", + "rule_dbg002_rec": "Eliminar logs con informaci\u00f3n sensible antes de publicar.", + "rule_dbg003_title": "printStackTrace() en producci\u00f3n", + "rule_dbg003_desc": "printStackTrace() expone detalles de implementaci\u00f3n interna en los logs.", + "rule_dbg003_rec": "Usar un logger con nivel de control (Timber, SLF4J) que pueda desactivarse.", + # OBF / DESER / EXTRA + "rule_obf001_title": "Reflexi\u00f3n din\u00e1mica (posible evasi\u00f3n de an\u00e1lisis)", + "rule_obf001_desc": "Uso de reflexi\u00f3n Java. Puede usarse para cargar c\u00f3digo din\u00e1micamente.", + "rule_obf001_rec": "Auditar el uso de reflexi\u00f3n para asegurarse de que no carga c\u00f3digo externo.", + "rule_deser001_title": "Deserializaci\u00f3n Java nativa (ObjectInputStream)", + "rule_deser001_desc": "Deserializaci\u00f3n Java nativa. Si el stream proviene de una fuente no confiable, puede derivar en ejecuci\u00f3n de c\u00f3digo arbitrario.", + "rule_deser001_rec": "Evitar la deserializaci\u00f3n Java nativa. Usar JSON/Protobuf con validaci\u00f3n de esquema. Si es imprescindible, implementar un ObjectInputFilter.", + "rule_extra001_title": "Permisos peligrosos en uso", + "rule_extra001_desc": "Uso de permisos considerados peligrosos por Android. Verificar que cada permiso solicitado es estrictamente necesario.", + "rule_extra001_rec": "Seguir el principio de m\u00ednimo privilegio: solicitar solo los permisos imprescindibles y justificar cada uno en la ficha de Play Store.", + # NAT: C\u00f3digo nativo + "rule_nat001_title": "Funciones inseguras de manejo de buffer importadas", + "rule_nat001_desc": "La librer\u00eda importa funciones C inseguras que no verifican l\u00edmites de buffer (strcpy, gets, sprintf, etc.). Si los argumentos provienen de fuentes no confiables, pueden derivar en buffer overflow, stack smashing o corrupci\u00f3n de memoria.", + "rule_nat001_rec": "Reemplazar con equivalentes seguros: strncpy\u2192strlcpy, sprintf\u2192snprintf, gets\u2192fgets. Activar flags de compilaci\u00f3n: -D_FORTIFY_SOURCE=2 -fstack-protector-all.", + "rule_nat002_title": "Ejecuci\u00f3n de comandos del sistema en c\u00f3digo nativo", + "rule_nat002_desc": "La librer\u00eda importa funciones de ejecuci\u00f3n de comandos del sistema (system, popen, execve, etc.). Si el input no est\u00e1 sanitizado, permite inyecci\u00f3n de comandos del SO.", + "rule_nat002_rec": "Evitar system() y popen(). Si es imprescindible, sanitizar y validar todo argumento externo. Nunca construir comandos por concatenaci\u00f3n de strings.", + "rule_nat003_title": "Strings de anti-debug / detecci\u00f3n de an\u00e1lisis", + "rule_nat003_desc": "La librer\u00eda contiene strings relacionados con detecci\u00f3n de debuggers, Frida, Xposed o t\u00e9cnicas anti-an\u00e1lisis. Esto indica que implementa protecciones RASP a nivel nativo.", + "rule_nat003_rec": "Documentar para el report. Estos strings son candidatos para patch con Memory.patchCode() o patch_native_lib durante el an\u00e1lisis din\u00e1mico.", + "rule_nat004_title": "Posibles secretos hardcodeados en c\u00f3digo nativo (alta entrop\u00eda)", + "rule_nat004_desc": "Se encontraron strings de alta entrop\u00eda en la secci\u00f3n .rodata de la librer\u00eda. Pueden ser API keys, tokens, claves de cifrado o secretos hardcodeados incrustados en el binario nativo.", + "rule_nat004_rec": "Auditar manualmente cada string de alta entrop\u00eda. Mover secretos a almacenamiento seguro (Android Keystore, servidor). No incrustar credenciales en binarios distribuidos.", + "rule_nat005_title": "Strings de bypass TLS / desactivaci\u00f3n de verificaci\u00f3n SSL", + "rule_nat005_desc": "La librer\u00eda contiene strings asociados con la desactivaci\u00f3n de verificaci\u00f3n TLS/SSL (CURLOPT_SSL_VERIFYPEER=0, SSL_VERIFY_NONE, etc.). Indica que las conexiones HTTPS pueden no verificar el certificado del servidor.", + "rule_nat005_rec": "Verificar el contexto exacto con disassemble_native_lib. SSL_CTX_set_verify debe llamarse con SSL_VERIFY_PEER, nunca con SSL_VERIFY_NONE.", + "rule_nat006_title": "Strings de detecci\u00f3n de root en c\u00f3digo nativo", + "rule_nat006_desc": "La librer\u00eda contiene strings de rutas y binarios t\u00edpicos de dispositivos rooteados. Implementa detecci\u00f3n de root a nivel nativo.", + "rule_nat006_rec": "Documentar. Durante bypass din\u00e1mico, estos strings son candidatos a parchear con Memory.patchCode() para que las comprobaciones devuelvan 'no root'.", + "rule_nat007_title": "Carga din\u00e1mica de libssl / pinning nativo custom", + "rule_nat007_desc": "La librer\u00eda usa dlopen/dlsym junto con strings de SSL. Indica pinning implementado nativamente cargando libssl de forma din\u00e1mica, lo que evita los hooks est\u00e1ndar de Interceptor.attach sobre s\u00edmbolos exportados.", + "rule_nat007_rec": "Usar Memory.scanSync para encontrar la funci\u00f3n SSL en memoria en lugar de resolver por s\u00edmbolo exportado. Ver Phase 2 del sistema de bypass.", + "rule_nat008_title": "Algoritmos criptogr\u00e1ficos d\u00e9biles en c\u00f3digo nativo (MD5, DES, RC4)", + "rule_nat008_desc": "La librer\u00eda importa funciones de algoritmos criptogr\u00e1ficos d\u00e9biles o rotos: MD5, MD2, DES, RC4 y/o SHA1. Estos algoritmos no son seguros para uso criptogr\u00e1fico.", + "rule_nat008_rec": "Migrar a AES-256-GCM para cifrado, SHA-256/SHA-3 para hashing. Nunca usar MD5 o SHA1 para verificar integridad de datos sensibles.", + # Manifest-specific findings + "rule_net001_manifest_title": "usesCleartextTraffic habilitado", + "rule_net001_manifest_desc": "La app permite tr\u00e1fico HTTP sin cifrar a cualquier destino.", + "rule_net001_manifest_rec": "Eliminar usesCleartextTraffic o restringir con un Network Security Config.", + "rule_info001_title": "android:debuggable=true en producci\u00f3n", + "rule_info001_desc": "La app tiene depuraci\u00f3n habilitada. Permite attach con adb/jdb y extracci\u00f3n de datos.", + "rule_info001_rec": "Nunca distribuir con android:debuggable=\"true\". Usar BuildConfig.DEBUG.", + "rule_comp006_title_prefix": "Activity exportada sin permiso", + "rule_comp007_title_prefix": "Service exportado sin permiso", + "rule_comp004_title_prefix": "BroadcastReceiver exportado sin permiso", + "rule_comp008_title_prefix": "ContentProvider exportado sin permiso", + "rule_exported_comp_desc": "{tag} `{name}` tiene android:exported=\"true\" sin android:permission. Cualquier app o comando ADB puede invocarlo directamente.", + "rule_exported_comp_rec": "A\u00f1adir android:exported=\"false\" o proteger con android:permission=\"\".", }, } diff --git a/nutcracker_core/native_scanner.py b/nutcracker_core/native_scanner.py index 24d8ebb..84ecfdb 100644 --- a/nutcracker_core/native_scanner.py +++ b/nutcracker_core/native_scanner.py @@ -25,6 +25,7 @@ from typing import Callable from .vuln_scanner import VulnFinding +from .i18n import t as _t # ── Definición de reglas nativas ────────────────────────────────────────────── @@ -45,6 +46,21 @@ class _NativeRule: require_high_entropy: bool = False entropy_threshold: float = 3.5 + def i18n_title(self) -> str: + key = f"rule_{self.rule_id.lower()}_title" + val = _t(key) + return val if val != key else self.title + + def i18n_desc(self) -> str: + key = f"rule_{self.rule_id.lower()}_desc" + val = _t(key) + return val if val != key else self.description + + def i18n_rec(self) -> str: + key = f"rule_{self.rule_id.lower()}_rec" + val = _t(key) + return val if val != key else self.recommendation + _NATIVE_RULES: list[_NativeRule] = [ # ── NAT001: Funciones de manejo de buffer inseguras ─────────────────────── @@ -461,14 +477,14 @@ def _cb(msg: str) -> None: matched_rules.add(rule.rule_id) findings.append(VulnFinding( rule_id=rule.rule_id, - title=rule.title, + title=rule.i18n_title(), severity=rule.severity, category=rule.category, file=so_path, line=matched_line, matched_text=matched_text, - description=rule.description, - recommendation=rule.recommendation, + description=rule.i18n_desc(), + recommendation=rule.i18n_rec(), )) _cb(f"native_scanner: {len(findings)} hallazgo(s) en librerías nativas") diff --git a/nutcracker_core/plugins/aireview/__init__.py b/nutcracker_core/plugins/aireview/__init__.py index ca460d3..fe0082a 100644 --- a/nutcracker_core/plugins/aireview/__init__.py +++ b/nutcracker_core/plugins/aireview/__init__.py @@ -277,76 +277,89 @@ def _llm_call(llm_cfg: dict, system: str, user: str) -> str: return str(content) -# ── Auto-run hook (triggered by fire_post_hooks) ────────────────────────────── - -def _after_analysis_hook( +# ── Shared helpers ───────────────────────────────────────────────────────────── + +def _apply_severity_filter( + findings: list[dict], + review_severities: list[str] | None, +) -> tuple[list[dict], list[dict]]: + """Split findings into (to_review, to_skip) by review_severities config. + + findings_to_skip are passed through without LLM review (always kept). + Returns (findings_to_review, findings_to_skip). + """ + if not review_severities: + return findings, [] + allowed = {s.strip().lower() for s in review_severities} + to_review = [f for f in findings if (f.get("severity") or "").lower() in allowed] + to_skip = [f for f in findings if (f.get("severity") or "").lower() not in allowed] + return to_review, to_skip + + +def _run_review_batches( + findings_to_review: list[dict], package: str, - result, # AnalysisResult - vuln_scan, # ScanResult | None - config: dict, -) -> None: - """Runs ai-review automatically if enabled in config post_hooks.""" - enabled_hooks = config.get("post_hooks") or [] - if "ai-review" not in enabled_hooks: - return - - # Skip if there's no vuln scan with findings to review - if vuln_scan is None or not getattr(vuln_scan, "findings", None): - return - - # Skip if no LLM is configured - llm_cfg = config.get("llm") or {} - if not llm_cfg.get("provider"): - return - - from rich.console import Console - console = Console() - console.print("[dim][ai-review] Auto-running after analysis...[/dim]") - - ai_review_cfg = config.get("ai_review", {}) or {} - batch_size = int(ai_review_cfg.get("batch_size", _DEFAULT_BATCH_SIZE)) - context_lines = int(ai_review_cfg.get("context_lines", _DEFAULT_CONTEXT_LINES)) - regen_pdf = bool(ai_review_cfg.get("regen_pdf", True)) - - try: - path, data = _load_findings(package) - except Exception: # noqa: BLE001 - return # no findings file yet; silently skip - - findings = data.get("findings", []) or [] - if not findings: - return - + llm_cfg: dict, + batch_size: int, + context_lines: int, + on_batch_start=None, # callable(start: int, end: int, total: int) + on_batch_error=None, # callable(start: int, exc) +) -> tuple[list[dict], list[dict], list[dict], list[dict]]: + """Run LLM review over findings_to_review in batches. + + Callbacks (both optional): + on_batch_start(start, end, total) — called before each LLM call. + on_batch_error(start, exc) — called on LLM failure or unparseable + response; batch findings are kept. + + Returns (verdicts, kept, dropped, downgraded). + ``kept`` contains only findings from findings_to_review (not skipped ones). + """ verdicts: list[dict] = [] kept: list[dict] = [] dropped: list[dict] = [] downgraded: list[dict] = [] - for start in range(0, len(findings), batch_size): - batch = findings[start:start + batch_size] + for start in range(0, len(findings_to_review), batch_size): + batch = findings_to_review[start:start + batch_size] + end = start + len(batch) + if on_batch_start: + on_batch_start(start + 1, end, len(findings_to_review)) + user_prompt = _build_user_prompt(batch, package, context_lines) try: raw = _llm_call(llm_cfg, _SYSTEM_PROMPT, user_prompt) except Exception as exc: # noqa: BLE001 + if on_batch_error: + on_batch_error(start, exc) for i, f in enumerate(batch): verdicts.append({"id": start + i, "verdict": "TRUE_POSITIVE", "reason": f"LLM error: {exc}"}) kept.append(f) continue + parsed = _parse_verdicts(raw, len(batch)) if parsed is None: + if on_batch_error: + on_batch_error(start, "unparseable LLM response") for i, f in enumerate(batch): verdicts.append({"id": start + i, "verdict": "TRUE_POSITIVE", "reason": "unparseable"}) kept.append(f) continue + parsed.sort(key=lambda v: v["id"]) vmap = {v["id"]: v for v in parsed} for i, f in enumerate(batch): v = vmap.get(i, {"verdict": "TRUE_POSITIVE", "reason": "missing"}) - enriched = {"id": start + i, "rule_id": f.get("rule_id"), - "file": f.get("file"), "line": f.get("line"), - "verdict": v["verdict"], "reason": v.get("reason", "")} + enriched = { + "id": start + i, + "rule_id": f.get("rule_id"), + "file": f.get("file"), + "line": f.get("line"), + "verdict": v["verdict"], + "reason": v.get("reason", ""), + } if v["verdict"] == "DOWNGRADE": if v.get("suggested_severity"): enriched["suggested_severity"] = v["suggested_severity"] @@ -364,74 +377,164 @@ def _after_analysis_hook( modified["_ai_note"] = v.get("reason", "") kept.append(modified) downgraded.append(modified) - else: + else: # TRUE_POSITIVE kept.append(f) - tp_count = len(kept) - fp_count = len(dropped) - dg_count = len(downgraded) - console.print( - f"[green]\u2714[/green] ai-review: kept {tp_count} TPs, " - f"dropped {fp_count} FPs, downgraded {dg_count} for {package}" - ) + return verdicts, kept, dropped, downgraded + + +def _persist_review_results( + package: str, + path: "Path", + data: dict, + findings_to_review: list[dict], + findings_skip: list[dict], + verdicts: list[dict], + kept_reviewed: list[dict], + dropped: list[dict], + downgraded: list[dict], + llm_cfg: dict, +) -> tuple[int, int, int]: + """Save review.json and rewrite vuln.json (with backup). + + Returns (tp_count, fp_count, dg_count). + tp_count includes both findings_skip and kept_reviewed. + """ + all_kept = list(findings_skip) + kept_reviewed + tp_count = len(all_kept) + fp_count = len(dropped) + dg_count = len(downgraded) - # Save audit trail — primario en reports//review.json pkg_reports_dir = _REPORTS_DIR / package pkg_reports_dir.mkdir(parents=True, exist_ok=True) - review_path = pkg_reports_dir / "review.json" - # Legacy copy en decompiled/ para compatibilidad - legacy_review_path = _DECOMPILED_DIR / f"vuln_{package}_review.json" + review_payload = { "package": package, "reviewed_at": time.strftime("%Y-%m-%dT%H:%M:%S"), "model": llm_cfg.get("model"), "provider": llm_cfg.get("provider"), - "total": len(findings), + "total": len(findings_to_review), "true_positives": tp_count, "false_positives": fp_count, "downgraded": dg_count, "verdicts": verdicts, } review_json = json.dumps(review_payload, ensure_ascii=False, indent=2) - review_path.write_text(review_json, encoding="utf-8") - legacy_review_path.write_text(review_json, encoding="utf-8") + (pkg_reports_dir / "review.json").write_text(review_json, encoding="utf-8") + (_DECOMPILED_DIR / f"vuln_{package}_review.json").write_text(review_json, encoding="utf-8") if fp_count == 0 and dg_count == 0: - return + return tp_count, fp_count, dg_count ts = time.strftime("%Y%m%d_%H%M%S") - import shutil as _shutil - _shutil.copy2(path, path.with_suffix(f".json.bak.{ts}")) + shutil.copy2(path, path.with_suffix(f".json.bak.{ts}")) + new_data = dict(data) # FPs se conservan en el JSON con _fp=True para auditoría; el PDF los ignora tagged_fps = [{**d, "_fp": True} for d in dropped] - new_data["findings"] = kept + tagged_fps - new_data["total_findings"] = tp_count # solo TP + downgrades - new_data["ai_reviewed"] = { - "at": review_payload["reviewed_at"], - "model": llm_cfg.get("model"), - "provider": llm_cfg.get("provider"), - "dropped": fp_count, + new_data["findings"] = all_kept + tagged_fps + new_data["total_findings"] = tp_count + new_data["ai_reviewed"] = { + "at": review_payload["reviewed_at"], + "model": llm_cfg.get("model"), + "provider": llm_cfg.get("provider"), + "dropped": fp_count, "downgraded": dg_count, } new_json = json.dumps(new_data, ensure_ascii=False, indent=2) - # Escribir en primario (reports/) y en legacy (decompiled/) - primary_vuln = pkg_reports_dir / "vuln.json" - primary_vuln.write_text(new_json, encoding="utf-8") + (pkg_reports_dir / "vuln.json").write_text(new_json, encoding="utf-8") path.write_text(new_json, encoding="utf-8") - console.print(f"[dim] vuln.json rewritten in {pkg_reports_dir} and {path.parent}[/dim]") - if regen_pdf: - try: - import sys as _sys - _nc = _sys.modules.get("nutcracker") or _sys.modules.get("__main__") - cb = getattr(getattr(_nc, "regen_pdf", None), "callback", None) - if cb: - cb(package=package) - except SystemExit: - raise - except Exception: - pass + return tp_count, fp_count, dg_count + + +def _regen_pdf(package: str, console=None) -> None: + """Attempt to regenerate the PDF for package, optionally printing errors.""" + try: + import sys as _sys + _nc = _sys.modules.get("nutcracker") or _sys.modules.get("__main__") + cb = getattr(getattr(_nc, "regen_pdf", None), "callback", None) + if cb is None: + raise RuntimeError("regen-pdf command not callable") + cb(package=package) + except SystemExit: + raise + except Exception as exc: # noqa: BLE001 + if console: + console.print( + f"[yellow]Could not auto-regenerate PDF: {exc}. " + f"Run manually:[/yellow] [bold]nutcracker regen-pdf {package}[/bold]" + ) + + +# ── Auto-run hook (triggered by fire_post_hooks) ────────────────────────────── + +def _after_analysis_hook( + package: str, + result, # AnalysisResult + vuln_scan, # ScanResult | None + config: dict, +) -> None: + """Runs ai-review automatically if enabled in config post_hooks.""" + enabled_hooks = config.get("post_hooks") or [] + if "ai-review" not in enabled_hooks: + return + if vuln_scan is None or not getattr(vuln_scan, "findings", None): + return + llm_cfg = config.get("llm") or {} + if not llm_cfg.get("provider"): + return + + from rich.console import Console + console = Console() + console.print("[dim][ai-review] Auto-running after analysis...[/dim]") + + ai_review_cfg = config.get("ai_review", {}) or {} + batch_size = int(ai_review_cfg.get("batch_size", _DEFAULT_BATCH_SIZE)) + context_lines = int(ai_review_cfg.get("context_lines", _DEFAULT_CONTEXT_LINES)) + do_regen_pdf = bool(ai_review_cfg.get("regen_pdf", True)) + + try: + path, data = _load_findings(package) + except Exception: # noqa: BLE001 + return + + findings = data.get("findings", []) or [] + if not findings: + return + + findings_to_review, findings_skip = _apply_severity_filter( + findings, ai_review_cfg.get("review_severities") + ) + console.print( + f"[dim][ai-review] {len(findings_to_review)} findings to review " + f"(batch={batch_size}" + + (f", skipping {len(findings_skip)} low/info)" if findings_skip else ")") + + "[/dim]" + ) + + verdicts, kept_reviewed, dropped, downgraded = _run_review_batches( + findings_to_review, package, llm_cfg, batch_size, context_lines, + on_batch_start=lambda s, e, t: console.print( + f"[dim][ai-review] batch {s}-{e} / {t}...[/dim]" + ), + on_batch_error=lambda s, exc: console.print( + f"[red][ai-review] batch at {s} failed: {exc}[/red]" + ), + ) + + tp_count, fp_count, dg_count = _persist_review_results( + package, path, data, + findings_to_review, findings_skip, + verdicts, kept_reviewed, dropped, downgraded, + llm_cfg, + ) + console.print( + f"[green]\u2714[/green] ai-review: kept {tp_count} TPs, " + f"dropped {fp_count} FPs, downgraded {dg_count} for {package}" + ) + if do_regen_pdf: + _regen_pdf(package) # ── CLI registration ───────────────────────────────────────────────────────── @@ -466,7 +569,7 @@ def ai_review_cmd( from nutcracker_core.config import load_config, get as cfg_get console = Console() - config = load_config(config_path) + config = load_config(config_path) llm_cfg = cfg_get(config, "llm", default={}) or {} if not llm_cfg.get("provider"): raise click.ClickException( @@ -474,117 +577,69 @@ def ai_review_cmd( "llm.provider, llm.model and llm.api_key." ) + # CLI flags override config; fall back to config only when the flag + # still holds the Click default value. + ai_review_cfg = cfg_get(config, "ai_review", default={}) or {} + if batch_size == _DEFAULT_BATCH_SIZE: + batch_size = int(ai_review_cfg.get("batch_size", _DEFAULT_BATCH_SIZE)) + if context_lines == _DEFAULT_CONTEXT_LINES: + context_lines = int(ai_review_cfg.get("context_lines", _DEFAULT_CONTEXT_LINES)) + path, data = _load_findings(package) - findings = data.get("findings", []) or [] + findings = data.get("findings", []) or [] if not findings: console.print(f"[yellow]No findings to review in {path}.[/yellow]") return + findings_to_review, findings_skip = _apply_severity_filter( + findings, ai_review_cfg.get("review_severities") + ) console.print( - f"[cyan]ai-review[/cyan] {len(findings)} findings | " - f"LLM: {llm_cfg.get('model', '?')} via " - f"{llm_cfg.get('provider', '?')} | batch={batch_size}" + f"[cyan]ai-review[/cyan] {len(findings_to_review)} findings | " + f"LLM: {llm_cfg.get('model', '?')} via {llm_cfg.get('provider', '?')} | " + f"batch={batch_size}" + + (f" | skipping {len(findings_skip)} low/info" if findings_skip else "") ) - verdicts: list[dict] = [] # parallel to findings; same order - kept: list[dict] = [] - dropped: list[dict] = [] - downgraded: list[dict] = [] + # ── Run batches with spinner ───────────────────────────────────── + verdicts: list[dict] = [] + kept_reviewed: list[dict] = [] + dropped: list[dict] = [] + downgraded: list[dict] = [] with console.status("[cyan]Reviewing findings...[/cyan]") as status: - for start in range(0, len(findings), batch_size): - batch = findings[start:start + batch_size] - status.update( - f"[cyan]Reviewing[/cyan] " - f"{start + 1}-{start + len(batch)} / {len(findings)}" - ) - user_prompt = _build_user_prompt(batch, package, context_lines) - try: - raw = _llm_call(llm_cfg, _SYSTEM_PROMPT, user_prompt) - except click.ClickException: - raise - except Exception as exc: # noqa: BLE001 - console.print( - f"[red]LLM call failed for batch starting at " - f"{start}: {exc}[/red]" - ) - # Conservative: keep all findings in this batch - for i, f in enumerate(batch): - verdicts.append({ - "id": start + i, - "verdict": "TRUE_POSITIVE", - "reason": f"LLM error: {exc}", - }) - kept.append(f) - continue - - parsed = _parse_verdicts(raw, len(batch)) - if parsed is None: - console.print( - f"[yellow]Could not parse LLM response for batch " - f"{start}; keeping all findings in this batch.[/yellow]" - ) - for i, f in enumerate(batch): - verdicts.append({ - "id": start + i, - "verdict": "TRUE_POSITIVE", - "reason": "LLM response unparseable", - }) - kept.append(f) - continue - - # Sort verdicts by id and apply - parsed.sort(key=lambda v: v["id"]) - vmap = {v["id"]: v for v in parsed} - for i, f in enumerate(batch): - v = vmap.get(i, { - "verdict": "TRUE_POSITIVE", - "reason": "missing verdict, kept by default", - }) - enriched = { - "id": start + i, - "rule_id": f.get("rule_id"), - "file": f.get("file"), - "line": f.get("line"), - "verdict": v["verdict"], - "reason": v.get("reason", ""), - } - if v["verdict"] == "DOWNGRADE": - if v.get("suggested_severity"): - enriched["suggested_severity"] = v["suggested_severity"] - if v.get("suggested_category"): - enriched["suggested_category"] = v["suggested_category"] - verdicts.append(enriched) - if v["verdict"] == "FALSE_POSITIVE": - dropped.append({**f, "_fp_reason": v.get("reason", "")}) - elif v["verdict"] == "DOWNGRADE": - modified = dict(f) - if v.get("suggested_severity"): - modified["severity"] = v["suggested_severity"] - if v.get("suggested_category"): - modified["category"] = v["suggested_category"] - modified["_ai_note"] = v.get("reason", "") - kept.append(modified) - downgraded.append(modified) - else: # TRUE_POSITIVE - kept.append(f) - - # ── Print summary ──────────────────────────────────────────────── - tp_count = len(kept) + def on_progress(start: int, end: int, total: int) -> None: + status.update(f"[cyan]Reviewing[/cyan] {start}-{end} / {total}") + + def on_error(start: int, exc) -> None: + console.print(f"[red]LLM call failed for batch at {start}: {exc}[/red]") + + _v, _k, _d, _dg = _run_review_batches( + findings_to_review, package, llm_cfg, batch_size, context_lines, + on_batch_start=on_progress, + on_batch_error=on_error, + ) + verdicts.extend(_v) + kept_reviewed.extend(_k) + dropped.extend(_d) + downgraded.extend(_dg) + + # ── Summary ────────────────────────────────────────────────────── + tp_count = len(findings_skip) + len(kept_reviewed) fp_count = len(dropped) dg_count = len(downgraded) console.print() console.print( - f"[green]✔[/green] Kept {tp_count} true positives " - f"[red]✘[/red] Dropped {fp_count} false positives " - f"[yellow]↓[/yellow] Downgraded {dg_count}" + f"[green]\u2714[/green] Kept {tp_count} true positives " + f"[red]\u2718[/red] Dropped {fp_count} false positives " + f"[yellow]\u2193[/yellow] Downgraded {dg_count}" ) if dropped: table = Table(title="False positives dropped", show_lines=False) - table.add_column("rule", style="magenta", no_wrap=True) - table.add_column("file:line", style="cyan", overflow="fold") - table.add_column("reason", style="dim", overflow="fold") + table.add_column("rule", style="magenta", no_wrap=True) + table.add_column("file:line", style="cyan", overflow="fold") + table.add_column("reason", style="dim", overflow="fold") for d in dropped[:50]: table.add_row( str(d.get("rule_id", "")), @@ -597,11 +652,11 @@ def ai_review_cmd( if downgraded: table = Table(title="Downgraded findings", show_lines=False) - table.add_column("rule", style="magenta", no_wrap=True) - table.add_column("file:line", style="cyan", overflow="fold") - table.add_column("severity", style="yellow", no_wrap=True) - table.add_column("category", style="blue", overflow="fold") - table.add_column("reason", style="dim", overflow="fold") + table.add_column("rule", style="magenta", no_wrap=True) + table.add_column("file:line", style="cyan", overflow="fold") + table.add_column("severity", style="yellow", no_wrap=True) + table.add_column("category", style="blue", overflow="fold") + table.add_column("reason", style="dim", overflow="fold") for d in downgraded[:50]: table.add_row( str(d.get("rule_id", "")), @@ -614,23 +669,6 @@ def ai_review_cmd( if len(downgraded) > 50: console.print(f"[dim]... and {len(downgraded) - 50} more[/dim]") - # ── Persist results ────────────────────────────────────────────── - pkg_reports_dir = _REPORTS_DIR / package - pkg_reports_dir.mkdir(parents=True, exist_ok=True) - review_path = pkg_reports_dir / "review.json" - legacy_review_path = _DECOMPILED_DIR / f"vuln_{package}_review.json" - review_payload = { - "package": package, - "reviewed_at": time.strftime("%Y-%m-%dT%H:%M:%S"), - "model": llm_cfg.get("model"), - "provider": llm_cfg.get("provider"), - "total": len(findings), - "true_positives": tp_count, - "false_positives": fp_count, - "downgraded": dg_count, - "verdicts": verdicts, - } - if dry_run: console.print( f"[yellow]Dry-run: not modifying {path}. " @@ -638,10 +676,16 @@ def ai_review_cmd( ) return - review_json = json.dumps(review_payload, ensure_ascii=False, indent=2) - review_path.write_text(review_json, encoding="utf-8") - legacy_review_path.write_text(review_json, encoding="utf-8") - console.print(f"[dim]Audit trail saved to {review_path}[/dim]") + # ── Persist results ────────────────────────────────────────────── + tp_count, fp_count, dg_count = _persist_review_results( + package, path, data, + findings_to_review, findings_skip, + verdicts, kept_reviewed, dropped, downgraded, + llm_cfg, + ) + console.print( + f"[dim]Audit trail saved to {_REPORTS_DIR / package / 'review.json'}[/dim]" + ) if fp_count == 0 and dg_count == 0: console.print( @@ -650,52 +694,17 @@ def ai_review_cmd( ) return - ts = time.strftime("%Y%m%d_%H%M%S") - backup = path.with_suffix(f".json.bak.{ts}") - shutil.copy2(path, backup) + backup = path.with_suffix(f".json.bak.{time.strftime('%Y%m%d_%H%M%S')}") console.print(f"[dim]Backup saved to {backup}[/dim]") + console.print(f"[green]\u2714[/green] vuln.json updated.") - new_data = dict(data) - # FPs se conservan en el JSON con _fp=True para auditoría; el PDF los ignora - tagged_fps = [{**d, "_fp": True} for d in dropped] - new_data["findings"] = kept + tagged_fps - new_data["total_findings"] = tp_count # solo TP + downgrades - new_data["ai_reviewed"] = { - "at": review_payload["reviewed_at"], - "model": llm_cfg.get("model"), - "provider": llm_cfg.get("provider"), - "dropped": fp_count, - "downgraded": dg_count, - } - new_json = json.dumps(new_data, ensure_ascii=False, indent=2) - # Escribir en primario (reports/) y en legacy (decompiled/) - (pkg_reports_dir / "vuln.json").write_text(new_json, encoding="utf-8") - path.write_text(new_json, encoding="utf-8") - console.print(f"[green]✔[/green] vuln.json updated in {pkg_reports_dir} and {path.parent}.") - - # ── Optionally regenerate the PDF ──────────────────────────────── if no_regen_pdf: - console.print( - "[dim]Skipping PDF regeneration (--no-regen-pdf).[/dim]" - ) + console.print("[dim]Skipping PDF regeneration (--no-regen-pdf).[/dim]") return - try: - import sys as _sys - _nc = _sys.modules.get("nutcracker") or _sys.modules.get("__main__") - cb = getattr(getattr(_nc, "regen_pdf", None), "callback", None) - if cb is None: - raise RuntimeError("regen-pdf command not callable") - console.print("[cyan]Regenerating PDF with filtered findings...[/cyan]") - cb(package=package) - except SystemExit: - raise - except Exception as exc: # noqa: BLE001 - console.print( - f"[yellow]Could not auto-regenerate PDF: {exc}. " - f"Run manually:[/yellow] [bold]nutcracker regen-pdf {package}[/bold]" - ) + console.print("[cyan]Regenerating PDF with filtered findings...[/cyan]") + _regen_pdf(package, console=console) - # ── Register post-hook (runs when fire_post_hooks("after_analysis") is called) ── + # ── Register post-hook ──────────────────────────────────────────────────── try: from nutcracker_core.plugins import register_post_hook register_post_hook("after_analysis", _after_analysis_hook) diff --git a/nutcracker_core/reporter.py b/nutcracker_core/reporter.py index 35a5d3a..5cad500 100644 --- a/nutcracker_core/reporter.py +++ b/nutcracker_core/reporter.py @@ -245,11 +245,7 @@ def print_masvs_summary(masvs_report: "MASVSReport") -> None: grade_color = _GRADE_COLOR.get(masvs_report.grade, "white") score_text = Text(justify="center") - score_text.append(f"\n {t('masvs_title')} | {t('score_label')}: ", style="bold white") - score_text.append(f"{masvs_report.score}/100", style=f"bold {grade_color}") - score_text.append(f" | {t('grade_label')}: ", style="bold white") - score_text.append(masvs_report.grade, style=f"bold {grade_color}") - score_text.append(f" | {t('coverage_label')}: ", style="bold white") + score_text.append(f"\n {t('masvs_title')} | {t('coverage_label')}: ", style="bold white") score_text.append(f"{_covered}/{_MASVS_TOTAL} {t('controls')}", style="bold cyan") if masvs_report.bypass_confirmed: score_text.append(f" | ⚡ {t('bypass_confirmed')}", style="bold yellow") diff --git a/nutcracker_core/vuln_scanner.py b/nutcracker_core/vuln_scanner.py index 4558023..8c60263 100644 --- a/nutcracker_core/vuln_scanner.py +++ b/nutcracker_core/vuln_scanner.py @@ -12,6 +12,8 @@ from dataclasses import dataclass, field from pathlib import Path +from .i18n import t as _t + @dataclass class VulnFinding: @@ -27,6 +29,8 @@ class VulnFinding: recommendation: str def relative_path(self, base: Path) -> str: + if self.file is None: + return "AndroidManifest.xml" try: return str(self.file.relative_to(base)) except ValueError: @@ -51,6 +55,21 @@ class VulnRule: # (útil para filtrar valores que son identificadores, no secretos reales) ignore_value_regex: re.Pattern | None = None + def i18n_title(self) -> str: + key = f"rule_{self.rule_id.lower()}_title" + val = _t(key) + return val if val != key else self.title + + def i18n_desc(self) -> str: + key = f"rule_{self.rule_id.lower()}_desc" + val = _t(key) + return val if val != key else self.description + + def i18n_rec(self) -> str: + key = f"rule_{self.rule_id.lower()}_rec" + val = _t(key) + return val if val != key else self.recommendation + # ── Reglas de detección ─────────────────────────────────────────────────────── @@ -878,14 +897,14 @@ def scan_directory( continue findings.append(VulnFinding( rule_id=rule.rule_id, - title=rule.title, + title=rule.i18n_title(), severity=rule.severity, category=rule.category, file=file_path, line=lineno, matched_text=line.strip()[:120], - description=rule.description, - recommendation=rule.recommendation, + description=rule.i18n_desc(), + recommendation=rule.i18n_rec(), )) return ScanResult(base_dir=source_dir, findings=findings, files_scanned=files_scanned, scanner_engine="regex") @@ -1231,27 +1250,27 @@ def scan_manifest_components(source_dir: Path) -> list[VulnFinding]: if app_el.get(f"{{{ns}}}usesCleartextTraffic") == "true": findings.append(VulnFinding( rule_id="NET001", - title="usesCleartextTraffic habilitado", + title=_t("rule_net001_manifest_title"), severity="high", category="M3 - Comunicación insegura", file=manifest_path, line=0, matched_text='android:usesCleartextTraffic="true"', - description="La app permite tráfico HTTP sin cifrar a cualquier destino.", - recommendation="Eliminar usesCleartextTraffic o restringir con un Network Security Config.", + description=_t("rule_net001_manifest_desc"), + recommendation=_t("rule_net001_manifest_rec"), )) # ── debuggable ─────────────────────────────────────────────────────── if app_el.get(f"{{{ns}}}debuggable") == "true": findings.append(VulnFinding( rule_id="INFO001", - title="android:debuggable=true en producción", + title=_t("rule_info001_title"), severity="high", category="M7 - Calidad del código", file=manifest_path, line=0, matched_text='android:debuggable="true"', - description="La app tiene depuración habilitada. Permite attach con adb/jdb y extracción de datos.", - recommendation="Nunca distribuir con android:debuggable=\"true\". Usar BuildConfig.DEBUG.", + description=_t("rule_info001_desc"), + recommendation=_t("rule_info001_rec"), )) # ── Componentes exported sin permission ────────────────────────────────── @@ -1278,24 +1297,19 @@ def scan_manifest_components(source_dir: Path) -> list[VulnFinding]: continue if exported == "true" and not permission: - desc = ( - f"{tag.capitalize()} `{name}` tiene android:exported=\"true\" sin " - f"android:permission. Cualquier app o comando ADB puede invocarlo directamente." - ) - rec = ( - f"Añadir android:exported=\"false\" o proteger con " - f"android:permission=\"\"." - ) + _title_prefix = _t(f"rule_{rule_id.lower()}_title_prefix") + _desc = _t("rule_exported_comp_desc", tag=tag.capitalize(), name=name) + _rec = _t("rule_exported_comp_rec") findings.append(VulnFinding( rule_id=rule_id, - title=f"{title}: {name.split('.')[-1]}", + title=f"{_title_prefix}: {name.split('.')[-1]}", severity=severity, category=category, file=manifest_path, line=0, matched_text=f'<{tag} android:name="{name}" android:exported="true">', - description=desc, - recommendation=rec, + description=_desc, + recommendation=_rec, )) return findings @@ -1358,14 +1372,14 @@ def _scan_xml_resources_for_secrets(base_dir: Path) -> list[VulnFinding]: seen_keys.add(key) findings.append(VulnFinding( rule_id=rule.rule_id, - title=rule.title, + title=rule.i18n_title(), severity=rule.severity, category=rule.category, file=file_path, line=lineno, matched_text=line.strip()[:120], - description=rule.description, - recommendation=rule.recommendation, + description=rule.i18n_desc(), + recommendation=rule.i18n_rec(), )) return findings From ff23bb18591a0e829e219d2844e68465467f0ac2 Mon Sep 17 00:00:00 2001 From: drneox Date: Fri, 8 May 2026 22:48:45 -0500 Subject: [PATCH 12/30] fix: tighten AWS key regex; roadmap: add dynamic bypass vs DEX extraction entry - vuln_scanner.py: replace loose (?i)(AKIA|...) pattern with anchored \b(AKIA|AIPA|ASIA)[A-Z0-9]{16}\b to reduce false positives - ROADMAP.md: new Reporting section documenting the disconnect between FridaAgent bypass success and DEX extraction, with proposed two-verdict model and implementation touch-points (AnalysisResult, masvs.py, reporter.py, PDF report) - update aipwn submodule pointer to feat/vuln-exploit-native-scan HEAD --- ROADMAP.md | 11 +++++++++++ nutcracker_core/vuln_scanner.py | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9418d80..9539cb5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -9,6 +9,17 @@ ## Platforms - [ ] **iOS / IPA support** — download with `ipatool`, static Mach-O analysis with `jtool2`/`class-dump`, secrets in `.plist`/`.strings`, `Info.plist` analysis (permissions, ATS), jailbreak check detection. MVP static only; dynamic requires jailbreak. +## Reporting +- [ ] **Differentiate runtime bypass vs DEX extraction in reports** — The current verdict only distinguishes `PROTECTED` from `PROTECTION BROKEN`, where `PROTECTION BROKEN` requires a successful in-memory DEX dump (`dex_count > 0`). This is misleading: the FridaAgent can bypass all anti-root protections and run the app on a rooted device without ever dumping DEX (e.g. Tenpo). Proposed fix — two independent verdicts: + - **Static protection** (`PROTECTED` / `UNPROTECTED`): driven by the existing static detectors. + - **Dynamic analysis** (`BYPASS CONFIRMED` / `DEX EXTRACTED` / `NOT ATTEMPTED`): fed by the FridaAgent result (`report_success`) and the FART/dexdump pipeline respectively. + + Implementation touch-points: + 1. Add `aipwn_bypass_confirmed: bool` field to `AnalysisResult` (set by `aipwn.py` after `report_success`). + 2. Change `build_masvs_report()` in `masvs.py`: replace `bypass_confirmed = analysis.protection_broken` with `bypass_confirmed = analysis.protection_broken or analysis.aipwn_bypass_confirmed`. This makes `MASVS-RESILIENCE-*` controls flip to `bypass` status and apply the `_BYPASS_PENALTY` even when DEX extraction failed. + 3. Update `reporter.py` to show a separate dynamic analysis banner alongside the static verdict. + 4. Update the PDF report with a new dynamic analysis section. + ## Architecture - [ ] **Split `vuln_scanner.py`** — 1400+ lines with three responsibilities. Split into `scan_types.py` (dataclasses), `vuln_scanner.py` (regex + semgrep) and `leak_scanner.py` (apkleaks, gitleaks). Do this before adding new leak sources or porting to Go. - [ ] **Port secret scanner to Go** — `string_extractor.py` and the HC* rules are the slowest steps. Go binary `nutcracker-strings` that takes a directory and patterns and returns JSON. Python invokes it as a subprocess, the same way it does semgrep. Prerequisite: split modules first. diff --git a/nutcracker_core/vuln_scanner.py b/nutcracker_core/vuln_scanner.py index 8c60263..e2c3e4f 100644 --- a/nutcracker_core/vuln_scanner.py +++ b/nutcracker_core/vuln_scanner.py @@ -166,7 +166,7 @@ def i18n_rec(self) -> str: title="AWS credentials hardcodeadas", severity="critical", category="M1 - Credenciales", - pattern=re.compile(r'(?i)(AKIA|AIPA|ASIA)[0-9A-Z]{16}'), + pattern=re.compile(r'\b(AKIA|AIPA|ASIA)[A-Z0-9]{16}\b'), description="AWS Access Key ID encontrada en el código.", recommendation="Revocar la clave inmediatamente y nunca incluir credenciales AWS en el APK.", ), From 5a5490aa85ee898f43ee84fd7b494afa00d4ab84 Mon Sep 17 00:00:00 2001 From: drneox Date: Fri, 8 May 2026 22:48:45 -0500 Subject: [PATCH 13/30] fix: tighten AWS key regex; roadmap: add dynamic bypass vs DEX extraction entry - vuln_scanner.py: replace loose (?i)(AKIA|...) pattern with anchored \b(AKIA|AIPA|ASIA)[A-Z0-9]{16}\b to reduce false positives - ROADMAP.md: new Reporting section documenting the disconnect between FridaAgent bypass success and DEX extraction, with proposed two-verdict model and implementation touch-points (AnalysisResult, masvs.py, reporter.py, PDF report) - update aipwn submodule pointer to feat/vuln-exploit-native-scan HEAD --- ROADMAP.md | 11 +++++++++++ nutcracker_core/vuln_scanner.py | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9418d80..cf1be0a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -9,6 +9,17 @@ ## Platforms - [ ] **iOS / IPA support** — download with `ipatool`, static Mach-O analysis with `jtool2`/`class-dump`, secrets in `.plist`/`.strings`, `Info.plist` analysis (permissions, ATS), jailbreak check detection. MVP static only; dynamic requires jailbreak. +## Reporting +- [ ] **Differentiate runtime bypass vs DEX extraction in reports** — The current verdict only distinguishes `PROTECTED` from `PROTECTION BROKEN`, where `PROTECTION BROKEN` requires a successful in-memory DEX dump (`dex_count > 0`). This is misleading: the FridaAgent can bypass all anti-root protections and run the app on a rooted device without ever dumping DEX (e.g. apps with aggressive native anti-root that kill the process before FART hooks run). Proposed fix — two independent verdicts: + - **Static protection** (`PROTECTED` / `UNPROTECTED`): driven by the existing static detectors. + - **Dynamic analysis** (`BYPASS CONFIRMED` / `DEX EXTRACTED` / `NOT ATTEMPTED`): fed by the FridaAgent result (`report_success`) and the FART/dexdump pipeline respectively. + + Implementation touch-points: + 1. Add `aipwn_bypass_confirmed: bool` field to `AnalysisResult` (set by `aipwn.py` after `report_success`). + 2. Change `build_masvs_report()` in `masvs.py`: replace `bypass_confirmed = analysis.protection_broken` with `bypass_confirmed = analysis.protection_broken or analysis.aipwn_bypass_confirmed`. This makes `MASVS-RESILIENCE-*` controls flip to `bypass` status and apply the `_BYPASS_PENALTY` even when DEX extraction failed. + 3. Update `reporter.py` to show a separate dynamic analysis banner alongside the static verdict. + 4. Update the PDF report with a new dynamic analysis section. + ## Architecture - [ ] **Split `vuln_scanner.py`** — 1400+ lines with three responsibilities. Split into `scan_types.py` (dataclasses), `vuln_scanner.py` (regex + semgrep) and `leak_scanner.py` (apkleaks, gitleaks). Do this before adding new leak sources or porting to Go. - [ ] **Port secret scanner to Go** — `string_extractor.py` and the HC* rules are the slowest steps. Go binary `nutcracker-strings` that takes a directory and patterns and returns JSON. Python invokes it as a subprocess, the same way it does semgrep. Prerequisite: split modules first. diff --git a/nutcracker_core/vuln_scanner.py b/nutcracker_core/vuln_scanner.py index 8c60263..e2c3e4f 100644 --- a/nutcracker_core/vuln_scanner.py +++ b/nutcracker_core/vuln_scanner.py @@ -166,7 +166,7 @@ def i18n_rec(self) -> str: title="AWS credentials hardcodeadas", severity="critical", category="M1 - Credenciales", - pattern=re.compile(r'(?i)(AKIA|AIPA|ASIA)[0-9A-Z]{16}'), + pattern=re.compile(r'\b(AKIA|AIPA|ASIA)[A-Z0-9]{16}\b'), description="AWS Access Key ID encontrada en el código.", recommendation="Revocar la clave inmediatamente y nunca incluir credenciales AWS en el APK.", ), From d983461f03a50a1ab8276300f5a77cf15e7756b5 Mon Sep 17 00:00:00 2001 From: Hiteek Date: Tue, 21 Jul 2026 03:28:36 -0500 Subject: [PATCH 14/30] docs: add phased plan for mass execution, OWASP MAS alignment, and dashboard --- plan.md | 260 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 plan.md diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..e6c1512 --- /dev/null +++ b/plan.md @@ -0,0 +1,260 @@ +# Plan — Nutcracker: ejecución masiva, alineación OWASP MAS y dashboard + +## Context + +`nutcracker` es una herramienta de seguridad móvil (Android APK) en Python, **solo terminal**, con +un CLI monolítico (`nutcracker.py`, 2.260 líneas) sobre un paquete `nutcracker_core/` +(analyzer, pipeline runtime, detectores, scanners, MASVS) y un **sistema de plugins limpio** +(`nutcracker_core/plugins/__init__.py`): auto-descubre carpetas con +`register(cli)` y expone post-hooks `after_analysis` / `after_batch`. Toda la IA vive como plugin +(`aipwn` = dos agentes ReAct Frida/exploit vía `any-llm-sdk`; `aireview` = filtro de falsos positivos). + +Tres accionables, en fases: + +1. **Ejecución masiva de APKs** con cola paralela/secuencial y **scheduler configurable** que garantice + revisiones periódicas (por defecto ≥1/mes por APK). +2. **Alinear el proyecto a OWASP MAS** (MASVS + MASWE + MASTG + CWE) ejecutando **tests `.py` + deterministas** estáticos y dinámicos para mejores hallazgos. +3. **Dashboard web** (hoy todo es terminal): vídeo del celular físico vía scrcpy, logs junto al + razonamiento del agente, panel del prompt del sistema/turno, y un chat que dispare acciones del agente. + +Además: refactoring para mantenibilidad, respetando la frontera **core (todo) vs plugin (IA)**. + +### Estado actual — huecos confirmados +- **Batch** (`nutcracker.py:1964`): bucle **secuencial**, sin persistencia, sin cola, + sin paralelismo, sin estado entre corridas, **sin scheduling**. +- **Persistencia**: solo archivos planos `reports//*.json` + PDF (`reporter.py:184`). + **No hay BD**. +- **OWASP**: mapeo a **MASVS v2** (subconjunto, `masvs.py`) + reglas semgrep MASTG + (`MSTG-*`). **MASWE ausente por completo**; **CWE** solo se consume de metadata de semgrep para escalar + severidad, no forma parte de la taxonomía emitida. Checks en 3 registros hechos a mano + (`RULES` en `vuln_scanner.py`, `_NATIVE_RULES` en `native_scanner.py`, + `ALL_DETECTORS` en `analyzer.py:87`). +- **Tests**: casi inexistentes (solo `tests/test_i18n.py`). +- **IA**: agentes ya imprimen razonamiento (`aipwn.show_thinking`) y streamean salida Frida/logcat en + tiempo real (`frida_capture.py`) — buena base para el dashboard. + No hay streaming a nivel de token ni chat interactivo humano. + +### Restricción arquitectónica clave (define la cola) +El análisis **estático** (decompilación, semgrep, regex, native, OSINT) **paraleliza bien**. +El análisis **dinámico** (Frida/ADB/aipwn) usa el **teléfono físico** y **debe serializarse por +dispositivo** — dos análisis dinámicos no pueden compartir el mismo celular. La cola será: +**pool paralelo para estático + lock por dispositivo para dinámico**. + +### ⚠️ Nota de seguridad +`config.yaml` local contiene secretos reales (`google_play.aas_token`, `llm.api_key` de z.ai/GLM). +Está en `.gitignore` (no se commitea) — bien, pero **rotar la `api_key` LLM** si estuvo expuesta y +migrar secretos a variables de entorno (ver Fase 0). + +### Decisiones tomadas +- Orden: **Fase 0 Fundamentos → Fase 1 Cola/Scheduler → Fase 2 OWASP MAS → Fase 3 Dashboard**. +- Persistencia: **SQLite** (un archivo, sin servidor extra). +- Ejecución: **daemon con scheduler interno** (`nutcracker serve` + APScheduler + pool + lock por device). +- Dashboard: **web local** (FastAPI + WebSocket + SPA), scrcpy embebido, como **plugin nuevo**. + +--- + +## Principios rectores +- **No romper la frontera core/plugin.** Persistencia, cola y scheduler son **core** (utilidad general). + El dashboard y la IA son **plugins**. El dashboard **lee** el store SQLite y **consume** el streaming de + aipwn; no mete lógica de negocio en el plugin. +- **Reusar lo que existe:** el registro de post-hooks (`register_post_hook`/`fire_post_hooks`), el loader de + plugins, `load_config`/`cfg_get`, los dataclasses `AnalysisResult`/`ScanResult`/`MASVSReport`, y el + streaming ya presente en `frida_capture.py` y `print_agent_thinking`. +- **Incremental y verificable:** cada fase deja el CLI actual funcionando y añade una capa. + +--- + +## Fase 0 — Refactoring + fundamentos de persistencia (SQLite) + +Objetivo: crear la base de estado compartido y ordenar el código antes de construir encima. + +### 0.1 Capa de persistencia SQLite (core, nuevo) +Nuevo módulo `nutcracker_core/store/` (paquete): +- `schema.sql` / `db.py` — conexión (stdlib `sqlite3`, WAL mode), migraciones simples versionadas. +- Tablas: + - `apps(package PK, source, first_seen, last_run_at, next_due_at, schedule_json)` + - `runs(id PK, package FK, kind[static|dynamic|full], status[queued|running|done|error], started_at, finished_at, verdict, masvs_score, grade, error)` + - `findings(id PK, run_id FK, rule_id, title, severity, category, masvs, maswe, cwe, file, line, confirmed[bool])` + - `artifacts(run_id FK, type[pdf|json|screenshot|frida_log], path)` + - `schedule(package FK, interval_days default 30, cron, enabled, last_scheduled_at)` +- `repository.py` — funciones CRUD tipadas (`insert_run`, `update_run_status`, `record_findings`, + `apps_due(before)`, `history(package)`). +- **Doble escritura no destructiva:** un post-hook core `after_analysis` persiste el resultado en SQLite + reusando el flujo actual (los JSON/PDF en disco siguen igual). Convertir hallazgos con los dataclasses + existentes (`VulnFinding`, `MASVSReport.to_dict()`). + +### 0.2 Refactor del CLI monolítico +`nutcracker.py` (2.260 líneas) → paquete `nutcracker_core/cli/`: +- `cli/__init__.py` (grupo Click + `load_plugins`), `cli/scan.py`, `cli/analyze.py`, `cli/launch.py`, + `cli/batch.py`, `cli/setup_token.py`, `cli/regen_pdf.py`. +- Extraer los helpers de orquestación (`_run_analysis`, `_post_analysis_flow`, `_generate_pdf`, + `save_analysis_json`) a `nutcracker_core/orchestrator.py` para que **CLI, daemon y dashboard** los + reusen sin duplicar. Este es el cambio de mantenibilidad más importante. +- Mantener `nutcracker.py` como shim de entrada (`from nutcracker_core.cli import cli`). + +### 0.3 Split de `vuln_scanner.py` (ya en ROADMAP) +Ejecutar el ítem de arquitectura del `ROADMAP.md`: dividir en `scan_types.py` (dataclasses), +`vuln_scanner.py` (regex+semgrep), `leak_scanner.py` (apkleaks+gitleaks). Prerrequisito para escalar checks +en Fase 2. + +### 0.4 Higiene de config/secretos +- Soportar `${ENV_VAR}` en `load_config` (`config.py`) para leer + `llm.api_key`, `google_play.aas_token`, tokens OSINT desde entorno. +- Documentar en `config.yaml.example`. Rotar la key expuesta. + +### 0.5 Base de tests +Introducir `pytest` + `pytest.ini`, fixtures con un APK mínimo de prueba, y tests unitarios para +`store/repository.py`, el mapeo MASVS y el split del scanner. CI opcional (GitHub Actions) en esta fase. + +--- + +## Fase 1 — Ejecución masiva: cola paralela/secuencial + scheduler + +Objetivo: `nutcracker serve` = daemon que encola, ejecuta y agenda revisiones periódicas. + +### 1.1 Cola y workers (core, nuevo `nutcracker_core/queue/`) +- `job.py` — `Job(package, kind, priority, created_at)` persistido en tabla `runs` (estado `queued`). +- `engine.py` — orquestador basado en `concurrent.futures`: + - **Pool estático** configurable (`queue.static_workers`, p.ej. 4) para la fase estática de cada APK. + - **Lock por dispositivo** (`threading.Lock` keyed por `serial`) que serializa la fase + dinámica; si hay varios devices en `adb devices`, un lock por serial permite paralelismo entre + teléfonos pero nunca en el mismo. + - Reusa `orchestrator.run_static(apk)` y `orchestrator.run_dynamic(apk, serial)` de Fase 0.2. +- Modo **secuencial** = pool de tamaño 1 (config `queue.mode: sequential|parallel`). + +### 1.2 Scheduler (core, APScheduler) +- `scheduler.py` — `BackgroundScheduler` de APScheduler: + - Job maestro periódico (cada hora) que llama `repository.apps_due(now)` y encola las apps cuyo + `next_due_at <= now`. + - Al terminar un run, recalcula `next_due_at = finished_at + interval_days` (default **30**). + - Config por app (tabla `schedule`) y global en `config.yaml` (nuevo bloque `scheduler:` con + `default_interval_days: 30`, `cron` opcional, `enabled`). +- Añadir `requirements.txt`: `apscheduler>=3.10`. + +### 1.3 Comandos CLI nuevos +- `nutcracker serve` — arranca daemon (scheduler + cola + API para el dashboard de Fase 3). +- `nutcracker queue add ` / `queue ls` / `queue rm`. +- `nutcracker schedule set --every 30d` / `schedule ls`. +- `nutcracker batch --due` — modo one-shot para cron/systemd (encola solo lo vencido y sale), por si se + quiere disparo externo además del daemon. +- Migrar el `batch` actual para que use `queue.engine` (compatibilidad hacia atrás con `list_file`). + +**Entregable:** dado un `list_file`, el daemon revisa todas las apps, respeta paralelo/secuencial y +device-lock, y re-agenda cada una a ≥1/mes automáticamente. + +--- + +## Fase 2 — Alineación OWASP MAS (MASVS + MASWE + MASTG + CWE) con tests `.py` deterministas + +Objetivo: convertir los 3 registros hechos a mano en un **framework de checks unificado** con taxonomía +OWASP completa y separación estático/dinámico explícita. + +### 2.1 Taxonomía enriquecida +- Extender el modelo de finding (`VulnFinding` / `scan_types.py`) con campos **`maswe`** (p.ej. `MASWE-0001`) + y **`cwe`** de primera clase (hoy CWE solo vive en metadata semgrep, `vuln_scanner.py:997`). +- En `masvs.py`: añadir `RULE_TO_MASWE` y `RULE_TO_CWE` junto a los `RULE_TO_MASVS` + existentes; añadir `MASTG_TEST_IDS` por control. Completar `MASVS_CONTROLS` a la matriz v2 real + (incluir PRIVACY y los controles hoy ausentes) para que el "24 controles" del README sea fiel. +- Reporte (`reporter.py` + `pdf_reporter.py`) muestra por hallazgo: MASVS control, MASWE, CWE, MASTG test-id. + +### 2.2 Framework de checks deterministas `.py` (core, nuevo `nutcracker_core/checks/`) +Unificar el patrón de extensión (hoy fragmentado en 3 listas) en un registro con auto-descubrimiento, +al estilo del loader de plugins: +- `checks/base.py` — `class Check` con metadata `id, masvs, maswe, cwe, mastg, severity, kind[static|dynamic]` + y método `run(ctx) -> list[Finding]`. `ctx` da acceso a: dir decompilado, manifest, strings/classes, + y (para dinámicos) `serial`/handle Frida. +- `checks/registry.py` — descubre todos los `Check` en `checks/static/` y `checks/dynamic/`. +- **Estáticos** (`checks/static/*.py`): portar las `VulnRule`/`_NativeRule`/detectores actuales como checks + deterministas mapeados a MASWE/MASTG. Cada uno es un `.py` testeable de forma aislada. +- **Dinámicos** (`checks/dynamic/*.py`): checks deterministas que corren en el device **sin** LLM + (p.ej. verificar `android:debuggable`, cleartext real, componentes exportados invocables, prefs + world-readable) — extrae la lógica ya presente en las 12 tools de + `exploit_agent.py` hacia checks core reutilizables; el + agente LLM queda para lo que requiere razonamiento/adaptación (bypass RASP). +- El scanner actual (`scan_directory`, `scan_native_libs`, detectores) se convierte en runner que itera el + registry. Migración gradual: registry envuelve los registros existentes primero, luego se portan. + +### 2.3 Suite de tests deterministas (MASTG-style) +- `tests/checks/` con casos por check (APK/snippet mínimo → hallazgo esperado), garantizando + determinismo y no-regresión. Mapear cada test a su MASTG test-id. +- Doc `docs/owasp-mas-coverage.md` (matriz MASVS×MASWE×MASTG cubierto / pendiente), generable desde el + registry. + +### 2.4 Cerrar ítem de ROADMAP relacionado +Implementar la separación **verdicto estático vs bypass dinámico** ya especificada en +`ROADMAP.md` (`aipwn_bypass_confirmed` en `AnalysisResult`, ajuste en `build_masvs_report`), +que encaja naturalmente con el modelo `runs.kind` de Fase 0. + +--- + +## Fase 3 — Dashboard web local (plugin nuevo) + +Objetivo: plugin `nutcracker_core/plugins/dashboard/` que sirva una web local sobre el daemon de Fase 1. +Respeta la frontera: **lee SQLite y consume el streaming de aipwn**; no reimplementa análisis. + +### 3.1 Backend (FastAPI, dentro del daemon `serve`) +- `dashboard/server.py` — FastAPI montado por `nutcracker serve`: + - REST: `/api/apps`, `/api/runs`, `/api/runs/{id}/findings`, `/api/schedule`, control de cola + (add/pause/trigger), historial y tendencias MASVS (desde SQLite). + - **WebSocket `/ws/run/{id}`** — streamea en vivo: razonamiento del agente (`print_agent_thinking` → + evento), "Nutcracker says", cada `tool_call`, y salida Frida/logcat (ya emitida por + `frida_capture.py`). Se logra publicando esos eventos a + un bus (callback) que hoy va a `rich.Console`: añadir un sink opcional en aipwn que empuje al WS. + - **WebSocket `/ws/scrcpy`** — vídeo del teléfono físico. Empaquetar **ws-scrcpy** (scrcpy sobre + WebSocket/WebRTC) y proxyearlo; el frontend renderiza el stream H.264. + - **Chat `/ws/chat`** — endpoint que inyecta mensajes del operador en la conversación del agente + (los agentes ya son loops multi-turno con `self.messages`); permite pedir acciones ad-hoc + ("toma screenshot", "prueba este hook") traducidas a tool calls. + +### 3.2 Frontend (SPA ligera servida como estático por FastAPI) +Layout: +- **Panel vídeo scrcpy** (celular en vivo) a la izquierda. +- **Panel razonamiento del agente** + **logs Frida/logcat** al costado (dos pestañas o split). +- **Panel "Prompt"**: system prompt vigente + contexto del turno + últimas tool calls (transparencia del + razonamiento). +- **Chat** con el agente (acciones). +- **Vista cola/scheduler**: runs en curso, encolados, próximos vencimientos, historial y score MASVS por app. +- Stack: SPA vanilla o Preact + WebSocket; todo servido local. Sin CDNs (self-contained). + +### 3.3 Comando +- `nutcracker serve --dashboard` (o siempre-on) levanta API+WS+estáticos en `127.0.0.1:`. +- Config `dashboard:` en `config.yaml` (`port`, `bind`, `enable_chat`, `scrcpy: true`). + +--- + +## Ideas transversales de mantenibilidad (a lo largo de las fases) +- **`orchestrator.py`** como única fuente de la verdad del pipeline (usado por CLI/daemon/dashboard) — evita + la lógica duplicada que hoy vive en `nutcracker.py`. +- **Registry pattern** para checks y downloaders (auto-descubrimiento) igual que el loader de plugins ya + probado — reduce el acoplamiento de las 3 listas manuales. +- **Modelo de dominio compartido** (`scan_types.py`) reusado por store, reporter y dashboard. +- **Tests + CI** desde Fase 0 para no regresionar durante los refactors. +- **Empaquetado**: añadir `pyproject.toml` (hoy corre como script suelto) para instalar `nutcracker` como + paquete con entry-points (`nutcracker`, `nutcracker-serve`). + +--- + +## Verificación por fase +- **Fase 0:** `pytest` verde; `nutcracker analyze ` produce el mismo JSON/PDF que antes **y** una fila + en `runs`/`findings` de SQLite (`sqlite3 nutcracker.db "select * from runs"`). CLI refactorizado pasa un + smoke test de cada subcomando. +- **Fase 1:** con un `list_file` de 3 apps y `static_workers=2`, verificar en logs/DB que la fase estática + corre en paralelo y la dinámica se serializa por device; `schedule set` fija `next_due_at`; adelantar reloj + (o `--every 0d`) demuestra el re-encolado automático. `nutcracker serve` arranca y agenda. +- **Fase 2:** cada check nuevo tiene test determinista en `tests/checks/`; un APK de prueba con secreto + conocido reporta `rule_id + MASVS + MASWE + CWE + MASTG`; `docs/owasp-mas-coverage.md` se genera desde el + registry. Verdicto estático vs dinámico separado en el reporte. +- **Fase 3:** con el daemon activo, abrir `http://127.0.0.1:`: se ve el celular vía scrcpy, el + razonamiento y logs del agente en vivo durante un `aipwn`, el panel de prompt, el historial MASVS desde + SQLite, y el chat inyecta una acción que el agente ejecuta en el device. + +## Archivos/áreas críticas a tocar +- Nuevos (core): `nutcracker_core/store/`, `nutcracker_core/orchestrator.py`, `nutcracker_core/queue/`, + `nutcracker_core/scheduler.py`, `nutcracker_core/checks/`, `nutcracker_core/cli/`. +- Modificar: `nutcracker.py` (→ shim/`cli/`), `vuln_scanner.py` + (split), `masvs.py` (MASWE/CWE/MASTG), `config.py` + (env vars), `reporter.py` + `pdf_reporter.py` + (taxonomía + verdicto dual), `analyzer.py` (`aipwn_bypass_confirmed`). +- Nuevos (plugin): `nutcracker_core/plugins/dashboard/` (FastAPI + WS + SPA + ws-scrcpy). Sink de streaming + opcional en `plugins/aipwn/frida_agent.py` / `frida_capture.py`. From 40d9f15acb06fa88422ad3142e2ad62e102e5e3a Mon Sep 17 00:00:00 2001 From: Hiteek Date: Fri, 24 Jul 2026 12:25:12 -0500 Subject: [PATCH 15/30] advance --- config.yaml.example | 10 + nutcracker.py | 2252 +-------------------------- nutcracker_core/cli/__init__.py | 145 ++ nutcracker_core/cli/analyze.py | 60 + nutcracker_core/cli/batch.py | 256 +++ nutcracker_core/cli/launch.py | 80 + nutcracker_core/cli/regen_pdf.py | 96 ++ nutcracker_core/cli/scan.py | 185 +++ nutcracker_core/cli/setup_token.py | 54 + nutcracker_core/config.py | 32 +- nutcracker_core/leak_scanner.py | 364 +++++ nutcracker_core/orchestrator.py | 1496 ++++++++++++++++++ nutcracker_core/scan_types.py | 95 ++ nutcracker_core/store/__init__.py | 25 + nutcracker_core/store/db.py | 60 + nutcracker_core/store/hooks.py | 145 ++ nutcracker_core/store/repository.py | 199 +++ nutcracker_core/store/schema.sql | 61 + nutcracker_core/vuln_scanner.py | 456 +----- pytest.ini | 3 + requirements-dev.txt | 3 + tests/conftest.py | 4 + tests/test_config_env.py | 54 + tests/test_store_hooks.py | 122 ++ tests/test_store_repository.py | 128 ++ tests/test_vuln_scanner_split.py | 85 + 26 files changed, 3787 insertions(+), 2683 deletions(-) create mode 100644 nutcracker_core/cli/__init__.py create mode 100644 nutcracker_core/cli/analyze.py create mode 100644 nutcracker_core/cli/batch.py create mode 100644 nutcracker_core/cli/launch.py create mode 100644 nutcracker_core/cli/regen_pdf.py create mode 100644 nutcracker_core/cli/scan.py create mode 100644 nutcracker_core/cli/setup_token.py create mode 100644 nutcracker_core/leak_scanner.py create mode 100644 nutcracker_core/orchestrator.py create mode 100644 nutcracker_core/scan_types.py create mode 100644 nutcracker_core/store/__init__.py create mode 100644 nutcracker_core/store/db.py create mode 100644 nutcracker_core/store/hooks.py create mode 100644 nutcracker_core/store/repository.py create mode 100644 nutcracker_core/store/schema.sql create mode 100644 pytest.ini create mode 100644 requirements-dev.txt create mode 100644 tests/conftest.py create mode 100644 tests/test_config_env.py create mode 100644 tests/test_store_hooks.py create mode 100644 tests/test_store_repository.py create mode 100644 tests/test_vuln_scanner_split.py diff --git a/config.yaml.example b/config.yaml.example index 588851b..624e0ef 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -1,3 +1,6 @@ +# Values can reference environment variables with "${VAR_NAME}", e.g.: +# aas_token: '${GOOGLE_PLAY_AAS_TOKEN}' +# Useful to keep real secrets out of config.yaml (which is still gitignored). google_play: email: '' aas_token: '' @@ -141,3 +144,10 @@ auto: batch: list_file: '' # path to the list file (optional if passed as CLI argument) stop_on_error: false # stop batch processing if an app fails + +# ── Persistence (SQLite) ────────────────────────────────────────────────────── +# Every analysis is also recorded in a local SQLite DB (runs/findings/artifacts), +# in addition to the existing reports//*.json and PDF (non-destructive). +store: + enabled: true # set false to skip SQLite persistence entirely + db_path: '' # empty = ./nutcracker.db at the project root diff --git a/nutcracker.py b/nutcracker.py index b68e525..7dc97eb 100644 --- a/nutcracker.py +++ b/nutcracker.py @@ -6,2255 +6,13 @@ python nutcracker.py scan # descarga desde APKPure + analiza python nutcracker.py scan --source google-play # descarga desde Google Play + analiza python nutcracker.py analyze # analiza una APK local -""" - -import sys -import os -import subprocess -import time -from pathlib import Path - -import click -from rich.console import Console -from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, DownloadColumn, TransferSpeedColumn - -from nutcracker_core.analyzer import APKAnalyzer -from nutcracker_core.config import load_config, get as cfg_get -from nutcracker_core.decompiler import decompile, get_available_tool, install_instructions, DecompilerError -from nutcracker_core.deobfuscator import ( - apply_decrypt_map, - check_adb, - decompile_dumps, -) -from nutcracker_core.downloader import APKPureDownloader, GooglePlayDownloader, DirectURLDownloader, APKDownloadError, is_direct_apk_url, download_apk_from_config -from nutcracker_core.device import ( - find_sdk_tools, - get_frida_version, - list_avds, -) -from nutcracker_core.frida_bypass import ( - fart_run_instructions, - frida_run_instructions, - generate_bypass_script, - generate_fart_script, -) -from nutcracker_core import i18n, __version__ as _VERSION -from nutcracker_core.i18n import t -from nutcracker_core.plugins import load_plugins, fire_post_hooks -from nutcracker_core.manifest_analyzer import analyze_decompiled_dir, Misconfiguration -from nutcracker_core.pdf_reporter import generate_pdf_report -from nutcracker_core.reporter import print_report, save_json_report, save_analysis_json, print_vuln_report, print_masvs_summary -from nutcracker_core.pipeline import ( - ExtractionResult, - connected_adb_devices, - deobf_method_order, - do_fart_emulator, - do_fart_manual, - is_emulator_serial, -) -from nutcracker_core.vuln_scanner import scan_directory, auto_scan, scan_with_apkleaks, scan_with_gitleaks, ScanResult -from nutcracker_core.osint import run_osint, OsintResult - -console = Console() - - -# ── Configuración global (se carga una vez en el comando principal) ──────────── -_CFG: dict = {} -_MANIFEST_ANALYSIS = None # ManifestAnalysisResult del último scan -_OSINT_RESULT = None # OsintResult del último scan -_LAUNCH_APP: bool = False # --launch: lanzar app con bypass script tras el análisis -_LAUNCH_SERIAL: str | None = None # --serial para --launch - - -def _init_i18n(config: dict) -> None: - """Initialize the i18n module from the loaded config.""" - language = str(cfg_get(config, "language", default="en")).strip().lower() - if language not in i18n.SUPPORTED_LANGUAGES: - console.print(f"[yellow]⚠[/yellow] {t('unsupported_language', lang=language)}") - i18n.init(language) - - -def _format_elapsed(seconds: float) -> str: - """Formatea una duración en formato legible para consola.""" - total_seconds = max(0, int(round(seconds))) - minutes, secs = divmod(total_seconds, 60) - hours, mins = divmod(minutes, 60) - - parts: list[str] = [] - if hours: - parts.append(f"{hours}h") - if mins or hours: - parts.append(f"{mins}m") - parts.append(f"{secs}s") - return " ".join(parts) - - -def _print_elapsed(label: str, seconds: float) -> None: - """Imprime el tiempo total consumido por una ejecución.""" - console.print(f"[bold cyan]⏱ {label}:[/bold cyan] {_format_elapsed(seconds)}") - - -def _find_latest_bypass_script(package: str, scripts_dir: Path = Path("frida_scripts")) -> Path | None: - """Devuelve el script de bypass más reciente para el paquete, o None.""" - if not scripts_dir.exists(): - return None - candidates = sorted( - scripts_dir.glob(f"bypass_{package}_*.js"), - key=lambda p: p.stat().st_mtime, - reverse=True, - ) - return candidates[0] if candidates else None - - -def _launch_frida_bypass( - package: str, - script_path: Path, - serial: str | None = None, - frida_host: str | None = None, -) -> None: - """Reinicia frida-server y lanza la app con el bypass script (reemplaza el proceso).""" - import shutil as _shutil - import time as _time - - adb = _shutil.which("adb") - if not adb: - console.print(f"[red]Error:[/red] {t('cli_dep_adb_missing')}") - return - - frida_bin = str(Path(sys.executable).parent / "frida") - if not Path(frida_bin).exists(): - frida_bin = _shutil.which("frida") or "" - if not frida_bin: - console.print(f"[red]Error:[/red] {t('cli_dep_frida_missing')}") - return - - adb_args = [adb] + (["-s", serial] if serial else []) - - console.print(f"[dim] {t('cli_restarting_frida')}[/dim]") - subprocess.run(adb_args + ["shell", "killall frida-server 2>/dev/null; true"], capture_output=True) - subprocess.run(adb_args + ["root"], capture_output=True) - _time.sleep(2) - subprocess.run( - adb_args + ["shell", "nohup /data/local/tmp/frida-server > /dev/null 2>&1 &"], - capture_output=True, - ) - _time.sleep(2) - subprocess.run(adb_args + ["shell", f"am force-stop {package}"], capture_output=True) - _time.sleep(1) - - if frida_host: - frida_cmd = [frida_bin, "-H", frida_host, "-f", package, "-l", str(script_path)] - elif serial: - frida_cmd = [frida_bin, "-D", serial, "-f", package, "-l", str(script_path)] - else: - frida_cmd = [frida_bin, "-U", "-f", package, "-l", str(script_path)] - - console.print(f"[green]▶[/green] [bold cyan]{' '.join(frida_cmd)}[/bold cyan]") - os.execvp(frida_cmd[0], frida_cmd) - - -def _auto(key: str) -> "bool | None": - """Lee un flag del bloque `auto:` en config.yaml. None si no está configurado.""" - auto_block = _CFG.get("auto", {}) - if not isinstance(auto_block, dict): - return None - val = auto_block.get(key) - return bool(val) if val is not None else None - - -def _unattended() -> bool: - """Modo no interactivo global.""" - return bool(cfg_get(_CFG, "auto", "unattended", default=False)) - - -def _ask_or_auto(prompt: str, key: str, default: bool = False) -> bool: - """Usa el flag de config si está explícitamente configurado; si no, pregunta.""" - cfg_val = _auto(key) - if cfg_val is not None: - tag = "yes" if cfg_val else "no" - console.print(f"[dim] {t('pipe_auto_skip', key=key, tag=tag)}[/dim]") - return cfg_val - if _unattended(): - tag = "yes" if default else "no" - console.print(f"[dim] {t('pipe_unattended_skip', prompt=prompt, tag=tag)}[/dim]") - return default - return click.confirm(prompt, default=default) - - -def _feature_enabled(name: str, default: bool = True) -> bool: - """Lee flags de features: con fallback al default.""" - v = cfg_get(_CFG, "features", name, default=default) - return bool(v) - - -def _pipeline_decompilation_mode(protected: bool) -> str: - """Modo de decompilación desde pipelines..""" - if protected: - mode = str(cfg_get(_CFG, "pipelines", "protected", "decompilation", default="")).strip().lower() - return mode if mode in ("runtime", "jadx") else "runtime" - # unprotected: booleano decompilation_jadx - jadx_enabled = cfg_get(_CFG, "pipelines", "unprotected", "decompilation_jadx", default=True) - return "jadx" if jadx_enabled else "none" - - -def _validate_all_dependencies(protected: bool = True) -> bool: - """ - Valida temprano todas las dependencias según config (jadx, frida, adb, apktool, etc). - - Retorna True si todo está ok. Si falta algo, imprime error y retorna False. - """ - import shutil as _shutil - - errors = [] - warnings = [] - - # ── Decompilación ───────────────────────────────────────────────────────── - decompilation_enabled = _feature_enabled("decompilation", default=True) - if decompilation_enabled: - decompilation_mode = _pipeline_decompilation_mode(protected) - if decompilation_mode == "jadx": - if not _shutil.which("jadx"): - errors.append(t("cli_dep_jadx_missing")) - - # ── Desofuscación runtime ───────────────────────────────────────────────── - runtime_target = str( - cfg_get(_CFG, "strategies", "runtime_target", default="auto") - ).strip().lower() - - decompilation_mode = _pipeline_decompilation_mode(protected) - should_validate_runtime = decompilation_mode == "runtime" - - if should_validate_runtime: - scope = "protected" if protected else "unprotected" - runtime_methods = cfg_get(_CFG, "pipelines", scope, "runtime_methods", - default=["frida_server", "gadget", "fart"]) or [] - - # Emulador - if runtime_target in ("auto", "emulator"): - sdk_tools = find_sdk_tools() - has_emulator = bool(sdk_tools.get("emulator")) and bool(list_avds(sdk_tools)) - - if runtime_target == "emulator" and not has_emulator: - errors.append(t("cli_dep_no_avd")) - - if not get_frida_version(): - errors.append(t("cli_dep_frida_missing")) - - # Dispositivo físico - if runtime_target in ("auto", "device"): - if not _shutil.which("adb"): - errors.append(t("cli_dep_adb_missing")) - - if not get_frida_version(): - errors.append(t("cli_dep_frida_missing")) - - # Herramientas opcionales para runtime - if "frida_server" in runtime_methods or "gadget" in runtime_methods: - if not _shutil.which("frida-dexdump"): - warnings.append(t("cli_dep_dexdump_warn")) - - # Si gadget está habilitado, necesita apktool + apksigner - if "gadget" in runtime_methods: - if not _shutil.which("apktool"): - errors.append(t("cli_dep_apktool_missing")) - # apksigner está en Android SDK build-tools - sdk_tools = find_sdk_tools() - apksigner = sdk_tools.get("apksigner") - if not apksigner: - warnings.append(t("cli_dep_apksigner_warn")) - - # ── Escaneo de vulnerabilidades ─────────────────────────────────────────── - scanner_engine = cfg_get(_CFG, "sast", "engine", default="auto") or "auto" - if str(scanner_engine).lower() == "semgrep": - if not _shutil.which("semgrep"): - warnings.append(t("cli_dep_semgrep_warn")) - - # ── Mostrar errores y advertencias ──────────────────────────────────────── - if errors: - console.print(f"\n[red][bold]{t('cli_dep_errors_header')}[/bold][/red]") - for err in errors: - console.print(f" [red]✘[/red] {err}") - console.print(f"\n [dim]{t('cli_requirements_url')}[/dim]\n") - return False - - if warnings: - console.print(f"[yellow][bold]{t('cli_dep_warnings_header')}[/bold][/yellow]") - for warn in warnings: - console.print(f" [yellow]⚠[/yellow] {warn}") - console.print() - - return True - - -# ── Banner ──────────────────────────────────────────────────────────────────── - -def _print_banner() -> None: - from rich.text import Text - from rich.panel import Panel - from rich.align import Align - from rich.console import Group - - # ── Mapa de colores ─────────────────────────────────────────────────── - _COLORS = { - ".": None, - "G": "#444444", # gris oscuro (sombrero) - "Y": "#FFD700", # dorado (hombreras) - "K": "#2A2A2A", # cara - "W": "#EEEEEE", # blanco - "r": "#FF1111", # ojos rojos - "B": "#996633", # barba - "L": "#555555", # contorno - } - _SPECIAL_CHARS = {"S": ("★", "#44CC44")} - - _PIXELS = [ - "......GGGG......", - ".....GGGGGG.....", - ".....GGGGGG.....", - ".....GGGSGG.....", - ".....GGGGGG.....", - "...WLKKKKKKLW...", - "...WLrrKKrrLW...", - "...WWWWWWWWWW...", - "...WLWKKKKWLW...", - "...WLBBBBBBLW...", - "YYY.LBBBBBBL.YYY", - ".YY..LBBBBL..YY.", - ".....LBBBBL.....", - "......LBBL......", - ] - - w = len(_PIXELS[0]) - lines: list[Text] = [] - for y in range(0, len(_PIXELS), 2): - top, bot = _PIXELS[y], _PIXELS[y + 1] - line = Text() - for x in range(w): - ts = _SPECIAL_CHARS.get(top[x]) - bs = _SPECIAL_CHARS.get(bot[x]) - if ts or bs: - ch, col = ts or bs - other = _COLORS.get(bot[x] if ts else top[x]) - line.append(ch, style=f"{col} on {other}" if other else col) - continue - tc = _COLORS.get(top[x]) - bc = _COLORS.get(bot[x]) - if tc is None and bc is None: - line.append(" ") - elif tc == bc: - line.append("█", style=tc) - elif tc and bc is None: - line.append("▀", style=tc) - elif tc is None and bc: - line.append("▄", style=bc) - else: - line.append("▀", style=f"{tc} on {bc}") - lines.append(line) - - n = len(lines) - name_rows = [ - "╔╗╔╦ ╦╔╦╗╔═╗╦═╗╔═╗╔═╗╦╔═╔═╗╦═╗", - "║║║║ ║ ║ ║ ╠╦╝╠═╣║ ╠╩╗║╣ ╠╦╝", - "╝╚╝╚═╝ ╩ ╚═╝╩╚═╩ ╩╚═╝╩ ╩╚═╝╩╚═", - ] - - right: list[Text | None] = [None] * n - for i, row in enumerate(name_rows): - t = Text() - t.append(row, style="bold red") - right[1 + i] = t - - tag = Text() - tag.append("★ ", style="bold green") - tag.append("Mobile Security & Offensive Threat Intelligence", style="bold white") - tag.append(" ★", style="bold green") - right[min(4, n - 1)] = tag - - ver = Text() - ver.append(f"v{_VERSION}", style="dim green") - ver.append(" · ", style="dim") - ver.append("nutcracker.sh", style="dim red link https://nutcracker.sh") - right[min(5, n - 1)] = ver - - combined = Text() - gap = " " - for i, sl in enumerate(lines): - combined.append_text(sl) - combined.append(gap) - if right[i] is not None: - combined.append_text(right[i]) - combined.append("\n") - - content = Align.center(combined) - console.print(Panel(content, border_style="red", padding=(1, 2))) - console.print() - - -# ── Grupo de comandos ────────────────────────────────────────────────────────── - -@click.group(invoke_without_command=True) -@click.version_option("0.1.0", prog_name="nutcracker") -@click.pass_context -def cli(ctx: click.Context) -> None: - """nutcracker: detects anti-root protections in Android applications (APK).""" - _print_banner() - if ctx.invoked_subcommand is None: - click.echo("usage: python nutcracker.py scan 'https://play.google.com/store/apps/details?id=...'") - ctx.exit(0) - - -load_plugins(cli) - - -# ── Comando: scan ───────────────────────────────────────────────────────────── - -@cli.command() -@click.argument("url") -@click.option( - "--config", "-c", - "config_path", - default="config.yaml", - show_default=True, - metavar="ARCHIVO", - help="Path to YAML config file.", -) -@click.option( - "--source", "-s", - default=None, - type=click.Choice(["apk-pure", "google-play"], case_sensitive=False), - help="Download source. Default: google-play if credentials in config, else apk-pure.", -) -@click.option( - "--output-dir", "-o", - default=None, - help="Directory to save downloaded APKs.", -) -@click.option( - "--keep-apk", - is_flag=True, - default=False, - help="Keep the APK after analysis.", -) -@click.option( - "--report", "-r", - default=None, - metavar="ARCHIVO", - help="Path to save the JSON report.", -) -def scan(url: str, config_path: str, source: str | None, output_dir: str | None, - keep_apk: bool, report: str | None) -> None: - """ - Download an APK and analyze it for anti-root protections. - - URL can be: - - Google Play URL (https://play.google.com/store/apps/details?id=...) - - Package ID directly (com.example.app) - - Direct URL to an .apk file (https://example.com/app.apk) - """ - global _CFG - config = load_config(config_path) - _CFG = config - _init_i18n(config) - output_dir = output_dir or cfg_get(config, "downloader", "output_dir") or "./downloads" - - if not keep_apk: - keep_apk = bool(cfg_get(config, "downloader", "keep_apk", default=False)) - - # Informe JSON automático si está configurado - save_json_cfg = bool( - cfg_get(config, "features", "report_json", default=cfg_get(config, "reports", "save_json", default=False)) - ) - if not report and save_json_cfg and not is_direct_apk_url(url): - reports_dir = cfg_get(config, "reports", "output_dir") or "./reports" - Path(reports_dir).mkdir(parents=True, exist_ok=True) - pkg = url.split("id=")[-1].split("&")[0].rstrip("/") - report = str(Path(reports_dir) / f"{pkg}.json") - save_pdf = bool( - cfg_get(config, "features", "report_pdf", default=cfg_get(config, "reports", "save_pdf", default=True)) - ) - - def _token_resolver(email: str, cfg: dict) -> str | None: - """Genera el aas_token interactivamente si falta, recarga config y lo devuelve.""" - nonlocal config - console.print(f"[yellow]Warning:[/yellow] {t('cli_gplay_token_empty')}") - script = Path(__file__).parent / "tools" / "extract_token.py" - if not script.exists(): - console.print(f"[red]Error:[/red] {t('cli_extract_token_not_found')}") - sys.exit(1) - cmd = [sys.executable, str(script), "--config", config_path] - preferred_serial = _select_token_serial(cfg) - if preferred_serial: - cmd += ["--serial", preferred_serial] - if _unattended(): - cmd.append("--no-interactive") - token_proc = subprocess.run(cmd) - if token_proc.returncode != 0: - console.print(f"[red]Error:[/red] {t('cli_token_gen_failed')}") - sys.exit(token_proc.returncode) - config = load_config(config_path) - _CFG = config - token = cfg_get(config, "google_play", "aas_token") - if not token: - console.print(f"[red]Error:[/red] {t('cli_token_still_empty')}") - sys.exit(1) - return token - - def _on_start(label: str) -> None: - _on_start._progress_ctx.__enter__() - _on_start._progress_ctx.add_task(t("cli_downloading_from", label=label), total=None) - - # Progreso para URL directa (con BarColumn) o spinner para stores - _progress_direct: Progress | None = None - _progress_store: Progress | None = None - _task_ref: list = [] - - def _progress_callback(downloaded: int, total: int | None) -> None: - if _progress_direct and _task_ref: - _progress_direct.update(_task_ref[0], completed=downloaded, total=total) - - def _on_start_label(label: str) -> None: - nonlocal _progress_store - _progress_store.__enter__() - _progress_store.add_task(t("cli_downloading_from", label=label), total=None) - - apk_path: Path | None = None - _from_cache = False - try: - if is_direct_apk_url(url): - _dl_check = DirectURLDownloader(output_dir) - _from_cache = keep_apk and _dl_check.dest_path(url).exists() - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - BarColumn(), - DownloadColumn(), - TransferSpeedColumn(), - console=console, - transient=True, - ) as _progress_direct: - _task = _progress_direct.add_task(t("cli_downloading_apk"), total=None) - _task_ref.append(_task) - apk_path = download_apk_from_config( - url, config, - output_dir=output_dir, - use_cache=keep_apk, - progress_callback=_progress_callback, - ) - else: - if source == "google-play": - email = cfg_get(config, "google_play", "email") - if not email: - console.print(f"[red]Error:[/red] {t('cli_gplay_requires_email')}") - sys.exit(1) - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - console=console, - transient=True, - ) as _progress_store: - apk_path = download_apk_from_config( - url, config, - source=source, - output_dir=output_dir, - token_resolver=_token_resolver, - on_start=_on_start_label, - ) - - if _from_cache: - console.print(f"[green]✔[/green] {t('cli_apk_cached')} [bold]{apk_path}[/bold]") - else: - console.print(f"[green]✔[/green] {t('cli_apk_downloaded')} [bold]{apk_path}[/bold]") - except APKDownloadError as exc: - console.print(f"[red]{t('cli_error_download')}[/red] {exc}") - sys.exit(1) - - _run_analysis(apk_path, report, keep_apk, gen_pdf=cfg_get(config, "reports", "save_pdf", default=True)) - - -# ── Comando: analyze (APK local) ────────────────────────────────────────────── - -@cli.command() -@click.argument("apk_path", type=click.Path(exists=True, dir_okay=False)) -@click.option( - "--config", "-c", - "config_path", - default="config.yaml", - show_default=True, - metavar="ARCHIVO", -) -@click.option( - "--report", "-r", - default=None, - metavar="ARCHIVO", - help="Path to save the JSON report.", -) -@click.option( - "--launch", "-L", - is_flag=True, - default=False, - help="After analysis, launch the app on device with the generated bypass script.", -) -@click.option( - "--serial", "-s", - default=None, - metavar="SERIAL", - help="ADB device serial for --launch (default: first available).", -) -def analyze(apk_path: str, config_path: str, report: str | None, launch: bool, serial: str | None) -> None: - """Analyze a local APK for anti-root protections.""" - global _CFG, _LAUNCH_APP, _LAUNCH_SERIAL - _LAUNCH_APP = launch - _LAUNCH_SERIAL = serial - config = load_config(config_path) - _CFG = config - _init_i18n(config) - save_json_cfg = bool( - cfg_get(config, "features", "report_json", default=cfg_get(config, "reports", "save_json", default=False)) - ) - if not report and save_json_cfg: - reports_dir = cfg_get(config, "reports", "output_dir") or "./reports" - Path(reports_dir).mkdir(parents=True, exist_ok=True) - report = str(Path(reports_dir) / f"{Path(apk_path).stem}.json") - save_pdf = bool( - cfg_get(config, "features", "report_pdf", default=cfg_get(config, "reports", "save_pdf", default=True)) - ) - - _run_analysis(Path(apk_path), report, keep_apk=True, gen_pdf=save_pdf) - - -# ── Comando: launch (lanzar app con bypass script) ──────────────────────────── - -@cli.command() -@click.argument("package") -@click.option( - "--script", "-l", - default=None, - metavar="SCRIPT", - help="Bypass JS script to use. If omitted, uses the latest generated for the package.", -) -@click.option( - "--serial", "-s", - default=None, - metavar="SERIAL", - help="ADB device serial (default: first available via -U).", -) -@click.option( - "--scripts-dir", - default="frida_scripts", - show_default=True, - metavar="DIR", - help="Directory to search for bypass scripts.", -) -@click.option( - "--config", "config_path", - default="config.yaml", - show_default=True, - metavar="FILE", - help="Config file (reads strategies.frida_host).", -) -def launch(package: str, script: str | None, serial: str | None, scripts_dir: str, config_path: str) -> None: - """Launch the app on device with the latest generated bypass script. - - \b - Examples: - python nutcracker.py launch com.appexample - python nutcracker.py launch downloads/com.appexample/com.appexample.apk - python nutcracker.py launch com.appexample --script frida_scripts/bypass_....js - python nutcracker.py launch com.appexample --serial emulator-5554 - """ - scripts_path = Path(scripts_dir) - - # Aceptar APK path como argumento: extraer package del nombre del archivo - pkg = package - p = Path(package) - if p.suffix.lower() == ".apk" or "/" in package: - pkg = p.stem # e.g. "com.appexample.apk" → "com.appexample" - console.print(f"[dim] {t('cli_apk_detected_pkg', pkg=pkg)}[/dim]") - - if script: - script_path = Path(script) - if not script_path.exists(): - console.print(f"[red]Error:[/red] {t('cli_script_not_found', script=script)}") - raise SystemExit(1) - else: - script_path = _find_latest_bypass_script(pkg, scripts_path) - if not script_path: - console.print( - f"[yellow]⚠[/yellow] {t('cli_no_bypass_found', pkg=pkg, scripts_dir=scripts_dir)}" - ) - raise SystemExit(1) - - console.print(f"[green]✔[/green] Script: [bold]{script_path}[/bold]") - cfg = load_config(config_path) - frida_host = str(cfg_get(cfg, "strategies", "frida_host", default="")).strip() or None - _launch_frida_bypass(pkg, script_path, serial=serial, frida_host=frida_host) - - -@cli.command("setup-token") -@click.option( - "--config", "config_path", - default="config.yaml", - show_default=True, - metavar="ARCHIVO", - help="Path to YAML config file.", -) -@click.option("--serial", default=None, help="ADB serial of the target device.") -@click.option( - "--method", - default="auto", - type=click.Choice(["auto", "root", "dumpsys", "gsf"], case_sensitive=False), - show_default=True, - help="Token extraction method.", -) -@click.option("--no-interactive", is_flag=True, default=False, help="Skip confirmation prompts.") -def setup_token(config_path: str, serial: str | None, method: str, no_interactive: bool) -> None: - """Interactive wizard to obtain and save google_play.aas_token.""" - script = Path(__file__).parent / "tools" / "extract_token.py" - if not script.exists(): - console.print(f"[red]Error:[/red] {t('cli_extract_token_not_found')}") - raise SystemExit(1) - - cfg = load_config(config_path) - auto_serial = serial or _select_token_serial(cfg) - - cmd = [sys.executable, str(script), "--config", config_path, "--method", method.lower()] - if auto_serial: - cmd += ["--serial", auto_serial] - if no_interactive: - cmd.append("--no-interactive") - - result = subprocess.run(cmd) - if result.returncode != 0: - raise SystemExit(result.returncode) - - -# ── Lógica compartida ───────────────────────────────────────────────────────── - -def _run_analysis(apk_path: Path, report_path: str | None, keep_apk: bool, gen_pdf: bool = True) -> None: - started_at = time.perf_counter() - result = None - elapsed_seconds = 0.0 - try: - anti_root_engine = str( - cfg_get(_CFG, "strategies", "anti_root_engine", default="native") - ).strip().lower() - if anti_root_engine == "builtin": - anti_root_engine = "native" - if anti_root_engine not in ("native", "apkid"): - anti_root_engine = "native" - with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), - console=console, transient=True) as progress: - task = progress.add_task(t("cli_analyzing_apk"), total=None) - - def on_progress(msg: str) -> None: - progress.update(task, description=msg) - - analyzer = APKAnalyzer(progress_callback=on_progress, engine=anti_root_engine) - result = analyzer.analyze(apk_path) - # Si anti_root_analysis=false en config, ignorar la detección de protección - if not bool(cfg_get(_CFG, "strategies", "anti_root_analysis", default=True)): - result.protected = False - console.print("[dim] strategies.anti_root_analysis=false → omitiendo flujo Frida/emulador[/dim]") - - except FileNotFoundError as exc: - console.print(f"[red]Error:[/red] {exc}") - sys.exit(1) - except Exception as exc: # noqa: BLE001 - console.print(f"[red]{t('cli_error_unexpected')}[/red] {exc}") - sys.exit(1) - - if result: - print_report(result) - # Flujo post-análisis: bypass, vuln scan, etc. (puede poblar decompilation_info) - vuln_scan = _post_analysis_flow(result, apk_path) - - elapsed_seconds = time.perf_counter() - started_at - result.elapsed_seconds = elapsed_seconds - - # Guardar JSON una vez que todos los datos están completos - save_analysis_json(result, scan_result=vuln_scan, manifest=_MANIFEST_ANALYSIS) - - # ── Resumen MASVS v2 ───────────────────────────────────────────── - try: - from nutcracker_core.masvs import build_masvs_report - _masvs = build_masvs_report(result, vuln_scan, _MANIFEST_ANALYSIS) - print_masvs_summary(_masvs) - except Exception: - pass - - # ── Veredicto final en terminal ─────────────────────────────────── - _print_verdict(result, vuln_scan) - - # Generar PDF solo si está habilitado en config (save_pdf) - if gen_pdf: - _generate_pdf(result, vuln_scan, - vuln_scan_enabled=_feature_enabled("sast_scan", default=True)) - - # ── Post-hooks de plugins ────────────────────────────────────────── - fire_post_hooks( - "after_analysis", - package=result.package, - result=result, - vuln_scan=vuln_scan, - config=_CFG, - ) - - if not keep_apk and apk_path and apk_path.exists(): - apk_path.unlink() - console.print(f"[dim]{t('cli_apk_deleted', path=apk_path)}[/dim]") - - _print_elapsed(t("cli_elapsed"), elapsed_seconds) - - -def _print_bypass_banner(dex_count: int) -> None: - """Panel naranja de alerta: protección eludida tras volcar los DEX.""" - from rich.panel import Panel - from rich.text import Text - from rich.align import Align - - banner_text = Text(justify="center") - banner_text.append("\n ✔ " + t("cli_protection_broken_banner") + " \n\n", style="bold yellow") - banner_text.append( - t("cli_protection_broken_frida", dex_count=dex_count), - style="dim white", - ) - banner_text.append("\n") - console.print() - console.print(Panel(Align.center(banner_text), border_style="yellow", padding=(0, 4))) - console.print() - - -def _print_verdict(result, vuln_scan) -> None: - """Imprime un banner de veredicto final en la terminal.""" - from rich.panel import Panel - from rich.text import Text - from rich.align import Align - - has_vulns = vuln_scan is not None and bool(vuln_scan.findings) - protected = result.protected - - # Bypass real: extracción runtime de DEX vía Frida/FART/Gadget. - decomp = getattr(result, "decompilation_info", None) - method = "" - dex_count = 0 - if isinstance(decomp, dict): - method = str(decomp.get("method", "")) - try: - dex_count = int(decomp.get("dex_count", 0) or 0) - except (TypeError, ValueError): - dex_count = 0 - - runtime_bypass = any(k in method.lower() for k in ("frida", "fart", "gadget")) - was_bypassed = protected and runtime_bypass and dex_count > 0 - - if not protected: - color = "red" - icon = "✘" - title = t("cli_no_protection_banner") - detail = t("cli_no_protection_detail") - elif was_bypassed: - color = "yellow" - icon = "⚡" - title = t("cli_protection_broken_banner") - detail = t("cli_bypassed_detail", method=method, dex_count=dex_count) - else: - color = "green" - icon = "✔" - title = t("cli_protected_banner") - if has_vulns: - n = len(vuln_scan.findings) - detail = t("cli_protected_vulns_detail", count=n) - else: - detail = t("cli_protected_detail") - - verdict_text = Text(justify="center") - verdict_text.append(f"\n {icon} {title} {icon}\n\n", style=f"bold {color}") - verdict_text.append(f" {detail} ", style="dim white") - verdict_text.append("\n") - - console.print() - console.print(Panel(Align.center(verdict_text), border_style=color, padding=(0, 4))) - console.print() - - -def _post_analysis_flow(result, apk_path: Path): - """Flujo interactivo tras el análisis. Retorna el ScanResult si se escanearon vulns.""" - console.print() - - # ── Detectar si hay ofuscación DexGuard ─────────────────────────────────── - dexguard_result = next( - (r for r in result.results if r.name == "DexGuardDetector" and r.detected), - None, - ) - - _label = "[green]ℹ[/green]" if result.protected else "[yellow]ℹ[/yellow]" - _estado = t("cli_app_has_protection") if result.protected else t("cli_app_no_protection") - console.print(f"{_label} {t('cli_app_protection_line', status=_estado, protection=t('cli_anti_root_label'))}") - - # ── Selección automática del mejor método ───────────────────────────────── - decomp_mode = _pipeline_decompilation_mode(result.protected) - runtime_target = str( - cfg_get(_CFG, "strategies", "runtime_target", default="auto") - ).strip().lower() - # DexGuard detectado → frida-dexdump (bytecode post-descifrado en memoria) - # Sin DexGuard → JADX por defecto, salvo pipeline runtime explícito - # Si decompilation=jadx en config, nunca usar runtime aunque haya DexGuard - should_try_runtime = decomp_mode == "runtime" - - if should_try_runtime: - if dexguard_result: - if runtime_target == "device": - console.print( - f"[yellow]⚠[/yellow] {t('cli_dexguard_device_warn')}" - ) - else: - console.print( - f"[yellow]⚠[/yellow] {t('cli_dexguard_emulator_warn')}" - ) - else: - console.print( - f"[cyan]ℹ[/cyan] {t('cli_runtime_pipeline_info')}" - ) - # Con protección anti-root: ofrecer combinar bypass en el mismo script - if result.protected: - if _LAUNCH_APP or _ask_or_auto(t("cli_include_bypass_prompt"), "bypass_script", default=True): - scripts_dir = Path("./frida_scripts") - try: - bp_path = generate_bypass_script(result, scripts_dir) - console.print( - f"[green]✔[/green] {t('cli_bypass_script_generated')} [bold]{bp_path}[/bold]" - ) - if _LAUNCH_APP: - _fh = str(cfg_get(_CFG, "strategies", "frida_host", default="")).strip() or None - _launch_frida_bypass(result.package, bp_path, _LAUNCH_SERIAL, frida_host=_fh) - except Exception as exc: # noqa: BLE001 - console.print(f"[red]{t('cli_error_bypass')}[/red] {exc}") - - runtime_prompt = ( - t("cli_fart_prompt_device") - if runtime_target == "device" - else t("cli_fart_prompt_emulator") - ) - if _ask_or_auto(runtime_prompt, "fart", default=True): - return _do_dexguard_deobf(result, apk_path) - - # Sin DexGuard (o usuario rechazó frida) → jadx directo - # Si hay protección anti-root sin DexGuard, ofrecer script de bypass por separado - if result.protected and not dexguard_result: - if _LAUNCH_APP or _ask_or_auto(t("cli_gen_bypass_prompt"), "bypass_script", default=False): - scripts_dir = Path("./frida_scripts") - try: - script_path = generate_bypass_script(result, scripts_dir) - console.print(f"[green]✔[/green] {t('cli_frida_script_generated')} [bold]{script_path}[/bold]") - if _LAUNCH_APP: - _fh = str(cfg_get(_CFG, "strategies", "frida_host", default="")).strip() or None - _launch_frida_bypass(result.package, script_path, _LAUNCH_SERIAL, frida_host=_fh) - else: - console.print(frida_run_instructions(result.package, script_path)) - except Exception as exc: # noqa: BLE001 - console.print(f"[red]{t('cli_error_bypass')}[/red] {exc}") - - if not _should_fallback_jadx(result.protected): - return None - - if not _ask_or_auto(t("cli_decompile_jadx_prompt"), "decompile", default=True): - return None - return _do_decompile(apk_path, result.package) - - -def _should_fallback_jadx(protected: bool) -> bool: - """Determina si se debe intentar decompilación jadx como fallback.""" - if not _feature_enabled("decompilation", default=True): - console.print(f"[dim]{t('cli_skipping_decompilation')}[/dim]") - return False - if protected: - fallback = cfg_get(_CFG, "pipelines", "protected", "fallback_jadx", default=True) - if not fallback: - console.print( - f"[dim]{t('cli_fallback_jadx_disabled')}[/dim]" - ) - return False - return True - - -def _do_dexguard_deobf(result, apk_path: Path) -> "ScanResult | None": - """ - Flujo completo de desofuscación para apps DexGuard/Arxan. - - Estrategia primaria: frida-dexdump (sin script en disco). - Fallback: FART - el script se genera en temp solo si es necesario. - - Ofrece dos modos: - A) Emulador automático - arranca AVD, instala APK, extrae DEX, descarga DEX - B) Dispositivo físico - genera el script FART y el usuario lo ejecuta manualmente - """ - # ── Validar todas las dependencias (jadx, frida, adb, apktool, etc) ────── - if not _validate_all_dependencies(protected=result.protected): - console.print( - f"[yellow]⚠[/yellow] {t('cli_skip_runtime_fallback_jadx')}" - ) - if not _should_fallback_jadx(result.protected): - return None - if not _ask_or_auto(t("cli_decompile_jadx_prompt"), "decompile", default=True): - return None - return _do_decompile(apk_path, result.package) - - # ── Elegir modo ────────────────────────────────────────────────────────── - sdk_tools = find_sdk_tools() - avds = list_avds(sdk_tools) - has_emulator = bool(sdk_tools.get("emulator")) and bool(avds) - use_emulator = False - - runtime_target = str( - cfg_get(_CFG, "strategies", "runtime_target", default="auto") - ).strip().lower() - if runtime_target not in {"auto", "emulator", "device"}: - runtime_target = "auto" - - if runtime_target == "emulator": - if has_emulator: - use_emulator = True - console.print("[dim] strategies.runtime_target=emulator[/dim]") - else: - console.print( - f"[yellow]⚠[/yellow] {t('cli_no_avd_using_device')}" - ) - use_emulator = False - elif runtime_target == "device": - use_emulator = False - console.print("[dim] strategies.runtime_target=device[/dim]") - connected = connected_adb_devices() - physical = [d for d in connected if not is_emulator_serial(d)] - if not physical: - console.print( - f"[yellow]⚠[/yellow] {t('cli_no_physical_device')}" - ) - elif has_emulator: - # runtime_target=auto conserva comportamiento histórico - if _unattended(): - use_emulator = True - console.print(t("cli_fart_mode_unattended")) - else: - console.print(t("cli_fart_mode_choice", avd_count=len(avds))) - choice = click.prompt( - t("cli_fart_choose_prompt"), - default="A", - type=click.Choice(["A", "a", "B", "b"], case_sensitive=False), - show_choices=False, - ).upper() - use_emulator = choice == "A" - else: - console.print( - f"[yellow]⚠[/yellow] {t('cli_no_emulator_using_device')}" - ) - - method_order = deobf_method_order(_CFG, protected=True) - - if use_emulator: - ext = do_fart_emulator(_CFG, result.package, apk_path, sdk_tools, avds) - else: - # Modo manual: el usuario necesita el script en disco para ejecutarlo - scripts_dir = Path("./frida_scripts") - try: - script_path = generate_fart_script(result.package, scripts_dir) - except Exception as exc: # noqa: BLE001 - console.print(f"[red]{t('cli_fart_script_error')}[/red] {exc}") - return None - ext = do_fart_manual(_CFG, result.package, script_path, apk_path, method_order) - - if ext is None: - console.print( - f"[yellow]⚠[/yellow] {t('cli_runtime_extraction_failed')}" - ) - if not _should_fallback_jadx(result.protected): - return None - if not _ask_or_auto(t("cli_decompile_jadx_prompt"), "decompile", default=True): - return None - return _do_decompile(apk_path, result.package) - result.decompilation_info = { - "method": ext.method_used, - "dex_count": len(ext.dex_files), - "source_dir": str(ext.clean_dir), - } - return _decompile_and_scan( - ext.dex_files, - ext.clean_dir, - ext.local_dump_dir, - result.package, - dex_count=len(ext.dex_files), - apk_path=apk_path, - ) - - -def _select_token_serial(config: dict) -> str: - """Elige serial para setup-token respetando runtime_target y default_device_id.""" - preferred = str(cfg_get(config, "strategies", "default_device_id", default="")).strip() - devices = connected_adb_devices() - - if preferred and preferred in devices: - return preferred - - runtime_target = str(cfg_get(config, "strategies", "runtime_target", default="auto")).strip().lower() - if runtime_target == "emulator": - emus = [d for d in devices if is_emulator_serial(d)] - if emus: - return emus[0] - elif runtime_target == "device": - physical = [d for d in devices if not is_emulator_serial(d)] - if physical: - return physical[0] - - if preferred: - return preferred - return "" - -def _decompile_and_scan( - dex_files: list, - clean_dir: Path, - local_dump_dir: Path, - package: str, - dex_count: int = 0, - apk_path: Path | None = None, -) -> "ScanResult | None": - """Paso final compartido: jadx + decrypt_map + scan de vulns.""" - from nutcracker_core.deobfuscator import decompile_dumps, apply_decrypt_map - - try: - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - console=console, - transient=True, - ) as progress: - task = progress.add_task(t("cli_decompiling_dex_jadx"), total=None) - decompile_dumps( - dex_files, - clean_dir, - progress_callback=lambda m: progress.update(task, description=m), - ) - except RuntimeError as exc: - console.print(f"[red]{t('cli_error_decompiling_dex')}[/red] {exc}") - return None - - java_count = len(list(clean_dir.rglob("*.java"))) - console.print( - f"[green]✔[/green] {t('cli_clean_source', path=clean_dir, count=java_count)}" - ) - - if dex_count > 0: - _print_bypass_banner(dex_count) - - decrypt_map = local_dump_dir / "decrypt_map.txt" - if decrypt_map.exists(): - replaced = apply_decrypt_map(clean_dir, decrypt_map) - if replaced > 0: - console.print( - f"[green]✔[/green] {t('cli_strings_replaced', count=replaced)}" - ) - else: - console.print( - f"[dim] {t('cli_decrypt_map_not_found')}[/dim]" - ) - - # Análisis de misconfigs del manifest - manifest_analysis = None - if _feature_enabled("manifest_scan", default=True): - manifest_analysis = _do_manifest_scan(clean_dir, apk_path=apk_path) - else: - console.print(f"[dim]{t('cli_skipping_manifest_scan')}[/dim]") - - # Escaneo de vulnerabilidades y leaks (antes de OSINT para alimentarlo) - scan_result = None - vuln_enabled = _feature_enabled("sast_scan", default=True) - leak_enabled = _feature_enabled("leak_scan", default=True) - if not vuln_enabled and not leak_enabled: - console.print(f"[dim]{t('cli_skipping_vuln_scan')}[/dim]") - elif _ask_or_auto( - t("cli_vuln_scan_deobf_prompt"), - "sast_scan", - default=True, - ): - scan_result = _do_vuln_scan( - clean_dir, - apk_path=apk_path, - package_hint=package, - include_vuln_scan=vuln_enabled, - include_leak_scan=leak_enabled, - ) - - # OSINT sobre el código decompilado (alimentado por los hallazgos del scanner) - leak_findings = scan_result.findings if scan_result else None - _do_osint_scan(clean_dir, package, scan_findings=leak_findings) - - return scan_result - - -def _do_manifest_scan(decompiled_dir: Path, apk_path: Path | None = None) -> "ManifestAnalysisResult | None": - """Analiza AndroidManifest.xml y archivos de config buscando misconfigs.""" - global _MANIFEST_ANALYSIS - console.print() - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - console=console, - transient=True, - ) as progress: - task = progress.add_task(t("cli_analyzing_manifest"), total=None) - analysis = analyze_decompiled_dir( - decompiled_dir, - progress_callback=lambda m: progress.update(task, description=m), - apk_path=apk_path, - ) - - _print_manifest_report(analysis) - _MANIFEST_ANALYSIS = analysis - return analysis - - -def _do_osint_scan(source_dir: Path, package: str, scan_findings: list | None = None) -> "OsintResult | None": - """Ejecuta el pipeline OSINT si está habilitado en config.""" - global _OSINT_RESULT - if not _feature_enabled("osint_scan", default=True): - console.print(f"[dim]{t('cli_skipping_osint')}[/dim]") - return None - - crt_sh = bool(cfg_get(_CFG, "osint", "crt_sh", default=True)) - github_search = bool(cfg_get(_CFG, "osint", "github_search", default=True)) - github_token = cfg_get(_CFG, "osint", "github_token", default="") or None - grep_app_search = bool(cfg_get(_CFG, "osint", "grep_app_search", default=True)) - fofa_search = bool(cfg_get(_CFG, "osint", "fofa_search", default=False)) - fofa_key = cfg_get(_CFG, "osint", "fofa_key", default="") or None - shodan_search = bool(cfg_get(_CFG, "osint", "shodan_search", default=False)) - shodan_key = cfg_get(_CFG, "osint", "shodan_key", default="") or None - postman_search = bool(cfg_get(_CFG, "osint", "postman_search", default=True)) - execute_dorks_flag = bool(cfg_get(_CFG, "osint", "execute_dorks", default=False)) - dork_engines_cfg = cfg_get(_CFG, "osint", "dork_engines", default=["duckduckgo"]) - dork_engines = list(dork_engines_cfg) if isinstance(dork_engines_cfg, (list, tuple)) else ["duckduckgo"] - dork_max_per_engine = int(cfg_get(_CFG, "osint", "dork_max_per_engine", default=5)) - dork_max_results_per_dork = int(cfg_get(_CFG, "osint", "dork_max_results_per_dork", default=5)) - wayback_search = bool(cfg_get(_CFG, "osint", "wayback_search", default=True)) - wayback_limit_per_domain = int(cfg_get(_CFG, "osint", "wayback_limit_per_domain", default=200)) - wayback_filter_interesting = bool(cfg_get(_CFG, "osint", "wayback_filter_interesting", default=True)) - - console.print() - try: - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - console=console, - transient=True, - ) as progress: - task = progress.add_task("OSINT: extrayendo secretos...", total=None) - app_label = getattr(_MANIFEST_ANALYSIS, "app_label", "") if _MANIFEST_ANALYSIS else "" - osint_result = run_osint( - source_dir, - package, - scan_findings=scan_findings, - crt_sh=crt_sh, - github_search=github_search, - github_token=github_token, - grep_app_search=grep_app_search, - fofa_search=fofa_search, - fofa_key=fofa_key, - shodan_search=shodan_search, - shodan_key=shodan_key, - postman_search=postman_search, - execute_dorks_flag=execute_dorks_flag, - dork_engines=dork_engines, - dork_max_per_engine=dork_max_per_engine, - dork_max_results_per_dork=dork_max_results_per_dork, - wayback_search=wayback_search, - wayback_limit_per_domain=wayback_limit_per_domain, - wayback_filter_interesting=wayback_filter_interesting, - app_label=app_label, - progress_callback=lambda m: progress.update(task, description=m), - ) - except Exception as exc: # noqa: BLE001 - console.print(f"[yellow]⚠[/yellow] {t('cli_error_osint')} {exc}") - return None - - _OSINT_RESULT = osint_result - _print_osint_report(osint_result) - - # Guardar JSON de OSINT - _save_osint_json(osint_result, package) - - return osint_result - - -def _print_osint_report(osint: OsintResult) -> None: - """Imprime un resumen del análisis OSINT en la consola.""" - from rich.table import Table - - # ── Secretos ────────────────────────────────────────────────────────── - if osint.secrets: - console.print(f"\n[bold]{t('cli_osint_secrets_header', count=len(osint.secrets))}[/bold]") - table = Table(show_header=True, header_style="bold", box=None, padding=(0, 1)) - table.add_column(t("cli_osint_field_col"), style="bold cyan") - table.add_column(t("cli_osint_value_col"), style="dim", max_width=50, no_wrap=True) - table.add_column(t("cli_osint_service_col"), style="yellow") - table.add_column(t("cli_osint_file_col"), style="dim") - for s in osint.secrets[:20]: - val = s.value if len(s.value) <= 50 else s.value[:47] + "..." - table.add_row(s.name, val, s.service or "-", s.file) - console.print(table) - if len(osint.secrets) > 20: - console.print(f" [dim]{t('cli_osint_more', count=len(osint.secrets) - 20)}[/dim]") - - # ── Dominios ────────────────────────────────────────────────────────── - if osint.domains_scanned: - console.print(f"\n[bold]{t('cli_osint_domains_header')}[/bold] {', '.join(osint.domains_scanned)}") - - # ── Subdominios ─────────────────────────────────────────────────────── - if osint.subdomains: - console.print(f"\n[bold]{t('cli_osint_subdomains_header', count=len(osint.subdomains))}[/bold]") - # Clasificar - dev_subs = [s for s in osint.subdomains if any( - e in s.name for e in ("dev", "qa", "uat", "test", "staging", "pre.") - )] - if dev_subs: - console.print(f" [yellow]⚠ {t('cli_osint_dev_exposed', count=len(dev_subs))}[/yellow]") - for s in dev_subs[:10]: - console.print(f" [yellow]▸[/yellow] {s.name}") - # Mostrar los primeros - for s in osint.subdomains[:15]: - if s not in dev_subs: - console.print(f" {s.name}") - if len(osint.subdomains) > 15: - console.print(f" [dim]{t('cli_osint_more', count=len(osint.subdomains) - 15)}[/dim]") - - # ── Leaks públicos ──────────────────────────────────────────────────── - if osint.public_leaks: - console.print(f"\n[bold]{t('cli_osint_public_leaks_header', count=len(osint.public_leaks))}[/bold]") - for leak in osint.public_leaks[:10]: - console.print(f" [{leak.source}] {leak.title}") - if leak.url: - console.print(f" [dim]{leak.url}[/dim]") - - # ── Auth flows hardcodeados ─────────────────────────────────────────── - if osint.auth_flows: - console.print(f"\n[yellow][bold]{t('cli_osint_auth_header', count=len(osint.auth_flows))}[/bold][/yellow]") - for af in osint.auth_flows[:5]: - console.print(f" [yellow]⚠[/yellow] {af['type']} en {af['file']}:{af['line']}") - - -def _save_osint_json(osint: OsintResult, package: str) -> None: - """Guarda el resultado OSINT en JSON dentro de reports//osint.json.""" - import json - pkg_dir = Path("./reports") / package - pkg_dir.mkdir(parents=True, exist_ok=True) - out = pkg_dir / "osint.json" - with out.open("w", encoding="utf-8") as fh: - json.dump(osint.to_dict(), fh, ensure_ascii=False, indent=2) - console.print(f"[dim]{t('cli_osint_saved')}[/dim] [bold]{out}[/bold]") - - - -def _print_manifest_report(analysis) -> None: - """Imprime un resumen de misconfigs del manifest en la consola.""" - from rich.table import Table - - misconfigs = analysis.misconfigurations - if not misconfigs: - console.print(f"[green]✔[/green] {t('cli_no_manifest_misconfigs')}") - return - - severity_order = {"critical": 0, "high": 1, "medium": 2, "info": 3} - severity_color = { - "critical": "bold red", - "high": "red", - "medium": "yellow", - "info": "dim", - } - - misconfigs_sorted = sorted(misconfigs, key=lambda m: severity_order.get(m.severity, 9)) - - counts = {} - for m in misconfigs_sorted: - counts[m.severity] = counts.get(m.severity, 0) + 1 - - summary_parts = [ - f"[{severity_color.get(sev, '')}]{cnt} {sev.upper()}[/{severity_color.get(sev, '')}]" - for sev, cnt in sorted(counts.items(), key=lambda kv: severity_order.get(kv[0], 9)) - ] - console.print( - f"\n[bold]{t('cli_manifest_misconfigs_header')}[/bold] " + " ".join(summary_parts) - ) - - table = Table(show_header=True, header_style="bold", box=None, padding=(0, 1)) - table.add_column(t("cli_manifest_sev_col"), style="bold", width=9, no_wrap=True) - table.add_column(t("cli_manifest_finding_col")) - table.add_column(t("cli_manifest_evidence_col"), style="dim") - table.add_column(t("cli_manifest_location_col"), style="dim") - - for m in misconfigs_sorted: - color = severity_color.get(m.severity, "") - sev_text = f"[{color}]{m.severity.upper()}[/{color}]" - # Mostrar descripción truncada como evidencia - evidence = m.description[:80] if m.description else "-" - table.add_row(sev_text, m.title, evidence, m.location) - - console.print(table) - - # Mostrar recomendaciones de los críticos y altos - shown = [m for m in misconfigs_sorted if m.severity in ("critical", "high")] - if shown: - console.print(f"\n[bold]{t('cli_manifest_recs_header')}[/bold]") - for m in shown: - color = severity_color.get(m.severity, "") - console.print(f" [{color}]▸[/{color}] [bold]{m.title}[/bold]") - console.print(f" {m.recommendation}\n") - - -def _do_decompile(apk_path: Path, package: str) -> Path | None: - """Ejecuta la decompilación con feedback en consola. Devuelve el directorio o None.""" - tool, _ = get_available_tool() - if tool is None: - console.print(f"[red]✘[/red] {install_instructions()}") - return None - - output_dir = Path("./decompiled") - console.print(f" {t('cli_decompiling_with', tool=tool, output_dir=output_dir, package=package)}") - - try: - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - console=console, - transient=True, - ) as progress: - progress.add_task(t("cli_decompiling_pkg", package=package), total=None) - dest = decompile(apk_path, output_dir) - - console.print(f"[green]✔[/green] {t('cli_source_code_at')} [bold]{dest}[/bold]") - - java_files = list(dest.rglob("*.java")) - smali_files = list(dest.rglob("*.smali")) - if java_files: - console.print(f" {t('cli_java_files', count=len(java_files))}") - elif smali_files: - console.print(f" {t('cli_smali_files', count=len(smali_files))}") - - # Análisis de misconfigs del manifest - if _feature_enabled("manifest_scan", default=True): - _do_manifest_scan(dest, apk_path=apk_path) - else: - console.print(f"[dim]{t('cli_skipping_manifest_scan')}[/dim]") - - # Escaneo de vulnerabilidades y leaks (antes de OSINT para alimentarlo) - scan_result = None - vuln_enabled = _feature_enabled("sast_scan", default=True) - leak_enabled = _feature_enabled("leak_scan", default=True) - if not vuln_enabled and not leak_enabled: - console.print(f"[dim]{t('cli_skipping_vuln_scan')}[/dim]") - elif _ask_or_auto(t("cli_vuln_scan_code_prompt"), "sast_scan", default=True): - scan_result = _do_vuln_scan( - dest, - apk_path=apk_path, - package_hint=package, - include_vuln_scan=vuln_enabled, - include_leak_scan=leak_enabled, - ) - - # OSINT sobre el código decompilado (alimentado por hallazgos del scanner) - leak_findings = scan_result.findings if scan_result else None - _do_osint_scan(dest, package, scan_findings=leak_findings) - - return scan_result - - except DecompilerError as exc: - console.print(f"[red]{t('cli_error_decompilation')}[/red] {exc}") - return None - - -def _do_vuln_scan( - source_dir: Path, - apk_path: "Path | None" = None, - package_hint: str | None = None, - include_vuln_scan: bool = True, - include_leak_scan: bool = True, -): - """Escanea el directorio decompilado en busca de vulnerabilidades.""" - console.print() - scan_result = None - - # Determinar motor de escaneo desde config - engine = cfg_get(_CFG, "sast", "engine") or "auto" - - # ── Leer sección leak_scan ─────────────────────────────────────────── - use_native = bool(cfg_get(_CFG, "leak_scan", "native", default=True)) - use_apkleaks = bool(cfg_get(_CFG, "leak_scan", "apkleaks", default=True)) - use_gitleaks = bool(cfg_get(_CFG, "leak_scan", "gitleaks", default=False)) - - # Derivar leak_engine para auto_scan (none|apk|code|both) - if not include_leak_scan: - leak_engine = "none" - elif use_native and use_apkleaks: - leak_engine = "both" - elif use_apkleaks: - leak_engine = "apk" - elif use_native: - leak_engine = "code" - else: - leak_engine = "none" - - default_semgrep_config = "p/android p/owasp-top-ten" - semgrep_config = default_semgrep_config - if str(engine).strip().lower() == "semgrep": - semgrep_config = ( - cfg_get(_CFG, "sast", "config") - or default_semgrep_config - ) - semgrep_config = " ".join( - token for token in str(semgrep_config).split() if token != "p/secrets" - ) or default_semgrep_config - - # Permite ejecución separada: vuln scan y leak scan pueden activarse de forma independiente. - apk_for_leaks = apk_path if include_leak_scan and use_apkleaks else None - - def _is_leak_finding(f) -> bool: - rid = str(getattr(f, "rule_id", "")).upper() - title = str(getattr(f, "title", "")).lower() - category = str(getattr(f, "category", "")).lower() - if rid.startswith("AL-") or rid.startswith("HC") or rid.startswith("GL-"): - return True - leak_terms = ("secret", "token", "apikey", "api key", "password", "credential", "jwt", "private key") - text = f"{title} {category}" - return any(t in text for t in leak_terms) - - # Anunciar en terminal qué motor se usará - import shutil as _shutil - if engine == "semgrep": - engine_label = "[bold cyan]semgrep[/bold cyan]" - elif engine == "regex": - engine_label = "[bold]regex interno[/bold]" - else: - engine_label = ( - "[bold cyan]semgrep[/bold cyan]" - if _shutil.which("semgrep") - else "[bold]regex interno[/bold] [dim](semgrep no instalado)[/dim]" - ) - console.print( - " " + t("cli_vuln_scan_header") + - ("[green]" + t("cli_vuln_scan_enabled") + "[/green]" if include_vuln_scan else "[yellow]" + t("cli_vuln_scan_disabled") + "[/yellow]") - ) - console.print( - " " + t("cli_vuln_engine_header") + - (engine_label if include_vuln_scan else "[dim]" + t("cli_vuln_scan_disabled") + "[/dim]") - ) - console.print( - " " + t("cli_leak_scan_header") + - ("[green]" + t("cli_vuln_scan_enabled") + "[/green]" if include_leak_scan else "[yellow]" + t("cli_vuln_scan_disabled") + "[/yellow]") - ) - # Detalle de motores de leak realmente activos - leak_parts = [] - if include_leak_scan and use_native: - leak_parts.append("native") - if include_leak_scan and use_apkleaks: - leak_parts.append("apkleaks") - if include_leak_scan and use_gitleaks: - leak_parts.append("gitleaks") - leak_engine_label = ", ".join(leak_parts) if leak_parts else ("[dim]" + t("cli_leak_engines_disabled") + "[/dim]") - console.print(f" {t('cli_leak_engines_header')} {leak_engine_label}") - - if not include_vuln_scan: - if include_leak_scan: - try: - leaks: list = [] - - # 1) Regex nativo (reglas HC*) sobre código decompilado - if use_native: - base_scan = auto_scan( - source_dir, - engine="regex", - progress_callback=None, - apk_path=None, - leak_engine="code", - include_code_leak_rules=True, - include_xml_leak_rules=False, - ) - leaks.extend([f for f in base_scan.findings if _is_leak_finding(f)]) - - # 2) apkleaks sobre el APK original - if use_apkleaks and apk_for_leaks is not None: - try: - leaks.extend(scan_with_apkleaks(apk_for_leaks)) - except Exception as exc: # noqa: BLE001 - console.print(f"[yellow]⚠[/yellow] {t('cli_apkleaks_failed')} {exc}") - - # 3) gitleaks sobre código decompilado - if use_gitleaks: - try: - leaks.extend(scan_with_gitleaks(source_dir)) - except Exception as exc: # noqa: BLE001 - console.print(f"[yellow]⚠[/yellow] gitleaks falló: {exc}") - - # Deduplicación: prefer HC (more context) over GL on same file+line - hc_keys = { - (str(f.file), int(f.line)) - for f in leaks if f.rule_id.startswith("HC") - } - seen: set[tuple[str, int, str, str]] = set() - uniq = [] - for f in leaks: - # Drop GL findings that overlap with HC on same file+line - if f.rule_id.startswith("GL-") and (str(f.file), int(f.line)) in hc_keys: - continue - key = (str(f.file), int(f.line), str(f.rule_id), str(f.matched_text)) - if key in seen: - continue - seen.add(key) - uniq.append(f) - leaks = uniq - - leak_tag = "+".join(leak_parts) if leak_parts else "" - scan_result = ScanResult( - base_dir=source_dir, - findings=leaks, - files_scanned=0, - scanner_engine="", - leak_engine=leak_tag, - ) - except Exception as exc: # noqa: BLE001 - console.print(f"[red]Error en leak scan:[/red] {exc}") - return None - else: - scan_result = ScanResult( - base_dir=source_dir, - findings=[], - files_scanned=0, - scanner_engine="none", - ) - - print_vuln_report(scan_result, source_dir) - pkg_name = package_hint or source_dir.name - _save_vuln_json(scan_result, pkg_name, manifest=_MANIFEST_ANALYSIS) - return scan_result - - try: - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - console=console, - transient=True, - ) as progress: - task = progress.add_task(t("cli_scanning_vulns"), total=None) - - def on_progress(msg: str) -> None: - progress.update(task, description=msg) - - scan_result = auto_scan( - source_dir, - engine=engine, - semgrep_config=semgrep_config, - progress_callback=on_progress, - apk_path=apk_for_leaks, - leak_engine=leak_engine, - include_code_leak_rules=include_leak_scan and use_native, - include_xml_leak_rules=include_leak_scan and use_native, - ) - - except RuntimeError as exc: - console.print(f"[yellow]⚠[/yellow] {exc}") - console.print(f"[dim] {t('cli_retry_regex')}[/dim]") - try: - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - console=console, - transient=True, - ) as progress: - task = progress.add_task(t("cli_scanning_vulns_regex"), total=None) - # Usar auto_scan con engine=regex para respetar la lógica sources/ + XML - from nutcracker_core.vuln_scanner import auto_scan as _auto_scan - scan_result = _auto_scan( - source_dir, - engine="regex", - progress_callback=lambda m: progress.update(task, description=m), - apk_path=apk_for_leaks, - leak_engine=leak_engine, - include_code_leak_rules=include_leak_scan and use_native, - include_xml_leak_rules=include_leak_scan and use_native, - ) - except Exception as exc2: # noqa: BLE001 - console.print(f"[red]{t('cli_error_vuln_scan')}[/red] {exc2}") - return None - except Exception as exc: # noqa: BLE001 - console.print(f"[red]{t('cli_error_vuln_scan')}[/red] {exc}") - return None - - if scan_result: - # Inyectar hallazgos de gitleaks si está habilitado - if use_gitleaks and include_leak_scan: - try: - gl_findings = scan_with_gitleaks(source_dir) - if gl_findings: - # Dedup: skip gitleaks findings that overlap with existing - # HC findings on the same file+line (HC has more context) - existing_keys = { - (str(f.file), f.line) - for f in scan_result.findings - if f.rule_id.startswith("HC") - } - new_gl = [ - f for f in gl_findings - if (str(f.file), f.line) not in existing_keys - ] - if new_gl: - scan_result.findings.extend(new_gl) - console.print( - f" [dim]{t('cli_gitleaks_stats', detected=len(gl_findings), discarded=len(gl_findings) - len(new_gl), added=len(new_gl))}[/dim]" - ) - if "gitleaks" not in scan_result.leak_engine: - scan_result.leak_engine += ("+gitleaks" if scan_result.leak_engine else "gitleaks") - except Exception as exc: # noqa: BLE001 - console.print(f"[yellow]⚠[/yellow] {t('cli_gitleaks_failed')} {exc}") - - engine_used = scan_result.scanner_engine - console.print( - f" [dim]{t('cli_scanner_used_dim', engine=engine_used, files=scan_result.files_scanned, findings=len(scan_result.findings))}[/dim]" - ) - - # ── Componentes del manifest exportados (COMP006/COMP007/COMP008) ───────── - # _MANIFEST_ANALYSIS ya tiene exported_components del manifest extraído. - if scan_result is not None and _MANIFEST_ANALYSIS and getattr(_MANIFEST_ANALYSIS, "exported_components", None): - try: - from nutcracker_core.vuln_scanner import VulnFinding - _COMP_MAP = { - "activity": ("COMP006", "Activity exported sin permission", "critical", "M6 - Componentes inseguros"), - "service": ("COMP007", "Service exported sin permission", "high", "M6 - Componentes inseguros"), - "receiver": ("COMP004", "BroadcastReceiver exported sin permission", "high", "M6 - Componentes inseguros"), - "provider": ("COMP008", "ContentProvider exported sin permission", "critical", "M6 - Componentes inseguros"), - } - existing_comp = {(f.rule_id, f.matched_text) for f in scan_result.findings if f.rule_id.startswith("COMP")} - new_comp = [] - for ec in _MANIFEST_ANALYSIS.exported_components: - tag = ec.get("tag", "").lower() - name = ec.get("name", "") - if tag not in _COMP_MAP: - continue - rule_id, title, severity, category = _COMP_MAP[tag] - matched = f'<{tag} android:name="{name}" android:exported="true">' - if (rule_id, matched) in existing_comp: - continue - new_comp.append(VulnFinding( - rule_id=rule_id, - title=f"{title}: {name.split('.')[-1]}", - severity=severity, - category=category, - file=None, - line=0, - matched_text=matched, - description=( - f"{tag.capitalize()} `{name}` tiene android:exported=\"true\" sin " - f"android:permission. Cualquier app o comando ADB puede invocarlo directamente." - ), - recommendation=( - f"Añadir android:exported=\"false\" o proteger con " - f"android:permission=\"\"." - ), - )) - existing_comp.add((rule_id, matched)) - if new_comp: - scan_result.findings.extend(new_comp) - console.print(f" [dim]Componentes exportados (manifest): {len(new_comp)} hallazgo(s) COMP[/dim]") - except Exception: - pass - - print_vuln_report(scan_result, source_dir) - - # Guardar JSON con nombre canónico por paquete. - pkg_name = package_hint or source_dir.name - _save_vuln_json(scan_result, pkg_name, manifest=_MANIFEST_ANALYSIS) - return scan_result - - -def _load_vuln_json(package: str): - """Carga el JSON de vulnerabilidades guardado previamente, si existe.""" - import json - from nutcracker_core.vuln_scanner import ScanResult, VulnFinding - - # Primario: reports//vuln.json - # Fallback: decompiled/vuln_.json (legacy) - json_path = Path("./reports") / package / "vuln.json" - if not json_path.exists(): - json_path = Path("./decompiled") / f"vuln_{package}.json" - if not json_path.exists(): - return None - try: - data = json.loads(json_path.read_text(encoding="utf-8")) - base_dir = Path("./decompiled") / package - import re as _re - _url_re = _re.compile(r'"(https?://[^"]{8,})"') - _ai_reviewed = bool(data.get("ai_reviewed")) - - def _effective_sev(f: dict) -> str: - """URL → INFO solo si el LLM no confirmó el finding como crítico/alto/medio. - - - Sin ai-review: aplicar heurística (URL → info). - - Con ai-review y LLM lo dejó como TRUE_POSITIVE (sin _ai_note): respetar severidad original. - - Con ai-review y LLM lo degradó (tiene _ai_note): ya tiene la severidad corregida, solo aplicar URL como fallback extra. - """ - sev = f.get("severity", "info") - if sev in ("info",): - return sev - # Si el LLM ya revisó este finding y lo mantuvo sin cambios (TRUE_POSITIVE), - # confiamos en su criterio y no aplicamos la degradación automática. - if _ai_reviewed and not f.get("_ai_note"): - return sev - # Sin revisión LLM o LLM lo DOWNGRADE: aplicar heurística URL → info - if _url_re.search(f.get("matched_text", "")): - return "info" - return sev - - findings = [ - VulnFinding( - rule_id=f["rule_id"], - title=f["title"], - severity=_effective_sev(f), - category=f["category"], - file=base_dir / f["file"], - line=f["line"], - matched_text=f["matched_text"], - description=f["description"], - recommendation=f["recommendation"], - ) - for f in data.get("findings", []) - if not f.get("_fp") # FPs etiquetados por ai-review no van al PDF - ] - return ScanResult(base_dir=base_dir, findings=findings, - files_scanned=data.get("files_scanned", 0)) - except Exception: # noqa: BLE001 - return None - - -# ── Persistencia del análisis (AnalysisResult) ──────────────────────────────── - - -def _load_analysis_json(package: str): - """Carga el AnalysisResult más reciente desde reports//.""" - import json - from nutcracker_core.analyzer import AnalysisResult - pkg_dir = Path("./reports") / package - # Soportar también el formato plano anterior (reports/.json) - legacy = Path("./reports") / f"{package}.json" - if pkg_dir.is_dir(): - jsons = sorted( - (f for f in pkg_dir.glob("*.json") if f.stem[0].isdigit()), - key=lambda f: f.stat().st_mtime, - reverse=True, - ) - if jsons: - try: - data = json.loads(jsons[0].read_text(encoding="utf-8")) - return AnalysisResult.from_dict(data) - except Exception: # noqa: BLE001 - pass - if legacy.exists(): - try: - data = json.loads(legacy.read_text(encoding="utf-8")) - return AnalysisResult.from_dict(data) - except Exception: # noqa: BLE001 - pass - return None - - -def _generate_pdf(result, vuln_scan=None, vuln_scan_enabled: bool = True) -> None: - """Genera el informe PDF final con los resultados de anti-root y vulnerabilidades.""" - # Si no se pasó un scan en esta sesión, intentar cargar el JSON guardado - if vuln_scan is None: - vuln_scan = _load_vuln_json(result.package) - if vuln_scan is not None: - console.print( - f"[dim] {t('cli_loading_prev_findings', count=len(vuln_scan.findings))}[/dim]" - ) - - reports_dir = Path("./reports") / result.package - reports_dir.mkdir(parents=True, exist_ok=True) - pdf_path = reports_dir / f"nutcracker_{result.package}_report.pdf" - try: - with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), - console=console, transient=True) as progress: - progress.add_task(t("cli_generating_pdf"), total=None) - generate_pdf_report(result, pdf_path, scan=vuln_scan, manifest=_MANIFEST_ANALYSIS, - osint=_OSINT_RESULT, vuln_scan_enabled=vuln_scan_enabled) - console.print(f"[green]✔[/green] {t('cli_pdf_saved')} [bold]{pdf_path}[/bold]") - except Exception as exc: # noqa: BLE001 - console.print(f"[red]{t('cli_error_pdf')}[/red] {exc}") - - -def _save_vuln_json(scan_result, package: str, manifest=None) -> None: - """Guarda los hallazgos de vulnerabilidades en JSON. - - Escribe en dos ubicaciones: - - reports//vuln.json (primario, junto al AnalysisResult) - - decompiled/vuln_.json (legacy, usado por aireview) - """ - import json - data = { - "files_scanned": scan_result.files_scanned, - "total_findings": len(scan_result.findings), - "findings": [ - { - "rule_id": f.rule_id, - "title": f.title, - "severity": f.severity, - "category": f.category, - "file": f.relative_path(scan_result.base_dir), - "line": f.line, - "matched_text": f.matched_text, - "description": f.description, - "recommendation": f.recommendation, - } - for f in scan_result.findings - ], - } - if manifest is not None and manifest.misconfigurations: - data["manifest_misconfigs"] = [ - { - "severity": m.severity, - "category": m.category, - "title": m.title, - "description": m.description, - "location": m.location, - "recommendation": m.recommendation, - } - for m in manifest.misconfigurations - ] - payload = json.dumps(data, ensure_ascii=False, indent=2) - primary = Path("./reports") / package / "vuln.json" - primary.parent.mkdir(parents=True, exist_ok=True) - primary.write_text(payload, encoding="utf-8") - # Legacy copy para aireview y otros usos directos de decompiled/ - legacy = Path("./decompiled") / f"vuln_{package}.json" - legacy.parent.mkdir(parents=True, exist_ok=True) - legacy.write_text(payload, encoding="utf-8") - console.print(f"[dim]{t('cli_vuln_json_saved')}[/dim] [bold]{primary}[/bold]") - - -# ── Comando: batch ─────────────────────────────────────────────────────────── - -@cli.command() -@click.argument("list_file", type=click.Path(dir_okay=False), required=False, default=None) -@click.option( - "--config", "-c", - "config_path", - default="config.yaml", - show_default=True, - metavar="ARCHIVO", - help="Path to YAML config file.", -) -@click.option( - "--output-dir", "-o", - default=None, - help="Directory to save PDF/JSON reports.", -) -@click.option( - "--keep-apk", - is_flag=True, - default=False, - help="Keep downloaded APKs after each analysis.", -) -@click.option( - "--stop-on-error", - is_flag=True, - default=False, - help="Stop on first error (default: continue).", -) -def batch( - list_file: str | None, - config_path: str, - output_dir: str | None, - keep_apk: bool, - stop_on_error: bool, -) -> None: - """ - Scan a list of APKs or URLs in batch mode. - - LIST_FILE is a text file with one entry per line: - - Google Play URLs (https://play.google.com/...) - - Package IDs (com.example.app) - - Direct APK URLs (https://cdn.example.com/app.apk) - - Local APK paths (/path/to/app.apk) - - Lines starting with '#' or empty lines are ignored. - """ - global _CFG - started_at = time.perf_counter() - config = load_config(config_path) - _CFG = config - _init_i18n(config) - - batch_cfg = cfg_get(config, "batch") or {} - _keep_apk = keep_apk or bool(batch_cfg.get("keep_apk", cfg_get(config, "downloader", "keep_apk", default=False))) - _stop_on_err = stop_on_error or bool(batch_cfg.get("stop_on_error", False)) - reports_dir = output_dir or batch_cfg.get("reports_dir") or cfg_get(config, "reports", "output_dir") or "./reports" - dl_dir = batch_cfg.get("download_dir") or cfg_get(config, "downloader", "output_dir") or "./downloads" - save_pdf = cfg_get(config, "reports", "save_pdf", default=True) - - # Resolver list_file: CLI > config batch.list_file - resolved_list_file = list_file or batch_cfg.get("list_file") - if not resolved_list_file: - console.print(f"[red]✘[/red] {t('cli_batch_list_missing')}") - raise SystemExit(1) - resolved_list_file = Path(resolved_list_file) - if not resolved_list_file.exists(): - console.print(f"[red]✘[/red] {t('cli_batch_file_not_found', path=resolved_list_file)}") - raise SystemExit(1) - - # Leer lista de targets - raw_lines = resolved_list_file.read_text(encoding="utf-8").splitlines() - targets = [l.strip() for l in raw_lines if l.strip() and not l.strip().startswith("#")] - - if not targets: - console.print(f"[yellow]{t('cli_batch_empty_list')}[/yellow]") - return - - console.print(f"[bold cyan]Batch scan:[/bold cyan] {t('cli_batch_scan_header', count=len(targets), file=list_file)}") - console.rule() - - results_summary: list[dict] = [] - - for idx, target in enumerate(targets, 1): - console.print(f"\n[bold][[{idx}/{len(targets)}]][/bold] {target}") - - apk_path: Path | None = None - is_local = Path(target).exists() and target.lower().endswith(".apk") - - # ── Descarga si no es local ─────────────────────────────────────────── - if not is_local: - try: - if is_direct_apk_url(target): - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - BarColumn(), - DownloadColumn(), - TransferSpeedColumn(), - console=console, - transient=True, - ) as progress: - task = progress.add_task(t("cli_batch_downloading"), total=None) - def _on_chunk(dl: int, tot: int | None) -> None: - progress.update(task, completed=dl, total=tot) - apk_path = DirectURLDownloader(dl_dir).download(target, progress_callback=_on_chunk, use_cache=_keep_apk) - else: - # Google Play / APKPure - email = cfg_get(config, "google_play", "email") - aas_token = cfg_get(config, "google_play", "aas_token") - if email and aas_token: - dl = GooglePlayDownloader(email, aas_token, dl_dir) - else: - dl = APKPureDownloader(dl_dir) - with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), - console=console, transient=True) as progress: - progress.add_task(t("cli_batch_downloading"), total=None) - apk_path = dl.download(target) - console.print(f" [green]✔[/green] {t('cli_batch_downloaded', name=apk_path.name)}") - except APKDownloadError as exc: - console.print(f" [red]{t('cli_batch_download_error')}[/red] {exc}") - results_summary.append({"target": target, "status": "error_download", "error": str(exc)}) - if _stop_on_err: - break - continue - else: - apk_path = Path(target) - - # ── Análisis ────────────────────────────────────────────────────────── - try: - with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), - console=console, transient=True) as progress: - task = progress.add_task(t("cli_batch_analyzing"), total=None) - def on_prog(msg: str) -> None: - progress.update(task, description=msg) - analyzer = APKAnalyzer(progress_callback=on_prog) - result = analyzer.analyze(apk_path) - - # PDF individual por app - pkg = result.package - pdf_path: Path | None = None - if save_pdf: - pkg_dir = Path(reports_dir) / pkg - pkg_dir.mkdir(parents=True, exist_ok=True) - pdf_dest = pkg_dir / f"nutcracker_{pkg}_report.pdf" - scan_result = _post_analysis_flow(result, apk_path) - # Guardar JSON una vez que todos los datos están completos - save_analysis_json(result, scan_result=scan_result, manifest=_MANIFEST_ANALYSIS) - from nutcracker_core.pdf_reporter import generate_pdf_report - pdf_path = generate_pdf_report(result, pdf_dest, scan=scan_result, manifest=_MANIFEST_ANALYSIS) - console.print(f" [green]✔[/green] PDF: [bold]{pdf_path}[/bold]") - else: - scan_result = _post_analysis_flow(result, apk_path) - # Guardar JSON una vez que todos los datos están completos - save_analysis_json(result, scan_result=scan_result, manifest=_MANIFEST_ANALYSIS) - - status = "protected_broken" if result.protection_broken \ - else ("protected" if result.protected else "unprotected") - - # Enriquecer datos para reporte batch consolidado - sev_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0} - leaks_count = 0 - top_findings: list[tuple[str, str, str]] = [] - cat_max_sev: dict[str, str] = {} # categoría → peor severidad - if scan_result: - seen_rules: set[str] = set() - sev_order = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4} - for f in scan_result.findings: - sev = getattr(f, "severity", "info").lower() - if sev in sev_counts: - sev_counts[sev] += 1 - rid = getattr(f, "rule_id", "") - cat = getattr(f, "category", "") - # Peor severidad por categoría - if cat: - prev = cat_max_sev.get(cat, "info") - if sev_order.get(sev, 5) < sev_order.get(prev, 5): - cat_max_sev[cat] = sev - # Contar leaks - if rid.upper().startswith(("AL-", "HC", "GL-")): - leaks_count += 1 - # Top findings (únicos por rule_id, ordenados por severidad) - if rid not in seen_rules: - seen_rules.add(rid) - top_findings.append((rid, getattr(f, "title", ""), sev)) - top_findings.sort(key=lambda x: sev_order.get(x[2], 5)) - top_findings = top_findings[:5] - - results_summary.append({ - "target": target, - "package": pkg, - "status": status, - "pdf": str(pdf_path) if pdf_path else None, - "findings": len(scan_result.findings) if scan_result else 0, - **sev_counts, - "leaks": leaks_count, - "top_findings": top_findings, - "categories": cat_max_sev, - }) - except Exception as exc: # noqa: BLE001 - console.print(f" [red]{t('cli_batch_analysis_error')}[/red] {exc}") - results_summary.append({"target": target, "status": "error_analysis", "error": str(exc)}) - if _stop_on_err: - break - - finally: - if not _keep_apk and apk_path and apk_path.exists() and not is_local: - apk_path.unlink() - - # ── Resumen final ───────────────────────────────────────────────────────── - console.rule() - console.print(f"\n[bold]{t('cli_batch_summary_header', count=len(results_summary))}[/bold]\n") - ok = [r for r in results_summary if "error" not in r["status"]] - errors = [r for r in results_summary if "error" in r["status"]] - broken = [r for r in ok if r["status"] == "protected_broken"] - console.print(f" [green]{t('cli_batch_ok_label')}[/green] {len(ok)}") - console.print(f" [red]{t('cli_batch_errors_label')}[/red] {len(errors)}") - console.print(f" [yellow]{t('cli_batch_broken_label')}[/yellow] {len(broken)}") - - if errors: - console.print(f"\n[dim]{t('cli_batch_error_targets')}[/dim]") - for r in errors: - console.print(f" - {r['target']} ({r['error'][:80]})") - - # ── Reporte batch consolidado ───────────────────────────────────────────── - if save_pdf and len(results_summary) > 1: - from nutcracker_core.pdf_reporter import generate_batch_report - batch_pdf = Path(reports_dir) / "batch_report.pdf" - generate_batch_report(results_summary, batch_pdf) - console.print(f"\n[bold green]✔[/bold green] {t('cli_batch_consolidated_report')} [bold]{batch_pdf}[/bold]") - - _print_elapsed(t("cli_elapsed_batch"), time.perf_counter() - started_at) - - -# ── Comando: regen-pdf ──────────────────────────────────────────────────────── - -@cli.command("regen-pdf") -@click.argument("package") -def regen_pdf(package: str) -> None: - """Regenerate the PDF for a package from its saved JSON in reports/. - - PACKAGE is the package name, e.g.: com.example.myapp - """ - result = _load_analysis_json(package) - if result is None: - console.print(f"[red]{t('cli_regen_pdf_not_found', package=package)}[/red]") - raise SystemExit(1) - console.print(f"[dim]{t('cli_regen_pdf_loaded', package=result.package, analyzed_at=result.analyzed_at)}[/dim]") - - # Cargar vuln scan si existe - vuln_scan = _load_vuln_json(package) - if vuln_scan is not None: - console.print(f"[dim]{t('cli_regen_pdf_findings', count=len(vuln_scan.findings))}[/dim]") - - # Cargar manifest si existe - manifest = None - try: - from nutcracker_core.manifest_analyzer import analyze_decompiled_dir as _addir - # Detectar directorio decompilado disponible - for candidate in [ - Path("./decompiled") / f"runtime_dump_{package}" / "source", - Path("./decompiled") / package, - ]: - if candidate.exists(): - # Buscar APK para fallback de manifest (runtime dump no tiene manifest) - apk_dir = Path("./downloads") / package - _apk = next(iter(sorted(apk_dir.glob(f"{package}.apk"))), None) if apk_dir.exists() else None - if _apk is None and apk_dir.exists(): - _apk = next(iter(sorted(apk_dir.glob("*.apk"))), None) - manifest = _addir(candidate, apk_path=_apk) - console.print(f"[dim]{t('cli_manifest_loaded', path=candidate)}[/dim]") - break - except Exception: # noqa: BLE001 - pass - - global _MANIFEST_ANALYSIS - _MANIFEST_ANALYSIS = manifest - - # Cargar OSINT si existe - global _OSINT_RESULT - _OSINT_RESULT = None - # Nueva ubicación: reports//osint.json - # Fallback: reports/osint_.json (formato legacy) - osint_path = Path("./reports") / package / "osint.json" - if not osint_path.exists(): - osint_path = Path("./reports") / f"osint_{package}.json" - if osint_path.exists(): - try: - import json as _json - from nutcracker_core.osint import OsintResult, PublicLeak, Secret, Subdomain - raw = _json.loads(osint_path.read_text(encoding="utf-8")) - _OSINT_RESULT = OsintResult( - package=raw.get("package", package), - secrets=[ - Secret(name=s["name"], value=s["value"], file=s.get("file", ""), - line=s.get("line", 0), service=s.get("service", "")) - for s in raw.get("secrets", []) - ], - subdomains=[ - Subdomain(name=s["name"], first_seen=s.get("first_seen", ""), - is_wildcard=s.get("is_wildcard", False)) - for s in raw.get("subdomains", []) - ], - public_leaks=[ - PublicLeak(source=l["source"], query=l.get("query", ""), - url=l.get("url", ""), title=l.get("title", ""), - snippet=l.get("snippet", ""), vulns=l.get("vulns", []), - vulns_cvss=l.get("vulns_cvss", {})) - for l in raw.get("public_leaks", []) - ], - domains_scanned=raw.get("domains_scanned", []), - auth_flows=raw.get("auth_flows", []), - ) - console.print(f"[dim]{t('cli_osint_loaded', path=osint_path)}[/dim]") - except Exception: # noqa: BLE001 - pass - - _vuln_enabled = _feature_enabled("sast_scan", default=True) - _generate_pdf(result, vuln_scan, vuln_scan_enabled=_vuln_enabled) - +El grupo de comandos y su orquestación viven en nutcracker_core/cli/ y +nutcracker_core/orchestrator.py (Fase 0.2 del plan) — este archivo es solo el +punto de entrada. +""" -# ── Punto de entrada ────────────────────────────────────────────────────────── +from nutcracker_core.cli import cli if __name__ == "__main__": cli() diff --git a/nutcracker_core/cli/__init__.py b/nutcracker_core/cli/__init__.py new file mode 100644 index 0000000..4c49ca4 --- /dev/null +++ b/nutcracker_core/cli/__init__.py @@ -0,0 +1,145 @@ +"""Grupo Click principal de nutcracker. + +Los comandos viven en módulos separados (scan.py, analyze.py, launch.py, +setup_token.py, batch.py, regen_pdf.py) que se registran sobre el grupo +``cli`` definido aquí. La orquestación real (helpers compartidos, estado de +sesión) vive en ``nutcracker_core.orchestrator`` — CLI, daemon y dashboard la +reusan sin duplicar lógica (Fase 0.2 del plan). +""" + +from __future__ import annotations + +import click + +from nutcracker_core import __version__ as _VERSION +from nutcracker_core.plugins import load_plugins +from nutcracker_core.store import install_persistence + + +def _print_banner() -> None: + from rich.text import Text + from rich.panel import Panel + from rich.align import Align + from rich.console import Group + + from nutcracker_core.orchestrator import console + + # ── Mapa de colores ─────────────────────────────────────────────────── + _COLORS = { + ".": None, + "G": "#444444", # gris oscuro (sombrero) + "Y": "#FFD700", # dorado (hombreras) + "K": "#2A2A2A", # cara + "W": "#EEEEEE", # blanco + "r": "#FF1111", # ojos rojos + "B": "#996633", # barba + "L": "#555555", # contorno + } + _SPECIAL_CHARS = {"S": ("★", "#44CC44")} + + _PIXELS = [ + "......GGGG......", + ".....GGGGGG.....", + ".....GGGGGG.....", + ".....GGGSGG.....", + ".....GGGGGG.....", + "...WLKKKKKKLW...", + "...WLrrKKrrLW...", + "...WWWWWWWWWW...", + "...WLWKKKKWLW...", + "...WLBBBBBBLW...", + "YYY.LBBBBBBL.YYY", + ".YY..LBBBBL..YY.", + ".....LBBBBL.....", + "......LBBL......", + ] + + w = len(_PIXELS[0]) + lines: list[Text] = [] + for y in range(0, len(_PIXELS), 2): + top, bot = _PIXELS[y], _PIXELS[y + 1] + line = Text() + for x in range(w): + ts = _SPECIAL_CHARS.get(top[x]) + bs = _SPECIAL_CHARS.get(bot[x]) + if ts or bs: + ch, col = ts or bs + other = _COLORS.get(bot[x] if ts else top[x]) + line.append(ch, style=f"{col} on {other}" if other else col) + continue + tc = _COLORS.get(top[x]) + bc = _COLORS.get(bot[x]) + if tc is None and bc is None: + line.append(" ") + elif tc == bc: + line.append("█", style=tc) + elif tc and bc is None: + line.append("▀", style=tc) + elif tc is None and bc: + line.append("▄", style=bc) + else: + line.append("▀", style=f"{tc} on {bc}") + lines.append(line) + + n = len(lines) + name_rows = [ + "╔╗╔╦ ╦╔╦╗╔═╗╦═╗╔═╗╔═╗╦╔═╔═╗╦═╗", + "║║║║ ║ ║ ║ ╠╦╝╠═╣║ ╠╩╗║╣ ╠╦╝", + "╝╚╝╚═╝ ╩ ╚═╝╩╚═╩ ╩╚═╝╩ ╩╚═╝╩╚═", + ] + + right: list[Text | None] = [None] * n + for i, row in enumerate(name_rows): + rt = Text() + rt.append(row, style="bold red") + right[1 + i] = rt + + tag = Text() + tag.append("★ ", style="bold green") + tag.append("Mobile Security & Offensive Threat Intelligence", style="bold white") + tag.append(" ★", style="bold green") + right[min(4, n - 1)] = tag + + ver = Text() + ver.append(f"v{_VERSION}", style="dim green") + ver.append(" · ", style="dim") + ver.append("nutcracker.sh", style="dim red link https://nutcracker.sh") + right[min(5, n - 1)] = ver + + combined = Text() + gap = " " + for i, sl in enumerate(lines): + combined.append_text(sl) + combined.append(gap) + if right[i] is not None: + combined.append_text(right[i]) + combined.append("\n") + + content = Align.center(combined) + console.print(Panel(content, border_style="red", padding=(1, 2))) + console.print() + + +# ── Grupo de comandos ────────────────────────────────────────────────────────── + +@click.group(invoke_without_command=True) +@click.version_option("0.1.0", prog_name="nutcracker") +@click.pass_context +def cli(ctx: click.Context) -> None: + """nutcracker: detects anti-root protections in Android applications (APK).""" + _print_banner() + if ctx.invoked_subcommand is None: + click.echo("usage: python nutcracker.py scan 'https://play.google.com/store/apps/details?id=...'") + ctx.exit(0) + + +# Importar los módulos de comando registra cada @cli.command() sobre este grupo. +from . import scan as _scan # noqa: E402,F401 +from . import analyze as _analyze # noqa: E402,F401 +from . import launch as _launch # noqa: E402,F401 +from . import setup_token as _setup_token # noqa: E402,F401 +from . import batch as _batch # noqa: E402,F401 +from . import regen_pdf as _regen_pdf # noqa: E402,F401 + +install_persistence() +load_plugins(cli) diff --git a/nutcracker_core/cli/analyze.py b/nutcracker_core/cli/analyze.py new file mode 100644 index 0000000..e68c4fa --- /dev/null +++ b/nutcracker_core/cli/analyze.py @@ -0,0 +1,60 @@ +"""Comando `nutcracker analyze`: analiza un APK local.""" + +from __future__ import annotations + +from pathlib import Path + +import click + +from nutcracker_core.config import load_config, get as cfg_get +from nutcracker_core import orchestrator as orch + +from . import cli + + +@cli.command() +@click.argument("apk_path", type=click.Path(exists=True, dir_okay=False)) +@click.option( + "--config", "-c", + "config_path", + default="config.yaml", + show_default=True, + metavar="ARCHIVO", +) +@click.option( + "--report", "-r", + default=None, + metavar="ARCHIVO", + help="Path to save the JSON report.", +) +@click.option( + "--launch", "-L", + is_flag=True, + default=False, + help="After analysis, launch the app on device with the generated bypass script.", +) +@click.option( + "--serial", "-s", + default=None, + metavar="SERIAL", + help="ADB device serial for --launch (default: first available).", +) +def analyze(apk_path: str, config_path: str, report: str | None, launch: bool, serial: str | None) -> None: + """Analyze a local APK for anti-root protections.""" + orch._LAUNCH_APP = launch + orch._LAUNCH_SERIAL = serial + config = load_config(config_path) + orch._CFG = config + orch._init_i18n(config) + save_json_cfg = bool( + cfg_get(config, "features", "report_json", default=cfg_get(config, "reports", "save_json", default=False)) + ) + if not report and save_json_cfg: + reports_dir = cfg_get(config, "reports", "output_dir") or "./reports" + Path(reports_dir).mkdir(parents=True, exist_ok=True) + report = str(Path(reports_dir) / f"{Path(apk_path).stem}.json") + save_pdf = bool( + cfg_get(config, "features", "report_pdf", default=cfg_get(config, "reports", "save_pdf", default=True)) + ) + + orch._run_analysis(Path(apk_path), report, keep_apk=True, gen_pdf=save_pdf) diff --git a/nutcracker_core/cli/batch.py b/nutcracker_core/cli/batch.py new file mode 100644 index 0000000..b4d570f --- /dev/null +++ b/nutcracker_core/cli/batch.py @@ -0,0 +1,256 @@ +"""Comando `nutcracker batch`: escanea una lista de APKs/URLs.""" + +from __future__ import annotations + +import time +from pathlib import Path + +import click +from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, DownloadColumn, TransferSpeedColumn + +from nutcracker_core.analyzer import APKAnalyzer +from nutcracker_core.config import load_config, get as cfg_get +from nutcracker_core.downloader import ( + APKPureDownloader, + GooglePlayDownloader, + DirectURLDownloader, + APKDownloadError, + is_direct_apk_url, +) +from nutcracker_core.i18n import t +from nutcracker_core.reporter import save_analysis_json +from nutcracker_core import orchestrator as orch +from nutcracker_core.orchestrator import console + +from . import cli + + +@cli.command() +@click.argument("list_file", type=click.Path(dir_okay=False), required=False, default=None) +@click.option( + "--config", "-c", + "config_path", + default="config.yaml", + show_default=True, + metavar="ARCHIVO", + help="Path to YAML config file.", +) +@click.option( + "--output-dir", "-o", + default=None, + help="Directory to save PDF/JSON reports.", +) +@click.option( + "--keep-apk", + is_flag=True, + default=False, + help="Keep downloaded APKs after each analysis.", +) +@click.option( + "--stop-on-error", + is_flag=True, + default=False, + help="Stop on first error (default: continue).", +) +def batch( + list_file: str | None, + config_path: str, + output_dir: str | None, + keep_apk: bool, + stop_on_error: bool, +) -> None: + """ + Scan a list of APKs or URLs in batch mode. + + LIST_FILE is a text file with one entry per line: + - Google Play URLs (https://play.google.com/...) + - Package IDs (com.example.app) + - Direct APK URLs (https://cdn.example.com/app.apk) + - Local APK paths (/path/to/app.apk) + + Lines starting with '#' or empty lines are ignored. + """ + started_at = time.perf_counter() + config = load_config(config_path) + orch._CFG = config + orch._init_i18n(config) + + batch_cfg = cfg_get(config, "batch") or {} + _keep_apk = keep_apk or bool(batch_cfg.get("keep_apk", cfg_get(config, "downloader", "keep_apk", default=False))) + _stop_on_err = stop_on_error or bool(batch_cfg.get("stop_on_error", False)) + reports_dir = output_dir or batch_cfg.get("reports_dir") or cfg_get(config, "reports", "output_dir") or "./reports" + dl_dir = batch_cfg.get("download_dir") or cfg_get(config, "downloader", "output_dir") or "./downloads" + save_pdf = cfg_get(config, "reports", "save_pdf", default=True) + + # Resolver list_file: CLI > config batch.list_file + resolved_list_file = list_file or batch_cfg.get("list_file") + if not resolved_list_file: + console.print(f"[red]✘[/red] {t('cli_batch_list_missing')}") + raise SystemExit(1) + resolved_list_file = Path(resolved_list_file) + if not resolved_list_file.exists(): + console.print(f"[red]✘[/red] {t('cli_batch_file_not_found', path=resolved_list_file)}") + raise SystemExit(1) + + # Leer lista de targets + raw_lines = resolved_list_file.read_text(encoding="utf-8").splitlines() + targets = [l.strip() for l in raw_lines if l.strip() and not l.strip().startswith("#")] + + if not targets: + console.print(f"[yellow]{t('cli_batch_empty_list')}[/yellow]") + return + + console.print(f"[bold cyan]Batch scan:[/bold cyan] {t('cli_batch_scan_header', count=len(targets), file=list_file)}") + console.rule() + + results_summary: list[dict] = [] + + for idx, target in enumerate(targets, 1): + console.print(f"\n[bold][[{idx}/{len(targets)}]][/bold] {target}") + + apk_path: Path | None = None + is_local = Path(target).exists() and target.lower().endswith(".apk") + + # ── Descarga si no es local ─────────────────────────────────────────── + if not is_local: + try: + if is_direct_apk_url(target): + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), + DownloadColumn(), + TransferSpeedColumn(), + console=console, + transient=True, + ) as progress: + task = progress.add_task(t("cli_batch_downloading"), total=None) + def _on_chunk(dl: int, tot: int | None) -> None: + progress.update(task, completed=dl, total=tot) + apk_path = DirectURLDownloader(dl_dir).download(target, progress_callback=_on_chunk, use_cache=_keep_apk) + else: + # Google Play / APKPure + email = cfg_get(config, "google_play", "email") + aas_token = cfg_get(config, "google_play", "aas_token") + if email and aas_token: + dl = GooglePlayDownloader(email, aas_token, dl_dir) + else: + dl = APKPureDownloader(dl_dir) + with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), + console=console, transient=True) as progress: + progress.add_task(t("cli_batch_downloading"), total=None) + apk_path = dl.download(target) + console.print(f" [green]✔[/green] {t('cli_batch_downloaded', name=apk_path.name)}") + except APKDownloadError as exc: + console.print(f" [red]{t('cli_batch_download_error')}[/red] {exc}") + results_summary.append({"target": target, "status": "error_download", "error": str(exc)}) + if _stop_on_err: + break + continue + else: + apk_path = Path(target) + + # ── Análisis ────────────────────────────────────────────────────────── + try: + with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), + console=console, transient=True) as progress: + task = progress.add_task(t("cli_batch_analyzing"), total=None) + def on_prog(msg: str) -> None: + progress.update(task, description=msg) + analyzer = APKAnalyzer(progress_callback=on_prog) + result = analyzer.analyze(apk_path) + + # PDF individual por app + pkg = result.package + pdf_path: Path | None = None + if save_pdf: + pkg_dir = Path(reports_dir) / pkg + pkg_dir.mkdir(parents=True, exist_ok=True) + pdf_dest = pkg_dir / f"nutcracker_{pkg}_report.pdf" + scan_result = orch._post_analysis_flow(result, apk_path) + # Guardar JSON una vez que todos los datos están completos + save_analysis_json(result, scan_result=scan_result, manifest=orch._MANIFEST_ANALYSIS) + from nutcracker_core.pdf_reporter import generate_pdf_report + pdf_path = generate_pdf_report(result, pdf_dest, scan=scan_result, manifest=orch._MANIFEST_ANALYSIS) + console.print(f" [green]✔[/green] PDF: [bold]{pdf_path}[/bold]") + else: + scan_result = orch._post_analysis_flow(result, apk_path) + # Guardar JSON una vez que todos los datos están completos + save_analysis_json(result, scan_result=scan_result, manifest=orch._MANIFEST_ANALYSIS) + + status = "protected_broken" if result.protection_broken \ + else ("protected" if result.protected else "unprotected") + + # Enriquecer datos para reporte batch consolidado + sev_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0} + leaks_count = 0 + top_findings: list[tuple[str, str, str]] = [] + cat_max_sev: dict[str, str] = {} # categoría → peor severidad + if scan_result: + seen_rules: set[str] = set() + sev_order = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4} + for f in scan_result.findings: + sev = getattr(f, "severity", "info").lower() + if sev in sev_counts: + sev_counts[sev] += 1 + rid = getattr(f, "rule_id", "") + cat = getattr(f, "category", "") + # Peor severidad por categoría + if cat: + prev = cat_max_sev.get(cat, "info") + if sev_order.get(sev, 5) < sev_order.get(prev, 5): + cat_max_sev[cat] = sev + # Contar leaks + if rid.upper().startswith(("AL-", "HC", "GL-")): + leaks_count += 1 + # Top findings (únicos por rule_id, ordenados por severidad) + if rid not in seen_rules: + seen_rules.add(rid) + top_findings.append((rid, getattr(f, "title", ""), sev)) + top_findings.sort(key=lambda x: sev_order.get(x[2], 5)) + top_findings = top_findings[:5] + + results_summary.append({ + "target": target, + "package": pkg, + "status": status, + "pdf": str(pdf_path) if pdf_path else None, + "findings": len(scan_result.findings) if scan_result else 0, + **sev_counts, + "leaks": leaks_count, + "top_findings": top_findings, + "categories": cat_max_sev, + }) + except Exception as exc: # noqa: BLE001 + console.print(f" [red]{t('cli_batch_analysis_error')}[/red] {exc}") + results_summary.append({"target": target, "status": "error_analysis", "error": str(exc)}) + if _stop_on_err: + break + + finally: + if not _keep_apk and apk_path and apk_path.exists() and not is_local: + apk_path.unlink() + + # ── Resumen final ───────────────────────────────────────────────────────── + console.rule() + console.print(f"\n[bold]{t('cli_batch_summary_header', count=len(results_summary))}[/bold]\n") + ok = [r for r in results_summary if "error" not in r["status"]] + errors = [r for r in results_summary if "error" in r["status"]] + broken = [r for r in ok if r["status"] == "protected_broken"] + console.print(f" [green]{t('cli_batch_ok_label')}[/green] {len(ok)}") + console.print(f" [red]{t('cli_batch_errors_label')}[/red] {len(errors)}") + console.print(f" [yellow]{t('cli_batch_broken_label')}[/yellow] {len(broken)}") + + if errors: + console.print(f"\n[dim]{t('cli_batch_error_targets')}[/dim]") + for r in errors: + console.print(f" - {r['target']} ({r['error'][:80]})") + + # ── Reporte batch consolidado ───────────────────────────────────────────── + if save_pdf and len(results_summary) > 1: + from nutcracker_core.pdf_reporter import generate_batch_report + batch_pdf = Path(reports_dir) / "batch_report.pdf" + generate_batch_report(results_summary, batch_pdf) + console.print(f"\n[bold green]✔[/bold green] {t('cli_batch_consolidated_report')} [bold]{batch_pdf}[/bold]") + + orch._print_elapsed(t("cli_elapsed_batch"), time.perf_counter() - started_at) diff --git a/nutcracker_core/cli/launch.py b/nutcracker_core/cli/launch.py new file mode 100644 index 0000000..07dc244 --- /dev/null +++ b/nutcracker_core/cli/launch.py @@ -0,0 +1,80 @@ +"""Comando `nutcracker launch`: lanza la app con el bypass script generado.""" + +from __future__ import annotations + +from pathlib import Path + +import click + +from nutcracker_core.config import load_config, get as cfg_get +from nutcracker_core.i18n import t +from nutcracker_core import orchestrator as orch +from nutcracker_core.orchestrator import console + +from . import cli + + +@cli.command() +@click.argument("package") +@click.option( + "--script", "-l", + default=None, + metavar="SCRIPT", + help="Bypass JS script to use. If omitted, uses the latest generated for the package.", +) +@click.option( + "--serial", "-s", + default=None, + metavar="SERIAL", + help="ADB device serial (default: first available via -U).", +) +@click.option( + "--scripts-dir", + default="frida_scripts", + show_default=True, + metavar="DIR", + help="Directory to search for bypass scripts.", +) +@click.option( + "--config", "config_path", + default="config.yaml", + show_default=True, + metavar="FILE", + help="Config file (reads strategies.frida_host).", +) +def launch(package: str, script: str | None, serial: str | None, scripts_dir: str, config_path: str) -> None: + """Launch the app on device with the latest generated bypass script. + + \b + Examples: + python nutcracker.py launch com.appexample + python nutcracker.py launch downloads/com.appexample/com.appexample.apk + python nutcracker.py launch com.appexample --script frida_scripts/bypass_....js + python nutcracker.py launch com.appexample --serial emulator-5554 + """ + scripts_path = Path(scripts_dir) + + # Aceptar APK path como argumento: extraer package del nombre del archivo + pkg = package + p = Path(package) + if p.suffix.lower() == ".apk" or "/" in package: + pkg = p.stem # e.g. "com.appexample.apk" → "com.appexample" + console.print(f"[dim] {t('cli_apk_detected_pkg', pkg=pkg)}[/dim]") + + if script: + script_path = Path(script) + if not script_path.exists(): + console.print(f"[red]Error:[/red] {t('cli_script_not_found', script=script)}") + raise SystemExit(1) + else: + script_path = orch._find_latest_bypass_script(pkg, scripts_path) + if not script_path: + console.print( + f"[yellow]⚠[/yellow] {t('cli_no_bypass_found', pkg=pkg, scripts_dir=scripts_dir)}" + ) + raise SystemExit(1) + + console.print(f"[green]✔[/green] Script: [bold]{script_path}[/bold]") + cfg = load_config(config_path) + frida_host = str(cfg_get(cfg, "strategies", "frida_host", default="")).strip() or None + orch._launch_frida_bypass(pkg, script_path, serial=serial, frida_host=frida_host) diff --git a/nutcracker_core/cli/regen_pdf.py b/nutcracker_core/cli/regen_pdf.py new file mode 100644 index 0000000..6157b56 --- /dev/null +++ b/nutcracker_core/cli/regen_pdf.py @@ -0,0 +1,96 @@ +"""Comando `nutcracker regen-pdf`: regenera el PDF de un paquete desde su JSON guardado.""" + +from __future__ import annotations + +from pathlib import Path + +import click + +from nutcracker_core.i18n import t +from nutcracker_core import orchestrator as orch +from nutcracker_core.orchestrator import console + +from . import cli + + +@cli.command("regen-pdf") +@click.argument("package") +def regen_pdf(package: str) -> None: + """Regenerate the PDF for a package from its saved JSON in reports/. + + PACKAGE is the package name, e.g.: com.example.myapp + """ + result = orch._load_analysis_json(package) + if result is None: + console.print(f"[red]{t('cli_regen_pdf_not_found', package=package)}[/red]") + raise SystemExit(1) + console.print(f"[dim]{t('cli_regen_pdf_loaded', package=result.package, analyzed_at=result.analyzed_at)}[/dim]") + + # Cargar vuln scan si existe + vuln_scan = orch._load_vuln_json(package) + if vuln_scan is not None: + console.print(f"[dim]{t('cli_regen_pdf_findings', count=len(vuln_scan.findings))}[/dim]") + + # Cargar manifest si existe + manifest = None + try: + from nutcracker_core.manifest_analyzer import analyze_decompiled_dir as _addir + # Detectar directorio decompilado disponible + for candidate in [ + Path("./decompiled") / f"runtime_dump_{package}" / "source", + Path("./decompiled") / package, + ]: + if candidate.exists(): + # Buscar APK para fallback de manifest (runtime dump no tiene manifest) + apk_dir = Path("./downloads") / package + _apk = next(iter(sorted(apk_dir.glob(f"{package}.apk"))), None) if apk_dir.exists() else None + if _apk is None and apk_dir.exists(): + _apk = next(iter(sorted(apk_dir.glob("*.apk"))), None) + manifest = _addir(candidate, apk_path=_apk) + console.print(f"[dim]{t('cli_manifest_loaded', path=candidate)}[/dim]") + break + except Exception: # noqa: BLE001 + pass + + orch._MANIFEST_ANALYSIS = manifest + + # Cargar OSINT si existe + orch._OSINT_RESULT = None + # Nueva ubicación: reports//osint.json + # Fallback: reports/osint_.json (formato legacy) + osint_path = Path("./reports") / package / "osint.json" + if not osint_path.exists(): + osint_path = Path("./reports") / f"osint_{package}.json" + if osint_path.exists(): + try: + import json as _json + from nutcracker_core.osint import OsintResult, PublicLeak, Secret, Subdomain + raw = _json.loads(osint_path.read_text(encoding="utf-8")) + orch._OSINT_RESULT = OsintResult( + package=raw.get("package", package), + secrets=[ + Secret(name=s["name"], value=s["value"], file=s.get("file", ""), + line=s.get("line", 0), service=s.get("service", "")) + for s in raw.get("secrets", []) + ], + subdomains=[ + Subdomain(name=s["name"], first_seen=s.get("first_seen", ""), + is_wildcard=s.get("is_wildcard", False)) + for s in raw.get("subdomains", []) + ], + public_leaks=[ + PublicLeak(source=l["source"], query=l.get("query", ""), + url=l.get("url", ""), title=l.get("title", ""), + snippet=l.get("snippet", ""), vulns=l.get("vulns", []), + vulns_cvss=l.get("vulns_cvss", {})) + for l in raw.get("public_leaks", []) + ], + domains_scanned=raw.get("domains_scanned", []), + auth_flows=raw.get("auth_flows", []), + ) + console.print(f"[dim]{t('cli_osint_loaded', path=osint_path)}[/dim]") + except Exception: # noqa: BLE001 + pass + + _vuln_enabled = orch._feature_enabled("sast_scan", default=True) + orch._generate_pdf(result, vuln_scan, vuln_scan_enabled=_vuln_enabled) diff --git a/nutcracker_core/cli/scan.py b/nutcracker_core/cli/scan.py new file mode 100644 index 0000000..1cbe1a3 --- /dev/null +++ b/nutcracker_core/cli/scan.py @@ -0,0 +1,185 @@ +"""Comando `nutcracker scan`: descarga un APK y lo analiza.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import click +from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, DownloadColumn, TransferSpeedColumn + +from nutcracker_core.config import load_config, get as cfg_get +from nutcracker_core.downloader import ( + DirectURLDownloader, + APKDownloadError, + is_direct_apk_url, + download_apk_from_config, +) +from nutcracker_core.i18n import t +from nutcracker_core import orchestrator as orch +from nutcracker_core.orchestrator import console + +from . import cli + + +@cli.command() +@click.argument("url") +@click.option( + "--config", "-c", + "config_path", + default="config.yaml", + show_default=True, + metavar="ARCHIVO", + help="Path to YAML config file.", +) +@click.option( + "--source", "-s", + default=None, + type=click.Choice(["apk-pure", "google-play"], case_sensitive=False), + help="Download source. Default: google-play if credentials in config, else apk-pure.", +) +@click.option( + "--output-dir", "-o", + default=None, + help="Directory to save downloaded APKs.", +) +@click.option( + "--keep-apk", + is_flag=True, + default=False, + help="Keep the APK after analysis.", +) +@click.option( + "--report", "-r", + default=None, + metavar="ARCHIVO", + help="Path to save the JSON report.", +) +def scan(url: str, config_path: str, source: str | None, output_dir: str | None, + keep_apk: bool, report: str | None) -> None: + """ + Download an APK and analyze it for anti-root protections. + + URL can be: + - Google Play URL (https://play.google.com/store/apps/details?id=...) + - Package ID directly (com.example.app) + - Direct URL to an .apk file (https://example.com/app.apk) + """ + config = load_config(config_path) + orch._CFG = config + orch._init_i18n(config) + output_dir = output_dir or cfg_get(config, "downloader", "output_dir") or "./downloads" + + if not keep_apk: + keep_apk = bool(cfg_get(config, "downloader", "keep_apk", default=False)) + + # Informe JSON automático si está configurado + save_json_cfg = bool( + cfg_get(config, "features", "report_json", default=cfg_get(config, "reports", "save_json", default=False)) + ) + if not report and save_json_cfg and not is_direct_apk_url(url): + reports_dir = cfg_get(config, "reports", "output_dir") or "./reports" + Path(reports_dir).mkdir(parents=True, exist_ok=True) + pkg = url.split("id=")[-1].split("&")[0].rstrip("/") + report = str(Path(reports_dir) / f"{pkg}.json") + save_pdf = bool( + cfg_get(config, "features", "report_pdf", default=cfg_get(config, "reports", "save_pdf", default=True)) + ) + + def _token_resolver(email: str, cfg: dict) -> str | None: + """Genera el aas_token interactivamente si falta, recarga config y lo devuelve.""" + nonlocal config + console.print(f"[yellow]Warning:[/yellow] {t('cli_gplay_token_empty')}") + script = Path(__file__).parent.parent.parent / "tools" / "extract_token.py" + if not script.exists(): + console.print(f"[red]Error:[/red] {t('cli_extract_token_not_found')}") + sys.exit(1) + cmd = [sys.executable, str(script), "--config", config_path] + preferred_serial = orch._select_token_serial(cfg) + if preferred_serial: + cmd += ["--serial", preferred_serial] + if orch._unattended(): + cmd.append("--no-interactive") + token_proc = subprocess.run(cmd) + if token_proc.returncode != 0: + console.print(f"[red]Error:[/red] {t('cli_token_gen_failed')}") + sys.exit(token_proc.returncode) + config = load_config(config_path) + orch._CFG = config + token = cfg_get(config, "google_play", "aas_token") + if not token: + console.print(f"[red]Error:[/red] {t('cli_token_still_empty')}") + sys.exit(1) + return token + + def _on_start(label: str) -> None: + _on_start._progress_ctx.__enter__() + _on_start._progress_ctx.add_task(t("cli_downloading_from", label=label), total=None) + + # Progreso para URL directa (con BarColumn) o spinner para stores + _progress_direct: Progress | None = None + _progress_store: Progress | None = None + _task_ref: list = [] + + def _progress_callback(downloaded: int, total: int | None) -> None: + if _progress_direct and _task_ref: + _progress_direct.update(_task_ref[0], completed=downloaded, total=total) + + def _on_start_label(label: str) -> None: + nonlocal _progress_store + _progress_store.__enter__() + _progress_store.add_task(t("cli_downloading_from", label=label), total=None) + + apk_path: Path | None = None + _from_cache = False + try: + if is_direct_apk_url(url): + _dl_check = DirectURLDownloader(output_dir) + _from_cache = keep_apk and _dl_check.dest_path(url).exists() + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), + DownloadColumn(), + TransferSpeedColumn(), + console=console, + transient=True, + ) as _progress_direct: + _task = _progress_direct.add_task(t("cli_downloading_apk"), total=None) + _task_ref.append(_task) + apk_path = download_apk_from_config( + url, config, + output_dir=output_dir, + use_cache=keep_apk, + progress_callback=_progress_callback, + ) + else: + if source == "google-play": + email = cfg_get(config, "google_play", "email") + if not email: + console.print(f"[red]Error:[/red] {t('cli_gplay_requires_email')}") + sys.exit(1) + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=console, + transient=True, + ) as _progress_store: + apk_path = download_apk_from_config( + url, config, + source=source, + output_dir=output_dir, + token_resolver=_token_resolver, + on_start=_on_start_label, + ) + + if _from_cache: + console.print(f"[green]✔[/green] {t('cli_apk_cached')} [bold]{apk_path}[/bold]") + else: + console.print(f"[green]✔[/green] {t('cli_apk_downloaded')} [bold]{apk_path}[/bold]") + except APKDownloadError as exc: + console.print(f"[red]{t('cli_error_download')}[/red] {exc}") + sys.exit(1) + + orch._run_analysis(apk_path, report, keep_apk, gen_pdf=cfg_get(config, "reports", "save_pdf", default=True)) diff --git a/nutcracker_core/cli/setup_token.py b/nutcracker_core/cli/setup_token.py new file mode 100644 index 0000000..c623f08 --- /dev/null +++ b/nutcracker_core/cli/setup_token.py @@ -0,0 +1,54 @@ +"""Comando `nutcracker setup-token`: wizard interactivo para el aas_token de Google Play.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import click + +from nutcracker_core.config import load_config +from nutcracker_core.i18n import t +from nutcracker_core import orchestrator as orch +from nutcracker_core.orchestrator import console + +from . import cli + + +@cli.command("setup-token") +@click.option( + "--config", "config_path", + default="config.yaml", + show_default=True, + metavar="ARCHIVO", + help="Path to YAML config file.", +) +@click.option("--serial", default=None, help="ADB serial of the target device.") +@click.option( + "--method", + default="auto", + type=click.Choice(["auto", "root", "dumpsys", "gsf"], case_sensitive=False), + show_default=True, + help="Token extraction method.", +) +@click.option("--no-interactive", is_flag=True, default=False, help="Skip confirmation prompts.") +def setup_token(config_path: str, serial: str | None, method: str, no_interactive: bool) -> None: + """Interactive wizard to obtain and save google_play.aas_token.""" + script = Path(__file__).parent.parent.parent / "tools" / "extract_token.py" + if not script.exists(): + console.print(f"[red]Error:[/red] {t('cli_extract_token_not_found')}") + raise SystemExit(1) + + cfg = load_config(config_path) + auto_serial = serial or orch._select_token_serial(cfg) + + cmd = [sys.executable, str(script), "--config", config_path, "--method", method.lower()] + if auto_serial: + cmd += ["--serial", auto_serial] + if no_interactive: + cmd.append("--no-interactive") + + result = subprocess.run(cmd) + if result.returncode != 0: + raise SystemExit(result.returncode) diff --git a/nutcracker_core/config.py b/nutcracker_core/config.py index c9ccd21..5a7b7b8 100644 --- a/nutcracker_core/config.py +++ b/nutcracker_core/config.py @@ -2,6 +2,9 @@ from __future__ import annotations +import logging +import os +import re from pathlib import Path from typing import Any @@ -9,6 +12,13 @@ DEFAULT_CONFIG_PATH = Path(__file__).parent.parent / "config.yaml" +_log = logging.getLogger(__name__) + +# Interpola "${VAR_NAME}" con os.environ dentro de valores string del YAML. +# Permite mantener secretos (llm.api_key, google_play.aas_token, tokens OSINT) +# fuera de config.yaml, p.ej.: aas_token: "${GOOGLE_PLAY_AAS_TOKEN}" +_ENV_VAR_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}") + def load_config(path: str | Path | None = None) -> dict[str, Any]: """ @@ -18,7 +28,8 @@ def load_config(path: str | Path | None = None) -> dict[str, Any]: path: Ruta al archivo. Si es None, usa config.yaml en la raíz del proyecto. Returns: - Diccionario con la configuración. + Diccionario con la configuración, con placeholders "${VAR}" resueltos + contra variables de entorno. """ config_path = Path(path) if path else DEFAULT_CONFIG_PATH @@ -28,7 +39,24 @@ def load_config(path: str | Path | None = None) -> dict[str, Any]: with config_path.open("r", encoding="utf-8") as f: data = yaml.safe_load(f) - return data or {} + return _resolve_env_vars(data or {}) + + +def _resolve_env_vars(value: Any) -> Any: + if isinstance(value, dict): + return {k: _resolve_env_vars(v) for k, v in value.items()} + if isinstance(value, list): + return [_resolve_env_vars(v) for v in value] + if isinstance(value, str): + def _sub(match: re.Match) -> str: + var_name = match.group(1) + if var_name not in os.environ: + _log.warning("Variable de entorno %r no definida (placeholder sin resolver)", var_name) + return match.group(0) + return os.environ[var_name] + + return _ENV_VAR_RE.sub(_sub, value) + return value def get(config: dict[str, Any], *keys: str, default: Any = None) -> Any: diff --git a/nutcracker_core/leak_scanner.py b/nutcracker_core/leak_scanner.py new file mode 100644 index 0000000..352c88e --- /dev/null +++ b/nutcracker_core/leak_scanner.py @@ -0,0 +1,364 @@ +"""Scanners de leaks/secretos vía herramientas externas: apkleaks y gitleaks. + +Extraído de vuln_scanner.py (Fase 0.3 del plan): vuln_scanner.py conserva las +reglas regex + semgrep (código estático de la app); este módulo cubre los dos +motores externos de detección de secretos que operan sobre el binario APK +(apkleaks) o el árbol de código decompilado (gitleaks). +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from .scan_types import VulnFinding + +# ── Integración con apkleaks ────────────────────────────────────────────────── + +# Mapeo de categoría apkleaks → severidad +_APKLEAKS_SEVERITY: dict[str, str] = { + "RSA_Private_Key": "critical", + "PGP_private_key_block": "critical", + "SSH_DSA_Private_Key": "critical", + "SSH_EC_Private_Key": "critical", + "Amazon_AWS_Access_Key_ID": "high", + "AWS_API_Key": "high", + "GitHub_Access_Token": "high", + "Stripe_API_Key": "high", + "Stripe_Restricted_API_Key": "high", + "PayPal_Braintree_Access_Token": "high", + "Heroku_API_Key": "high", + "Twilio_API_Key": "high", + "Firebase": "high", + "Google_API_Key": "high", + "Google_Cloud_Platform_Service_Account": "high", + "Google_OAuth_Access_Token": "high", + "Slack_Token": "high", + "Slack_Webhook": "high", + "MailChimp_API_Key": "high", + "Mailgun_API_Key": "high", + "Picatic_API_Key": "high", + "Square_Access_Token": "high", + "Square_OAuth_Secret": "high", + "Facebook_Access_Token": "high", + "Facebook_Secret_Key": "high", + "Twitter_Secret_Key": "high", + "Twitter_Access_Token": "high", + "Twitter_OAuth": "high", + "Authorization_Basic": "high", + "Authorization_Bearer": "high", + "JSON_Web_Token": "high", + "Password_in_URL": "high", + "Basic_Auth_Credentials": "high", + "Cloudinary_Basic_Auth": "high", + "Artifactory_API_Token": "medium", + "Artifactory_Password": "medium", + "Generic_API_Key": "medium", + "Generic_Secret": "medium", + "Amazon_AWS_S3_Bucket": "medium", + "Google_Cloud_Platform_OAuth": "medium", + "Facebook_ClientID": "low", + "Facebook_OAuth": "low", + "Twitter_ClientID": "low", + "GitHub": "low", + "Discord_BOT_Token": "medium", + "IP_Address": "info", + "Mac_Address": "info", + "Mailto": "info", + "LinkFinder": "info", + "DEFCON_CTF_Flag": "info", + "HackerOne_CTF_Flag": "info", + "HackTheBox_CTF_Flag": "info", + "TryHackMe_CTF_Flag": "info", +} + + +def scan_with_apkleaks( + apk_path: Path, + progress_callback=None, +) -> list[VulnFinding]: + """ + Ejecuta apkleaks sobre el APK original y convierte sus hallazgos a VulnFinding. + Requiere apkleaks instalado (pip install apkleaks). + Devuelve lista vacía si apkleaks no está disponible o falla. + """ + import json as _json + import shutil + import subprocess + import tempfile + + def _parse_plain_output(raw_text: str) -> dict[str, list[str]]: + """Parsea salida estilo texto de apkleaks: [Categoria] y lineas '- valor'.""" + parsed: dict[str, list[str]] = {} + current: str | None = None + for line in raw_text.splitlines(): + s = line.strip() + if not s: + continue + if s.startswith("[") and s.endswith("]") and len(s) > 2: + current = s[1:-1].strip() + if current: + parsed.setdefault(current, []) + continue + if current and s.startswith("- "): + value = s[2:].strip() + if value: + parsed[current].append(value) + return parsed + + apkleaks_bin = shutil.which("apkleaks") + if not apkleaks_bin: + if progress_callback: + progress_callback("apkleaks no encontrado — omitiendo scan de secretos con apkleaks") + return [] + + if progress_callback: + progress_callback("Escaneando secretos con apkleaks...") + + with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tmp: + out_path = tmp.name + + try: + proc = subprocess.run( + [apkleaks_bin, "-f", str(apk_path), "-o", out_path, "--json"], + capture_output=True, + text=True, + timeout=240, + ) + if proc.returncode not in (0, 1): + if progress_callback: + progress_callback(f"apkleaks terminó con rc={proc.returncode}") + return [] + + raw = Path(out_path).read_text(encoding="utf-8", errors="replace").strip() + if not raw: + return [] + + data: dict[str, object] + try: + decoded = _json.loads(raw) + if isinstance(decoded, dict): + data = decoded + else: + data = {"results": []} + except _json.JSONDecodeError: + plain = _parse_plain_output(raw) + data = { + "results": [ + {"name": name, "matches": values} + for name, values in plain.items() + ] + } + except subprocess.TimeoutExpired: + if progress_callback: + progress_callback("apkleaks timeout (>240s), omitiendo") + return [] + except OSError: + return [] + finally: + Path(out_path).unlink(missing_ok=True) + + findings: list[VulnFinding] = [] + for entry in data.get("results", []): + name: str = entry.get("name", "Unknown") + matches: list[str] = entry.get("matches", []) + severity = _APKLEAKS_SEVERITY.get(name, "medium") + # Omitir categorías de solo info (IP, URLs, etc.) — demasiado ruido + if severity == "info": + continue + if name == "LinkFinder": + continue + for match in matches: + # ── Filtro de falsos positivos conocidos de apkleaks ────────── + if _is_apkleaks_false_positive(name, match): + continue + findings.append(VulnFinding( + rule_id=f"AL-{name[:20]}", + title=name.replace("_", " "), + severity=severity, + category="M1 - Credenciales", + file=apk_path, + line=0, + matched_text=match[:120], + description=f"Secreto o credencial detectada por apkleaks: {name}.", + recommendation="Eliminar credenciales del código. Usar variables de entorno o un gestor de secretos.", + )) + + if progress_callback: + pre_count = sum(len(e.get("matches", [])) for e in data.get("results", [])) + if pre_count != len(findings): + progress_callback( + f"apkleaks: {len(findings)} secreto(s) tras filtrar " + f"{pre_count - len(findings)} falso(s) positivo(s)" + ) + elif findings: + progress_callback(f"apkleaks: {len(findings)} secreto(s) encontrado(s)") + + return findings + + +# ── Filtro de falsos positivos de apkleaks ──────────────────────────────────── + +# Patrones de valores que apkleaks reporta pero NO son secretos reales +_APKLEAKS_FP_PATTERNS: list[re.Pattern] = [ + # "version=X.Y.Z" matcheado como JWT (es metadata de librerías GMS) + re.compile(r'^(?:version|common_client|googleid_client|image_client|review_client)=[\d.]+'), + # "basic constraint(s)" matcheado como Authorization Basic (es parte de X.509 certs) + re.compile(r'^basic\s+constraint'), + # Números de versión sueltos que no son tokens + re.compile(r'^[\d.]+$'), +] + +# Categorías de apkleaks con alta tasa de FP en APKs Android normales +_APKLEAKS_NOISY_CATEGORIES: dict[str, re.Pattern] = { + # JSON_Web_Token: apkleaks matchea "key=value" de metadata GMS como JWT + "JSON_Web_Token": re.compile( + r'^(?:version|common_client|googleid_client|image_client|review_client)=' + r'|^[\w._]+=[\d.]+$' + ), + # Authorization_Basic: matchea "basic constraint" de certificados X.509 + "Authorization_Basic": re.compile( + r'basic\s+constraint|BasicConstraints' + ), + # Facebook_Secret_Key: FACEBOOK_SIGNATURE es la firma pública del SDK, no un secreto + "Facebook_Secret_Key": re.compile( + r'FACEBOOK_SIGNATURE\s*=' + ), +} + + +def _is_apkleaks_false_positive(category: str, match_text: str) -> bool: + """Devuelve True si el hallazgo de apkleaks es un falso positivo conocido.""" + text = match_text.strip() + + # Filtros genéricos (aplican a cualquier categoría) + for fp_pattern in _APKLEAKS_FP_PATTERNS: + if fp_pattern.search(text): + return True + + # Filtros por categoría + cat_pattern = _APKLEAKS_NOISY_CATEGORIES.get(category) + if cat_pattern and cat_pattern.search(text): + return True + + return False + + +# ── Gitleaks scanner ────────────────────────────────────────────────────────── + +# Reglas de gitleaks cuyo hallazgo se considera critical (claves privadas, AWS, etc.) +_GITLEAKS_CRITICAL_RULES: set[str] = { + "private-key", + "aws-access-token", + "aws-secret-access-key", + "github-pat", + "github-fine-grained-pat", + "gitlab-pat", + "stripe-access-token", + "twilio-api-key", + "generic-api-key", +} + + +def scan_with_gitleaks( + source_dir: Path, + progress_callback=None, +) -> list[VulnFinding]: + """ + Ejecuta gitleaks sobre un directorio de código decompilado y convierte + sus hallazgos a VulnFinding. + Requiere gitleaks instalado (brew install gitleaks). + Devuelve lista vacía si gitleaks no está disponible o falla. + """ + import json as _json + import shutil + import subprocess + import tempfile + + gitleaks_bin = shutil.which("gitleaks") + if not gitleaks_bin: + if progress_callback: + progress_callback("gitleaks no encontrado — omitiendo scan") + return [] + + if progress_callback: + progress_callback("Escaneando secretos con gitleaks...") + + with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tmp: + out_path = tmp.name + + try: + proc = subprocess.run( + [ + gitleaks_bin, "detect", + "--no-git", + "--no-banner", + "-s", str(source_dir), + "-f", "json", + "-r", out_path, + "--log-level", "error", + ], + capture_output=True, + text=True, + timeout=300, + ) + # rc=0 → sin hallazgos, rc=1 → hallazgos encontrados, ≥2 → error + if proc.returncode >= 2: + if progress_callback: + progress_callback(f"gitleaks terminó con rc={proc.returncode}") + return [] + + raw = Path(out_path).read_text(encoding="utf-8", errors="replace").strip() + if not raw: + return [] + + try: + items = _json.loads(raw) + except _json.JSONDecodeError: + if progress_callback: + progress_callback("gitleaks: JSON inválido") + return [] + + if not isinstance(items, list): + return [] + + except subprocess.TimeoutExpired: + if progress_callback: + progress_callback("gitleaks timeout (>300s), omitiendo") + return [] + except OSError: + return [] + finally: + Path(out_path).unlink(missing_ok=True) + + findings: list[VulnFinding] = [] + for item in items: + rule_id = item.get("RuleID", "unknown") + description = item.get("Description", rule_id) + secret = item.get("Secret", item.get("Match", "")) + file_path = item.get("File", "") + start_line = item.get("StartLine", 0) + entropy = item.get("Entropy", 0.0) + + severity = "critical" if rule_id in _GITLEAKS_CRITICAL_RULES else "high" + + desc_text = f"{description}" + if entropy: + desc_text += f" (entropy: {entropy:.2f})" + + findings.append(VulnFinding( + rule_id=f"GL-{rule_id}", + title=description, + severity=severity, + category="M1 - Credenciales", + file=Path(file_path), + line=start_line, + matched_text=secret[:120] if secret else "", + description=desc_text, + recommendation="Eliminar credenciales del código. Usar variables de entorno o un gestor de secretos.", + )) + + if progress_callback: + progress_callback(f"gitleaks: {len(findings)} secreto(s) encontrado(s)") + + return findings diff --git a/nutcracker_core/orchestrator.py b/nutcracker_core/orchestrator.py new file mode 100644 index 0000000..da54d94 --- /dev/null +++ b/nutcracker_core/orchestrator.py @@ -0,0 +1,1496 @@ +"""Orquestación compartida del pipeline de análisis de nutcracker. + +Extraído de nutcracker.py (Fase 0.2 del plan): fuente única de la verdad del +pipeline (descarga → análisis → bypass/decompilación → scan → OSINT → PDF/JSON), +para que CLI, daemon y dashboard la reusen sin duplicar lógica. + +Mantiene el mismo patrón de estado mutuo a nivel de módulo que tenía +nutcracker.py (``_CFG``, ``_MANIFEST_ANALYSIS``, ``_OSINT_RESULT``, +``_LAUNCH_APP``, ``_LAUNCH_SERIAL``): los comandos CLI (nutcracker_core/cli/) +los asignan antes de invocar estas funciones. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import time +from pathlib import Path + +import click +from rich.console import Console +from rich.progress import Progress, SpinnerColumn, TextColumn + +from nutcracker_core.analyzer import APKAnalyzer +from nutcracker_core.config import get as cfg_get +from nutcracker_core.decompiler import decompile, get_available_tool, install_instructions, DecompilerError +from nutcracker_core.deobfuscator import ( + apply_decrypt_map, + check_adb, + decompile_dumps, +) +from nutcracker_core.device import ( + find_sdk_tools, + get_frida_version, + list_avds, +) +from nutcracker_core.frida_bypass import ( + fart_run_instructions, + frida_run_instructions, + generate_bypass_script, + generate_fart_script, +) +from nutcracker_core import i18n +from nutcracker_core.i18n import t +from nutcracker_core.plugins import fire_post_hooks +from nutcracker_core.manifest_analyzer import analyze_decompiled_dir, Misconfiguration +from nutcracker_core.pdf_reporter import generate_pdf_report +from nutcracker_core.reporter import print_report, save_json_report, save_analysis_json, print_vuln_report, print_masvs_summary +from nutcracker_core.pipeline import ( + ExtractionResult, + connected_adb_devices, + deobf_method_order, + do_fart_emulator, + do_fart_manual, + is_emulator_serial, +) +from nutcracker_core.vuln_scanner import scan_directory, auto_scan, scan_with_apkleaks, scan_with_gitleaks, ScanResult +from nutcracker_core.osint import run_osint, OsintResult + +console = Console() + + +# ── Configuración global (se carga una vez en el comando principal) ──────────── +_CFG: dict = {} +_MANIFEST_ANALYSIS = None # ManifestAnalysisResult del último scan +_OSINT_RESULT = None # OsintResult del último scan +_LAUNCH_APP: bool = False # --launch: lanzar app con bypass script tras el análisis +_LAUNCH_SERIAL: str | None = None # --serial para --launch + + +def _init_i18n(config: dict) -> None: + """Initialize the i18n module from the loaded config.""" + language = str(cfg_get(config, "language", default="en")).strip().lower() + if language not in i18n.SUPPORTED_LANGUAGES: + console.print(f"[yellow]⚠[/yellow] {t('unsupported_language', lang=language)}") + i18n.init(language) + + +def _format_elapsed(seconds: float) -> str: + """Formatea una duración en formato legible para consola.""" + total_seconds = max(0, int(round(seconds))) + minutes, secs = divmod(total_seconds, 60) + hours, mins = divmod(minutes, 60) + + parts: list[str] = [] + if hours: + parts.append(f"{hours}h") + if mins or hours: + parts.append(f"{mins}m") + parts.append(f"{secs}s") + return " ".join(parts) + + +def _print_elapsed(label: str, seconds: float) -> None: + """Imprime el tiempo total consumido por una ejecución.""" + console.print(f"[bold cyan]⏱ {label}:[/bold cyan] {_format_elapsed(seconds)}") + + +def _find_latest_bypass_script(package: str, scripts_dir: Path = Path("frida_scripts")) -> Path | None: + """Devuelve el script de bypass más reciente para el paquete, o None.""" + if not scripts_dir.exists(): + return None + candidates = sorted( + scripts_dir.glob(f"bypass_{package}_*.js"), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + return candidates[0] if candidates else None + + +def _launch_frida_bypass( + package: str, + script_path: Path, + serial: str | None = None, + frida_host: str | None = None, +) -> None: + """Reinicia frida-server y lanza la app con el bypass script (reemplaza el proceso).""" + import shutil as _shutil + import time as _time + + adb = _shutil.which("adb") + if not adb: + console.print(f"[red]Error:[/red] {t('cli_dep_adb_missing')}") + return + + frida_bin = str(Path(sys.executable).parent / "frida") + if not Path(frida_bin).exists(): + frida_bin = _shutil.which("frida") or "" + if not frida_bin: + console.print(f"[red]Error:[/red] {t('cli_dep_frida_missing')}") + return + + adb_args = [adb] + (["-s", serial] if serial else []) + + console.print(f"[dim] {t('cli_restarting_frida')}[/dim]") + subprocess.run(adb_args + ["shell", "killall frida-server 2>/dev/null; true"], capture_output=True) + subprocess.run(adb_args + ["root"], capture_output=True) + _time.sleep(2) + subprocess.run( + adb_args + ["shell", "nohup /data/local/tmp/frida-server > /dev/null 2>&1 &"], + capture_output=True, + ) + _time.sleep(2) + subprocess.run(adb_args + ["shell", f"am force-stop {package}"], capture_output=True) + _time.sleep(1) + + if frida_host: + frida_cmd = [frida_bin, "-H", frida_host, "-f", package, "-l", str(script_path)] + elif serial: + frida_cmd = [frida_bin, "-D", serial, "-f", package, "-l", str(script_path)] + else: + frida_cmd = [frida_bin, "-U", "-f", package, "-l", str(script_path)] + + console.print(f"[green]▶[/green] [bold cyan]{' '.join(frida_cmd)}[/bold cyan]") + os.execvp(frida_cmd[0], frida_cmd) + + +def _auto(key: str) -> "bool | None": + """Lee un flag del bloque `auto:` en config.yaml. None si no está configurado.""" + auto_block = _CFG.get("auto", {}) + if not isinstance(auto_block, dict): + return None + val = auto_block.get(key) + return bool(val) if val is not None else None + + +def _unattended() -> bool: + """Modo no interactivo global.""" + return bool(cfg_get(_CFG, "auto", "unattended", default=False)) + + +def _ask_or_auto(prompt: str, key: str, default: bool = False) -> bool: + """Usa el flag de config si está explícitamente configurado; si no, pregunta.""" + cfg_val = _auto(key) + if cfg_val is not None: + tag = "yes" if cfg_val else "no" + console.print(f"[dim] {t('pipe_auto_skip', key=key, tag=tag)}[/dim]") + return cfg_val + if _unattended(): + tag = "yes" if default else "no" + console.print(f"[dim] {t('pipe_unattended_skip', prompt=prompt, tag=tag)}[/dim]") + return default + return click.confirm(prompt, default=default) + + +def _feature_enabled(name: str, default: bool = True) -> bool: + """Lee flags de features: con fallback al default.""" + v = cfg_get(_CFG, "features", name, default=default) + return bool(v) + + +def _pipeline_decompilation_mode(protected: bool) -> str: + """Modo de decompilación desde pipelines..""" + if protected: + mode = str(cfg_get(_CFG, "pipelines", "protected", "decompilation", default="")).strip().lower() + return mode if mode in ("runtime", "jadx") else "runtime" + # unprotected: booleano decompilation_jadx + jadx_enabled = cfg_get(_CFG, "pipelines", "unprotected", "decompilation_jadx", default=True) + return "jadx" if jadx_enabled else "none" + + +def _validate_all_dependencies(protected: bool = True) -> bool: + """ + Valida temprano todas las dependencias según config (jadx, frida, adb, apktool, etc). + + Retorna True si todo está ok. Si falta algo, imprime error y retorna False. + """ + import shutil as _shutil + + errors = [] + warnings = [] + + # ── Decompilación ───────────────────────────────────────────────────────── + decompilation_enabled = _feature_enabled("decompilation", default=True) + if decompilation_enabled: + decompilation_mode = _pipeline_decompilation_mode(protected) + if decompilation_mode == "jadx": + if not _shutil.which("jadx"): + errors.append(t("cli_dep_jadx_missing")) + + # ── Desofuscación runtime ───────────────────────────────────────────────── + runtime_target = str( + cfg_get(_CFG, "strategies", "runtime_target", default="auto") + ).strip().lower() + + decompilation_mode = _pipeline_decompilation_mode(protected) + should_validate_runtime = decompilation_mode == "runtime" + + if should_validate_runtime: + scope = "protected" if protected else "unprotected" + runtime_methods = cfg_get(_CFG, "pipelines", scope, "runtime_methods", + default=["frida_server", "gadget", "fart"]) or [] + + # Emulador + if runtime_target in ("auto", "emulator"): + sdk_tools = find_sdk_tools() + has_emulator = bool(sdk_tools.get("emulator")) and bool(list_avds(sdk_tools)) + + if runtime_target == "emulator" and not has_emulator: + errors.append(t("cli_dep_no_avd")) + + if not get_frida_version(): + errors.append(t("cli_dep_frida_missing")) + + # Dispositivo físico + if runtime_target in ("auto", "device"): + if not _shutil.which("adb"): + errors.append(t("cli_dep_adb_missing")) + + if not get_frida_version(): + errors.append(t("cli_dep_frida_missing")) + + # Herramientas opcionales para runtime + if "frida_server" in runtime_methods or "gadget" in runtime_methods: + if not _shutil.which("frida-dexdump"): + warnings.append(t("cli_dep_dexdump_warn")) + + # Si gadget está habilitado, necesita apktool + apksigner + if "gadget" in runtime_methods: + if not _shutil.which("apktool"): + errors.append(t("cli_dep_apktool_missing")) + # apksigner está en Android SDK build-tools + sdk_tools = find_sdk_tools() + apksigner = sdk_tools.get("apksigner") + if not apksigner: + warnings.append(t("cli_dep_apksigner_warn")) + + # ── Escaneo de vulnerabilidades ─────────────────────────────────────────── + scanner_engine = cfg_get(_CFG, "sast", "engine", default="auto") or "auto" + if str(scanner_engine).lower() == "semgrep": + if not _shutil.which("semgrep"): + warnings.append(t("cli_dep_semgrep_warn")) + + # ── Mostrar errores y advertencias ──────────────────────────────────────── + if errors: + console.print(f"\n[red][bold]{t('cli_dep_errors_header')}[/bold][/red]") + for err in errors: + console.print(f" [red]✘[/red] {err}") + console.print(f"\n [dim]{t('cli_requirements_url')}[/dim]\n") + return False + + if warnings: + console.print(f"[yellow][bold]{t('cli_dep_warnings_header')}[/bold][/yellow]") + for warn in warnings: + console.print(f" [yellow]⚠[/yellow] {warn}") + console.print() + + return True + + +# ── Lógica compartida ───────────────────────────────────────────────────────── + +def _run_analysis(apk_path: Path, report_path: str | None, keep_apk: bool, gen_pdf: bool = True) -> None: + started_at = time.perf_counter() + result = None + elapsed_seconds = 0.0 + try: + anti_root_engine = str( + cfg_get(_CFG, "strategies", "anti_root_engine", default="native") + ).strip().lower() + if anti_root_engine == "builtin": + anti_root_engine = "native" + if anti_root_engine not in ("native", "apkid"): + anti_root_engine = "native" + with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), + console=console, transient=True) as progress: + task = progress.add_task(t("cli_analyzing_apk"), total=None) + + def on_progress(msg: str) -> None: + progress.update(task, description=msg) + + analyzer = APKAnalyzer(progress_callback=on_progress, engine=anti_root_engine) + result = analyzer.analyze(apk_path) + # Si anti_root_analysis=false en config, ignorar la detección de protección + if not bool(cfg_get(_CFG, "strategies", "anti_root_analysis", default=True)): + result.protected = False + console.print("[dim] strategies.anti_root_analysis=false → omitiendo flujo Frida/emulador[/dim]") + + except FileNotFoundError as exc: + console.print(f"[red]Error:[/red] {exc}") + sys.exit(1) + except Exception as exc: # noqa: BLE001 + console.print(f"[red]{t('cli_error_unexpected')}[/red] {exc}") + sys.exit(1) + + if result: + print_report(result) + # Flujo post-análisis: bypass, vuln scan, etc. (puede poblar decompilation_info) + vuln_scan = _post_analysis_flow(result, apk_path) + + elapsed_seconds = time.perf_counter() - started_at + result.elapsed_seconds = elapsed_seconds + + # Guardar JSON una vez que todos los datos están completos + save_analysis_json(result, scan_result=vuln_scan, manifest=_MANIFEST_ANALYSIS) + + # ── Resumen MASVS v2 ───────────────────────────────────────────── + try: + from nutcracker_core.masvs import build_masvs_report + _masvs = build_masvs_report(result, vuln_scan, _MANIFEST_ANALYSIS) + print_masvs_summary(_masvs) + except Exception: + pass + + # ── Veredicto final en terminal ─────────────────────────────────── + _print_verdict(result, vuln_scan) + + # Generar PDF solo si está habilitado en config (save_pdf) + if gen_pdf: + _generate_pdf(result, vuln_scan, + vuln_scan_enabled=_feature_enabled("sast_scan", default=True)) + + # ── Post-hooks de plugins ────────────────────────────────────────── + # Firma estable (package, result, vuln_scan, config): otros plugins (p.ej. aireview) + # ya registran hooks con esta firma exacta y no aceptan **kwargs adicionales. + fire_post_hooks( + "after_analysis", + package=result.package, + result=result, + vuln_scan=vuln_scan, + config=_CFG, + ) + + if not keep_apk and apk_path and apk_path.exists(): + apk_path.unlink() + console.print(f"[dim]{t('cli_apk_deleted', path=apk_path)}[/dim]") + + _print_elapsed(t("cli_elapsed"), elapsed_seconds) + + +def _print_bypass_banner(dex_count: int) -> None: + """Panel naranja de alerta: protección eludida tras volcar los DEX.""" + from rich.panel import Panel + from rich.text import Text + from rich.align import Align + + banner_text = Text(justify="center") + banner_text.append("\n ✔ " + t("cli_protection_broken_banner") + " \n\n", style="bold yellow") + banner_text.append( + t("cli_protection_broken_frida", dex_count=dex_count), + style="dim white", + ) + banner_text.append("\n") + console.print() + console.print(Panel(Align.center(banner_text), border_style="yellow", padding=(0, 4))) + console.print() + + +def _print_verdict(result, vuln_scan) -> None: + """Imprime un banner de veredicto final en la terminal.""" + from rich.panel import Panel + from rich.text import Text + from rich.align import Align + + has_vulns = vuln_scan is not None and bool(vuln_scan.findings) + protected = result.protected + + # Bypass real: extracción runtime de DEX vía Frida/FART/Gadget. + decomp = getattr(result, "decompilation_info", None) + method = "" + dex_count = 0 + if isinstance(decomp, dict): + method = str(decomp.get("method", "")) + try: + dex_count = int(decomp.get("dex_count", 0) or 0) + except (TypeError, ValueError): + dex_count = 0 + + runtime_bypass = any(k in method.lower() for k in ("frida", "fart", "gadget")) + was_bypassed = protected and runtime_bypass and dex_count > 0 + + if not protected: + color = "red" + icon = "✘" + title = t("cli_no_protection_banner") + detail = t("cli_no_protection_detail") + elif was_bypassed: + color = "yellow" + icon = "⚡" + title = t("cli_protection_broken_banner") + detail = t("cli_bypassed_detail", method=method, dex_count=dex_count) + else: + color = "green" + icon = "✔" + title = t("cli_protected_banner") + if has_vulns: + n = len(vuln_scan.findings) + detail = t("cli_protected_vulns_detail", count=n) + else: + detail = t("cli_protected_detail") + + verdict_text = Text(justify="center") + verdict_text.append(f"\n {icon} {title} {icon}\n\n", style=f"bold {color}") + verdict_text.append(f" {detail} ", style="dim white") + verdict_text.append("\n") + + console.print() + console.print(Panel(Align.center(verdict_text), border_style=color, padding=(0, 4))) + console.print() + + +def _post_analysis_flow(result, apk_path: Path): + """Flujo interactivo tras el análisis. Retorna el ScanResult si se escanearon vulns.""" + console.print() + + # ── Detectar si hay ofuscación DexGuard ─────────────────────────────────── + dexguard_result = next( + (r for r in result.results if r.name == "DexGuardDetector" and r.detected), + None, + ) + + _label = "[green]ℹ[/green]" if result.protected else "[yellow]ℹ[/yellow]" + _estado = t("cli_app_has_protection") if result.protected else t("cli_app_no_protection") + console.print(f"{_label} {t('cli_app_protection_line', status=_estado, protection=t('cli_anti_root_label'))}") + + # ── Selección automática del mejor método ───────────────────────────────── + decomp_mode = _pipeline_decompilation_mode(result.protected) + runtime_target = str( + cfg_get(_CFG, "strategies", "runtime_target", default="auto") + ).strip().lower() + # DexGuard detectado → frida-dexdump (bytecode post-descifrado en memoria) + # Sin DexGuard → JADX por defecto, salvo pipeline runtime explícito + # Si decompilation=jadx en config, nunca usar runtime aunque haya DexGuard + should_try_runtime = decomp_mode == "runtime" + + if should_try_runtime: + if dexguard_result: + if runtime_target == "device": + console.print( + f"[yellow]⚠[/yellow] {t('cli_dexguard_device_warn')}" + ) + else: + console.print( + f"[yellow]⚠[/yellow] {t('cli_dexguard_emulator_warn')}" + ) + else: + console.print( + f"[cyan]ℹ[/cyan] {t('cli_runtime_pipeline_info')}" + ) + # Con protección anti-root: ofrecer combinar bypass en el mismo script + if result.protected: + if _LAUNCH_APP or _ask_or_auto(t("cli_include_bypass_prompt"), "bypass_script", default=True): + scripts_dir = Path("./frida_scripts") + try: + bp_path = generate_bypass_script(result, scripts_dir) + console.print( + f"[green]✔[/green] {t('cli_bypass_script_generated')} [bold]{bp_path}[/bold]" + ) + if _LAUNCH_APP: + _fh = str(cfg_get(_CFG, "strategies", "frida_host", default="")).strip() or None + _launch_frida_bypass(result.package, bp_path, _LAUNCH_SERIAL, frida_host=_fh) + except Exception as exc: # noqa: BLE001 + console.print(f"[red]{t('cli_error_bypass')}[/red] {exc}") + + runtime_prompt = ( + t("cli_fart_prompt_device") + if runtime_target == "device" + else t("cli_fart_prompt_emulator") + ) + if _ask_or_auto(runtime_prompt, "fart", default=True): + return _do_dexguard_deobf(result, apk_path) + + # Sin DexGuard (o usuario rechazó frida) → jadx directo + # Si hay protección anti-root sin DexGuard, ofrecer script de bypass por separado + if result.protected and not dexguard_result: + if _LAUNCH_APP or _ask_or_auto(t("cli_gen_bypass_prompt"), "bypass_script", default=False): + scripts_dir = Path("./frida_scripts") + try: + script_path = generate_bypass_script(result, scripts_dir) + console.print(f"[green]✔[/green] {t('cli_frida_script_generated')} [bold]{script_path}[/bold]") + if _LAUNCH_APP: + _fh = str(cfg_get(_CFG, "strategies", "frida_host", default="")).strip() or None + _launch_frida_bypass(result.package, script_path, _LAUNCH_SERIAL, frida_host=_fh) + else: + console.print(frida_run_instructions(result.package, script_path)) + except Exception as exc: # noqa: BLE001 + console.print(f"[red]{t('cli_error_bypass')}[/red] {exc}") + + if not _should_fallback_jadx(result.protected): + return None + + if not _ask_or_auto(t("cli_decompile_jadx_prompt"), "decompile", default=True): + return None + return _do_decompile(apk_path, result.package) + + +def _should_fallback_jadx(protected: bool) -> bool: + """Determina si se debe intentar decompilación jadx como fallback.""" + if not _feature_enabled("decompilation", default=True): + console.print(f"[dim]{t('cli_skipping_decompilation')}[/dim]") + return False + if protected: + fallback = cfg_get(_CFG, "pipelines", "protected", "fallback_jadx", default=True) + if not fallback: + console.print( + f"[dim]{t('cli_fallback_jadx_disabled')}[/dim]" + ) + return False + return True + + +def _do_dexguard_deobf(result, apk_path: Path) -> "ScanResult | None": + """ + Flujo completo de desofuscación para apps DexGuard/Arxan. + + Estrategia primaria: frida-dexdump (sin script en disco). + Fallback: FART - el script se genera en temp solo si es necesario. + + Ofrece dos modos: + A) Emulador automático - arranca AVD, instala APK, extrae DEX, descarga DEX + B) Dispositivo físico - genera el script FART y el usuario lo ejecuta manualmente + """ + # ── Validar todas las dependencias (jadx, frida, adb, apktool, etc) ────── + if not _validate_all_dependencies(protected=result.protected): + console.print( + f"[yellow]⚠[/yellow] {t('cli_skip_runtime_fallback_jadx')}" + ) + if not _should_fallback_jadx(result.protected): + return None + if not _ask_or_auto(t("cli_decompile_jadx_prompt"), "decompile", default=True): + return None + return _do_decompile(apk_path, result.package) + + # ── Elegir modo ────────────────────────────────────────────────────────── + sdk_tools = find_sdk_tools() + avds = list_avds(sdk_tools) + has_emulator = bool(sdk_tools.get("emulator")) and bool(avds) + use_emulator = False + + runtime_target = str( + cfg_get(_CFG, "strategies", "runtime_target", default="auto") + ).strip().lower() + if runtime_target not in {"auto", "emulator", "device"}: + runtime_target = "auto" + + if runtime_target == "emulator": + if has_emulator: + use_emulator = True + console.print("[dim] strategies.runtime_target=emulator[/dim]") + else: + console.print( + f"[yellow]⚠[/yellow] {t('cli_no_avd_using_device')}" + ) + use_emulator = False + elif runtime_target == "device": + use_emulator = False + console.print("[dim] strategies.runtime_target=device[/dim]") + connected = connected_adb_devices() + physical = [d for d in connected if not is_emulator_serial(d)] + if not physical: + console.print( + f"[yellow]⚠[/yellow] {t('cli_no_physical_device')}" + ) + elif has_emulator: + # runtime_target=auto conserva comportamiento histórico + if _unattended(): + use_emulator = True + console.print(t("cli_fart_mode_unattended")) + else: + console.print(t("cli_fart_mode_choice", avd_count=len(avds))) + choice = click.prompt( + t("cli_fart_choose_prompt"), + default="A", + type=click.Choice(["A", "a", "B", "b"], case_sensitive=False), + show_choices=False, + ).upper() + use_emulator = choice == "A" + else: + console.print( + f"[yellow]⚠[/yellow] {t('cli_no_emulator_using_device')}" + ) + + method_order = deobf_method_order(_CFG, protected=True) + + if use_emulator: + ext = do_fart_emulator(_CFG, result.package, apk_path, sdk_tools, avds) + else: + # Modo manual: el usuario necesita el script en disco para ejecutarlo + scripts_dir = Path("./frida_scripts") + try: + script_path = generate_fart_script(result.package, scripts_dir) + except Exception as exc: # noqa: BLE001 + console.print(f"[red]{t('cli_fart_script_error')}[/red] {exc}") + return None + ext = do_fart_manual(_CFG, result.package, script_path, apk_path, method_order) + + if ext is None: + console.print( + f"[yellow]⚠[/yellow] {t('cli_runtime_extraction_failed')}" + ) + if not _should_fallback_jadx(result.protected): + return None + if not _ask_or_auto(t("cli_decompile_jadx_prompt"), "decompile", default=True): + return None + return _do_decompile(apk_path, result.package) + result.decompilation_info = { + "method": ext.method_used, + "dex_count": len(ext.dex_files), + "source_dir": str(ext.clean_dir), + } + return _decompile_and_scan( + ext.dex_files, + ext.clean_dir, + ext.local_dump_dir, + result.package, + dex_count=len(ext.dex_files), + apk_path=apk_path, + ) + + +def _select_token_serial(config: dict) -> str: + """Elige serial para setup-token respetando runtime_target y default_device_id.""" + preferred = str(cfg_get(config, "strategies", "default_device_id", default="")).strip() + devices = connected_adb_devices() + + if preferred and preferred in devices: + return preferred + + runtime_target = str(cfg_get(config, "strategies", "runtime_target", default="auto")).strip().lower() + if runtime_target == "emulator": + emus = [d for d in devices if is_emulator_serial(d)] + if emus: + return emus[0] + elif runtime_target == "device": + physical = [d for d in devices if not is_emulator_serial(d)] + if physical: + return physical[0] + + if preferred: + return preferred + return "" + +def _decompile_and_scan( + dex_files: list, + clean_dir: Path, + local_dump_dir: Path, + package: str, + dex_count: int = 0, + apk_path: Path | None = None, +) -> "ScanResult | None": + """Paso final compartido: jadx + decrypt_map + scan de vulns.""" + from nutcracker_core.deobfuscator import decompile_dumps, apply_decrypt_map + + try: + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=console, + transient=True, + ) as progress: + task = progress.add_task(t("cli_decompiling_dex_jadx"), total=None) + decompile_dumps( + dex_files, + clean_dir, + progress_callback=lambda m: progress.update(task, description=m), + ) + except RuntimeError as exc: + console.print(f"[red]{t('cli_error_decompiling_dex')}[/red] {exc}") + return None + + java_count = len(list(clean_dir.rglob("*.java"))) + console.print( + f"[green]✔[/green] {t('cli_clean_source', path=clean_dir, count=java_count)}" + ) + + if dex_count > 0: + _print_bypass_banner(dex_count) + + decrypt_map = local_dump_dir / "decrypt_map.txt" + if decrypt_map.exists(): + replaced = apply_decrypt_map(clean_dir, decrypt_map) + if replaced > 0: + console.print( + f"[green]✔[/green] {t('cli_strings_replaced', count=replaced)}" + ) + else: + console.print( + f"[dim] {t('cli_decrypt_map_not_found')}[/dim]" + ) + + # Análisis de misconfigs del manifest + manifest_analysis = None + if _feature_enabled("manifest_scan", default=True): + manifest_analysis = _do_manifest_scan(clean_dir, apk_path=apk_path) + else: + console.print(f"[dim]{t('cli_skipping_manifest_scan')}[/dim]") + + # Escaneo de vulnerabilidades y leaks (antes de OSINT para alimentarlo) + scan_result = None + vuln_enabled = _feature_enabled("sast_scan", default=True) + leak_enabled = _feature_enabled("leak_scan", default=True) + if not vuln_enabled and not leak_enabled: + console.print(f"[dim]{t('cli_skipping_vuln_scan')}[/dim]") + elif _ask_or_auto( + t("cli_vuln_scan_deobf_prompt"), + "sast_scan", + default=True, + ): + scan_result = _do_vuln_scan( + clean_dir, + apk_path=apk_path, + package_hint=package, + include_vuln_scan=vuln_enabled, + include_leak_scan=leak_enabled, + ) + + # OSINT sobre el código decompilado (alimentado por los hallazgos del scanner) + leak_findings = scan_result.findings if scan_result else None + _do_osint_scan(clean_dir, package, scan_findings=leak_findings) + + return scan_result + + +def _do_manifest_scan(decompiled_dir: Path, apk_path: Path | None = None) -> "ManifestAnalysisResult | None": + """Analiza AndroidManifest.xml y archivos de config buscando misconfigs.""" + global _MANIFEST_ANALYSIS + console.print() + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=console, + transient=True, + ) as progress: + task = progress.add_task(t("cli_analyzing_manifest"), total=None) + analysis = analyze_decompiled_dir( + decompiled_dir, + progress_callback=lambda m: progress.update(task, description=m), + apk_path=apk_path, + ) + + _print_manifest_report(analysis) + _MANIFEST_ANALYSIS = analysis + return analysis + + +def _do_osint_scan(source_dir: Path, package: str, scan_findings: list | None = None) -> "OsintResult | None": + """Ejecuta el pipeline OSINT si está habilitado en config.""" + global _OSINT_RESULT + if not _feature_enabled("osint_scan", default=True): + console.print(f"[dim]{t('cli_skipping_osint')}[/dim]") + return None + + crt_sh = bool(cfg_get(_CFG, "osint", "crt_sh", default=True)) + github_search = bool(cfg_get(_CFG, "osint", "github_search", default=True)) + github_token = cfg_get(_CFG, "osint", "github_token", default="") or None + grep_app_search = bool(cfg_get(_CFG, "osint", "grep_app_search", default=True)) + fofa_search = bool(cfg_get(_CFG, "osint", "fofa_search", default=False)) + fofa_key = cfg_get(_CFG, "osint", "fofa_key", default="") or None + shodan_search = bool(cfg_get(_CFG, "osint", "shodan_search", default=False)) + shodan_key = cfg_get(_CFG, "osint", "shodan_key", default="") or None + postman_search = bool(cfg_get(_CFG, "osint", "postman_search", default=True)) + execute_dorks_flag = bool(cfg_get(_CFG, "osint", "execute_dorks", default=False)) + dork_engines_cfg = cfg_get(_CFG, "osint", "dork_engines", default=["duckduckgo"]) + dork_engines = list(dork_engines_cfg) if isinstance(dork_engines_cfg, (list, tuple)) else ["duckduckgo"] + dork_max_per_engine = int(cfg_get(_CFG, "osint", "dork_max_per_engine", default=5)) + dork_max_results_per_dork = int(cfg_get(_CFG, "osint", "dork_max_results_per_dork", default=5)) + wayback_search = bool(cfg_get(_CFG, "osint", "wayback_search", default=True)) + wayback_limit_per_domain = int(cfg_get(_CFG, "osint", "wayback_limit_per_domain", default=200)) + wayback_filter_interesting = bool(cfg_get(_CFG, "osint", "wayback_filter_interesting", default=True)) + + console.print() + try: + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=console, + transient=True, + ) as progress: + task = progress.add_task("OSINT: extrayendo secretos...", total=None) + app_label = getattr(_MANIFEST_ANALYSIS, "app_label", "") if _MANIFEST_ANALYSIS else "" + osint_result = run_osint( + source_dir, + package, + scan_findings=scan_findings, + crt_sh=crt_sh, + github_search=github_search, + github_token=github_token, + grep_app_search=grep_app_search, + fofa_search=fofa_search, + fofa_key=fofa_key, + shodan_search=shodan_search, + shodan_key=shodan_key, + postman_search=postman_search, + execute_dorks_flag=execute_dorks_flag, + dork_engines=dork_engines, + dork_max_per_engine=dork_max_per_engine, + dork_max_results_per_dork=dork_max_results_per_dork, + wayback_search=wayback_search, + wayback_limit_per_domain=wayback_limit_per_domain, + wayback_filter_interesting=wayback_filter_interesting, + app_label=app_label, + progress_callback=lambda m: progress.update(task, description=m), + ) + except Exception as exc: # noqa: BLE001 + console.print(f"[yellow]⚠[/yellow] {t('cli_error_osint')} {exc}") + return None + + _OSINT_RESULT = osint_result + _print_osint_report(osint_result) + + # Guardar JSON de OSINT + _save_osint_json(osint_result, package) + + return osint_result + + +def _print_osint_report(osint: OsintResult) -> None: + """Imprime un resumen del análisis OSINT en la consola.""" + from rich.table import Table + + # ── Secretos ────────────────────────────────────────────────────────── + if osint.secrets: + console.print(f"\n[bold]{t('cli_osint_secrets_header', count=len(osint.secrets))}[/bold]") + table = Table(show_header=True, header_style="bold", box=None, padding=(0, 1)) + table.add_column(t("cli_osint_field_col"), style="bold cyan") + table.add_column(t("cli_osint_value_col"), style="dim", max_width=50, no_wrap=True) + table.add_column(t("cli_osint_service_col"), style="yellow") + table.add_column(t("cli_osint_file_col"), style="dim") + for s in osint.secrets[:20]: + val = s.value if len(s.value) <= 50 else s.value[:47] + "..." + table.add_row(s.name, val, s.service or "-", s.file) + console.print(table) + if len(osint.secrets) > 20: + console.print(f" [dim]{t('cli_osint_more', count=len(osint.secrets) - 20)}[/dim]") + + # ── Dominios ────────────────────────────────────────────────────────── + if osint.domains_scanned: + console.print(f"\n[bold]{t('cli_osint_domains_header')}[/bold] {', '.join(osint.domains_scanned)}") + + # ── Subdominios ─────────────────────────────────────────────────────── + if osint.subdomains: + console.print(f"\n[bold]{t('cli_osint_subdomains_header', count=len(osint.subdomains))}[/bold]") + # Clasificar + dev_subs = [s for s in osint.subdomains if any( + e in s.name for e in ("dev", "qa", "uat", "test", "staging", "pre.") + )] + if dev_subs: + console.print(f" [yellow]⚠ {t('cli_osint_dev_exposed', count=len(dev_subs))}[/yellow]") + for s in dev_subs[:10]: + console.print(f" [yellow]▸[/yellow] {s.name}") + # Mostrar los primeros + for s in osint.subdomains[:15]: + if s not in dev_subs: + console.print(f" {s.name}") + if len(osint.subdomains) > 15: + console.print(f" [dim]{t('cli_osint_more', count=len(osint.subdomains) - 15)}[/dim]") + + # ── Leaks públicos ──────────────────────────────────────────────────── + if osint.public_leaks: + console.print(f"\n[bold]{t('cli_osint_public_leaks_header', count=len(osint.public_leaks))}[/bold]") + for leak in osint.public_leaks[:10]: + console.print(f" [{leak.source}] {leak.title}") + if leak.url: + console.print(f" [dim]{leak.url}[/dim]") + + # ── Auth flows hardcodeados ─────────────────────────────────────────── + if osint.auth_flows: + console.print(f"\n[yellow][bold]{t('cli_osint_auth_header', count=len(osint.auth_flows))}[/bold][/yellow]") + for af in osint.auth_flows[:5]: + console.print(f" [yellow]⚠[/yellow] {af['type']} en {af['file']}:{af['line']}") + + +def _save_osint_json(osint: OsintResult, package: str) -> None: + """Guarda el resultado OSINT en JSON dentro de reports//osint.json.""" + import json + pkg_dir = Path("./reports") / package + pkg_dir.mkdir(parents=True, exist_ok=True) + out = pkg_dir / "osint.json" + with out.open("w", encoding="utf-8") as fh: + json.dump(osint.to_dict(), fh, ensure_ascii=False, indent=2) + console.print(f"[dim]{t('cli_osint_saved')}[/dim] [bold]{out}[/bold]") + + +def _print_manifest_report(analysis) -> None: + """Imprime un resumen de misconfigs del manifest en la consola.""" + from rich.table import Table + + misconfigs = analysis.misconfigurations + if not misconfigs: + console.print(f"[green]✔[/green] {t('cli_no_manifest_misconfigs')}") + return + + severity_order = {"critical": 0, "high": 1, "medium": 2, "info": 3} + severity_color = { + "critical": "bold red", + "high": "red", + "medium": "yellow", + "info": "dim", + } + + misconfigs_sorted = sorted(misconfigs, key=lambda m: severity_order.get(m.severity, 9)) + + counts = {} + for m in misconfigs_sorted: + counts[m.severity] = counts.get(m.severity, 0) + 1 + + summary_parts = [ + f"[{severity_color.get(sev, '')}]{cnt} {sev.upper()}[/{severity_color.get(sev, '')}]" + for sev, cnt in sorted(counts.items(), key=lambda kv: severity_order.get(kv[0], 9)) + ] + console.print( + f"\n[bold]{t('cli_manifest_misconfigs_header')}[/bold] " + " ".join(summary_parts) + ) + + table = Table(show_header=True, header_style="bold", box=None, padding=(0, 1)) + table.add_column(t("cli_manifest_sev_col"), style="bold", width=9, no_wrap=True) + table.add_column(t("cli_manifest_finding_col")) + table.add_column(t("cli_manifest_evidence_col"), style="dim") + table.add_column(t("cli_manifest_location_col"), style="dim") + + for m in misconfigs_sorted: + color = severity_color.get(m.severity, "") + sev_text = f"[{color}]{m.severity.upper()}[/{color}]" + # Mostrar descripción truncada como evidencia + evidence = m.description[:80] if m.description else "-" + table.add_row(sev_text, m.title, evidence, m.location) + + console.print(table) + + # Mostrar recomendaciones de los críticos y altos + shown = [m for m in misconfigs_sorted if m.severity in ("critical", "high")] + if shown: + console.print(f"\n[bold]{t('cli_manifest_recs_header')}[/bold]") + for m in shown: + color = severity_color.get(m.severity, "") + console.print(f" [{color}]▸[/{color}] [bold]{m.title}[/bold]") + console.print(f" {m.recommendation}\n") + + +def _do_decompile(apk_path: Path, package: str) -> Path | None: + """Ejecuta la decompilación con feedback en consola. Devuelve el directorio o None.""" + tool, _ = get_available_tool() + if tool is None: + console.print(f"[red]✘[/red] {install_instructions()}") + return None + + output_dir = Path("./decompiled") + console.print(f" {t('cli_decompiling_with', tool=tool, output_dir=output_dir, package=package)}") + + try: + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=console, + transient=True, + ) as progress: + progress.add_task(t("cli_decompiling_pkg", package=package), total=None) + dest = decompile(apk_path, output_dir) + + console.print(f"[green]✔[/green] {t('cli_source_code_at')} [bold]{dest}[/bold]") + + java_files = list(dest.rglob("*.java")) + smali_files = list(dest.rglob("*.smali")) + if java_files: + console.print(f" {t('cli_java_files', count=len(java_files))}") + elif smali_files: + console.print(f" {t('cli_smali_files', count=len(smali_files))}") + + # Análisis de misconfigs del manifest + if _feature_enabled("manifest_scan", default=True): + _do_manifest_scan(dest, apk_path=apk_path) + else: + console.print(f"[dim]{t('cli_skipping_manifest_scan')}[/dim]") + + # Escaneo de vulnerabilidades y leaks (antes de OSINT para alimentarlo) + scan_result = None + vuln_enabled = _feature_enabled("sast_scan", default=True) + leak_enabled = _feature_enabled("leak_scan", default=True) + if not vuln_enabled and not leak_enabled: + console.print(f"[dim]{t('cli_skipping_vuln_scan')}[/dim]") + elif _ask_or_auto(t("cli_vuln_scan_code_prompt"), "sast_scan", default=True): + scan_result = _do_vuln_scan( + dest, + apk_path=apk_path, + package_hint=package, + include_vuln_scan=vuln_enabled, + include_leak_scan=leak_enabled, + ) + + # OSINT sobre el código decompilado (alimentado por hallazgos del scanner) + leak_findings = scan_result.findings if scan_result else None + _do_osint_scan(dest, package, scan_findings=leak_findings) + + return scan_result + + except DecompilerError as exc: + console.print(f"[red]{t('cli_error_decompilation')}[/red] {exc}") + return None + + +def _do_vuln_scan( + source_dir: Path, + apk_path: "Path | None" = None, + package_hint: str | None = None, + include_vuln_scan: bool = True, + include_leak_scan: bool = True, +): + """Escanea el directorio decompilado en busca de vulnerabilidades.""" + console.print() + scan_result = None + + # Determinar motor de escaneo desde config + engine = cfg_get(_CFG, "sast", "engine") or "auto" + + # ── Leer sección leak_scan ─────────────────────────────────────────── + use_native = bool(cfg_get(_CFG, "leak_scan", "native", default=True)) + use_apkleaks = bool(cfg_get(_CFG, "leak_scan", "apkleaks", default=True)) + use_gitleaks = bool(cfg_get(_CFG, "leak_scan", "gitleaks", default=False)) + + # Derivar leak_engine para auto_scan (none|apk|code|both) + if not include_leak_scan: + leak_engine = "none" + elif use_native and use_apkleaks: + leak_engine = "both" + elif use_apkleaks: + leak_engine = "apk" + elif use_native: + leak_engine = "code" + else: + leak_engine = "none" + + default_semgrep_config = "p/android p/owasp-top-ten" + semgrep_config = default_semgrep_config + if str(engine).strip().lower() == "semgrep": + semgrep_config = ( + cfg_get(_CFG, "sast", "config") + or default_semgrep_config + ) + semgrep_config = " ".join( + token for token in str(semgrep_config).split() if token != "p/secrets" + ) or default_semgrep_config + + # Permite ejecución separada: vuln scan y leak scan pueden activarse de forma independiente. + apk_for_leaks = apk_path if include_leak_scan and use_apkleaks else None + + def _is_leak_finding(f) -> bool: + rid = str(getattr(f, "rule_id", "")).upper() + title = str(getattr(f, "title", "")).lower() + category = str(getattr(f, "category", "")).lower() + if rid.startswith("AL-") or rid.startswith("HC") or rid.startswith("GL-"): + return True + leak_terms = ("secret", "token", "apikey", "api key", "password", "credential", "jwt", "private key") + text = f"{title} {category}" + return any(t in text for t in leak_terms) + + # Anunciar en terminal qué motor se usará + import shutil as _shutil + if engine == "semgrep": + engine_label = "[bold cyan]semgrep[/bold cyan]" + elif engine == "regex": + engine_label = "[bold]regex interno[/bold]" + else: + engine_label = ( + "[bold cyan]semgrep[/bold cyan]" + if _shutil.which("semgrep") + else "[bold]regex interno[/bold] [dim](semgrep no instalado)[/dim]" + ) + console.print( + " " + t("cli_vuln_scan_header") + + ("[green]" + t("cli_vuln_scan_enabled") + "[/green]" if include_vuln_scan else "[yellow]" + t("cli_vuln_scan_disabled") + "[/yellow]") + ) + console.print( + " " + t("cli_vuln_engine_header") + + (engine_label if include_vuln_scan else "[dim]" + t("cli_vuln_scan_disabled") + "[/dim]") + ) + console.print( + " " + t("cli_leak_scan_header") + + ("[green]" + t("cli_vuln_scan_enabled") + "[/green]" if include_leak_scan else "[yellow]" + t("cli_vuln_scan_disabled") + "[/yellow]") + ) + # Detalle de motores de leak realmente activos + leak_parts = [] + if include_leak_scan and use_native: + leak_parts.append("native") + if include_leak_scan and use_apkleaks: + leak_parts.append("apkleaks") + if include_leak_scan and use_gitleaks: + leak_parts.append("gitleaks") + leak_engine_label = ", ".join(leak_parts) if leak_parts else ("[dim]" + t("cli_leak_engines_disabled") + "[/dim]") + console.print(f" {t('cli_leak_engines_header')} {leak_engine_label}") + + if not include_vuln_scan: + if include_leak_scan: + try: + leaks: list = [] + + # 1) Regex nativo (reglas HC*) sobre código decompilado + if use_native: + base_scan = auto_scan( + source_dir, + engine="regex", + progress_callback=None, + apk_path=None, + leak_engine="code", + include_code_leak_rules=True, + include_xml_leak_rules=False, + ) + leaks.extend([f for f in base_scan.findings if _is_leak_finding(f)]) + + # 2) apkleaks sobre el APK original + if use_apkleaks and apk_for_leaks is not None: + try: + leaks.extend(scan_with_apkleaks(apk_for_leaks)) + except Exception as exc: # noqa: BLE001 + console.print(f"[yellow]⚠[/yellow] {t('cli_apkleaks_failed')} {exc}") + + # 3) gitleaks sobre código decompilado + if use_gitleaks: + try: + leaks.extend(scan_with_gitleaks(source_dir)) + except Exception as exc: # noqa: BLE001 + console.print(f"[yellow]⚠[/yellow] gitleaks falló: {exc}") + + # Deduplicación: prefer HC (more context) over GL on same file+line + hc_keys = { + (str(f.file), int(f.line)) + for f in leaks if f.rule_id.startswith("HC") + } + seen: set[tuple[str, int, str, str]] = set() + uniq = [] + for f in leaks: + # Drop GL findings that overlap with HC on same file+line + if f.rule_id.startswith("GL-") and (str(f.file), int(f.line)) in hc_keys: + continue + key = (str(f.file), int(f.line), str(f.rule_id), str(f.matched_text)) + if key in seen: + continue + seen.add(key) + uniq.append(f) + leaks = uniq + + leak_tag = "+".join(leak_parts) if leak_parts else "" + scan_result = ScanResult( + base_dir=source_dir, + findings=leaks, + files_scanned=0, + scanner_engine="", + leak_engine=leak_tag, + ) + except Exception as exc: # noqa: BLE001 + console.print(f"[red]Error en leak scan:[/red] {exc}") + return None + else: + scan_result = ScanResult( + base_dir=source_dir, + findings=[], + files_scanned=0, + scanner_engine="none", + ) + + print_vuln_report(scan_result, source_dir) + pkg_name = package_hint or source_dir.name + _save_vuln_json(scan_result, pkg_name, manifest=_MANIFEST_ANALYSIS) + return scan_result + + try: + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=console, + transient=True, + ) as progress: + task = progress.add_task(t("cli_scanning_vulns"), total=None) + + def on_progress(msg: str) -> None: + progress.update(task, description=msg) + + scan_result = auto_scan( + source_dir, + engine=engine, + semgrep_config=semgrep_config, + progress_callback=on_progress, + apk_path=apk_for_leaks, + leak_engine=leak_engine, + include_code_leak_rules=include_leak_scan and use_native, + include_xml_leak_rules=include_leak_scan and use_native, + ) + + except RuntimeError as exc: + console.print(f"[yellow]⚠[/yellow] {exc}") + console.print(f"[dim] {t('cli_retry_regex')}[/dim]") + try: + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=console, + transient=True, + ) as progress: + task = progress.add_task(t("cli_scanning_vulns_regex"), total=None) + # Usar auto_scan con engine=regex para respetar la lógica sources/ + XML + from nutcracker_core.vuln_scanner import auto_scan as _auto_scan + scan_result = _auto_scan( + source_dir, + engine="regex", + progress_callback=lambda m: progress.update(task, description=m), + apk_path=apk_for_leaks, + leak_engine=leak_engine, + include_code_leak_rules=include_leak_scan and use_native, + include_xml_leak_rules=include_leak_scan and use_native, + ) + except Exception as exc2: # noqa: BLE001 + console.print(f"[red]{t('cli_error_vuln_scan')}[/red] {exc2}") + return None + except Exception as exc: # noqa: BLE001 + console.print(f"[red]{t('cli_error_vuln_scan')}[/red] {exc}") + return None + + if scan_result: + # Inyectar hallazgos de gitleaks si está habilitado + if use_gitleaks and include_leak_scan: + try: + gl_findings = scan_with_gitleaks(source_dir) + if gl_findings: + # Dedup: skip gitleaks findings that overlap with existing + # HC findings on the same file+line (HC has more context) + existing_keys = { + (str(f.file), f.line) + for f in scan_result.findings + if f.rule_id.startswith("HC") + } + new_gl = [ + f for f in gl_findings + if (str(f.file), f.line) not in existing_keys + ] + if new_gl: + scan_result.findings.extend(new_gl) + console.print( + f" [dim]{t('cli_gitleaks_stats', detected=len(gl_findings), discarded=len(gl_findings) - len(new_gl), added=len(new_gl))}[/dim]" + ) + if "gitleaks" not in scan_result.leak_engine: + scan_result.leak_engine += ("+gitleaks" if scan_result.leak_engine else "gitleaks") + except Exception as exc: # noqa: BLE001 + console.print(f"[yellow]⚠[/yellow] {t('cli_gitleaks_failed')} {exc}") + + engine_used = scan_result.scanner_engine + console.print( + f" [dim]{t('cli_scanner_used_dim', engine=engine_used, files=scan_result.files_scanned, findings=len(scan_result.findings))}[/dim]" + ) + + # ── Componentes del manifest exportados (COMP006/COMP007/COMP008) ───────── + # _MANIFEST_ANALYSIS ya tiene exported_components del manifest extraído. + if scan_result is not None and _MANIFEST_ANALYSIS and getattr(_MANIFEST_ANALYSIS, "exported_components", None): + try: + from nutcracker_core.vuln_scanner import VulnFinding + _COMP_MAP = { + "activity": ("COMP006", "Activity exported sin permission", "critical", "M6 - Componentes inseguros"), + "service": ("COMP007", "Service exported sin permission", "high", "M6 - Componentes inseguros"), + "receiver": ("COMP004", "BroadcastReceiver exported sin permission", "high", "M6 - Componentes inseguros"), + "provider": ("COMP008", "ContentProvider exported sin permission", "critical", "M6 - Componentes inseguros"), + } + existing_comp = {(f.rule_id, f.matched_text) for f in scan_result.findings if f.rule_id.startswith("COMP")} + new_comp = [] + for ec in _MANIFEST_ANALYSIS.exported_components: + tag = ec.get("tag", "").lower() + name = ec.get("name", "") + if tag not in _COMP_MAP: + continue + rule_id, title, severity, category = _COMP_MAP[tag] + matched = f'<{tag} android:name="{name}" android:exported="true">' + if (rule_id, matched) in existing_comp: + continue + new_comp.append(VulnFinding( + rule_id=rule_id, + title=f"{title}: {name.split('.')[-1]}", + severity=severity, + category=category, + file=None, + line=0, + matched_text=matched, + description=( + f"{tag.capitalize()} `{name}` tiene android:exported=\"true\" sin " + f"android:permission. Cualquier app o comando ADB puede invocarlo directamente." + ), + recommendation=( + f"Añadir android:exported=\"false\" o proteger con " + f"android:permission=\"\"." + ), + )) + existing_comp.add((rule_id, matched)) + if new_comp: + scan_result.findings.extend(new_comp) + console.print(f" [dim]Componentes exportados (manifest): {len(new_comp)} hallazgo(s) COMP[/dim]") + except Exception: + pass + + print_vuln_report(scan_result, source_dir) + + # Guardar JSON con nombre canónico por paquete. + pkg_name = package_hint or source_dir.name + _save_vuln_json(scan_result, pkg_name, manifest=_MANIFEST_ANALYSIS) + return scan_result + + +def _load_vuln_json(package: str): + """Carga el JSON de vulnerabilidades guardado previamente, si existe.""" + import json + from nutcracker_core.vuln_scanner import ScanResult, VulnFinding + + # Primario: reports//vuln.json + # Fallback: decompiled/vuln_.json (legacy) + json_path = Path("./reports") / package / "vuln.json" + if not json_path.exists(): + json_path = Path("./decompiled") / f"vuln_{package}.json" + if not json_path.exists(): + return None + try: + data = json.loads(json_path.read_text(encoding="utf-8")) + base_dir = Path("./decompiled") / package + import re as _re + _url_re = _re.compile(r'"(https?://[^"]{8,})"') + _ai_reviewed = bool(data.get("ai_reviewed")) + + def _effective_sev(f: dict) -> str: + """URL → INFO solo si el LLM no confirmó el finding como crítico/alto/medio. + + - Sin ai-review: aplicar heurística (URL → info). + - Con ai-review y LLM lo dejó como TRUE_POSITIVE (sin _ai_note): respetar severidad original. + - Con ai-review y LLM lo degradó (tiene _ai_note): ya tiene la severidad corregida, solo aplicar URL como fallback extra. + """ + sev = f.get("severity", "info") + if sev in ("info",): + return sev + # Si el LLM ya revisó este finding y lo mantuvo sin cambios (TRUE_POSITIVE), + # confiamos en su criterio y no aplicamos la degradación automática. + if _ai_reviewed and not f.get("_ai_note"): + return sev + # Sin revisión LLM o LLM lo DOWNGRADE: aplicar heurística URL → info + if _url_re.search(f.get("matched_text", "")): + return "info" + return sev + + findings = [ + VulnFinding( + rule_id=f["rule_id"], + title=f["title"], + severity=_effective_sev(f), + category=f["category"], + file=base_dir / f["file"], + line=f["line"], + matched_text=f["matched_text"], + description=f["description"], + recommendation=f["recommendation"], + ) + for f in data.get("findings", []) + if not f.get("_fp") # FPs etiquetados por ai-review no van al PDF + ] + return ScanResult(base_dir=base_dir, findings=findings, + files_scanned=data.get("files_scanned", 0)) + except Exception: # noqa: BLE001 + return None + + +# ── Persistencia del análisis (AnalysisResult) ──────────────────────────────── + + +def _load_analysis_json(package: str): + """Carga el AnalysisResult más reciente desde reports//.""" + import json + from nutcracker_core.analyzer import AnalysisResult + pkg_dir = Path("./reports") / package + # Soportar también el formato plano anterior (reports/.json) + legacy = Path("./reports") / f"{package}.json" + if pkg_dir.is_dir(): + jsons = sorted( + (f for f in pkg_dir.glob("*.json") if f.stem[0].isdigit()), + key=lambda f: f.stat().st_mtime, + reverse=True, + ) + if jsons: + try: + data = json.loads(jsons[0].read_text(encoding="utf-8")) + return AnalysisResult.from_dict(data) + except Exception: # noqa: BLE001 + pass + if legacy.exists(): + try: + data = json.loads(legacy.read_text(encoding="utf-8")) + return AnalysisResult.from_dict(data) + except Exception: # noqa: BLE001 + pass + return None + + +def _generate_pdf(result, vuln_scan=None, vuln_scan_enabled: bool = True) -> Path | None: + """Genera el informe PDF final con los resultados de anti-root y vulnerabilidades.""" + # Si no se pasó un scan en esta sesión, intentar cargar el JSON guardado + if vuln_scan is None: + vuln_scan = _load_vuln_json(result.package) + if vuln_scan is not None: + console.print( + f"[dim] {t('cli_loading_prev_findings', count=len(vuln_scan.findings))}[/dim]" + ) + + reports_dir = Path("./reports") / result.package + reports_dir.mkdir(parents=True, exist_ok=True) + pdf_path = reports_dir / f"nutcracker_{result.package}_report.pdf" + try: + with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), + console=console, transient=True) as progress: + progress.add_task(t("cli_generating_pdf"), total=None) + generate_pdf_report(result, pdf_path, scan=vuln_scan, manifest=_MANIFEST_ANALYSIS, + osint=_OSINT_RESULT, vuln_scan_enabled=vuln_scan_enabled) + console.print(f"[green]✔[/green] {t('cli_pdf_saved')} [bold]{pdf_path}[/bold]") + return pdf_path + except Exception as exc: # noqa: BLE001 + console.print(f"[red]{t('cli_error_pdf')}[/red] {exc}") + return None + + +def _save_vuln_json(scan_result, package: str, manifest=None) -> None: + """Guarda los hallazgos de vulnerabilidades en JSON. + + Escribe en dos ubicaciones: + - reports//vuln.json (primario, junto al AnalysisResult) + - decompiled/vuln_.json (legacy, usado por aireview) + """ + import json + data = { + "files_scanned": scan_result.files_scanned, + "total_findings": len(scan_result.findings), + "findings": [ + { + "rule_id": f.rule_id, + "title": f.title, + "severity": f.severity, + "category": f.category, + "file": f.relative_path(scan_result.base_dir), + "line": f.line, + "matched_text": f.matched_text, + "description": f.description, + "recommendation": f.recommendation, + } + for f in scan_result.findings + ], + } + if manifest is not None and manifest.misconfigurations: + data["manifest_misconfigs"] = [ + { + "severity": m.severity, + "category": m.category, + "title": m.title, + "description": m.description, + "location": m.location, + "recommendation": m.recommendation, + } + for m in manifest.misconfigurations + ] + payload = json.dumps(data, ensure_ascii=False, indent=2) + primary = Path("./reports") / package / "vuln.json" + primary.parent.mkdir(parents=True, exist_ok=True) + primary.write_text(payload, encoding="utf-8") + # Legacy copy para aireview y otros usos directos de decompiled/ + legacy = Path("./decompiled") / f"vuln_{package}.json" + legacy.parent.mkdir(parents=True, exist_ok=True) + legacy.write_text(payload, encoding="utf-8") + console.print(f"[dim]{t('cli_vuln_json_saved')}[/dim] [bold]{primary}[/bold]") diff --git a/nutcracker_core/scan_types.py b/nutcracker_core/scan_types.py new file mode 100644 index 0000000..a39aa2d --- /dev/null +++ b/nutcracker_core/scan_types.py @@ -0,0 +1,95 @@ +"""Dataclasses del pipeline de escaneo de vulnerabilidades (Fase 0.3 del plan). + +Extraído de vuln_scanner.py: modelo de dominio compartido por vuln_scanner.py +(regex+semgrep), leak_scanner.py (apkleaks+gitleaks), store/, reporter.py y +pdf_reporter.py — una sola fuente de la verdad para VulnFinding/VulnRule/ScanResult. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from pathlib import Path + +from .i18n import t as _t + + +@dataclass +class VulnFinding: + """Una vulnerabilidad encontrada en el código fuente.""" + rule_id: str + title: str + severity: str # critical / high / medium / low / info + category: str # OWASP M-number o categoría propia + file: Path + line: int + matched_text: str + description: str + recommendation: str + + def relative_path(self, base: Path) -> str: + if self.file is None: + return "AndroidManifest.xml" + try: + return str(self.file.relative_to(base)) + except ValueError: + return str(self.file) + + +@dataclass +class VulnRule: + """Regla de detección basada en regex.""" + rule_id: str + title: str + severity: str + category: str + pattern: re.Pattern + description: str + recommendation: str + # Si se especifica, solo aplica a archivos cuyo path contenga este substring + file_filter: str | None = None + # Líneas a ignorar si contienen alguno de estos strings (reduce falsos positivos) + ignore_if_contains: list[str] = field(default_factory=list) + # Si el valor entre comillas capturado por el patrón coincide con este regex, ignorar + # (útil para filtrar valores que son identificadores, no secretos reales) + ignore_value_regex: re.Pattern | None = None + + def i18n_title(self) -> str: + key = f"rule_{self.rule_id.lower()}_title" + val = _t(key) + return val if val != key else self.title + + def i18n_desc(self) -> str: + key = f"rule_{self.rule_id.lower()}_desc" + val = _t(key) + return val if val != key else self.description + + def i18n_rec(self) -> str: + key = f"rule_{self.rule_id.lower()}_rec" + val = _t(key) + return val if val != key else self.recommendation + + +@dataclass +class ScanResult: + base_dir: Path + findings: list[VulnFinding] + files_scanned: int + scanner_engine: str = "regex" # "semgrep" | "regex" — motor de vulns + leak_engine: str = "" # "apkleaks+gitleaks+native" — motor de leaks + + @property + def by_severity(self) -> dict[str, list[VulnFinding]]: + order = ["critical", "high", "medium", "low", "info"] + result: dict[str, list[VulnFinding]] = {s: [] for s in order} + for f in self.findings: + result.setdefault(f.severity, []).append(f) + return result + + @property + def critical_count(self) -> int: + return sum(1 for f in self.findings if f.severity == "critical") + + @property + def high_count(self) -> int: + return sum(1 for f in self.findings if f.severity == "high") diff --git a/nutcracker_core/store/__init__.py b/nutcracker_core/store/__init__.py new file mode 100644 index 0000000..4c79b4c --- /dev/null +++ b/nutcracker_core/store/__init__.py @@ -0,0 +1,25 @@ +"""Capa de persistencia SQLite de nutcracker (core, no plugin). + + from nutcracker_core.store import db, repository + from nutcracker_core.store.hooks import install as install_persistence + +``install_persistence()`` engancha el post-hook ``after_analysis`` para +que cada análisis quede registrado en ``runs``/``findings``/``artifacts`` sin +tocar el flujo JSON/PDF existente. La config se lee en el momento del hook +(bloque ``store:`` de config.yaml), no al instalar. +""" + +from __future__ import annotations + +from . import db, repository +from .db import DEFAULT_DB_PATH, connect, migrate +from .hooks import install as install_persistence + +__all__ = [ + "db", + "repository", + "connect", + "migrate", + "DEFAULT_DB_PATH", + "install_persistence", +] diff --git a/nutcracker_core/store/db.py b/nutcracker_core/store/db.py new file mode 100644 index 0000000..08a4080 --- /dev/null +++ b/nutcracker_core/store/db.py @@ -0,0 +1,60 @@ +"""Conexión SQLite (WAL) y migraciones simples versionadas para nutcracker. + +El esquema completo vive en ``schema.sql``. Las migraciones son statements SQL +adicionales aplicados en orden cuando ``PRAGMA user_version`` está por debajo de +``SCHEMA_VERSION``; hoy solo existe la versión 1 (esquema inicial). +""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +SCHEMA_VERSION = 1 + +_SCHEMA_PATH = Path(__file__).parent / "schema.sql" + +DEFAULT_DB_PATH = Path(__file__).parent.parent.parent / "nutcracker.db" + +# Migraciones futuras: {version_destino: [statements]}. La versión 1 se aplica +# directamente desde schema.sql (ver _apply_schema). +_MIGRATIONS: dict[int, list[str]] = {} + + +def connect(db_path: str | Path | None = None) -> sqlite3.Connection: + """Abre (o crea) la base SQLite en modo WAL y aplica migraciones pendientes.""" + path = Path(db_path) if db_path else DEFAULT_DB_PATH + if str(path) != ":memory:": + path.parent.mkdir(parents=True, exist_ok=True) + + conn = sqlite3.connect(str(path)) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + migrate(conn) + return conn + + +def migrate(conn: sqlite3.Connection) -> None: + """Aplica el esquema inicial y cualquier migración pendiente.""" + current = conn.execute("PRAGMA user_version").fetchone()[0] + + if current == 0: + _apply_schema(conn) + current = 1 + conn.execute(f"PRAGMA user_version={current}") + + for version in sorted(v for v in _MIGRATIONS if v > current): + with conn: + for stmt in _MIGRATIONS[version]: + conn.execute(stmt) + conn.execute(f"PRAGMA user_version={version}") + current = version + + conn.commit() + + +def _apply_schema(conn: sqlite3.Connection) -> None: + sql = _SCHEMA_PATH.read_text(encoding="utf-8") + with conn: + conn.executescript(sql) diff --git a/nutcracker_core/store/hooks.py b/nutcracker_core/store/hooks.py new file mode 100644 index 0000000..64d6fc4 --- /dev/null +++ b/nutcracker_core/store/hooks.py @@ -0,0 +1,145 @@ +"""Post-hook core: persiste cada análisis en SQLite sin tocar el flujo actual. + +Doble escritura no destructiva (plan.md Fase 0.1): los JSON/PDF en disco siguen +igual, este hook solo añade una fila en ``runs``/``findings``/``artifacts``. +Se registra una sola vez, en el arranque del CLI (``install``), reusando el +mismo bus de post-hooks que usan los plugins (``after_analysis``). +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +from ..config import get as cfg_get +from . import db, repository + +_log = logging.getLogger(__name__) + +_installed = False + + +def install() -> None: + """Registra el post-hook after_analysis. Idempotente: llamar más de una vez no duplica.""" + global _installed + if _installed: + return + from ..plugins import register_post_hook + + register_post_hook("after_analysis", _persist_after_analysis) + _installed = True + + +def db_path_from_config(config: dict | None) -> str | None: + if not config: + return None + return cfg_get(config, "store", "db_path", default=None) + + +def _persist_after_analysis( + package: str, + result: Any, + vuln_scan: Any = None, + config: dict | None = None, +) -> None: + """Firma estable (package, result, vuln_scan, config): otros post-hooks registrados + (p.ej. aireview) comparten esta misma firma sin ``**kwargs``, así que este hook no + puede depender de argumentos extra en ``fire_post_hooks`` — deriva json_path/pdf_path + de la misma convención de rutas que usa reporter.py/orchestrator.py.""" + if config is not None and not bool(cfg_get(config, "store", "enabled", default=True)): + return + + conn = db.connect(db_path_from_config(config)) + try: + run_id = repository.insert_run(conn, package, kind="full", status="running") + + masvs_report = None + try: + from ..masvs import build_masvs_report + + masvs_report = build_masvs_report(result, vuln_scan, None) + except Exception as exc: # noqa: BLE001 + _log.debug("build_masvs_report falló para %s: %s", package, exc) + + verdict = _verdict_from_result(result) + repository.update_run_status( + conn, + run_id, + status="done", + verdict=verdict, + masvs_score=masvs_report.score if masvs_report else None, + grade=masvs_report.grade if masvs_report else None, + ) + + if vuln_scan is not None and getattr(vuln_scan, "findings", None): + records = [_to_finding_record(f, vuln_scan) for f in vuln_scan.findings] + repository.record_findings(conn, run_id, records) + + json_path, pdf_path = _find_artifacts(package) + for artifact_type, path in (("json", json_path), ("pdf", pdf_path)): + if path: + repository.record_artifact(conn, run_id, artifact_type, str(path)) + + repository.touch_app_run(conn, package) + finally: + conn.close() + + +def _find_artifacts(package: str) -> tuple[Path | None, Path | None]: + """Localiza el JSON/PDF que reporter.py acaba de guardar para ``package``. + + orchestrator._run_analysis llama a save_analysis_json()/_generate_pdf() sin pasar + reports_dir, así que ambos usan siempre su default "./reports" (relativo al cwd), + sin importar reports.output_dir en config.yaml. Replicamos ese mismo hardcode aquí + en vez de leer la config, para no desalinearnos si ese default cambia de nuevo. + """ + pkg_dir = Path("./reports") / package + if not pkg_dir.is_dir(): + return None, None + + json_candidates = sorted( + (f for f in pkg_dir.glob("*.json") if f.stem[:1].isdigit()), + key=lambda f: f.stat().st_mtime, + reverse=True, + ) + json_path = json_candidates[0] if json_candidates else None + + pdf_path = pkg_dir / f"nutcracker_{package}_report.pdf" + if not pdf_path.exists(): + pdf_path = None + + return json_path, pdf_path + + +def _verdict_from_result(result: Any) -> str: + if getattr(result, "protection_broken", False): + return "protection_broken" + if getattr(result, "protected", False): + return "protected" + return "not_protected" + + +def _to_finding_record(finding: Any, vuln_scan: Any) -> repository.FindingRecord: + try: + from ..masvs import RULE_TO_MASVS + + masvs_ids = RULE_TO_MASVS.get(finding.rule_id, []) + except Exception: # noqa: BLE001 + masvs_ids = [] + + file_str = "" + try: + file_str = finding.relative_path(vuln_scan.base_dir) + except Exception: # noqa: BLE001 + file_str = str(getattr(finding, "file", "") or "") + + return repository.FindingRecord( + rule_id=finding.rule_id, + title=getattr(finding, "title", ""), + severity=getattr(finding, "severity", ""), + category=getattr(finding, "category", ""), + masvs=masvs_ids, + file=file_str, + line=getattr(finding, "line", 0) or 0, + ) diff --git a/nutcracker_core/store/repository.py b/nutcracker_core/store/repository.py new file mode 100644 index 0000000..ea05880 --- /dev/null +++ b/nutcracker_core/store/repository.py @@ -0,0 +1,199 @@ +"""CRUD tipado sobre el esquema SQLite de nutcracker. + +Todas las funciones reciben una ``sqlite3.Connection`` ya abierta (ver ``db.connect``) +para que sean triviales de testear con una base ``:memory:``. No hay estado de +módulo ni conexión global: quien orquesta (CLI, daemon, dashboard) decide el +ciclo de vida de la conexión. +""" + +from __future__ import annotations + +import datetime +import sqlite3 +from dataclasses import dataclass, field + + +def _utcnow() -> str: + return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds") + + +# ── apps ────────────────────────────────────────────────────────────────────── + +def upsert_app(conn: sqlite3.Connection, package: str, source: str | None = None) -> None: + """Crea la app si no existe; si existe, no pisa first_seen ni next_due_at.""" + conn.execute( + """ + INSERT INTO apps (package, source, first_seen) + VALUES (?, ?, ?) + ON CONFLICT(package) DO UPDATE SET + source = COALESCE(excluded.source, apps.source) + """, + (package, source, _utcnow()), + ) + conn.commit() + + +def touch_app_run(conn: sqlite3.Connection, package: str, ran_at: str | None = None, + next_due_at: str | None = None) -> None: + """Actualiza last_run_at (y opcionalmente next_due_at) tras un run.""" + ran_at = ran_at or _utcnow() + conn.execute( + "UPDATE apps SET last_run_at = ?, next_due_at = COALESCE(?, next_due_at) WHERE package = ?", + (ran_at, next_due_at, package), + ) + conn.commit() + + +def get_app(conn: sqlite3.Connection, package: str) -> sqlite3.Row | None: + return conn.execute("SELECT * FROM apps WHERE package = ?", (package,)).fetchone() + + +def apps_due(conn: sqlite3.Connection, before: str | None = None) -> list[sqlite3.Row]: + """Apps cuyo next_due_at <= before (por defecto: ahora), incluyendo las sin next_due_at.""" + before = before or _utcnow() + return conn.execute( + "SELECT * FROM apps WHERE next_due_at IS NOT NULL AND next_due_at <= ? ORDER BY next_due_at", + (before,), + ).fetchall() + + +# ── runs ────────────────────────────────────────────────────────────────────── + +def insert_run(conn: sqlite3.Connection, package: str, kind: str = "full", + status: str = "queued", started_at: str | None = None) -> int: + """Inserta un run nuevo (asegura la app vía upsert_app) y retorna su id.""" + upsert_app(conn, package) + cur = conn.execute( + "INSERT INTO runs (package, kind, status, started_at) VALUES (?, ?, ?, ?)", + (package, kind, status, started_at or (_utcnow() if status == "running" else None)), + ) + conn.commit() + return cur.lastrowid + + +def update_run_status( + conn: sqlite3.Connection, + run_id: int, + status: str, + verdict: str | None = None, + masvs_score: int | None = None, + grade: str | None = None, + error: str | None = None, + finished_at: str | None = None, +) -> None: + """Actualiza el estado de un run. Marca finished_at automáticamente en done/error.""" + if finished_at is None and status in ("done", "error"): + finished_at = _utcnow() + conn.execute( + """ + UPDATE runs SET + status = ?, + verdict = COALESCE(?, verdict), + masvs_score = COALESCE(?, masvs_score), + grade = COALESCE(?, grade), + error = COALESCE(?, error), + finished_at = COALESCE(?, finished_at) + WHERE id = ? + """, + (status, verdict, masvs_score, grade, error, finished_at, run_id), + ) + conn.commit() + + +def get_run(conn: sqlite3.Connection, run_id: int) -> sqlite3.Row | None: + return conn.execute("SELECT * FROM runs WHERE id = ?", (run_id,)).fetchone() + + +def history(conn: sqlite3.Connection, package: str, limit: int = 50) -> list[sqlite3.Row]: + return conn.execute( + "SELECT * FROM runs WHERE package = ? ORDER BY id DESC LIMIT ?", + (package, limit), + ).fetchall() + + +# ── findings ────────────────────────────────────────────────────────────────── + +@dataclass +class FindingRecord: + """Vista mínima de un hallazgo para persistir, desacoplada de VulnFinding.""" + rule_id: str + title: str = "" + severity: str = "" + category: str = "" + masvs: list[str] = field(default_factory=list) + maswe: list[str] = field(default_factory=list) + cwe: list[str] = field(default_factory=list) + file: str = "" + line: int = 0 + confirmed: bool | None = None + + +def record_findings(conn: sqlite3.Connection, run_id: int, findings: list[FindingRecord]) -> None: + conn.executemany( + """ + INSERT INTO findings (run_id, rule_id, title, severity, category, masvs, maswe, cwe, file, line, confirmed) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + [ + ( + run_id, f.rule_id, f.title, f.severity, f.category, + ",".join(f.masvs), ",".join(f.maswe), ",".join(f.cwe), + f.file, f.line, + None if f.confirmed is None else int(f.confirmed), + ) + for f in findings + ], + ) + conn.commit() + + +def findings_for_run(conn: sqlite3.Connection, run_id: int) -> list[sqlite3.Row]: + return conn.execute("SELECT * FROM findings WHERE run_id = ?", (run_id,)).fetchall() + + +# ── artifacts ───────────────────────────────────────────────────────────────── + +def record_artifact(conn: sqlite3.Connection, run_id: int, type: str, path: str) -> None: + conn.execute( + "INSERT INTO artifacts (run_id, type, path) VALUES (?, ?, ?)", + (run_id, type, path), + ) + conn.commit() + + +def artifacts_for_run(conn: sqlite3.Connection, run_id: int) -> list[sqlite3.Row]: + return conn.execute("SELECT * FROM artifacts WHERE run_id = ?", (run_id,)).fetchall() + + +# ── schedule ────────────────────────────────────────────────────────────────── + +def set_schedule(conn: sqlite3.Connection, package: str, interval_days: int = 30, + cron: str | None = None, enabled: bool = True) -> None: + upsert_app(conn, package) + conn.execute( + """ + INSERT INTO schedule (package, interval_days, cron, enabled, last_scheduled_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(package) DO UPDATE SET + interval_days = excluded.interval_days, + cron = excluded.cron, + enabled = excluded.enabled, + last_scheduled_at = excluded.last_scheduled_at + """, + (package, interval_days, cron, int(enabled), _utcnow()), + ) + next_due = _utcnow() + if interval_days: + next_due = ( + datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=interval_days) + ).isoformat(timespec="seconds") + conn.execute("UPDATE apps SET next_due_at = ? WHERE package = ?", (next_due, package)) + conn.commit() + + +def get_schedule(conn: sqlite3.Connection, package: str) -> sqlite3.Row | None: + return conn.execute("SELECT * FROM schedule WHERE package = ?", (package,)).fetchone() + + +def list_schedules(conn: sqlite3.Connection) -> list[sqlite3.Row]: + return conn.execute("SELECT * FROM schedule ORDER BY package").fetchall() diff --git a/nutcracker_core/store/schema.sql b/nutcracker_core/store/schema.sql new file mode 100644 index 0000000..e5ed939 --- /dev/null +++ b/nutcracker_core/store/schema.sql @@ -0,0 +1,61 @@ +-- Esquema SQLite de nutcracker (Fase 0.1 del plan: persistencia core). +-- Convenciones: timestamps en ISO-8601 UTC (texto), booleans como INTEGER 0/1. + +CREATE TABLE IF NOT EXISTS apps ( + package TEXT PRIMARY KEY, + source TEXT, -- apkpure | google-play | local | url + first_seen TEXT NOT NULL, + last_run_at TEXT, + next_due_at TEXT, + schedule_json TEXT +); + +CREATE TABLE IF NOT EXISTS runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + package TEXT NOT NULL REFERENCES apps(package) ON DELETE CASCADE, + kind TEXT NOT NULL DEFAULT 'full', -- static | dynamic | full + status TEXT NOT NULL DEFAULT 'queued', -- queued | running | done | error + started_at TEXT, + finished_at TEXT, + verdict TEXT, -- protected | protection_broken | not_protected | ... + masvs_score INTEGER, + grade TEXT, + error TEXT +); + +CREATE INDEX IF NOT EXISTS idx_runs_package ON runs(package); +CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status); + +CREATE TABLE IF NOT EXISTS findings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id INTEGER NOT NULL REFERENCES runs(id) ON DELETE CASCADE, + rule_id TEXT NOT NULL, + title TEXT, + severity TEXT, -- critical | high | medium | low | info + category TEXT, + masvs TEXT, -- CSV de control ids, p.ej. "MASVS-STORAGE-1,MASVS-CRYPTO-1" + maswe TEXT, -- CSV de MASWE ids + cwe TEXT, -- CSV de CWE ids + file TEXT, + line INTEGER, + confirmed INTEGER -- NULL=no evaluado, 1=confirmado (aireview), 0=falso positivo +); + +CREATE INDEX IF NOT EXISTS idx_findings_run ON findings(run_id); + +CREATE TABLE IF NOT EXISTS artifacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id INTEGER NOT NULL REFERENCES runs(id) ON DELETE CASCADE, + type TEXT NOT NULL, -- pdf | json | screenshot | frida_log + path TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_artifacts_run ON artifacts(run_id); + +CREATE TABLE IF NOT EXISTS schedule ( + package TEXT PRIMARY KEY REFERENCES apps(package) ON DELETE CASCADE, + interval_days INTEGER NOT NULL DEFAULT 30, + cron TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + last_scheduled_at TEXT +); diff --git a/nutcracker_core/vuln_scanner.py b/nutcracker_core/vuln_scanner.py index e2c3e4f..5a46c3b 100644 --- a/nutcracker_core/vuln_scanner.py +++ b/nutcracker_core/vuln_scanner.py @@ -2,73 +2,37 @@ Scanner de vulnerabilidades sobre código decompilado (Java/Kotlin/Smali). Analiza los archivos fuente generados por jadx o apktool buscando patrones -conocidos de vulnerabilidades Android (OWASP Mobile Top 10). +conocidos de vulnerabilidades Android (OWASP Mobile Top 10): reglas regex + +motor semgrep. Los scanners de leaks vía herramientas externas (apkleaks, +gitleaks) viven en leak_scanner.py; los dataclasses compartidos (VulnFinding, +VulnRule, ScanResult) viven en scan_types.py (Fase 0.3 del plan) — se +re-exportan aquí para no romper a los consumidores existentes +(orchestrator.py, reporter.py, pdf_reporter.py, masvs.py, native_scanner.py, +plugins/aipwn, plugins/aireview). """ from __future__ import annotations import os import re -from dataclasses import dataclass, field from pathlib import Path from .i18n import t as _t - - -@dataclass -class VulnFinding: - """Una vulnerabilidad encontrada en el código fuente.""" - rule_id: str - title: str - severity: str # critical / high / medium / low / info - category: str # OWASP M-number o categoría propia - file: Path - line: int - matched_text: str - description: str - recommendation: str - - def relative_path(self, base: Path) -> str: - if self.file is None: - return "AndroidManifest.xml" - try: - return str(self.file.relative_to(base)) - except ValueError: - return str(self.file) - - -@dataclass -class VulnRule: - """Regla de detección basada en regex.""" - rule_id: str - title: str - severity: str - category: str - pattern: re.Pattern - description: str - recommendation: str - # Si se especifica, solo aplica a archivos cuyo path contenga este substring - file_filter: str | None = None - # Líneas a ignorar si contienen alguno de estos strings (reduce falsos positivos) - ignore_if_contains: list[str] = field(default_factory=list) - # Si el valor entre comillas capturado por el patrón coincide con este regex, ignorar - # (útil para filtrar valores que son identificadores, no secretos reales) - ignore_value_regex: re.Pattern | None = None - - def i18n_title(self) -> str: - key = f"rule_{self.rule_id.lower()}_title" - val = _t(key) - return val if val != key else self.title - - def i18n_desc(self) -> str: - key = f"rule_{self.rule_id.lower()}_desc" - val = _t(key) - return val if val != key else self.description - - def i18n_rec(self) -> str: - key = f"rule_{self.rule_id.lower()}_rec" - val = _t(key) - return val if val != key else self.recommendation +from .scan_types import VulnFinding, VulnRule, ScanResult +from .leak_scanner import scan_with_apkleaks, scan_with_gitleaks + +__all__ = [ + "VulnFinding", + "VulnRule", + "ScanResult", + "RULES", + "scan_directory", + "scan_with_semgrep", + "auto_scan", + "scan_manifest_components", + "scan_with_apkleaks", + "scan_with_gitleaks", +] # ── Reglas de detección ─────────────────────────────────────────────────────── @@ -743,31 +707,6 @@ def i18n_rec(self) -> str: # ── Scanner ──────────────────────────────────────────────────────────────────── -@dataclass -class ScanResult: - base_dir: Path - findings: list[VulnFinding] - files_scanned: int - scanner_engine: str = "regex" # "semgrep" | "regex" — motor de vulns - leak_engine: str = "" # "apkleaks+gitleaks+native" — motor de leaks - - @property - def by_severity(self) -> dict[str, list[VulnFinding]]: - order = ["critical", "high", "medium", "low", "info"] - result: dict[str, list[VulnFinding]] = {s: [] for s in order} - for f in self.findings: - result.setdefault(f.severity, []).append(f) - return result - - @property - def critical_count(self) -> int: - return sum(1 for f in self.findings if f.severity == "critical") - - @property - def high_count(self) -> int: - return sum(1 for f in self.findings if f.severity == "high") - - def scan_directory( source_dir: Path, rules: list[VulnRule] | None = None, @@ -1383,354 +1322,3 @@ def _scan_xml_resources_for_secrets(base_dir: Path) -> list[VulnFinding]: )) return findings - - -# ── Integración con apkleaks ────────────────────────────────────────────────── - -# Mapeo de categoría apkleaks → severidad -_APKLEAKS_SEVERITY: dict[str, str] = { - "RSA_Private_Key": "critical", - "PGP_private_key_block": "critical", - "SSH_DSA_Private_Key": "critical", - "SSH_EC_Private_Key": "critical", - "Amazon_AWS_Access_Key_ID": "high", - "AWS_API_Key": "high", - "GitHub_Access_Token": "high", - "Stripe_API_Key": "high", - "Stripe_Restricted_API_Key": "high", - "PayPal_Braintree_Access_Token": "high", - "Heroku_API_Key": "high", - "Twilio_API_Key": "high", - "Firebase": "high", - "Google_API_Key": "high", - "Google_Cloud_Platform_Service_Account": "high", - "Google_OAuth_Access_Token": "high", - "Slack_Token": "high", - "Slack_Webhook": "high", - "MailChimp_API_Key": "high", - "Mailgun_API_Key": "high", - "Picatic_API_Key": "high", - "Square_Access_Token": "high", - "Square_OAuth_Secret": "high", - "Facebook_Access_Token": "high", - "Facebook_Secret_Key": "high", - "Twitter_Secret_Key": "high", - "Twitter_Access_Token": "high", - "Twitter_OAuth": "high", - "Authorization_Basic": "high", - "Authorization_Bearer": "high", - "JSON_Web_Token": "high", - "Password_in_URL": "high", - "Basic_Auth_Credentials": "high", - "Cloudinary_Basic_Auth": "high", - "Artifactory_API_Token": "medium", - "Artifactory_Password": "medium", - "Generic_API_Key": "medium", - "Generic_Secret": "medium", - "Amazon_AWS_S3_Bucket": "medium", - "Google_Cloud_Platform_OAuth": "medium", - "Facebook_ClientID": "low", - "Facebook_OAuth": "low", - "Twitter_ClientID": "low", - "GitHub": "low", - "Discord_BOT_Token": "medium", - "IP_Address": "info", - "Mac_Address": "info", - "Mailto": "info", - "LinkFinder": "info", - "DEFCON_CTF_Flag": "info", - "HackerOne_CTF_Flag": "info", - "HackTheBox_CTF_Flag": "info", - "TryHackMe_CTF_Flag": "info", -} - - -def scan_with_apkleaks( - apk_path: Path, - progress_callback=None, -) -> list[VulnFinding]: - """ - Ejecuta apkleaks sobre el APK original y convierte sus hallazgos a VulnFinding. - Requiere apkleaks instalado (pip install apkleaks). - Devuelve lista vacía si apkleaks no está disponible o falla. - """ - import json as _json - import shutil - import subprocess - import tempfile - - def _parse_plain_output(raw_text: str) -> dict[str, list[str]]: - """Parsea salida estilo texto de apkleaks: [Categoria] y lineas '- valor'.""" - parsed: dict[str, list[str]] = {} - current: str | None = None - for line in raw_text.splitlines(): - s = line.strip() - if not s: - continue - if s.startswith("[") and s.endswith("]") and len(s) > 2: - current = s[1:-1].strip() - if current: - parsed.setdefault(current, []) - continue - if current and s.startswith("- "): - value = s[2:].strip() - if value: - parsed[current].append(value) - return parsed - - apkleaks_bin = shutil.which("apkleaks") - if not apkleaks_bin: - if progress_callback: - progress_callback("apkleaks no encontrado — omitiendo scan de secretos con apkleaks") - return [] - - if progress_callback: - progress_callback("Escaneando secretos con apkleaks...") - - with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tmp: - out_path = tmp.name - - try: - proc = subprocess.run( - [apkleaks_bin, "-f", str(apk_path), "-o", out_path, "--json"], - capture_output=True, - text=True, - timeout=240, - ) - if proc.returncode not in (0, 1): - if progress_callback: - progress_callback(f"apkleaks terminó con rc={proc.returncode}") - return [] - - raw = Path(out_path).read_text(encoding="utf-8", errors="replace").strip() - if not raw: - return [] - - data: dict[str, object] - try: - decoded = _json.loads(raw) - if isinstance(decoded, dict): - data = decoded - else: - data = {"results": []} - except _json.JSONDecodeError: - plain = _parse_plain_output(raw) - data = { - "results": [ - {"name": name, "matches": values} - for name, values in plain.items() - ] - } - except subprocess.TimeoutExpired: - if progress_callback: - progress_callback("apkleaks timeout (>240s), omitiendo") - return [] - except OSError: - return [] - finally: - Path(out_path).unlink(missing_ok=True) - - findings: list[VulnFinding] = [] - for entry in data.get("results", []): - name: str = entry.get("name", "Unknown") - matches: list[str] = entry.get("matches", []) - severity = _APKLEAKS_SEVERITY.get(name, "medium") - # Omitir categorías de solo info (IP, URLs, etc.) — demasiado ruido - if severity == "info": - continue - if name == "LinkFinder": - continue - for match in matches: - # ── Filtro de falsos positivos conocidos de apkleaks ────────── - if _is_apkleaks_false_positive(name, match): - continue - findings.append(VulnFinding( - rule_id=f"AL-{name[:20]}", - title=name.replace("_", " "), - severity=severity, - category="M1 - Credenciales", - file=apk_path, - line=0, - matched_text=match[:120], - description=f"Secreto o credencial detectada por apkleaks: {name}.", - recommendation="Eliminar credenciales del código. Usar variables de entorno o un gestor de secretos.", - )) - - if progress_callback: - pre_count = sum(len(e.get("matches", [])) for e in data.get("results", [])) - if pre_count != len(findings): - progress_callback( - f"apkleaks: {len(findings)} secreto(s) tras filtrar " - f"{pre_count - len(findings)} falso(s) positivo(s)" - ) - elif findings: - progress_callback(f"apkleaks: {len(findings)} secreto(s) encontrado(s)") - - return findings - - -# ── Filtro de falsos positivos de apkleaks ──────────────────────────────────── - -# Patrones de valores que apkleaks reporta pero NO son secretos reales -_APKLEAKS_FP_PATTERNS: list[re.Pattern] = [ - # "version=X.Y.Z" matcheado como JWT (es metadata de librerías GMS) - re.compile(r'^(?:version|common_client|googleid_client|image_client|review_client)=[\d.]+'), - # "basic constraint(s)" matcheado como Authorization Basic (es parte de X.509 certs) - re.compile(r'^basic\s+constraint'), - # Números de versión sueltos que no son tokens - re.compile(r'^[\d.]+$'), -] - -# Categorías de apkleaks con alta tasa de FP en APKs Android normales -_APKLEAKS_NOISY_CATEGORIES: dict[str, re.Pattern] = { - # JSON_Web_Token: apkleaks matchea "key=value" de metadata GMS como JWT - "JSON_Web_Token": re.compile( - r'^(?:version|common_client|googleid_client|image_client|review_client)=' - r'|^[\w._]+=[\d.]+$' - ), - # Authorization_Basic: matchea "basic constraint" de certificados X.509 - "Authorization_Basic": re.compile( - r'basic\s+constraint|BasicConstraints' - ), - # Facebook_Secret_Key: FACEBOOK_SIGNATURE es la firma pública del SDK, no un secreto - "Facebook_Secret_Key": re.compile( - r'FACEBOOK_SIGNATURE\s*=' - ), -} - - -def _is_apkleaks_false_positive(category: str, match_text: str) -> bool: - """Devuelve True si el hallazgo de apkleaks es un falso positivo conocido.""" - text = match_text.strip() - - # Filtros genéricos (aplican a cualquier categoría) - for fp_pattern in _APKLEAKS_FP_PATTERNS: - if fp_pattern.search(text): - return True - - # Filtros por categoría - cat_pattern = _APKLEAKS_NOISY_CATEGORIES.get(category) - if cat_pattern and cat_pattern.search(text): - return True - - return False - - -# ── Gitleaks scanner ────────────────────────────────────────────────────────── - -# Reglas de gitleaks cuyo hallazgo se considera critical (claves privadas, AWS, etc.) -_GITLEAKS_CRITICAL_RULES: set[str] = { - "private-key", - "aws-access-token", - "aws-secret-access-key", - "github-pat", - "github-fine-grained-pat", - "gitlab-pat", - "stripe-access-token", - "twilio-api-key", - "generic-api-key", -} - - -def scan_with_gitleaks( - source_dir: Path, - progress_callback=None, -) -> list[VulnFinding]: - """ - Ejecuta gitleaks sobre un directorio de código decompilado y convierte - sus hallazgos a VulnFinding. - Requiere gitleaks instalado (brew install gitleaks). - Devuelve lista vacía si gitleaks no está disponible o falla. - """ - import json as _json - import shutil - import subprocess - import tempfile - - gitleaks_bin = shutil.which("gitleaks") - if not gitleaks_bin: - if progress_callback: - progress_callback("gitleaks no encontrado — omitiendo scan") - return [] - - if progress_callback: - progress_callback("Escaneando secretos con gitleaks...") - - with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tmp: - out_path = tmp.name - - try: - proc = subprocess.run( - [ - gitleaks_bin, "detect", - "--no-git", - "--no-banner", - "-s", str(source_dir), - "-f", "json", - "-r", out_path, - "--log-level", "error", - ], - capture_output=True, - text=True, - timeout=300, - ) - # rc=0 → sin hallazgos, rc=1 → hallazgos encontrados, ≥2 → error - if proc.returncode >= 2: - if progress_callback: - progress_callback(f"gitleaks terminó con rc={proc.returncode}") - return [] - - raw = Path(out_path).read_text(encoding="utf-8", errors="replace").strip() - if not raw: - return [] - - try: - items = _json.loads(raw) - except _json.JSONDecodeError: - if progress_callback: - progress_callback("gitleaks: JSON inválido") - return [] - - if not isinstance(items, list): - return [] - - except subprocess.TimeoutExpired: - if progress_callback: - progress_callback("gitleaks timeout (>300s), omitiendo") - return [] - except OSError: - return [] - finally: - Path(out_path).unlink(missing_ok=True) - - findings: list[VulnFinding] = [] - for item in items: - rule_id = item.get("RuleID", "unknown") - description = item.get("Description", rule_id) - secret = item.get("Secret", item.get("Match", "")) - file_path = item.get("File", "") - start_line = item.get("StartLine", 0) - entropy = item.get("Entropy", 0.0) - - severity = "critical" if rule_id in _GITLEAKS_CRITICAL_RULES else "high" - - desc_text = f"{description}" - if entropy: - desc_text += f" (entropy: {entropy:.2f})" - - findings.append(VulnFinding( - rule_id=f"GL-{rule_id}", - title=description, - severity=severity, - category="M1 - Credenciales", - file=Path(file_path), - line=start_line, - matched_text=secret[:120] if secret else "", - description=desc_text, - recommendation="Eliminar credenciales del código. Usar variables de entorno o un gestor de secretos.", - )) - - if progress_callback: - progress_callback(f"gitleaks: {len(findings)} secreto(s) encontrado(s)") - - return findings diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..27eec68 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +python_files = test_*.py diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..a9daf50 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +# Dependencias de desarrollo (tests). No requeridas en producción. +-r requirements.txt +pytest>=8.0.0 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..7592b96 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,4 @@ +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) diff --git a/tests/test_config_env.py b/tests/test_config_env.py new file mode 100644 index 0000000..baf0ac9 --- /dev/null +++ b/tests/test_config_env.py @@ -0,0 +1,54 @@ +"""Tests de interpolación ${ENV_VAR} en nutcracker_core.config.load_config.""" + +import pytest + +from nutcracker_core.config import load_config + + +@pytest.fixture +def config_file(tmp_path): + path = tmp_path / "config.yaml" + path.write_text( + "google_play:\n" + " aas_token: '${NUTCRACKER_TEST_TOKEN}'\n" + "llm:\n" + " api_key: '${NUTCRACKER_TEST_MISSING}'\n" + "nested:\n" + " list:\n" + " - '${NUTCRACKER_TEST_TOKEN}'\n" + " - plain_value\n" + "language: en\n", + encoding="utf-8", + ) + return path + + +def test_resolves_env_var_when_set(config_file, monkeypatch): + monkeypatch.setenv("NUTCRACKER_TEST_TOKEN", "secret-123") + + cfg = load_config(config_file) + + assert cfg["google_play"]["aas_token"] == "secret-123" + assert cfg["nested"]["list"][0] == "secret-123" + + +def test_leaves_placeholder_when_env_var_missing(config_file, monkeypatch): + monkeypatch.setenv("NUTCRACKER_TEST_TOKEN", "secret-123") + monkeypatch.delenv("NUTCRACKER_TEST_MISSING", raising=False) + + cfg = load_config(config_file) + + assert cfg["llm"]["api_key"] == "${NUTCRACKER_TEST_MISSING}" + + +def test_non_string_values_untouched(config_file, monkeypatch): + monkeypatch.setenv("NUTCRACKER_TEST_TOKEN", "secret-123") + + cfg = load_config(config_file) + + assert cfg["language"] == "en" + assert cfg["nested"]["list"][1] == "plain_value" + + +def test_missing_file_returns_empty_dict(tmp_path): + assert load_config(tmp_path / "does_not_exist.yaml") == {} diff --git a/tests/test_store_hooks.py b/tests/test_store_hooks.py new file mode 100644 index 0000000..4408cc3 --- /dev/null +++ b/tests/test_store_hooks.py @@ -0,0 +1,122 @@ +"""Tests del post-hook after_analysis -> SQLite (store/hooks.py). + +Usa datos simulados (mocks) de AnalysisResult/ScanResult en vez de correr un +análisis real de APK, para verificar la persistencia de forma aislada y rápida. +""" + +from pathlib import Path + +import pytest + +from nutcracker_core.analyzer import AnalysisResult +from nutcracker_core.detectors.base import DetectionResult +from nutcracker_core.store import db, repository +from nutcracker_core.store.hooks import _persist_after_analysis +from nutcracker_core.vuln_scanner import ScanResult, VulnFinding + + +def _mock_result(protected: bool = True, protection_broken: bool = False) -> AnalysisResult: + result = AnalysisResult( + package="com.example.mock", + version_name="1.0", + version_code="1", + min_sdk="21", + target_sdk="34", + analyzed_at="2026-07-21T10:00:00", + results=[DetectionResult(name="RootBeer", detected=protected, strength="high")], + ) + if protection_broken: + result.decompilation_info = {"method": "frida-dexdump", "dex_count": 3} + return result + + +def _mock_scan(base_dir: Path) -> ScanResult: + finding = VulnFinding( + rule_id="HC001", + title="Hardcoded secret", + severity="high", + category="M9", + file=base_dir / "Foo.java", + line=42, + matched_text="API_KEY=...", + description="desc", + recommendation="rec", + ) + return ScanResult(base_dir=base_dir, findings=[finding], files_scanned=1) + + +@pytest.fixture +def db_path(tmp_path): + return tmp_path / "nutcracker.db" + + +def _write_fake_artifacts(package: str) -> None: + """Crea JSON/PDF de mentira en ./reports//, la misma convención hardcodeada + que usan save_analysis_json()/_generate_pdf() (relativa al cwd), para que + hooks._find_artifacts() los descubra igual que con un análisis real.""" + pkg_dir = Path("./reports") / package + pkg_dir.mkdir(parents=True, exist_ok=True) + (pkg_dir / "20260721_160530.json").write_text("{}", encoding="utf-8") + (pkg_dir / f"nutcracker_{package}_report.pdf").write_bytes(b"%PDF-1.4 fake") + + +def test_persist_after_analysis_writes_run_findings_and_artifacts(db_path, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = _mock_result(protected=True, protection_broken=True) + scan = _mock_scan(tmp_path) + _write_fake_artifacts(result.package) + config = {"store": {"db_path": str(db_path)}} + + _persist_after_analysis( + package=result.package, + result=result, + vuln_scan=scan, + config=config, + ) + + conn = db.connect(str(db_path)) + try: + app = repository.get_app(conn, "com.example.mock") + assert app is not None + assert app["last_run_at"] is not None + + runs = repository.history(conn, "com.example.mock") + assert len(runs) == 1 + run = runs[0] + assert run["status"] == "done" + assert run["verdict"] == "protection_broken" + + findings = repository.findings_for_run(conn, run["id"]) + assert len(findings) == 1 + assert findings[0]["rule_id"] == "HC001" + assert findings[0]["masvs"] == "MASVS-STORAGE-2" + + artifacts = repository.artifacts_for_run(conn, run["id"]) + assert {a["type"] for a in artifacts} == {"json", "pdf"} + finally: + conn.close() + + +def test_persist_after_analysis_respects_store_disabled(db_path, tmp_path): + result = _mock_result(protected=False) + config = {"store": {"db_path": str(db_path), "enabled": False}} + + _persist_after_analysis( + package=result.package, result=result, vuln_scan=None, config=config, + ) + + assert not db_path.exists() + + +def test_persist_after_analysis_verdict_not_protected(db_path, tmp_path): + result = _mock_result(protected=False) + config = {"store": {"db_path": str(db_path)}} + + _persist_after_analysis(package=result.package, result=result, vuln_scan=None, config=config) + + conn = db.connect(str(db_path)) + try: + run = repository.history(conn, "com.example.mock")[0] + assert run["verdict"] == "not_protected" + finally: + conn.close() diff --git a/tests/test_store_repository.py b/tests/test_store_repository.py new file mode 100644 index 0000000..37ad9e0 --- /dev/null +++ b/tests/test_store_repository.py @@ -0,0 +1,128 @@ +"""Tests de nutcracker_core.store.repository sobre una base SQLite en memoria.""" + +import pytest + +from nutcracker_core.store import db, repository + + +@pytest.fixture +def conn(): + connection = db.connect(":memory:") + yield connection + connection.close() + + +def test_upsert_app_creates_row(conn): + repository.upsert_app(conn, "com.example.app", source="apkpure") + + row = repository.get_app(conn, "com.example.app") + assert row is not None + assert row["source"] == "apkpure" + assert row["first_seen"] is not None + + +def test_upsert_app_does_not_overwrite_first_seen(conn): + repository.upsert_app(conn, "com.example.app") + first_seen = repository.get_app(conn, "com.example.app")["first_seen"] + + repository.upsert_app(conn, "com.example.app", source="google-play") + + row = repository.get_app(conn, "com.example.app") + assert row["first_seen"] == first_seen + assert row["source"] == "google-play" + + +def test_insert_run_creates_app_and_run(conn): + run_id = repository.insert_run(conn, "com.example.app", kind="static", status="queued") + + assert run_id > 0 + assert repository.get_app(conn, "com.example.app") is not None + run = repository.get_run(conn, run_id) + assert run["package"] == "com.example.app" + assert run["kind"] == "static" + assert run["status"] == "queued" + + +def test_update_run_status_sets_finished_at_on_done(conn): + run_id = repository.insert_run(conn, "com.example.app") + + repository.update_run_status(conn, run_id, status="done", verdict="protected", + masvs_score=80, grade="B") + + run = repository.get_run(conn, run_id) + assert run["status"] == "done" + assert run["verdict"] == "protected" + assert run["masvs_score"] == 80 + assert run["grade"] == "B" + assert run["finished_at"] is not None + + +def test_record_and_fetch_findings(conn): + run_id = repository.insert_run(conn, "com.example.app") + findings = [ + repository.FindingRecord( + rule_id="HC001", title="Hardcoded secret", severity="high", + category="M9", masvs=["MASVS-STORAGE-2"], file="Foo.java", line=42, + ), + repository.FindingRecord(rule_id="COMP001", title="Exported activity", severity="medium"), + ] + + repository.record_findings(conn, run_id, findings) + + rows = repository.findings_for_run(conn, run_id) + assert len(rows) == 2 + assert rows[0]["rule_id"] == "HC001" + assert rows[0]["masvs"] == "MASVS-STORAGE-2" + assert rows[0]["line"] == 42 + + +def test_record_artifact(conn): + run_id = repository.insert_run(conn, "com.example.app") + + repository.record_artifact(conn, run_id, "json", "/reports/com.example.app/20260101.json") + repository.record_artifact(conn, run_id, "pdf", "/reports/com.example.app/report.pdf") + + artifacts = repository.artifacts_for_run(conn, run_id) + assert {a["type"] for a in artifacts} == {"json", "pdf"} + + +def test_history_orders_most_recent_first(conn): + first = repository.insert_run(conn, "com.example.app") + second = repository.insert_run(conn, "com.example.app") + + rows = repository.history(conn, "com.example.app") + + assert [r["id"] for r in rows] == [second, first] + + +def test_set_schedule_computes_next_due_at(conn): + repository.set_schedule(conn, "com.example.app", interval_days=30) + + app = repository.get_app(conn, "com.example.app") + schedule = repository.get_schedule(conn, "com.example.app") + assert schedule["interval_days"] == 30 + assert schedule["enabled"] == 1 + assert app["next_due_at"] is not None + + +def test_apps_due_returns_only_past_due(conn): + repository.set_schedule(conn, "com.due.app", interval_days=30) + conn.execute( + "UPDATE apps SET next_due_at = '2000-01-01T00:00:00+00:00' WHERE package = ?", + ("com.due.app",), + ) + conn.commit() + repository.set_schedule(conn, "com.future.app", interval_days=30) # vence en 30 días + + due = repository.apps_due(conn) + + assert [row["package"] for row in due] == ["com.due.app"] + + +def test_list_schedules(conn): + repository.set_schedule(conn, "com.a", interval_days=15) + repository.set_schedule(conn, "com.b", interval_days=30) + + schedules = repository.list_schedules(conn) + + assert [s["package"] for s in schedules] == ["com.a", "com.b"] diff --git a/tests/test_vuln_scanner_split.py b/tests/test_vuln_scanner_split.py new file mode 100644 index 0000000..b1a2be3 --- /dev/null +++ b/tests/test_vuln_scanner_split.py @@ -0,0 +1,85 @@ +"""Tests del split de vuln_scanner.py (Fase 0.3): scan_types / vuln_scanner / leak_scanner. + +Verifica que la separación en 3 módulos no rompió el comportamiento: las +dataclasses siguen siendo la misma identidad de clase entre módulos y el +scanner regex sigue detectando hallazgos reales sobre código de prueba. +""" + +from pathlib import Path + +from nutcracker_core import leak_scanner, scan_types, vuln_scanner + + +def test_vuln_scanner_reexports_same_classes_as_scan_types(): + assert vuln_scanner.VulnFinding is scan_types.VulnFinding + assert vuln_scanner.VulnRule is scan_types.VulnRule + assert vuln_scanner.ScanResult is scan_types.ScanResult + + +def test_vuln_scanner_reexports_same_functions_as_leak_scanner(): + assert vuln_scanner.scan_with_apkleaks is leak_scanner.scan_with_apkleaks + assert vuln_scanner.scan_with_gitleaks is leak_scanner.scan_with_gitleaks + + +def test_scan_directory_detects_hardcoded_password(tmp_path): + java_file = tmp_path / "Login.java" + java_file.write_text( + 'public class Login {\n' + ' String password = "hunter2_super_secret";\n' + '}\n', + encoding="utf-8", + ) + + result = vuln_scanner.scan_directory(tmp_path) + + assert result.files_scanned == 1 + rule_ids = {f.rule_id for f in result.findings} + assert "HC002" in rule_ids + + +def test_scan_directory_ignores_placeholder_values(tmp_path): + java_file = tmp_path / "Config.java" + java_file.write_text( + 'public class Config {\n' + ' String password = "your_password_here_example";\n' + '}\n', + encoding="utf-8", + ) + + result = vuln_scanner.scan_directory(tmp_path) + + rule_ids = {f.rule_id for f in result.findings} + assert "HC002" not in rule_ids + + +def test_scan_manifest_components_flags_exported_activity(tmp_path): + manifest = tmp_path / "AndroidManifest.xml" + manifest.write_text( + '\n' + '\n' + ' \n' + ' \n' + ' \n' + '\n', + encoding="utf-8", + ) + + findings = vuln_scanner.scan_manifest_components(tmp_path) + + rule_ids = {f.rule_id for f in findings} + assert "COMP006" in rule_ids # activity exported sin permission + assert "INFO001" in rule_ids # debuggable=true + + +def test_apkleaks_false_positive_filter(): + assert leak_scanner._is_apkleaks_false_positive("JSON_Web_Token", "version=1.2.3") + assert not leak_scanner._is_apkleaks_false_positive("Generic_Secret", "sk_live_abcdef123456") + + +def test_scan_with_gitleaks_returns_empty_without_binary(tmp_path, monkeypatch): + import shutil as real_shutil + monkeypatch.setattr(real_shutil, "which", lambda name: None) + + findings = vuln_scanner.scan_with_gitleaks(tmp_path) + + assert findings == [] From d979f8e5d4055a1da8853c9470a0f0001cdd6652 Mon Sep 17 00:00:00 2001 From: Hiteek Date: Fri, 24 Jul 2026 13:00:08 -0500 Subject: [PATCH 16/30] fase 1 --- .gitignore | 3 + config.yaml.example | 16 ++ nutcracker_core/cli/__init__.py | 3 + nutcracker_core/cli/analyze.py | 12 +- nutcracker_core/cli/queue_cmd.py | 103 +++++++++++ nutcracker_core/cli/scan.py | 11 +- nutcracker_core/cli/schedule_cmd.py | 58 ++++++ nutcracker_core/cli/serve.py | 60 ++++++ nutcracker_core/orchestrator.py | 45 +++++ nutcracker_core/queue/__init__.py | 16 ++ nutcracker_core/queue/engine.py | 234 ++++++++++++++++++++++++ nutcracker_core/queue/job.py | 21 +++ nutcracker_core/scheduler.py | 62 +++++++ nutcracker_core/store/db.py | 40 +++- nutcracker_core/store/hooks.py | 12 ++ nutcracker_core/store/repository.py | 64 +++++++ plan.md | 125 +++++++++++++ requirements.txt | 3 + tests/test_queue_engine.py | 273 ++++++++++++++++++++++++++++ tests/test_scheduler.py | 66 +++++++ 20 files changed, 1221 insertions(+), 6 deletions(-) create mode 100644 nutcracker_core/cli/queue_cmd.py create mode 100644 nutcracker_core/cli/schedule_cmd.py create mode 100644 nutcracker_core/cli/serve.py create mode 100644 nutcracker_core/queue/__init__.py create mode 100644 nutcracker_core/queue/engine.py create mode 100644 nutcracker_core/queue/job.py create mode 100644 nutcracker_core/scheduler.py create mode 100644 tests/test_queue_engine.py create mode 100644 tests/test_scheduler.py diff --git a/.gitignore b/.gitignore index 1e227ca..dd9f9ab 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,9 @@ logs/ .semgrep/ frida_scripts/ config.yaml +nutcracker.db +nutcracker.db-wal +nutcracker.db-shm # AI / IDE tool files .code-review-graph/ diff --git a/config.yaml.example b/config.yaml.example index 624e0ef..69b4239 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -151,3 +151,19 @@ batch: store: enabled: true # set false to skip SQLite persistence entirely db_path: '' # empty = ./nutcracker.db at the project root + +# ── Queue (mass execution — plan.md Fase 1) ──────────────────────────────────── +# `nutcracker queue add`/`nutcracker serve`: static jobs (decompile/scan/OSINT) +# run in parallel; dynamic jobs (Frida/ADB on a physical device) are always +# serialized per device serial, regardless of dynamic_workers. +queue: + static_workers: 4 # parallel static analyses at once + dynamic_workers: 2 # concurrent *devices* for dynamic jobs (never same serial) + +# ── Scheduler (periodic reviews — plan.md Fase 1.2) ──────────────────────────── +# `nutcracker serve` re-queues every app whose next_due_at has passed, and +# reschedules it after each run using its own `schedule` entry (or this default). +scheduler: + enabled: true + poll_interval_minutes: 60 # how often the daemon checks for due apps + default_interval_days: 30 # ≥1 review/month per app unless overridden diff --git a/nutcracker_core/cli/__init__.py b/nutcracker_core/cli/__init__.py index 4c49ca4..f285cfa 100644 --- a/nutcracker_core/cli/__init__.py +++ b/nutcracker_core/cli/__init__.py @@ -140,6 +140,9 @@ def cli(ctx: click.Context) -> None: from . import setup_token as _setup_token # noqa: E402,F401 from . import batch as _batch # noqa: E402,F401 from . import regen_pdf as _regen_pdf # noqa: E402,F401 +from . import queue_cmd as _queue_cmd # noqa: E402,F401 — Fase 1: `nutcracker queue add|ls` +from . import schedule_cmd as _schedule_cmd # noqa: E402,F401 — Fase 1: `nutcracker schedule set|ls` +from . import serve as _serve # noqa: E402,F401 — Fase 1: `nutcracker serve` (daemon cola+scheduler) install_persistence() load_plugins(cli) diff --git a/nutcracker_core/cli/analyze.py b/nutcracker_core/cli/analyze.py index e68c4fa..423f22c 100644 --- a/nutcracker_core/cli/analyze.py +++ b/nutcracker_core/cli/analyze.py @@ -39,11 +39,21 @@ metavar="SERIAL", help="ADB device serial for --launch (default: first available).", ) -def analyze(apk_path: str, config_path: str, report: str | None, launch: bool, serial: str | None) -> None: +@click.option( + "--static-only", + is_flag=True, + default=False, + help="Force jadx-only decompilation and skip Frida/device runtime steps " + "(used by queued/scheduled batch runs; see `nutcracker queue`/`serve`).", +) +def analyze(apk_path: str, config_path: str, report: str | None, launch: bool, serial: str | None, + static_only: bool) -> None: """Analyze a local APK for anti-root protections.""" orch._LAUNCH_APP = launch orch._LAUNCH_SERIAL = serial config = load_config(config_path) + if static_only: + orch.apply_static_only_override(config) orch._CFG = config orch._init_i18n(config) save_json_cfg = bool( diff --git a/nutcracker_core/cli/queue_cmd.py b/nutcracker_core/cli/queue_cmd.py new file mode 100644 index 0000000..d78805f --- /dev/null +++ b/nutcracker_core/cli/queue_cmd.py @@ -0,0 +1,103 @@ +"""Comando `nutcracker queue`: gestión de la cola de jobs (Fase 1 del plan).""" + +from __future__ import annotations + +from pathlib import Path + +import click + +from nutcracker_core.config import load_config, get as cfg_get +from nutcracker_core.queue.engine import QueueEngine +from nutcracker_core.store import db, repository +from nutcracker_core.store.hooks import db_path_from_config + +from . import cli +from ..orchestrator import console + + +def _read_targets(target: str) -> list[str]: + """Si `target` es un list_file existente (una entrada por línea, igual que + `nutcracker batch`), lo expande; si no, lo trata como un target único.""" + p = Path(target) + if p.is_file() and not target.lower().endswith(".apk"): + lines = p.read_text(encoding="utf-8").splitlines() + return [l.strip() for l in lines if l.strip() and not l.strip().startswith("#")] + return [target] + + +def _build_engine(config_path: str) -> QueueEngine: + config = load_config(config_path) + return QueueEngine( + config_path=config_path, + db_path=db_path_from_config(config), + static_workers=int(cfg_get(config, "queue", "static_workers", default=4)), + dynamic_workers=int(cfg_get(config, "queue", "dynamic_workers", default=2)), + ) + + +@cli.group() +def queue() -> None: + """Gestiona la cola de jobs de análisis (estático/dinámico).""" + + +@queue.command("add") +@click.argument("target") +@click.option("--config", "-c", "config_path", default="config.yaml", show_default=True) +@click.option("--dynamic", is_flag=True, default=False, + help="Job dinámico (Frida/ADB sobre dispositivo). Requiere .apk local.") +@click.option("--serial", default=None, help="Serial ADB para job dinámico.") +@click.option("--run", "run_now", is_flag=True, default=False, + help="Ejecutar la cola inmediatamente tras encolar (bloqueante).") +def queue_add(target: str, config_path: str, dynamic: bool, serial: str | None, run_now: bool) -> None: + """Encola TARGET: ruta a .apk local, URL, package id, o un list_file con una + entrada por línea (mismo formato que `nutcracker batch`).""" + engine = _build_engine(config_path) + targets = _read_targets(target) + kind = "dynamic" if dynamic else "static" + + jobs = [] + for t in targets: + try: + jobs.append(engine.submit(t, kind=kind, serial=serial)) + except ValueError as exc: + console.print(f"[red]✘[/red] {t}: {exc}") + + console.print(f"[green]✔[/green] {len(jobs)} job(s) encolado(s) ({kind}).") + + if run_now and jobs: + def _on_result(o): # noqa: ANN001 + icon = "✔" if o.ok else "✘" + color = "green" if o.ok else "red" + extra = f" package={o.package}" if o.package else "" + console.print(f" [{color}]{icon}[/{color}] {o.job.target}{extra}") + + outcomes = engine.drain(on_result=_on_result) + n_ok = sum(1 for o in outcomes if o.ok) + console.print(f"\n[bold]Resumen:[/bold] {n_ok}/{len(outcomes)} OK") + + +@queue.command("ls") +@click.option("--config", "-c", "config_path", default="config.yaml", show_default=True) +@click.option("--status", default=None, + type=click.Choice(["queued", "running", "done", "error"])) +@click.option("--limit", default=20, show_default=True) +def queue_ls(config_path: str, status: str | None, limit: int) -> None: + """Lista jobs recientes de la cola.""" + config = load_config(config_path) + conn = db.connect(db_path_from_config(config)) + try: + rows = repository.list_jobs(conn, status=status, limit=limit) + finally: + conn.close() + + if not rows: + console.print("[dim](sin jobs)[/dim]") + return + + colors = {"queued": "cyan", "running": "yellow", "done": "green", "error": "red"} + for r in rows: + color = colors.get(r["status"], "white") + pkg = f" pkg={r['package']}" if r["package"] else "" + console.print( + f" #{r['id']:<5} [{color}]{r['status']:<8}[/{color}] {r['kind']:<8} {r['target']}{pkg}" + ) diff --git a/nutcracker_core/cli/scan.py b/nutcracker_core/cli/scan.py index 1cbe1a3..ab2003a 100644 --- a/nutcracker_core/cli/scan.py +++ b/nutcracker_core/cli/scan.py @@ -56,8 +56,15 @@ metavar="ARCHIVO", help="Path to save the JSON report.", ) +@click.option( + "--static-only", + is_flag=True, + default=False, + help="Force jadx-only decompilation and skip Frida/device runtime steps " + "(used by queued/scheduled batch runs; see `nutcracker queue`/`serve`).", +) def scan(url: str, config_path: str, source: str | None, output_dir: str | None, - keep_apk: bool, report: str | None) -> None: + keep_apk: bool, report: str | None, static_only: bool) -> None: """ Download an APK and analyze it for anti-root protections. @@ -67,6 +74,8 @@ def scan(url: str, config_path: str, source: str | None, output_dir: str | None, - Direct URL to an .apk file (https://example.com/app.apk) """ config = load_config(config_path) + if static_only: + orch.apply_static_only_override(config) orch._CFG = config orch._init_i18n(config) output_dir = output_dir or cfg_get(config, "downloader", "output_dir") or "./downloads" diff --git a/nutcracker_core/cli/schedule_cmd.py b/nutcracker_core/cli/schedule_cmd.py new file mode 100644 index 0000000..b2e0c6e --- /dev/null +++ b/nutcracker_core/cli/schedule_cmd.py @@ -0,0 +1,58 @@ +"""Comando `nutcracker schedule`: revisión periódica automática por app (Fase 1.2).""" + +from __future__ import annotations + +import click + +from nutcracker_core.config import load_config +from nutcracker_core.store import db, repository +from nutcracker_core.store.hooks import db_path_from_config + +from . import cli +from ..orchestrator import console + + +@cli.group() +def schedule() -> None: + """Gestiona la revisión periódica automática por app (mín. recomendado: 1/mes).""" + + +@schedule.command("set") +@click.argument("package") +@click.option("--config", "-c", "config_path", default="config.yaml", show_default=True) +@click.option("--every", "every_days", type=int, default=30, show_default=True, + help="Intervalo en días entre revisiones (por defecto 30 = ≥1/mes).") +@click.option("--disable", is_flag=True, default=False, help="Deshabilita la revisión periódica.") +def schedule_set(package: str, config_path: str, every_days: int, disable: bool) -> None: + """Agenda revisiones periódicas de PACKAGE cada --every días.""" + config = load_config(config_path) + conn = db.connect(db_path_from_config(config)) + try: + repository.set_schedule(conn, package, interval_days=every_days, enabled=not disable) + finally: + conn.close() + state = " [dim](deshabilitado)[/dim]" if disable else "" + console.print(f"[green]✔[/green] {package}: cada {every_days} día(s){state}") + + +@schedule.command("ls") +@click.option("--config", "-c", "config_path", default="config.yaml", show_default=True) +def schedule_ls(config_path: str) -> None: + """Lista todas las apps con revisión periódica configurada.""" + config = load_config(config_path) + conn = db.connect(db_path_from_config(config)) + try: + rows = repository.list_schedules(conn) + apps = {r["package"]: repository.get_app(conn, r["package"]) for r in rows} + finally: + conn.close() + + if not rows: + console.print("[dim](sin schedules)[/dim]") + return + + for r in rows: + state = "on" if r["enabled"] else "off" + app = apps.get(r["package"]) + due = f" next_due={app['next_due_at']}" if app and app["next_due_at"] else "" + console.print(f" {r['package']:<40} cada {r['interval_days']}d [{state}]{due}") diff --git a/nutcracker_core/cli/serve.py b/nutcracker_core/cli/serve.py new file mode 100644 index 0000000..9b22486 --- /dev/null +++ b/nutcracker_core/cli/serve.py @@ -0,0 +1,60 @@ +"""Comando `nutcracker serve`: daemon con cola + scheduler (Fase 1 del plan).""" + +from __future__ import annotations + +import signal +import time + +import click + +from nutcracker_core.config import load_config, get as cfg_get +from nutcracker_core.queue.engine import QueueEngine +from nutcracker_core.scheduler import NutcrackerScheduler +from nutcracker_core.store.hooks import db_path_from_config + +from . import cli +from ..orchestrator import console + + +@cli.command() +@click.option( + "--config", "-c", + "config_path", + default="config.yaml", + show_default=True, + metavar="ARCHIVO", +) +def serve(config_path: str) -> None: + """Arranca el daemon de nutcracker: cola paralela/secuencial + scheduler + periódico que garantiza revisiones mínimas por APK (bloque `scheduler:` en + config.yaml; por defecto, cada 30 días). Ctrl+C para detener.""" + config = load_config(config_path) + engine = QueueEngine( + config_path=config_path, + db_path=db_path_from_config(config), + static_workers=int(cfg_get(config, "queue", "static_workers", default=4)), + dynamic_workers=int(cfg_get(config, "queue", "dynamic_workers", default=2)), + ) + scheduler = NutcrackerScheduler(engine, config) + scheduler.start() + + console.print( + f"[bold green]✔[/bold green] nutcracker serve — daemon activo " + f"(static_workers={engine.static_workers}, dynamic_workers={engine.dynamic_workers}) " + f"— Ctrl+C para detener" + ) + + _stop = {"flag": False} + + def _handle_sigterm(signum, frame): # noqa: ANN001 + _stop["flag"] = True + + signal.signal(signal.SIGTERM, _handle_sigterm) + try: + while not _stop["flag"]: + time.sleep(1) + except KeyboardInterrupt: + pass + finally: + scheduler.stop() + console.print("[dim]nutcracker serve — detenido[/dim]") diff --git a/nutcracker_core/orchestrator.py b/nutcracker_core/orchestrator.py index da54d94..996b120 100644 --- a/nutcracker_core/orchestrator.py +++ b/nutcracker_core/orchestrator.py @@ -69,6 +69,51 @@ _LAUNCH_SERIAL: str | None = None # --serial para --launch +def apply_static_only_override(config: dict) -> None: + """Fuerza decompilación jadx y evita el flujo runtime/dispositivo para esta + ejecución, sin tocar config.yaml en disco (usado por `--static-only` en + `analyze`/`scan`). Pensado para jobs encolados o del scheduler (Fase 1 del + plan): una revisión masiva/periódica no debe competir por el teléfono físico, + que es un recurso compartido y escaso.""" + pipelines = config.setdefault("pipelines", {}) + protected = pipelines.setdefault("protected", {}) + protected["decompilation"] = "jadx" + protected.setdefault("fallback_jadx", True) + + +def build_job_cmd( + target: str, + *, + is_local_apk: bool, + config_path: str = "config.yaml", + static_only: bool = True, + launch: bool = False, + serial: str | None = None, +) -> list[str]: + """Construye el argv para ejecutar un job de análisis como subproceso aislado. + + Usado por nutcracker_core/queue/engine.py (Fase 1 del plan). Cada job corre en + su propio proceso Python en vez de llamar a `_run_analysis` en el mismo + proceso: este módulo mantiene estado mutuo a nivel de módulo (`_CFG`, + `_MANIFEST_ANALYSIS`, `_OSINT_RESULT`, ...) que no es seguro entre análisis + concurrentes de APKs distintas. El aislamiento por proceso evita ese riesgo + sin reescribir el orquestador, y de paso da paralelismo real (sin GIL). + """ + entry = str(Path(__file__).resolve().parent.parent / "nutcracker.py") + cmd = [sys.executable, entry] + if is_local_apk: + cmd += ["analyze", target, "--config", config_path] + if launch: + cmd.append("--launch") + if serial: + cmd += ["--serial", serial] + else: + cmd += ["scan", target, "--config", config_path, "--keep-apk"] + if static_only: + cmd.append("--static-only") + return cmd + + def _init_i18n(config: dict) -> None: """Initialize the i18n module from the loaded config.""" language = str(cfg_get(config, "language", default="en")).strip().lower() diff --git a/nutcracker_core/queue/__init__.py b/nutcracker_core/queue/__init__.py new file mode 100644 index 0000000..00282ba --- /dev/null +++ b/nutcracker_core/queue/__init__.py @@ -0,0 +1,16 @@ +"""Cola de ejecución masiva de nutcracker (Fase 1 del plan). + + from nutcracker_core.queue.engine import QueueEngine + +Pool paralelo para jobs estáticos + lock por dispositivo para jobs dinámicos +(dos análisis dinámicos nunca corren a la vez sobre el mismo serial ADB). Cada +job corre en un subproceso aislado del CLI existente (`nutcracker analyze`/ +`scan`), así que reutiliza el pipeline de análisis tal cual sin duplicarlo. +""" + +from __future__ import annotations + +from .job import Job +from .engine import QueueEngine, JobOutcome + +__all__ = ["Job", "QueueEngine", "JobOutcome"] diff --git a/nutcracker_core/queue/engine.py b/nutcracker_core/queue/engine.py new file mode 100644 index 0000000..99e5c46 --- /dev/null +++ b/nutcracker_core/queue/engine.py @@ -0,0 +1,234 @@ +"""Motor de la cola de nutcracker (Fase 1 del plan). + +Estrategia de concurrencia: + - Jobs **estáticos** (decompilación jadx, semgrep/regex, OSINT): corren en un + pool de ``static_workers`` hilos en paralelo. Cada hilo solo lanza un + subproceso y espera — el trabajo real (jadx, semgrep, androguard) ocurre en + ese proceso hijo, así que no hay contención de GIL relevante. + - Jobs **dinámicos** (Frida/ADB sobre un teléfono físico): nunca pueden + compartir el mismo dispositivo. Se ejecutan en su propio pool de + ``dynamic_workers`` hilos, pero cada ejecución adquiere un + ``threading.Lock`` propio de su ``serial`` antes de lanzar el subproceso — + dos jobs con el mismo serial jamás corren a la vez, aunque el pool tenga + más de un hilo (permite paralelismo *entre* dispositivos distintos). + +Cada job se ejecuta como un **subproceso aislado** que invoca el CLI existente +(`nutcracker analyze`/`scan`, ver orchestrator.build_job_cmd) en vez de llamar +funciones del orquestador en el mismo proceso: el orquestador usa estado mutuo +a nivel de módulo (`_CFG`, `_MANIFEST_ANALYSIS`, ...) que no es seguro entre +análisis concurrentes de APKs distintas. El aislamiento por proceso evita ese +riesgo reutilizando 100% del pipeline ya probado, sin reescribirlo. +""" + +from __future__ import annotations + +import datetime +import logging +import os +import subprocess +import threading +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + +from nutcracker_core import orchestrator as orch +from nutcracker_core.config import get as cfg_get, load_config +from nutcracker_core.store import db, repository + +from .job import Job + +_log = logging.getLogger(__name__) + + +@dataclass +class JobOutcome: + job: Job + ok: bool + returncode: int + error: str = "" + run_id: int | None = None + package: str | None = None + + +def _is_local_apk(target: str) -> bool: + return Path(target).exists() and target.lower().endswith(".apk") + + +class QueueEngine: + """Encola y ejecuta jobs de análisis de APKs.""" + + def __init__( + self, + config_path: str = "config.yaml", + db_path: str | None = None, + static_workers: int = 4, + dynamic_workers: int = 2, + ) -> None: + self.config_path = config_path + self.db_path = db_path + self.static_workers = max(1, static_workers) + self.dynamic_workers = max(1, dynamic_workers) + self._pending: list[Job] = [] + self._device_locks: dict[str, threading.Lock] = {} + self._locks_guard = threading.Lock() + + # ── Encolado ───────────────────────────────────────────────────────────── + + def submit(self, target: str, kind: str = "static", serial: str | None = None, + priority: int = 0) -> Job: + """Encola un job (persistido en SQLite como 'queued' de inmediato).""" + is_local = _is_local_apk(target) + if kind == "dynamic" and not is_local: + raise ValueError( + f"Job dinámico requiere un .apk local (recibido: {target!r}). " + "Descárgalo primero (nutcracker scan) o usa el plugin aipwn para " + "flujos dinámicos con descarga automática." + ) + job = Job(target=target, kind=kind, is_local_apk=is_local, serial=serial, priority=priority) + conn = db.connect(self.db_path) + try: + job.db_id = repository.enqueue_job(conn, target=target, kind=kind, + serial=serial, priority=priority) + finally: + conn.close() + self._pending.append(job) + return job + + def submit_many(self, targets: list[str], kind: str = "static") -> list[Job]: + return [self.submit(t, kind=kind) for t in targets] + + def _load_queued_from_db(self) -> None: + """Recupera de SQLite los jobs en estado 'queued' que no estén ya en + memoria. Necesario porque cada invocación del CLI (`queue add`) es un + proceso nuevo: su QueueEngine arranca con `_pending` vacío, así que sin + esto un job encolado por un proceso anterior (o por el scheduler en un + tick previo) nunca sería recogido por `drain()`.""" + known_ids = {j.db_id for j in self._pending} + conn = db.connect(self.db_path) + try: + rows = repository.list_jobs(conn, status="queued", limit=1000) + finally: + conn.close() + for row in rows: + if row["id"] in known_ids: + continue + self._pending.append(Job( + target=row["target"], + kind=row["kind"], + is_local_apk=_is_local_apk(row["target"]), + serial=row["serial"], + priority=row["priority"], + db_id=row["id"], + )) + + def enqueue_due_apps(self) -> int: + """Encola como jobs estáticos las apps cuyo next_due_at ya venció (lo + llama el scheduler en cada tick). Retorna cuántas se encolaron.""" + conn = db.connect(self.db_path) + try: + due = repository.apps_due(conn) + finally: + conn.close() + for row in due: + self.submit(row["package"], kind="static") + return len(due) + + # ── Ejecución ──────────────────────────────────────────────────────────── + + def _device_lock(self, serial: str) -> threading.Lock: + with self._locks_guard: + return self._device_locks.setdefault(serial, threading.Lock()) + + def _default_interval_days(self) -> int: + config = load_config(self.config_path) + return int(cfg_get(config, "scheduler", "default_interval_days", default=30)) + + def _reschedule(self, conn, package: str) -> None: + """Recalcula next_due_at tras completar un run (Fase 1.2 del plan: + garantiza ≥1 revisión/mes por defecto para toda app que pasa por la cola).""" + sched = repository.get_schedule(conn, package) + if not sched: + repository.set_schedule(conn, package, interval_days=self._default_interval_days()) + return + next_due = ( + datetime.datetime.now(datetime.timezone.utc) + + datetime.timedelta(days=sched["interval_days"]) + ).isoformat(timespec="seconds") + repository.touch_app_run(conn, package, next_due_at=next_due) + + def _run_job(self, job: Job) -> JobOutcome: + conn = db.connect(self.db_path) + try: + repository.update_job_status(conn, job.db_id, "running") + finally: + conn.close() + + cmd = orch.build_job_cmd( + job.target, + is_local_apk=job.is_local_apk, + config_path=self.config_path, + static_only=(job.kind == "static"), + launch=(job.kind == "dynamic"), + serial=job.serial, + ) + env = dict(os.environ) + env["NUTCRACKER_QUEUE_JOB_ID"] = str(job.db_id) + + _log.info("job #%s: %s", job.db_id, " ".join(cmd)) + proc = subprocess.run(cmd, env=env, capture_output=True, text=True) + ok = proc.returncode == 0 + tail = (proc.stderr or proc.stdout or "").strip()[-2000:] + error = "" if ok else tail + + conn = db.connect(self.db_path) + try: + repository.update_job_status( + conn, job.db_id, "done" if ok else "error", error=error or None, + ) + row = repository.get_job(conn, job.db_id) + package = row["package"] if row else None + run_id = row["run_id"] if row else None + if package: + self._reschedule(conn, package) + finally: + conn.close() + + return JobOutcome(job=job, ok=ok, returncode=proc.returncode, error=error, + run_id=run_id, package=package) + + def _run_dynamic(self, job: Job) -> JobOutcome: + serial = job.serial or "default" + with self._device_lock(serial): + return self._run_job(job) + + def drain(self, on_result: Callable[[JobOutcome], None] | None = None) -> list[JobOutcome]: + """Ejecuta todos los jobs pendientes (en memoria + persistidos en SQLite + por otros procesos/ticks anteriores) hasta vaciar la cola y retorna sus + resultados. Bloqueante: vuelve cuando no queda ningún job por correr.""" + self._load_queued_from_db() + static_jobs = [j for j in self._pending if j.kind == "static"] + dynamic_jobs = [j for j in self._pending if j.kind == "dynamic"] + self._pending = [] + + outcomes: list[JobOutcome] = [] + + if static_jobs: + with ThreadPoolExecutor(max_workers=self.static_workers) as pool: + futures = [pool.submit(self._run_job, j) for j in static_jobs] + for fut in as_completed(futures): + outcome = fut.result() + outcomes.append(outcome) + if on_result: + on_result(outcome) + + if dynamic_jobs: + with ThreadPoolExecutor(max_workers=self.dynamic_workers) as pool: + futures = [pool.submit(self._run_dynamic, j) for j in dynamic_jobs] + for fut in as_completed(futures): + outcome = fut.result() + outcomes.append(outcome) + if on_result: + on_result(outcome) + + return outcomes diff --git a/nutcracker_core/queue/job.py b/nutcracker_core/queue/job.py new file mode 100644 index 0000000..9c932ec --- /dev/null +++ b/nutcracker_core/queue/job.py @@ -0,0 +1,21 @@ +"""Representación en memoria de un job de la cola (ver engine.py).""" + +from __future__ import annotations + +import time +from dataclasses import dataclass, field + + +@dataclass +class Job: + """Un job pendiente o en curso. El estado autoritativo vive en SQLite + (tabla ``queue_jobs``, ver store/repository.py); esta instancia es solo el + handle en memoria que usa QueueEngine para despacharlo.""" + + target: str + kind: str = "static" # static | dynamic + is_local_apk: bool = False + serial: str | None = None + priority: int = 0 + db_id: int | None = None # id en queue_jobs, asignado al encolar + created_at: float = field(default_factory=time.time) diff --git a/nutcracker_core/scheduler.py b/nutcracker_core/scheduler.py new file mode 100644 index 0000000..b278f9c --- /dev/null +++ b/nutcracker_core/scheduler.py @@ -0,0 +1,62 @@ +"""Scheduler de nutcracker (Fase 1.2 del plan). + +Garantiza revisiones periódicas mínimas por APK (por defecto, cada 30 días = +"≥1/mes"): en cada tick, encola en QueueEngine las apps cuyo ``next_due_at`` +ya venció y drena la cola. El re-agendado del *siguiente* vencimiento ocurre +dentro de QueueEngine al terminar cada job (ver ``QueueEngine._reschedule``), +así el ciclo se sostiene solo sin lógica adicional aquí. +""" + +from __future__ import annotations + +import datetime +import logging + +from apscheduler.schedulers.background import BackgroundScheduler + +from nutcracker_core.config import get as cfg_get + +_log = logging.getLogger(__name__) + + +class NutcrackerScheduler: + """Envoltorio delgado sobre APScheduler para el tick periódico de la cola.""" + + def __init__(self, engine, config: dict) -> None: + self.engine = engine + self.config = config + self._sched = BackgroundScheduler() + self._started = False + + def start(self) -> None: + if not bool(cfg_get(self.config, "scheduler", "enabled", default=True)): + _log.info("scheduler.enabled=false — no se agendan revisiones periódicas") + return + minutes = int(cfg_get(self.config, "scheduler", "poll_interval_minutes", default=60)) + self._sched.add_job( + self._tick, + "interval", + minutes=minutes, + next_run_time=datetime.datetime.now(), # primer tick casi inmediato + id="nutcracker-scheduler-tick", + replace_existing=True, + ) + self._sched.start() + self._started = True + _log.info("scheduler activo (cada %s min)", minutes) + + def _tick(self) -> None: + try: + n = self.engine.enqueue_due_apps() + if n: + _log.info("scheduler: %d app(s) vencida(s) encoladas", n) + # drain() siempre corre (y es barato si no hay nada pendiente): además + # de lo recién vencido, recoge cualquier job encolado manualmente + # (`nutcracker queue add`, sin --run) desde el tick anterior. + self.engine.drain() + except Exception: # noqa: BLE001 + _log.exception("scheduler: fallo en el tick periódico") + + def stop(self) -> None: + if self._started and self._sched.running: + self._sched.shutdown(wait=False) diff --git a/nutcracker_core/store/db.py b/nutcracker_core/store/db.py index 08a4080..6f05802 100644 --- a/nutcracker_core/store/db.py +++ b/nutcracker_core/store/db.py @@ -10,15 +10,42 @@ import sqlite3 from pathlib import Path -SCHEMA_VERSION = 1 +SCHEMA_VERSION = 2 _SCHEMA_PATH = Path(__file__).parent / "schema.sql" DEFAULT_DB_PATH = Path(__file__).parent.parent.parent / "nutcracker.db" -# Migraciones futuras: {version_destino: [statements]}. La versión 1 se aplica -# directamente desde schema.sql (ver _apply_schema). -_MIGRATIONS: dict[int, list[str]] = {} +# Migraciones incrementales: {version_destino: [statements]}. La versión 1 se aplica +# directamente desde schema.sql (ver _apply_schema). Todas usan "IF NOT EXISTS" para +# ser idempotentes tanto en bases nuevas (0→1→2 en la misma conexión) como existentes. +_MIGRATIONS: dict[int, list[str]] = { + # Fase 1 del plan: tabla de jobs de la cola (nutcracker_core/queue/). + # Desacoplada de `runs`: `runs` registra el resultado real de un análisis + # (lo escribe store/hooks.py al terminar); `queue_jobs` registra el ciclo de + # vida de un encolado (queued→running→done/error) y se enlaza al `run_id` + # real vía NUTCRACKER_QUEUE_JOB_ID (ver store/hooks.py y queue/engine.py). + 2: [ + """ + CREATE TABLE IF NOT EXISTS queue_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + target TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'static', -- static | dynamic + status TEXT NOT NULL DEFAULT 'queued', -- queued | running | done | error + serial TEXT, -- serial ADB (solo jobs dinámicos) + priority INTEGER NOT NULL DEFAULT 0, + package TEXT, -- se completa cuando el job termina + run_id INTEGER REFERENCES runs(id), + error TEXT, + created_at TEXT NOT NULL, + started_at TEXT, + finished_at TEXT + ) + """, + "CREATE INDEX IF NOT EXISTS idx_queue_jobs_status ON queue_jobs(status)", + "CREATE INDEX IF NOT EXISTS idx_queue_jobs_package ON queue_jobs(package)", + ], +} def connect(db_path: str | Path | None = None) -> sqlite3.Connection: @@ -31,6 +58,11 @@ def connect(db_path: str | Path | None = None) -> sqlite3.Connection: conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA foreign_keys=ON") + # La cola (Fase 1) puede tener varios procesos (engine + subprocesos hijos) + # escribiendo en la misma base casi a la vez; con WAL esto es seguro pero + # puede haber contención breve — sqlite3 reintenta en vez de fallar con + # "database is locked" mientras esté dentro de este margen. + conn.execute("PRAGMA busy_timeout=10000") migrate(conn) return conn diff --git a/nutcracker_core/store/hooks.py b/nutcracker_core/store/hooks.py index 64d6fc4..1e51361 100644 --- a/nutcracker_core/store/hooks.py +++ b/nutcracker_core/store/hooks.py @@ -9,6 +9,7 @@ from __future__ import annotations import logging +import os from pathlib import Path from typing import Any @@ -82,6 +83,17 @@ def _persist_after_analysis( repository.record_artifact(conn, run_id, artifact_type, str(path)) repository.touch_app_run(conn, package) + + # Fase 1 (cola): si este análisis corrió como job de queue/engine.py, el + # subproceso hijo trae NUTCRACKER_QUEUE_JOB_ID en su entorno — enlazamos + # el job con el run real para que el motor pueda leer package/verdict de + # vuelta sin adivinarlos antes de que termine el análisis. + job_id = os.environ.get("NUTCRACKER_QUEUE_JOB_ID") + if job_id: + try: + repository.link_job_run(conn, int(job_id), run_id, package) + except Exception as exc: # noqa: BLE001 + _log.debug("link_job_run falló para job %s: %s", job_id, exc) finally: conn.close() diff --git a/nutcracker_core/store/repository.py b/nutcracker_core/store/repository.py index ea05880..b89cb41 100644 --- a/nutcracker_core/store/repository.py +++ b/nutcracker_core/store/repository.py @@ -197,3 +197,67 @@ def get_schedule(conn: sqlite3.Connection, package: str) -> sqlite3.Row | None: def list_schedules(conn: sqlite3.Connection) -> list[sqlite3.Row]: return conn.execute("SELECT * FROM schedule ORDER BY package").fetchall() + + +# ── queue_jobs (Fase 1: cola paralela/secuencial) ────────────────────────────── + +def enqueue_job(conn: sqlite3.Connection, target: str, kind: str = "static", + serial: str | None = None, priority: int = 0) -> int: + """Inserta un job en estado 'queued' y retorna su id.""" + cur = conn.execute( + """ + INSERT INTO queue_jobs (target, kind, serial, priority, status, created_at) + VALUES (?, ?, ?, ?, 'queued', ?) + """, + (target, kind, serial, priority, _utcnow()), + ) + conn.commit() + return cur.lastrowid + + +def update_job_status(conn: sqlite3.Connection, job_id: int, status: str, + error: str | None = None) -> None: + """Transiciona el estado de un job. Marca started_at/finished_at automáticamente.""" + now = _utcnow() + started_at = now if status == "running" else None + finished_at = now if status in ("done", "error") else None + conn.execute( + """ + UPDATE queue_jobs SET + status = ?, + error = COALESCE(?, error), + started_at = COALESCE(?, started_at), + finished_at = COALESCE(?, finished_at) + WHERE id = ? + """, + (status, error, started_at, finished_at, job_id), + ) + conn.commit() + + +def link_job_run(conn: sqlite3.Connection, job_id: int, run_id: int, package: str) -> None: + """Vincula un queue_job con el `run` real que produjo. El propio subproceso del + job llama a esto desde store/hooks.py (vía NUTCRACKER_QUEUE_JOB_ID), porque el + package real solo se conoce una vez terminado el análisis (descarga/parseo del + APK), no al momento de encolar.""" + conn.execute( + "UPDATE queue_jobs SET run_id = ?, package = ? WHERE id = ?", + (run_id, package, job_id), + ) + conn.commit() + + +def get_job(conn: sqlite3.Connection, job_id: int) -> sqlite3.Row | None: + return conn.execute("SELECT * FROM queue_jobs WHERE id = ?", (job_id,)).fetchone() + + +def list_jobs(conn: sqlite3.Connection, status: str | None = None, + limit: int = 100) -> list[sqlite3.Row]: + if status: + return conn.execute( + "SELECT * FROM queue_jobs WHERE status = ? ORDER BY id DESC LIMIT ?", + (status, limit), + ).fetchall() + return conn.execute( + "SELECT * FROM queue_jobs ORDER BY id DESC LIMIT ?", (limit,) + ).fetchall() diff --git a/plan.md b/plan.md index e6c1512..dd4cf79 100644 --- a/plan.md +++ b/plan.md @@ -54,6 +54,52 @@ migrar secretos a variables de entorno (ver Fase 0). --- +## Estado de avance (actualizado 2026-07-24) + +Revisión del commit `90fad34 "advance"` (posterior al commit del plan) contra este documento. + +| Fase | Estado | +|---|---| +| **Fase 0** — Fundamentos + persistencia | ✅ **Completa** (con 2 huecos menores, ver abajo) | +| **Fase 1** — Cola + scheduler | ✅ **Completa** (POC verificada con APK real, ver abajo) | +| **Fase 2** — OWASP MAS | ❌ No iniciada | +| **Fase 3** — Dashboard web | ❌ No iniciada | + +### Fase 0 — detalle de lo entregado +- ✅ **0.1 Persistencia SQLite**: `nutcracker_core/store/{db,repository,hooks}.py` + `schema.sql`. + Esquema idéntico al planeado (`apps, runs, findings, artifacts, schedule`); `findings` ya trae + columnas `masvs/maswe/cwe` listas para la Fase 2. Doble escritura no destructiva confirmada: + `store/hooks.py::install()` registra el post-hook `after_analysis` (llamado desde + `cli/__init__.py`); los JSON/PDF en disco siguen intactos. +- ✅ **0.2 Refactor CLI**: `nutcracker.py` quedó en **18 líneas** (shim → `from nutcracker_core.cli + import cli`). Paquete `cli/` con `scan.py, analyze.py, launch.py, batch.py, setup_token.py, + regen_pdf.py`. `orchestrator.py` (1.496 líneas) concentra la orquestación. +- ✅ **0.3 Split de `vuln_scanner.py`**: nacieron `scan_types.py` y `leak_scanner.py`; + `vuln_scanner.py` bajó de ~1.400 a 456 líneas. +- ✅ **0.4 Config con env vars**: `config.py` soporta `${ENV_VAR}`, cubierto por + `tests/test_config_env.py`. +- ✅ **0.5 Tests base**: `pytest.ini`, `requirements-dev.txt`, `conftest.py` + + `test_store_repository.py`, `test_store_hooks.py`, `test_config_env.py`, + `test_vuln_scanner_split.py`. + +**Huecos abiertos dentro de Fase 0 (bloquean el arranque limpio de Fase 1):** +1. ⚠️ `orchestrator.py` es un *lift-and-shift* de los helpers privados de `nutcracker.py` + (`_run_analysis`, `_post_analysis_flow`, etc.) — **no expone todavía una API pública + `run_static(apk)` / `run_dynamic(apk, serial)`** reutilizable por la cola. Se resuelve como + primer paso de la Fase 1 (ver 1.0 abajo) en vez de re-tocar Fase 0. +2. ❌ `pyproject.toml` no existe todavía (empaquetado/entry-points) — se deja para el cierre de + mantenibilidad transversal, no bloquea Fase 1. +3. ❔ Suite `pytest` no verificada en este entorno por falta de dependencias instaladas + (`loguru` ausente); es un problema de entorno local, no del código — pendiente de confirmar + en un entorno con `pip install -r requirements.txt -r requirements-dev.txt`. + +### Fase 1 — qué falta (antes de empezar, confirmado por inspección directa) +Nada de esto existe aún: `nutcracker_core/queue/` (engine con pool estático paralelo + lock por +device), `nutcracker_core/scheduler.py` (APScheduler), comando `nutcracker serve`, comandos +`queue`/`schedule`, `batch --due`. `apscheduler` no está en `requirements.txt`. + +--- + ## Principios rectores - **No romper la frontera core/plugin.** Persistencia, cola y scheduler son **core** (utilidad general). El dashboard y la IA son **plugins**. El dashboard **lee** el store SQLite y **consume** el streaming de @@ -143,6 +189,85 @@ Objetivo: `nutcracker serve` = daemon que encola, ejecuta y agenda revisiones pe **Entregable:** dado un `list_file`, el daemon revisa todas las apps, respeta paralelo/secuencial y device-lock, y re-agenda cada una a ≥1/mes automáticamente. +### Fase 1 — estado: ✅ completa (2026-07-24) + +**Decisiones de diseño tomadas durante la implementación** (ajustan el texto original de +esta fase a la realidad del código): + +- **Aislamiento por subproceso, no por hilo in-process.** `orchestrator.py` guarda estado + mutuo a nivel de módulo (`_CFG`, `_MANIFEST_ANALYSIS`, `_OSINT_RESULT`, ...) que no es + thread-safe entre análisis concurrentes. En vez de reescribir 1.500 líneas ya probadas, + cada job de la cola corre como **subproceso aislado** que invoca el CLI existente + (`nutcracker analyze`/`scan`), construido por el nuevo helper + `orchestrator.build_job_cmd()`. Esto da paralelismo real (sin GIL) y reutiliza el + pipeline al 100% sin duplicar lógica. `apply_static_only_override()` + el flag + `--static-only` (nuevo en `analyze`/`scan`) fuerzan decompilación jadx y evitan tocar el + dispositivo en jobs estáticos. +- **Tabla `queue_jobs` separada de `runs`** (migración SQLite versión 2, `store/db.py`). + `runs` sigue siendo escrita únicamente por el post-hook `after_analysis` + (`store/hooks.py`) al terminar un análisis real; `queue_jobs` registra el ciclo de vida + del encolado (`queued→running→done/error`) y se enlaza al `run_id`/`package` reales vía + la variable de entorno `NUTCRACKER_QUEUE_JOB_ID`, que el subproceso hijo propaga hasta el + hook (`repository.link_job_run`). Evita adivinar el package antes de que el análisis + termine y evita duplicar filas en `runs`. +- **Bug real encontrado y corregido en la propia POC:** cada invocación de + `nutcracker queue add` es un proceso nuevo, así que la lista en memoria `_pending` de + `QueueEngine` no persiste entre invocaciones. Sin corrección, un job encolado por un + proceso y drenado por otro (`queue add --run`, o el scheduler en un tick posterior) se + quedaba huérfano en estado `queued` para siempre. Fix: `QueueEngine.drain()` ahora llama + primero a `_load_queued_from_db()`, que recupera de SQLite cualquier job `queued` no + presente ya en memoria. `NutcrackerScheduler._tick()` también se simplificó para llamar + siempre a `drain()` (barato si no hay nada pendiente) en vez de solo cuando hay apps + vencidas — así recoge jobs encolados manualmente entre ticks. Regresión cubierta por + `tests/test_queue_engine.py::test_drain_picks_up_jobs_queued_by_a_different_engine_instance`. +- **`batch` NO se migró internamente al motor de cola** (a diferencia de lo previsto en el + texto original de 1.3). Se mantiene tal cual (secuencial, in-process, ya probado) por + relación riesgo/beneficio: reescribirlo para reconstruir su resumen consolidado + (severidades, top findings, categorías) desde SQLite en vez de desde objetos en memoria + era un cambio de mayor superficie sin beneficio claro sobre simplemente ofrecer el nuevo + camino recomendado (`queue add ` + `serve`/`--run`), que cubre el mismo + entregable (revisión masiva con paralelo/secuencial + reintentable + persistida). Pendiente + como mejora futura opcional, no bloqueante. + +**Archivos nuevos:** `nutcracker_core/queue/{__init__,job,engine}.py`, +`nutcracker_core/scheduler.py`, `nutcracker_core/cli/{serve,queue_cmd,schedule_cmd}.py`, +`tests/test_queue_engine.py`, `tests/test_scheduler.py`. +**Archivos modificados:** `store/schema` (vía migración en `db.py`, no se tocó +`schema.sql`), `store/repository.py` (CRUD de `queue_jobs`), `store/hooks.py` (enlace +`NUTCRACKER_QUEUE_JOB_ID`→`run_id`), `orchestrator.py` (`apply_static_only_override`, +`build_job_cmd`), `cli/analyze.py`+`cli/scan.py` (`--static-only`), `cli/__init__.py` +(registro de comandos), `requirements.txt` (+`apscheduler`), `config.yaml(.example)` +(bloques `queue:`/`scheduler:`), `.gitignore` (+`nutcracker.db*`). + +**Comandos nuevos:** `nutcracker serve`, `nutcracker queue add|ls`, `nutcracker schedule set|ls`. + +**Tests:** 15 nuevos (`test_queue_engine.py` ×11, `test_scheduler.py` ×5, uno reemplazado) — +paralelismo estático (timing), modo secuencial (pool=1), serialización por device-lock +(mismo serial nunca solapa; seriales distintos sí paralelizan), rechazo de job dinámico sin +`.apk` local, transición de estados + error capturado, re-agendado automático, filtro de +`enqueue_due_apps`, y el fix de recuperación cross-proceso. Suite completa: **54/54 passing** +(`.venv` con `requirements.txt`+`requirements-dev.txt`). + +**POC real ejecutada** (no simulada): usando un APK legítimo de Mobile Hacking Lab +(`com.mobilehackinglab.iotconnect.apk`, entorno de entrenamiento en seguridad móvil ya +presente en la máquina del usuario) copiado 3 veces, encolado vía **3 invocaciones +separadas** de `nutcracker queue add` (2 sin `--run`, la 3ª con `--run`): +- Los 3 jobs — encolados por procesos CLI distintos — se recuperaron y ejecutaron desde la + 3ª invocación gracias al fix de `_load_queued_from_db()`. +- `queue_jobs.started_at` idéntico para los 3 (`17:54:29`) → paralelismo real confirmado, + no solo solapamiento parcial. Tiempo total: **~84s** vs. ~174s que habría tomado en serie + (58s por análisis real × 3), con `static_workers=3`. +- Cada job quedó enlazado a su `run_id`/`package` real (`com.mobilehackinglab.iotconnect`, + verdict `protected`, score 80, grade B, 1 finding `AUTH001`→`MASVS-AUTH-2`) — persistido en + `runs`/`findings` por el hook de Fase 0, sin tocarlo. +- **Re-agendado automático confirmado**: sin que nadie llamara `schedule set`, al terminar + el primer job se creó una fila en `schedule` (`interval_days=30, enabled=1`) y + `apps.next_due_at` quedó en `last_run_at + 30 días` — el requisito "≥1 revisión/mes por + defecto" queda satisfecho automáticamente para cualquier app que pase por la cola. +- `nutcracker serve` arranca (banner + "daemon activo"), corre el scheduler en background, y + se apaga con SIGTERM imprimiendo "nutcracker serve — detenido" (sin colgarse). +- `nutcracker queue ls` y `nutcracker schedule ls` muestran el estado correctamente. + --- ## Fase 2 — Alineación OWASP MAS (MASVS + MASWE + MASTG + CWE) con tests `.py` deterministas diff --git a/requirements.txt b/requirements.txt index 2d70077..e267499 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,3 +20,6 @@ frida-dexdump>=2.0.1 # Utilidades HTTP requests>=2.31.0 + +# Cola y scheduler (Fase 1: ejecución masiva + revisiones periódicas) +apscheduler>=3.10 diff --git a/tests/test_queue_engine.py b/tests/test_queue_engine.py new file mode 100644 index 0000000..fc81f78 --- /dev/null +++ b/tests/test_queue_engine.py @@ -0,0 +1,273 @@ +"""Tests de nutcracker_core/queue/engine.py (Fase 1 del plan). + +subprocess.run se reemplaza por un doble de prueba: lo que se ejercita aquí es +la mecánica de la cola (paralelismo estático, serialización por dispositivo, +transiciones de estado, re-agendado), no el pipeline de análisis real (ya +cubierto por Fase 0 / tests existentes). +""" + +from __future__ import annotations + +import subprocess +import threading +import time + +import pytest + +from nutcracker_core.queue.engine import QueueEngine +from nutcracker_core.store import db, repository + + +def _touch_apk(tmp_path, name: str): + """Crea un archivo .apk vacío: basta para que _is_local_apk() lo acepte, + ya que subprocess.run está mockeado y nunca se invoca androguard de verdad.""" + p = tmp_path / name + p.write_bytes(b"PK\x03\x04") # cabecera zip mínima, contenido irrelevante + return p + + +@pytest.fixture +def engine(tmp_path): + db_path = tmp_path / "queue_test.db" + return QueueEngine(config_path="config.yaml", db_path=str(db_path), + static_workers=4, dynamic_workers=2) + + +# ── Paralelismo estático ──────────────────────────────────────────────────── + +def test_static_jobs_run_in_parallel(monkeypatch, tmp_path, engine): + calls = [] + lock = threading.Lock() + + def fake_run(cmd, env=None, capture_output=True, text=True): # noqa: ANN001 + start = time.monotonic() + time.sleep(0.25) + with lock: + calls.append((start, time.monotonic())) + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr("nutcracker_core.queue.engine.subprocess.run", fake_run) + + for i in range(4): + engine.submit(str(_touch_apk(tmp_path, f"app{i}.apk")), kind="static") + + t0 = time.monotonic() + outcomes = engine.drain() + elapsed = time.monotonic() - t0 + + assert len(outcomes) == 4 + assert all(o.ok for o in outcomes) + # 4 jobs de 0.25s con static_workers=4 deberían solaparse: muy por debajo + # de 4*0.25=1.0s si corrieran en serie. Margen generoso para CI lento. + assert elapsed < 0.7, f"jobs estáticos no parecen haber corrido en paralelo ({elapsed:.2f}s)" + + +def test_static_jobs_run_sequentially_with_one_worker(monkeypatch, tmp_path, tmp_path_factory): + db_path = tmp_path / "seq.db" + engine = QueueEngine(config_path="config.yaml", db_path=str(db_path), static_workers=1) + + def fake_run(cmd, env=None, capture_output=True, text=True): # noqa: ANN001 + time.sleep(0.15) + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr("nutcracker_core.queue.engine.subprocess.run", fake_run) + + for i in range(3): + engine.submit(str(_touch_apk(tmp_path, f"seq{i}.apk")), kind="static") + + t0 = time.monotonic() + engine.drain() + elapsed = time.monotonic() - t0 + + # queue.mode "sequential" = pool de tamaño 1: 3 jobs de 0.15s deben tardar + # ~0.45s (serie), no ~0.15s (paralelo). + assert elapsed >= 0.40, f"static_workers=1 no serializó los jobs ({elapsed:.2f}s)" + + +# ── Serialización por dispositivo (jobs dinámicos) ────────────────────────── + +def test_dynamic_jobs_same_serial_never_overlap(monkeypatch, tmp_path, engine): + intervals: list[tuple[float, float]] = [] + lock = threading.Lock() + + def fake_run(cmd, env=None, capture_output=True, text=True): # noqa: ANN001 + start = time.monotonic() + time.sleep(0.2) + end = time.monotonic() + with lock: + intervals.append((start, end)) + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr("nutcracker_core.queue.engine.subprocess.run", fake_run) + + for i in range(2): + engine.submit(str(_touch_apk(tmp_path, f"dyn{i}.apk")), kind="dynamic", serial="SAME-SERIAL") + + outcomes = engine.drain() + assert len(outcomes) == 2 and all(o.ok for o in outcomes) + + (s1, e1), (s2, e2) = sorted(intervals) + assert e1 <= s2, "dos jobs dinámicos con el mismo serial se solaparon (el lock por device falló)" + + +def test_dynamic_jobs_different_serials_run_in_parallel(monkeypatch, tmp_path, engine): + intervals: list[tuple[float, float]] = [] + lock = threading.Lock() + + def fake_run(cmd, env=None, capture_output=True, text=True): # noqa: ANN001 + start = time.monotonic() + time.sleep(0.2) + end = time.monotonic() + with lock: + intervals.append((start, end)) + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr("nutcracker_core.queue.engine.subprocess.run", fake_run) + + engine.submit(str(_touch_apk(tmp_path, "devA.apk")), kind="dynamic", serial="DEVICE-A") + engine.submit(str(_touch_apk(tmp_path, "devB.apk")), kind="dynamic", serial="DEVICE-B") + + outcomes = engine.drain() + assert len(outcomes) == 2 and all(o.ok for o in outcomes) + + (s1, e1), (s2, e2) = sorted(intervals) + # Solapan si el segundo empieza antes de que termine el primero. + assert s2 < e1, "jobs dinámicos en dispositivos distintos no corrieron en paralelo" + + +def test_dynamic_job_requires_local_apk(engine): + with pytest.raises(ValueError): + engine.submit("com.example.app", kind="dynamic", serial="X") + + +# ── Estado en SQLite ───────────────────────────────────────────────────────── + +def test_job_status_transitions_and_error_recorded(monkeypatch, tmp_path, engine): + def fake_run_fail(cmd, env=None, capture_output=True, text=True): # noqa: ANN001 + return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="boom: jadx not found") + + monkeypatch.setattr("nutcracker_core.queue.engine.subprocess.run", fake_run_fail) + + job = engine.submit(str(_touch_apk(tmp_path, "broken.apk")), kind="static") + outcomes = engine.drain() + + assert len(outcomes) == 1 and outcomes[0].ok is False + assert "boom" in outcomes[0].error + + conn = db.connect(engine.db_path) + try: + row = repository.get_job(conn, job.db_id) + finally: + conn.close() + assert row["status"] == "error" + assert "boom" in row["error"] + assert row["started_at"] is not None and row["finished_at"] is not None + + +# ── Re-agendado tras completar un job (Fase 1.2) ──────────────────────────── + +def test_reschedule_sets_next_due_at_after_job_completes(monkeypatch, tmp_path, engine): + """Simula lo que hace store/hooks.py en el subproceso real: enlaza el job + con un run_id/package vía repository.link_job_run. QueueEngine debe leer + ese package de vuelta y fijar next_due_at (~30 días) automáticamente.""" + + def fake_run_ok(cmd, env=None, capture_output=True, text=True): # noqa: ANN001 + job_id = int(env["NUTCRACKER_QUEUE_JOB_ID"]) + conn = db.connect(engine.db_path) + try: + run_id = repository.insert_run(conn, "com.example.testapp", kind="static", status="done") + repository.link_job_run(conn, job_id, run_id, "com.example.testapp") + finally: + conn.close() + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr("nutcracker_core.queue.engine.subprocess.run", fake_run_ok) + + engine.submit(str(_touch_apk(tmp_path, "sched.apk")), kind="static") + outcomes = engine.drain() + assert outcomes[0].ok and outcomes[0].package == "com.example.testapp" + + conn = db.connect(engine.db_path) + try: + app = repository.get_app(conn, "com.example.testapp") + sched = repository.get_schedule(conn, "com.example.testapp") + finally: + conn.close() + + assert app["next_due_at"] is not None + assert sched is not None and sched["interval_days"] == 30 # default_interval_days + + +# ── enqueue_due_apps ────────────────────────────────────────────────────────── + +def test_drain_picks_up_jobs_queued_by_a_different_engine_instance(monkeypatch, tmp_path): + """Reproduce el bug encontrado en la POC manual: `nutcracker queue add` sin + --run corre en un proceso CLI separado de `nutcracker queue add --run` (o + de `nutcracker serve`) — cada uno crea su propia QueueEngine con `_pending` + vacío. Sin cargar los jobs 'queued' desde SQLite, drain() de la segunda + instancia nunca vería el job que encoló la primera.""" + db_path = tmp_path / "shared.db" + apk = _touch_apk(tmp_path, "from_process_a.apk") + + engine_a = QueueEngine(config_path="config.yaml", db_path=str(db_path)) + job = engine_a.submit(str(apk), kind="static") + # engine_a nunca llama drain(): simula un `queue add` (sin --run) que + # termina el proceso justo después de encolar. + + engine_b = QueueEngine(config_path="config.yaml", db_path=str(db_path)) + + def fake_run(cmd, env=None, capture_output=True, text=True): # noqa: ANN001 + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr("nutcracker_core.queue.engine.subprocess.run", fake_run) + + outcomes = engine_b.drain() + + assert len(outcomes) == 1 + assert outcomes[0].job.db_id == job.db_id + assert outcomes[0].ok is True + + conn = db.connect(str(db_path)) + try: + row = repository.get_job(conn, job.db_id) + finally: + conn.close() + assert row["status"] == "done" + + +def test_drain_does_not_rerun_jobs_already_pending_in_memory(monkeypatch, tmp_path, engine): + """_load_queued_from_db() no debe duplicar un job que ya está en self._pending + (mismo proceso: submit() + drain() sin reiniciar).""" + run_count = {"n": 0} + + def fake_run(cmd, env=None, capture_output=True, text=True): # noqa: ANN001 + run_count["n"] += 1 + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr("nutcracker_core.queue.engine.subprocess.run", fake_run) + + engine.submit(str(_touch_apk(tmp_path, "once.apk")), kind="static") + outcomes = engine.drain() + + assert len(outcomes) == 1 + assert run_count["n"] == 1 + + +def test_enqueue_due_apps_only_queues_overdue_packages(tmp_path, engine): + import datetime + + conn = db.connect(engine.db_path) + try: + past = (datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=1)).isoformat() + future = (datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=10)).isoformat() + repository.upsert_app(conn, "com.overdue.app") + repository.touch_app_run(conn, "com.overdue.app", next_due_at=past) + repository.upsert_app(conn, "com.uptodate.app") + repository.touch_app_run(conn, "com.uptodate.app", next_due_at=future) + finally: + conn.close() + + n = engine.enqueue_due_apps() + assert n == 1 + assert len(engine._pending) == 1 + assert engine._pending[0].target == "com.overdue.app" diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py new file mode 100644 index 0000000..a866845 --- /dev/null +++ b/tests/test_scheduler.py @@ -0,0 +1,66 @@ +"""Tests de nutcracker_core/scheduler.py (Fase 1.2 del plan).""" + +from __future__ import annotations + +from nutcracker_core.scheduler import NutcrackerScheduler + + +class _StubEngine: + def __init__(self, due: int = 0): + self._due = due + self.enqueue_calls = 0 + self.drain_calls = 0 + + def enqueue_due_apps(self) -> int: + self.enqueue_calls += 1 + return self._due + + def drain(self): + self.drain_calls += 1 + return [] + + +def test_tick_drains_when_apps_are_due(): + engine = _StubEngine(due=3) + sched = NutcrackerScheduler(engine, config={}) + sched._tick() + assert engine.enqueue_calls == 1 + assert engine.drain_calls == 1 + + +def test_tick_still_drains_when_nothing_due(): + """drain() debe correr en cada tick aunque no haya apps vencidas: puede + haber jobs encolados manualmente (`queue add`, sin --run) esperando desde + un tick anterior, y drain() es barato cuando no hay nada pendiente.""" + engine = _StubEngine(due=0) + sched = NutcrackerScheduler(engine, config={}) + sched._tick() + assert engine.enqueue_calls == 1 + assert engine.drain_calls == 1 + + +def test_tick_never_raises_on_engine_failure(): + class _Boom(_StubEngine): + def enqueue_due_apps(self): + raise RuntimeError("db is on fire") + + sched = NutcrackerScheduler(_Boom(), config={}) + sched._tick() # no debe propagar la excepción + + +def test_start_respects_scheduler_enabled_false(): + engine = _StubEngine() + sched = NutcrackerScheduler(engine, config={"scheduler": {"enabled": False}}) + sched.start() + assert sched._started is False + sched.stop() # no-op seguro, no debe fallar aunque nunca arrancó + + +def test_start_and_stop_lifecycle(): + engine = _StubEngine() + sched = NutcrackerScheduler(engine, config={"scheduler": {"enabled": True, "poll_interval_minutes": 60}}) + sched.start() + assert sched._started is True + assert sched._sched.running is True + sched.stop() + assert sched._sched.running is False From 3cf0bd052ef44906bd84ba25b2f7c7b1d9cd0ecb Mon Sep 17 00:00:00 2001 From: Hiteek Date: Fri, 24 Jul 2026 14:04:22 -0500 Subject: [PATCH 17/30] Fase 2 --- docs/owasp-mas-coverage.md | 116 +++++ nutcracker_core/analyzer.py | 10 + nutcracker_core/checks/__init__.py | 50 +++ nutcracker_core/checks/base.py | 70 +++ nutcracker_core/checks/dynamic/__init__.py | 0 .../checks/dynamic/cleartext_traffic.py | 66 +++ nutcracker_core/checks/dynamic/context.py | 40 ++ nutcracker_core/checks/dynamic/debuggable.py | 60 +++ nutcracker_core/checks/registry.py | 39 ++ nutcracker_core/checks/static/__init__.py | 0 nutcracker_core/checks/static/adapter.py | 113 +++++ nutcracker_core/i18n.py | 4 + nutcracker_core/masvs.py | 405 +++++++++++++++--- nutcracker_core/orchestrator.py | 9 + nutcracker_core/store/hooks.py | 8 +- nutcracker_core/vuln_scanner.py | 21 +- plan.md | 98 ++++- tests/checks/__init__.py | 0 tests/checks/test_dynamic_checks.py | 98 +++++ tests/checks/test_registry.py | 113 +++++ tests/test_masvs_bypass.py | 62 +++ tools/gen_owasp_coverage.py | 94 ++++ 22 files changed, 1408 insertions(+), 68 deletions(-) create mode 100644 docs/owasp-mas-coverage.md create mode 100644 nutcracker_core/checks/__init__.py create mode 100644 nutcracker_core/checks/base.py create mode 100644 nutcracker_core/checks/dynamic/__init__.py create mode 100644 nutcracker_core/checks/dynamic/cleartext_traffic.py create mode 100644 nutcracker_core/checks/dynamic/context.py create mode 100644 nutcracker_core/checks/dynamic/debuggable.py create mode 100644 nutcracker_core/checks/registry.py create mode 100644 nutcracker_core/checks/static/__init__.py create mode 100644 nutcracker_core/checks/static/adapter.py create mode 100644 tests/checks/__init__.py create mode 100644 tests/checks/test_dynamic_checks.py create mode 100644 tests/checks/test_registry.py create mode 100644 tests/test_masvs_bypass.py create mode 100644 tools/gen_owasp_coverage.py diff --git a/docs/owasp-mas-coverage.md b/docs/owasp-mas-coverage.md new file mode 100644 index 0000000..df890d8 --- /dev/null +++ b/docs/owasp-mas-coverage.md @@ -0,0 +1,116 @@ +# Cobertura OWASP MAS de nutcracker + +Generado automáticamente por `tools/gen_owasp_coverage.py` desde `nutcracker_core/checks/` (Fase 2 del plan). No editar a mano — regenerar tras cambios en `masvs.py` o en los checks. + +**Resumen:** 14/24 controles MASVS v2.1 con al menos un check · 64 checks totales (62 estáticos, 2 dinámicos) · 29/119 debilidades MASWE referenciadas. + +## Cobertura por control MASVS + +| Control | Descripción | Checks | MASWE | +|---|---|---|---| +| `MASVS-STORAGE-1` | La app almacena de forma segura los datos sensibles, sin importar la ubicación (privada o pública) | `ST001`, `ST002`, `ST003`, `ST004` | MASWE-0002, MASWE-0006, MASWE-0007 | +| `MASVS-STORAGE-2` | La app evita la fuga no intencional de datos sensibles (por ejemplo vía backups o logs) | `HC001`, `HC002`, `HC003`, `HC004`, `HC005`, `HC007`, `HC008`, `AUTH001`, `DBG002`, `DBG003`, `ST006`, `NAT004` | MASWE-0001, MASWE-0002, MASWE-0005, MASWE-0013 | +| `MASVS-CRYPTO-1` | La app usa criptografía fuerte y actual según las mejores prácticas de la industria | `HC006`, `CRYPTO001`, `CRYPTO002`, `CRYPTO003`, `CRYPTO004`, `CRYPTO005`, `CRYPTO006`, `NAT008` | MASWE-0013, MASWE-0020, MASWE-0021, MASWE-0022, MASWE-0027 | +| `MASVS-CRYPTO-2` | La app gestiona las claves criptográficas (generación, almacenamiento, rotación) según las mejores prácticas | — | — | +| `MASVS-AUTH-1` | La app usa protocolos de autenticación y autorización seguros y sigue las mejores prácticas relevantes | — | — | +| `MASVS-AUTH-2` | La app realiza la autenticación local (biometría, PIN) de forma segura según las mejores prácticas de la plataforma | — | — | +| `MASVS-AUTH-3` | La app protege las operaciones sensibles con autenticación adicional (step-up) | — | — | +| `MASVS-NETWORK-1` | La app protege todo el tráfico de red según las mejores prácticas actuales (TLS, validación de certificados) | `HC008`, `NET001`, `NET002`, `NET003`, `NET004`, `NET006`, `NAT005`, `NAT007`, `DYN-CLEARTEXT-TRAFFIC` | MASWE-0047, MASWE-0050, MASWE-0052 | +| `MASVS-NETWORK-2` | La app implementa identity pinning (certificate/public key pinning) para los endpoints bajo control del desarrollador | `NET005`, `NAT007`, `Certificate pinning` | MASWE-0047 | +| `MASVS-PLATFORM-1` | La app usa los mecanismos de IPC de forma segura (intents, deep links, componentes exportados) | `COMP004`, `INJ004`, `COMP006`, `COMP007`, `COMP008` | MASWE-0058, MASWE-0062, MASWE-0063, MASWE-0064, MASWE-0119 | +| `MASVS-PLATFORM-2` | La app usa WebViews de forma segura (sin JS innecesario, sin acceso a archivos, sin puentes JS expuestos) | `COMP001`, `COMP002`, `COMP003`, `COMP005`, `NET006` | MASWE-0052, MASWE-0068, MASWE-0069, MASWE-0072 | +| `MASVS-PLATFORM-3` | La app usa la interfaz de usuario de forma segura (evita fugas vía capturas de pantalla, clipboard, etc.) | `ST005` | MASWE-0053 | +| `MASVS-CODE-1` | La app requiere una versión de plataforma actualizada | — | — | +| `MASVS-CODE-2` | La app tiene un mecanismo para forzar actualizaciones ante vulnerabilidades críticas | — | — | +| `MASVS-CODE-3` | La app solo usa componentes de software sin vulnerabilidades conocidas | — | — | +| `MASVS-CODE-4` | La app valida y sanitiza toda entrada no confiable (SQL, IPC, deserialización, comandos) | `INJ001`, `INJ002`, `INJ003`, `DESER001`, `NAT001`, `NAT002` | MASWE-0081, MASWE-0086, MASWE-0088 | +| `MASVS-RESILIENCE-1` | La app valida la integridad de la plataforma (detecta dispositivos rooteados/comprometidos) | `NAT006`, `Known anti-root libraries`, `SafetyNet / Play Integrity API`, `Manual root checks`, `Anti Magisk / SuperSU / KernelSU / Frida`, `AppDome` | — | +| `MASVS-RESILIENCE-2` | La app implementa mecanismos anti-tampering (verifica firma del paquete, integridad de DEX/código nativo/recursos) | `DBG001`, `DexGuardDetector`, `AppDome`, `APK signature verification` | MASWE-0095 | +| `MASVS-RESILIENCE-3` | La app implementa mecanismos anti-análisis-estático (ofuscación de código, recursos y strings) | `OBF001`, `DexGuardDetector`, `AppDome` | — | +| `MASVS-RESILIENCE-4` | La app implementa técnicas anti-análisis-dinámico (detección de debugger, Frida, hooking) | `INFO001`, `NAT003`, `Anti Magisk / SuperSU / KernelSU / Frida`, `DexGuardDetector`, `AppDome`, `DYN-DEBUGGABLE` | MASWE-0067, MASWE-0101 | +| `MASVS-PRIVACY-1` | La app minimiza el acceso a datos y recursos sensibles (principio de mínimo privilegio en permisos) | `EXTRA001` | MASWE-0117 | +| `MASVS-PRIVACY-2` | La app evita la identificación del usuario (anonimización/pseudonimización) | — | — | +| `MASVS-PRIVACY-3` | La app es transparente sobre la recolección y uso de datos | — | — | +| `MASVS-PRIVACY-4` | La app ofrece al usuario control sobre sus datos | — | — | + +## Controles sin cobertura hoy (gap honesto, no una promesa) + +- `MASVS-AUTH-1` — La app usa protocolos de autenticación y autorización seguros y sigue las mejores prácticas relevantes +- `MASVS-AUTH-2` — La app realiza la autenticación local (biometría, PIN) de forma segura según las mejores prácticas de la plataforma +- `MASVS-AUTH-3` — La app protege las operaciones sensibles con autenticación adicional (step-up) +- `MASVS-CODE-1` — La app requiere una versión de plataforma actualizada +- `MASVS-CODE-2` — La app tiene un mecanismo para forzar actualizaciones ante vulnerabilidades críticas +- `MASVS-CODE-3` — La app solo usa componentes de software sin vulnerabilidades conocidas +- `MASVS-CRYPTO-2` — La app gestiona las claves criptográficas (generación, almacenamiento, rotación) según las mejores prácticas +- `MASVS-PRIVACY-2` — La app evita la identificación del usuario (anonimización/pseudonimización) +- `MASVS-PRIVACY-3` — La app es transparente sobre la recolección y uso de datos +- `MASVS-PRIVACY-4` — La app ofrece al usuario control sobre sus datos + +## Checks registrados + +| ID | Kind | Fuente | Severidad | MASVS | MASWE | CWE | +|---|---|---|---|---|---|---| +| `DYN-CLEARTEXT-TRAFFIC` | dynamic | dynamic | high | MASVS-NETWORK-1 | MASWE-0050 | CWE-319 | +| `DYN-DEBUGGABLE` | dynamic | dynamic | high | MASVS-RESILIENCE-4 | MASWE-0067 | CWE-489 | +| `APK signature verification` | static | detector | medium | MASVS-RESILIENCE-2 | — | — | +| `Anti Magisk / SuperSU / KernelSU / Frida` | static | detector | high | MASVS-RESILIENCE-1, MASVS-RESILIENCE-4 | — | — | +| `AppDome` | static | detector | high | MASVS-RESILIENCE-1, MASVS-RESILIENCE-2, MASVS-RESILIENCE-3, MASVS-RESILIENCE-4 | — | — | +| `Certificate pinning` | static | detector | high | MASVS-NETWORK-2 | — | — | +| `DexGuardDetector` | static | detector | high | MASVS-RESILIENCE-2, MASVS-RESILIENCE-3, MASVS-RESILIENCE-4 | — | — | +| `Known anti-root libraries` | static | detector | high | MASVS-RESILIENCE-1 | — | — | +| `Manual root checks` | static | detector | medium | MASVS-RESILIENCE-1 | — | — | +| `SafetyNet / Play Integrity API` | static | detector | high | MASVS-RESILIENCE-1 | — | — | +| `NAT001` | static | native_scanner | high | MASVS-CODE-4 | — | CWE-120 | +| `NAT002` | static | native_scanner | critical | MASVS-CODE-4 | — | CWE-78 | +| `NAT003` | static | native_scanner | info | MASVS-RESILIENCE-4 | MASWE-0101 | — | +| `NAT004` | static | native_scanner | high | MASVS-STORAGE-2 | MASWE-0002 | CWE-798 | +| `NAT005` | static | native_scanner | high | MASVS-NETWORK-1 | MASWE-0052 | CWE-295 | +| `NAT006` | static | native_scanner | medium | MASVS-RESILIENCE-1 | — | — | +| `NAT007` | static | native_scanner | medium | MASVS-NETWORK-1, MASVS-NETWORK-2 | MASWE-0047 | — | +| `NAT008` | static | native_scanner | high | MASVS-CRYPTO-1 | MASWE-0020, MASWE-0021 | CWE-327 | +| `AUTH001` | static | vuln_scanner | high | MASVS-STORAGE-2 | MASWE-0001 | CWE-532 | +| `COMP001` | static | vuln_scanner | medium | MASVS-PLATFORM-2 | MASWE-0072 | CWE-79 | +| `COMP002` | static | vuln_scanner | high | MASVS-PLATFORM-2 | MASWE-0069 | CWE-200 | +| `COMP003` | static | vuln_scanner | critical | MASVS-PLATFORM-2 | MASWE-0068 | CWE-749 | +| `COMP004` | static | vuln_scanner | medium | MASVS-PLATFORM-1 | MASWE-0063 | CWE-925 | +| `COMP005` | static | vuln_scanner | high | MASVS-PLATFORM-2 | MASWE-0072 | CWE-79 | +| `COMP006` | static | vuln_scanner | critical | MASVS-PLATFORM-1 | MASWE-0119 | CWE-926 | +| `COMP007` | static | vuln_scanner | high | MASVS-PLATFORM-1 | MASWE-0062 | CWE-926 | +| `COMP008` | static | vuln_scanner | critical | MASVS-PLATFORM-1 | MASWE-0064 | CWE-926 | +| `CRYPTO001` | static | vuln_scanner | medium | MASVS-CRYPTO-1 | MASWE-0021 | CWE-328 | +| `CRYPTO002` | static | vuln_scanner | low | MASVS-CRYPTO-1 | MASWE-0021 | CWE-328 | +| `CRYPTO003` | static | vuln_scanner | high | MASVS-CRYPTO-1 | MASWE-0020 | CWE-327 | +| `CRYPTO004` | static | vuln_scanner | high | MASVS-CRYPTO-1 | MASWE-0020 | CWE-327 | +| `CRYPTO005` | static | vuln_scanner | high | MASVS-CRYPTO-1 | MASWE-0022 | CWE-329 | +| `CRYPTO006` | static | vuln_scanner | medium | MASVS-CRYPTO-1 | MASWE-0027 | CWE-330 | +| `DBG001` | static | vuln_scanner | medium | MASVS-RESILIENCE-2 | MASWE-0095 | CWE-489 | +| `DBG002` | static | vuln_scanner | medium | MASVS-STORAGE-2 | MASWE-0001 | CWE-532 | +| `DBG003` | static | vuln_scanner | low | MASVS-STORAGE-2 | MASWE-0001 | CWE-209 | +| `DESER001` | static | vuln_scanner | high | MASVS-CODE-4 | MASWE-0088 | CWE-502 | +| `EXTRA001` | static | vuln_scanner | info | MASVS-PRIVACY-1 | MASWE-0117 | CWE-250 | +| `HC001` | static | vuln_scanner | high | MASVS-STORAGE-2 | MASWE-0005 | CWE-798 | +| `HC002` | static | vuln_scanner | critical | MASVS-STORAGE-2 | MASWE-0002 | CWE-798 | +| `HC003` | static | vuln_scanner | critical | MASVS-STORAGE-2 | MASWE-0013 | CWE-798, CWE-321 | +| `HC004` | static | vuln_scanner | high | MASVS-STORAGE-2 | MASWE-0005 | CWE-798 | +| `HC005` | static | vuln_scanner | critical | MASVS-STORAGE-2 | MASWE-0005 | CWE-798 | +| `HC006` | static | vuln_scanner | high | MASVS-CRYPTO-1 | MASWE-0013 | CWE-321 | +| `HC007` | static | vuln_scanner | medium | MASVS-STORAGE-2 | — | — | +| `HC008` | static | vuln_scanner | high | MASVS-STORAGE-2, MASVS-NETWORK-1 | — | — | +| `INFO001` | static | vuln_scanner | high | MASVS-RESILIENCE-4 | MASWE-0067 | CWE-489 | +| `INJ001` | static | vuln_scanner | critical | MASVS-CODE-4 | MASWE-0086 | CWE-89 | +| `INJ002` | static | vuln_scanner | high | MASVS-CODE-4 | MASWE-0081 | CWE-22 | +| `INJ003` | static | vuln_scanner | critical | MASVS-CODE-4 | MASWE-0081 | CWE-78 | +| `INJ004` | static | vuln_scanner | high | MASVS-PLATFORM-1 | MASWE-0058 | CWE-926 | +| `NET001` | static | vuln_scanner | high | MASVS-NETWORK-1 | MASWE-0050 | CWE-319 | +| `NET002` | static | vuln_scanner | critical | MASVS-NETWORK-1 | MASWE-0052 | CWE-295 | +| `NET003` | static | vuln_scanner | high | MASVS-NETWORK-1 | MASWE-0052 | CWE-295 | +| `NET004` | static | vuln_scanner | high | MASVS-NETWORK-1 | — | CWE-327 | +| `NET005` | static | vuln_scanner | medium | MASVS-NETWORK-2 | MASWE-0047 | — | +| `NET006` | static | vuln_scanner | critical | MASVS-NETWORK-1, MASVS-PLATFORM-2 | MASWE-0052 | CWE-295 | +| `OBF001` | static | vuln_scanner | info | MASVS-RESILIENCE-3 | — | — | +| `ST001` | static | vuln_scanner | medium | MASVS-STORAGE-1 | MASWE-0006 | CWE-312 | +| `ST002` | static | vuln_scanner | high | MASVS-STORAGE-1 | MASWE-0002 | CWE-732 | +| `ST003` | static | vuln_scanner | high | MASVS-STORAGE-1 | MASWE-0007 | CWE-922 | +| `ST004` | static | vuln_scanner | medium | MASVS-STORAGE-1 | MASWE-0006 | CWE-312 | +| `ST005` | static | vuln_scanner | medium | MASVS-PLATFORM-3 | MASWE-0053 | — | +| `ST006` | static | vuln_scanner | high | MASVS-STORAGE-2 | — | — | diff --git a/nutcracker_core/analyzer.py b/nutcracker_core/analyzer.py index 4c6deed..fbd9e51 100644 --- a/nutcracker_core/analyzer.py +++ b/nutcracker_core/analyzer.py @@ -156,6 +156,14 @@ def __init__( # Campo opcional: rellena nutcracker.py si se hizo decompilación con frida # Formato: {"method": "frida-dexdump" | "FART" | None, "dex_count": int, "source_dir": str} self.decompilation_info: "dict | None" = None + # ROADMAP "Differentiate runtime bypass vs DEX extraction in reports": + # True cuando el plugin aipwn confirmó un bypass runtime (report_success + # del FridaAgent) SIN que necesariamente se haya volcado DEX en memoria + # (protection_broken exige dex_count > 0, lo cual es engañoso — apps con + # anti-root nativo agresivo pueden matar el proceso antes de que los + # hooks FART corran, aun con el bypass confirmado). Lo rellena + # plugins/aipwn/__init__.py tras un run exitoso; ver build_masvs_report(). + self.aipwn_bypass_confirmed: bool = False @property def protected(self) -> bool: @@ -202,6 +210,7 @@ def to_dict(self) -> dict[str, Any]: "elapsed_seconds": self.elapsed_seconds, "anti_root_protected": self.protected, "protection_broken": self.protection_broken, + "aipwn_bypass_confirmed": self.aipwn_bypass_confirmed, "confidence": self.confidence, "high_strength_detections": self.high_strength_count, "detections": [r.to_dict() for r in self.results], @@ -226,6 +235,7 @@ def from_dict(cls, data: dict[str, Any]) -> "AnalysisResult": elapsed_seconds=data.get("elapsed_seconds"), ) obj.decompilation_info = data.get("decompilation_info") + obj.aipwn_bypass_confirmed = bool(data.get("aipwn_bypass_confirmed", False)) return obj diff --git a/nutcracker_core/checks/__init__.py b/nutcracker_core/checks/__init__.py new file mode 100644 index 0000000..43c66f8 --- /dev/null +++ b/nutcracker_core/checks/__init__.py @@ -0,0 +1,50 @@ +"""Framework de checks deterministas de nutcracker (Fase 2.2 del plan). + + from nutcracker_core.checks import load_registry, all_checks + load_registry() + for check in all_checks(): + print(check.meta.id, check.meta.masvs, check.meta.maswe, check.meta.cwe) + +Unifica bajo un registry común los ~50 checks estáticos ya existentes +(adaptador sobre vuln_scanner/native_scanner/detectors — siguen corriendo por +su pipeline original) y los checks dinámicos nuevos (corren sobre ADB, sin +LLM). Base para docs/owasp-mas-coverage.md (Fase 2.3). +""" + +from __future__ import annotations + +from .base import Check, CheckFinding, CheckMeta +from .registry import ( + all_checks, + dynamic_checks, + register_dynamic, + register_static, + reset, + static_checks, +) + +def load_registry() -> None: + """Puebla el registry completo: adaptador estático + checks dinámicos. + + Idempotente respecto al estado real del registry (no un flag aparte): si + ya hay checks registrados, no vuelve a cargar. registry.reset() (usado en + tests) vacía el registry y por lo tanto reabre la carga en la siguiente + llamada. + """ + if all_checks(): + return + from .dynamic import cleartext_traffic as _cleartext_traffic + from .dynamic import debuggable as _debuggable + from .static import adapter as _static_adapter + + _static_adapter.load_all() + _debuggable.register() + _cleartext_traffic.register() + + +__all__ = [ + "Check", "CheckFinding", "CheckMeta", + "all_checks", "static_checks", "dynamic_checks", + "register_static", "register_dynamic", "reset", + "load_registry", +] diff --git a/nutcracker_core/checks/base.py b/nutcracker_core/checks/base.py new file mode 100644 index 0000000..3516fa9 --- /dev/null +++ b/nutcracker_core/checks/base.py @@ -0,0 +1,70 @@ +"""Framework de checks deterministas de nutcracker (Fase 2.2 del plan). + +Unifica el patrón de extensión hoy fragmentado en tres registros manuales +(``RULES`` en vuln_scanner.py, ``_NATIVE_RULES`` en native_scanner.py, +``ALL_DETECTORS`` en analyzer.py) bajo una interfaz común con metadata OWASP +MAS completa (MASVS + MASWE + CWE). No reemplaza esos registros — el +adaptador en checks/static/adapter.py los envuelve para no duplicar ~50 +reglas ya maduras y probadas; los checks *dinámicos* nuevos (sin LLM, corren +sobre ADB) sí se implementan aquí directamente como Check completos. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Literal + +CheckKind = Literal["static", "dynamic"] + + +@dataclass +class CheckMeta: + """Metadata OWASP MAS de un check: a qué controles/debilidades apunta.""" + + id: str + title: str + kind: CheckKind + severity: str = "medium" + masvs: list[str] = field(default_factory=list) + maswe: list[str] = field(default_factory=list) + cwe: list[str] = field(default_factory=list) + # De dónde viene: "vuln_scanner" | "native_scanner" | "detector" | "dynamic" + source: str = "" + + +@dataclass +class CheckFinding: + """Resultado de un check individual — forma homogénea entre static/dynamic.""" + + check_id: str + title: str + detected: bool + severity: str = "medium" + detail: str = "" + file: str = "" + line: int = 0 + masvs: list[str] = field(default_factory=list) + maswe: list[str] = field(default_factory=list) + cwe: list[str] = field(default_factory=list) + + +class Check: + """Un check determinista individual. + + Los checks *adaptados* (envoltura de vuln_scanner/native_scanner/ + detectors, kind="static") no implementan ``run()``: siguen ejecutándose + por su camino original (scan_directory(), etc.); el adaptador solo aporta + metadata unificada para el registry y docs/owasp-mas-coverage.md. + + Los checks *dinámicos* nuevos (kind="dynamic") sí implementan ``run(ctx)`` + de verdad — ver checks/dynamic/. + """ + + meta: CheckMeta + + def run(self, ctx: Any) -> list[CheckFinding]: + raise NotImplementedError( + f"{self.meta.id}: este check no ejecuta vía Check.run() — ver " + "checks/static/adapter.py (corre por su pipeline original) o " + "implementa run() si es un check dinámico nuevo." + ) diff --git a/nutcracker_core/checks/dynamic/__init__.py b/nutcracker_core/checks/dynamic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nutcracker_core/checks/dynamic/cleartext_traffic.py b/nutcracker_core/checks/dynamic/cleartext_traffic.py new file mode 100644 index 0000000..0af1400 --- /dev/null +++ b/nutcracker_core/checks/dynamic/cleartext_traffic.py @@ -0,0 +1,66 @@ +"""Check dinámico: detecta tráfico HTTP en claro observado en logcat durante +la ejecución real de la app, sin LLM. + +Versión deterministas y solo-ADB de la confirmación de cleartext traffic: +complementa al check estático NET001 (que busca literales "http://" en el +código decompilado, sin garantía de que ese código se ejecute) con evidencia +de tráfico HTTP real durante la corrida. No incluye la parte de +plugins/aipwn/exploit_agent.py::_tool_confirm_cleartext_traffic que inspecciona +campos estáticos vía Frida — eso requiere instrumentación en vivo y queda para +el agente de IA (plugin); este check cubre la porción puramente determinista. +""" + +from __future__ import annotations + +import time + +from ..base import Check, CheckFinding, CheckMeta +from ..registry import register_dynamic +from .context import DynamicCheckContext + + +class CleartextTrafficDynamicCheck(Check): + meta = CheckMeta( + id="DYN-CLEARTEXT-TRAFFIC", + title="Tráfico HTTP en claro observado en logcat durante la ejecución", + kind="dynamic", + severity="high", + masvs=["MASVS-NETWORK-1"], + maswe=["MASWE-0050"], + cwe=["CWE-319"], + source="dynamic", + ) + + def __init__(self, settle_seconds: float = 3.0, logcat_lines: int = 200) -> None: + self._settle_seconds = settle_seconds + self._logcat_lines = logcat_lines + + def run(self, ctx: DynamicCheckContext) -> list[CheckFinding]: + ctx.adb_run(["shell", "logcat", "-c"]) + if self._settle_seconds: + time.sleep(self._settle_seconds) + + logcat_out = ctx.adb_run(["shell", "logcat", "-d", "-t", str(self._logcat_lines)]) + http_lines = [ + line for line in logcat_out.splitlines() + if "http://" in line.lower() and "https://" not in line.lower() + ] + + detected = len(http_lines) > 0 + sample = http_lines[0][:200] if http_lines else "" + detail = f"{len(http_lines)} línea(s) con http:// en logcat" + (f" — ej: {sample}" if sample else "") + + return [CheckFinding( + check_id=self.meta.id, + title=self.meta.title, + detected=detected, + severity=self.meta.severity if detected else "info", + detail=detail, + masvs=self.meta.masvs, + maswe=self.meta.maswe, + cwe=self.meta.cwe, + )] + + +def register() -> None: + register_dynamic(CleartextTrafficDynamicCheck()) diff --git a/nutcracker_core/checks/dynamic/context.py b/nutcracker_core/checks/dynamic/context.py new file mode 100644 index 0000000..47e4665 --- /dev/null +++ b/nutcracker_core/checks/dynamic/context.py @@ -0,0 +1,40 @@ +"""Contexto compartido por los checks dinámicos (Fase 2.2 del plan).""" + +from __future__ import annotations + +import subprocess +from dataclasses import dataclass +from typing import Callable + +# (argv sin el binario "adb", p.ej. ["shell", "run-as", "com.example", "id"]) -> stdout +AdbRunner = Callable[[list[str]], str] + + +def default_adb_runner(serial: str | None, adb_bin: str = "adb", timeout: int = 10) -> AdbRunner: + """Runner real: invoca el binario adb del sistema. Los tests inyectan un + runner falso en su lugar (ver DynamicCheckContext), así los checks + dinámicos son deterministas y no requieren un dispositivo conectado.""" + + def _run(args: list[str]) -> str: + cmd = [adb_bin] + (["-s", serial] if serial else []) + args + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + return result.stdout + except (subprocess.TimeoutExpired, FileNotFoundError): + return "" + + return _run + + +@dataclass +class DynamicCheckContext: + """Contexto mínimo para checks dinámicos: package + serial + runner de adb + inyectable (permite testear sin dispositivo real).""" + + package: str + serial: str | None = None + adb_run: AdbRunner = None # type: ignore[assignment] + + def __post_init__(self) -> None: + if self.adb_run is None: + self.adb_run = default_adb_runner(self.serial) diff --git a/nutcracker_core/checks/dynamic/debuggable.py b/nutcracker_core/checks/dynamic/debuggable.py new file mode 100644 index 0000000..e707c6e --- /dev/null +++ b/nutcracker_core/checks/dynamic/debuggable.py @@ -0,0 +1,60 @@ +"""Check dinámico: confirma android:debuggable=true en el dispositivo real vía +ADB (run-as + JDWP), sin LLM. + +Complementa al check estático INFO001 (que solo lee el manifest declarado) con +una confirmación en tiempo de ejecución: un manifest puede declarar +debuggable=false pero el build real instalado en el device puede diferir +(builds de staging, APKs re-firmados, etc.), o viceversa. + +Lógica portada de plugins/aipwn/exploit_agent.py::_tool_check_debuggable, +reimplementada aquí como pieza core reutilizable e inyectable para tests, sin +depender de la clase interna del plugin de IA (el checks framework es core; +la IA es plugin — ver plan.md, "Principios rectores"). +""" + +from __future__ import annotations + +from ..base import Check, CheckFinding, CheckMeta +from ..registry import register_dynamic +from .context import DynamicCheckContext + + +class DebuggableDynamicCheck(Check): + meta = CheckMeta( + id="DYN-DEBUGGABLE", + title="android:debuggable confirmado en runtime (run-as / JDWP)", + kind="dynamic", + severity="high", + masvs=["MASVS-RESILIENCE-4"], + maswe=["MASWE-0067"], + cwe=["CWE-489"], + source="dynamic", + ) + + def run(self, ctx: DynamicCheckContext) -> list[CheckFinding]: + run_as_out = ctx.adb_run(["shell", "run-as", ctx.package, "id"]) + run_as_works = "uid=" in run_as_out + + pid_str = ctx.adb_run(["shell", "pidof", ctx.package]).strip() + jdwp_out = ctx.adb_run(["jdwp"]) + jdwp_confirms = bool(pid_str) and pid_str.isdigit() and pid_str in jdwp_out.split() + + detected = run_as_works or jdwp_confirms + detail = ( + f"run-as {'funcionó' if run_as_works else 'falló'}; " + f"JDWP {'confirma' if jdwp_confirms else 'no confirma'} (pid={pid_str or '?'})" + ) + return [CheckFinding( + check_id=self.meta.id, + title=self.meta.title, + detected=detected, + severity=self.meta.severity if detected else "info", + detail=detail, + masvs=self.meta.masvs, + maswe=self.meta.maswe, + cwe=self.meta.cwe, + )] + + +def register() -> None: + register_dynamic(DebuggableDynamicCheck()) diff --git a/nutcracker_core/checks/registry.py b/nutcracker_core/checks/registry.py new file mode 100644 index 0000000..2e0852c --- /dev/null +++ b/nutcracker_core/checks/registry.py @@ -0,0 +1,39 @@ +"""Registry de checks: descubre y unifica checks estáticos (adaptador) y +dinámicos (nuevos). Patrón de auto-descubrimiento análogo al plugin loader +(nutcracker_core/plugins/__init__.py), pero para checks de análisis en vez de +comandos CLI.""" + +from __future__ import annotations + +from .base import Check + +_STATIC: list[Check] = [] +_DYNAMIC: list[Check] = [] + + +def register_static(check: Check) -> Check: + _STATIC.append(check) + return check + + +def register_dynamic(check: Check) -> Check: + _DYNAMIC.append(check) + return check + + +def all_checks() -> list[Check]: + return [*_STATIC, *_DYNAMIC] + + +def static_checks() -> list[Check]: + return list(_STATIC) + + +def dynamic_checks() -> list[Check]: + return list(_DYNAMIC) + + +def reset() -> None: + """Solo para tests: vacía el registry para poder repoblar desde cero.""" + _STATIC.clear() + _DYNAMIC.clear() diff --git a/nutcracker_core/checks/static/__init__.py b/nutcracker_core/checks/static/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nutcracker_core/checks/static/adapter.py b/nutcracker_core/checks/static/adapter.py new file mode 100644 index 0000000..85fafb5 --- /dev/null +++ b/nutcracker_core/checks/static/adapter.py @@ -0,0 +1,113 @@ +"""Adaptador: expone las reglas ya existentes de vuln_scanner/native_scanner/ +detectors como Check unificados con metadata OWASP MAS (Fase 2.2 del plan). + +No ejecuta nada nuevo — el análisis real sigue corriendo por +scan_directory()/scan_native_libs()/detector.detect() (orquestado en +orchestrator._do_vuln_scan / _run_builtin_detectors), exactamente como antes. +Migrar cada una de las ~50 reglas existentes a un archivo .py individual +tendría bajo valor marginal (ya son deterministas y ya están probadas) frente +al riesgo de reescribirlas; este adaptador da el registry unificado y la +metadata MASVS/MASWE/CWE (desde nutcracker_core.masvs) sin ese riesgo, y sirve +de base para docs/owasp-mas-coverage.md. +""" + +from __future__ import annotations + +from ..base import Check, CheckMeta + + +class _AdaptedCheck(Check): + """Envuelve una regla/detector ya definido en su módulo de origen. + + No implementa run(): ver docstring de Check.run() y del módulo. + """ + + def __init__(self, meta: CheckMeta) -> None: + self.meta = meta + + +def _rule_meta(rule_id: str, title: str, severity: str, source: str) -> CheckMeta: + from ... import masvs + + return CheckMeta( + id=rule_id, + title=title, + kind="static", + severity=severity, + masvs=masvs.RULE_TO_MASVS.get(rule_id, []), + maswe=masvs.RULE_TO_MASWE.get(rule_id, []), + cwe=masvs.RULE_TO_CWE.get(rule_id, []), + source=source, + ) + + +def _detector_meta(name: str, severity: str) -> CheckMeta: + """Los detectores de protecciones (analyzer.ALL_DETECTORS) usan un mapeo + separado (DETECTOR_TO_MASVS), no RULE_TO_MASVS: son indicadores + *positivos* de protección presente, no reglas de vulnerabilidad — por eso + no tienen MASWE/CWE asociado (MASWE cataloga debilidades/ausencias, no + evidencia de controles implementados).""" + from ... import masvs + + return CheckMeta( + id=name, + title=name, + kind="static", + severity=severity, + masvs=masvs.DETECTOR_TO_MASVS.get(name, []), + source="detector", + ) + + +def _load_vuln_scanner_checks() -> None: + from ...vuln_scanner import EXPORTED_COMPONENT_RULES, RULES + from ..registry import register_static + + for rule in RULES: + register_static(_AdaptedCheck(_rule_meta(rule.rule_id, rule.title, rule.severity, "vuln_scanner"))) + + # Reglas sintetizadas desde el manifest (fuera de la lista RULES): ver + # vuln_scanner.scan_manifest_components(). COMP004 se excluye porque ese + # rule_id ya está cubierto arriba vía RULES (colisión preexistente: dos + # hallazgos distintos — receiver registrado en código vs. exportado en el + # manifest — comparten el mismo id "COMP004"; no se toca aquí para no + # romper JSON/reportes históricos que ya usan ese id). + for tag, (rule_id, title, severity, _category) in EXPORTED_COMPONENT_RULES.items(): + if rule_id == "COMP004": + continue + register_static(_AdaptedCheck(_rule_meta(rule_id, title, severity, "vuln_scanner"))) + + # INFO001 (android:debuggable="true"): también sintetizado desde el + # manifest en scan_manifest_components, sin un VulnRule estructurado del + # que leer título/severidad — se declaran aquí explícitos. + register_static(_AdaptedCheck(_rule_meta( + "INFO001", "android:debuggable=\"true\" en el manifest", "high", "vuln_scanner", + ))) + + +def _load_native_scanner_checks() -> None: + from ... import native_scanner + from ..registry import register_static + + for rule in native_scanner._NATIVE_RULES: + register_static(_AdaptedCheck(_rule_meta(rule.rule_id, rule.title, rule.severity, "native_scanner"))) + + +def _load_detector_checks() -> None: + from ...analyzer import ALL_DETECTORS + from ..registry import register_static + + for det in ALL_DETECTORS: + register_static(_AdaptedCheck(_detector_meta(det.name, det.strength))) + + +def load_all() -> None: + """Puebla el registry con los ~50 checks estáticos existentes. + + No es idempotente por sí sola (llamarla dos veces duplica entradas); la + idempotencia real vive en checks.load_registry(), que solo la invoca si + el registry está vacío. Ver también registry.reset() para tests. + """ + _load_vuln_scanner_checks() + _load_native_scanner_checks() + _load_detector_checks() diff --git a/nutcracker_core/i18n.py b/nutcracker_core/i18n.py index db237f0..5f45eea 100644 --- a/nutcracker_core/i18n.py +++ b/nutcracker_core/i18n.py @@ -353,6 +353,8 @@ def register(strings: dict[str, dict[str, str]]) -> None: "cli_protected_detail": "Active protections detected. No vulnerabilities found.", "cli_protected_vulns_detail": "Active protections detected. Found {count} vulnerability(ies) in static analysis.", "cli_bypassed_detail": "Protections detected but bypassed at runtime ({method}, {dex_count} DEX extracted).", + "cli_bypass_confirmed_banner": "BYPASS CONFIRMED", + "cli_bypass_confirmed_detail": "The aipwn agent confirmed a runtime bypass (report_success), though no DEX was extracted in memory.", "cli_app_has_protection": "has", "cli_app_no_protection": "does not have", "cli_anti_root_label": "anti-root / RASP protection", @@ -1395,6 +1397,8 @@ def register(strings: dict[str, dict[str, str]]) -> None: "cli_protected_detail": "Protecciones activas detectadas. No se encontraron vulnerabilidades expuestas.", "cli_protected_vulns_detail": "Protecciones activas detectadas. Se hallaron {count} vulnerabilidad(es) en analisis estatico.", "cli_bypassed_detail": "Protecciones detectadas pero eludidas en runtime ({method}, {dex_count} DEX extraidos).", + "cli_bypass_confirmed_banner": "BYPASS CONFIRMADO", + "cli_bypass_confirmed_detail": "El agente aipwn confirmó un bypass en runtime (report_success), aunque no se volcó DEX en memoria.", "cli_app_has_protection": "tiene", "cli_app_no_protection": "no tiene", "cli_anti_root_label": "protecci\u00f3n anti-root / RASP", diff --git a/nutcracker_core/masvs.py b/nutcracker_core/masvs.py index cee3c1d..cd6ab4d 100644 --- a/nutcracker_core/masvs.py +++ b/nutcracker_core/masvs.py @@ -1,9 +1,16 @@ -"""Módulo MASVS v2 — mapeo de hallazgos de nutcracker a controles OWASP MASVS. +"""Módulo MASVS v2.1 — mapeo de hallazgos de nutcracker a OWASP MASVS + MASWE + CWE. Genera un MASVSReport con: - - Estado de cada control (pass / fail / bypass / no_protection / not_tested) + - Estado de cada uno de los 24 controles MASVS v2.1 oficiales (pass / fail / + bypass / no_protection / not_tested) - Puntuación 0-100 y grado A-F - - Lista de hallazgos por control + - Lista de hallazgos por control, incluyendo su(s) debilidad(es) MASWE y CWE + +Fuente de verdad para los 24 controles y las 119 debilidades MASWE: extraídas +directamente de mas.owasp.org/MASVS/ y github.com/OWASP/maswe (Fase 2 del +plan, 2026-07-24) — no inventadas. MASWE_CATALOG guarda el catálogo completo; +RULE_TO_MASWE solo mapea el subconjunto que las reglas de nutcracker cubren +hoy con confianza razonable. Uso: from nutcracker_core.masvs import build_masvs_report @@ -22,47 +29,191 @@ from .manifest_analyzer import ManifestAnalysisResult -# ── Definición de controles MASVS v2 ───────────────────────────────────────── +# ── Definición de los 24 controles MASVS v2.1 oficiales ────────────────────── +# Paráfrasis en español del "statement" oficial de cada control +# (mas.owasp.org/MASVS/controls//). Los 15 que ya existían en versiones +# previas de este módulo se revisaron y corrigieron contra el texto oficial; +# ver notas "FIX" en el historial de plan.md Fase 2 para el detalle de qué +# cambió y por qué. MASVS_CONTROLS: dict[str, str] = { - # STORAGE - "MASVS-STORAGE-1": "No almacenar datos sensibles sin cifrado en almacenamiento local", - "MASVS-STORAGE-2": "No almacenar credenciales o secretos en el código o APK", + # STORAGE — almacenamiento seguro de datos sensibles (data-at-rest) + "MASVS-STORAGE-1": "La app almacena de forma segura los datos sensibles, sin importar la ubicación (privada o pública)", + "MASVS-STORAGE-2": "La app evita la fuga no intencional de datos sensibles (por ejemplo vía backups o logs)", # CRYPTO - "MASVS-CRYPTO-1": "Sin algoritmos criptográficos débiles ni configuraciones inseguras (MD5, DES, ECB, IV estático)", + "MASVS-CRYPTO-1": "La app usa criptografía fuerte y actual según las mejores prácticas de la industria", + "MASVS-CRYPTO-2": "La app gestiona las claves criptográficas (generación, almacenamiento, rotación) según las mejores prácticas", # AUTH - "MASVS-AUTH-2": "No exponer tokens de sesión o autenticación en canales inseguros", + "MASVS-AUTH-1": "La app usa protocolos de autenticación y autorización seguros y sigue las mejores prácticas relevantes", + "MASVS-AUTH-2": "La app realiza la autenticación local (biometría, PIN) de forma segura según las mejores prácticas de la plataforma", + "MASVS-AUTH-3": "La app protege las operaciones sensibles con autenticación adicional (step-up)", # NETWORK - "MASVS-NETWORK-1": "Comunicaciones de red cifradas con TLS y certificados válidos", - "MASVS-NETWORK-2": "Certificate pinning implementado en al menos un cliente HTTP", + "MASVS-NETWORK-1": "La app protege todo el tráfico de red según las mejores prácticas actuales (TLS, validación de certificados)", + "MASVS-NETWORK-2": "La app implementa identity pinning (certificate/public key pinning) para los endpoints bajo control del desarrollador", # PLATFORM - "MASVS-PLATFORM-1": "Mecanismos IPC usados de forma segura (intents, deep links, componentes exportados y permisos mínimos)", - "MASVS-PLATFORM-2": "WebViews configurados de forma segura (sin JS innecesario, sin acceso a archivos, sin interfaces nativas expuestas)", - "MASVS-PLATFORM-3": "Interfaz de usuario segura (clipboard, capturas de pantalla, caché de teclado)", + "MASVS-PLATFORM-1": "La app usa los mecanismos de IPC de forma segura (intents, deep links, componentes exportados)", + "MASVS-PLATFORM-2": "La app usa WebViews de forma segura (sin JS innecesario, sin acceso a archivos, sin puentes JS expuestos)", + "MASVS-PLATFORM-3": "La app usa la interfaz de usuario de forma segura (evita fugas vía capturas de pantalla, clipboard, etc.)", # CODE - "MASVS-CODE-2": "Sin información de debug ni datos sensibles en logs en producción", - "MASVS-CODE-4": "Validar y sanitizar toda entrada externa (SQL, comandos, rutas)", - # RESILIENCE - "MASVS-RESILIENCE-1": "Detectar y responder ante dispositivos rooteados o comprometidos", - "MASVS-RESILIENCE-2": "Detectar y responder ante debuggers y entornos de análisis", - "MASVS-RESILIENCE-3": "Protección ante ingeniería inversa (ofuscación, anti-tamper)", - "MASVS-RESILIENCE-4": "Detección de manipulación del APK (integridad de firma)", + "MASVS-CODE-1": "La app requiere una versión de plataforma actualizada", + "MASVS-CODE-2": "La app tiene un mecanismo para forzar actualizaciones ante vulnerabilidades críticas", + "MASVS-CODE-3": "La app solo usa componentes de software sin vulnerabilidades conocidas", + "MASVS-CODE-4": "La app valida y sanitiza toda entrada no confiable (SQL, IPC, deserialización, comandos)", + # RESILIENCE — resiliencia ante ingeniería inversa y manipulación + "MASVS-RESILIENCE-1": "La app valida la integridad de la plataforma (detecta dispositivos rooteados/comprometidos)", + "MASVS-RESILIENCE-2": "La app implementa mecanismos anti-tampering (verifica firma del paquete, integridad de DEX/código nativo/recursos)", + "MASVS-RESILIENCE-3": "La app implementa mecanismos anti-análisis-estático (ofuscación de código, recursos y strings)", + "MASVS-RESILIENCE-4": "La app implementa técnicas anti-análisis-dinámico (detección de debugger, Frida, hooking)", + # PRIVACY + "MASVS-PRIVACY-1": "La app minimiza el acceso a datos y recursos sensibles (principio de mínimo privilegio en permisos)", + "MASVS-PRIVACY-2": "La app evita la identificación del usuario (anonimización/pseudonimización)", + "MASVS-PRIVACY-3": "La app es transparente sobre la recolección y uso de datos", + "MASVS-PRIVACY-4": "La app ofrece al usuario control sobre sus datos", } -# Peso base de cada categoría en la puntuación final (suma = 100) -_CATEGORY_WEIGHTS: dict[str, int] = { - "MASVS-STORAGE": 15, - "MASVS-CRYPTO": 15, - "MASVS-AUTH": 10, - "MASVS-NETWORK": 20, - "MASVS-PLATFORM": 10, - "MASVS-CODE": 10, - "MASVS-RESILIENCE": 20, -} +# ── Catálogo MASWE (OWASP Mobile App Security Weakness Enumeration) ────────── +# Las 119 debilidades oficiales (github.com/OWASP/maswe, fetched 2026-07-24), +# id → título. RULE_TO_MASWE abajo solo usa el subconjunto mapeado a reglas +# existentes; el resto queda disponible como referencia (p.ej. para el gap +# report de docs/owasp-mas-coverage.md en Fase 2.3). +MASWE_CATALOG: dict[str, str] = { + "MASWE-0001": "Insertion of Sensitive Data into Logs", + "MASWE-0002": "Sensitive Data Stored With Insufficient Access Restrictions in Internal Locations", + "MASWE-0003": "Backup Unencrypted", + "MASWE-0004": "Sensitive Data Not Excluded From Backup", + "MASWE-0005": "API Keys Hardcoded in the App Package", + "MASWE-0006": "Sensitive Data Stored Unencrypted in Private Storage Locations", + "MASWE-0007": "Sensitive Data Stored Unencrypted in Shared Storage Requiring No User Interaction", + "MASWE-0008": "Missing Device Secure Lock Verification Implementation", + "MASWE-0009": "Improper Cryptographic Key Generation", + "MASWE-0010": "Improper Cryptographic Key Derivation", + "MASWE-0011": "Cryptographic Key Rotation Not Implemented", + "MASWE-0012": "Insecure or Wrong Usage of Cryptographic Key", + "MASWE-0013": "Hardcoded Cryptographic Keys in Use", + "MASWE-0014": "Cryptographic Keys Not Properly Protected at Rest", + "MASWE-0015": "Deprecated Android KeyStore Implementations", + "MASWE-0016": "Unsafe Handling of Imported Cryptographic Keys", + "MASWE-0017": "Cryptographic Keys Not Properly Protected on Export", + "MASWE-0018": "Cryptographic Keys Access Not Restricted", + "MASWE-0019": "Risky Cryptography Implementations", + "MASWE-0020": "Improper Encryption", + "MASWE-0021": "Improper Hashing", + "MASWE-0022": "Predictable Initialization Vectors (IVs)", + "MASWE-0023": "Risky Padding", + "MASWE-0024": "Improper Use of Message Authentication Code (MAC)", + "MASWE-0025": "Improper Generation of Cryptographic Signatures", + "MASWE-0026": "Improper Verification of Cryptographic Signature", + "MASWE-0027": "Improper Random Number Generation", + "MASWE-0028": "MFA Implementation Best Practices Not Followed", + "MASWE-0029": "Step-Up Authentication Not Implemented After Login", + "MASWE-0030": "Re-Authenticates Not Triggered On Contextual State Changes", + "MASWE-0031": "Insecure use of Android Protected Confirmation", + "MASWE-0032": "Platform-provided Authentication APIs Not Used", + "MASWE-0033": "Authentication or Authorization Protocol Security Best Practices Not Followed", + "MASWE-0034": "Insecure Implementation of Confirm Credentials", + "MASWE-0035": "Passwordless Authentication Not Implemented", + "MASWE-0036": "Authentication Material Stored Unencrypted on the Device", + "MASWE-0037": "Authentication Material Sent over Insecure Connections", + "MASWE-0038": "Authentication Tokens Not Validated", + "MASWE-0039": "Shared Web Credentials and Website-association Not Implemented", + "MASWE-0040": "Insecure Authentication in WebViews", + "MASWE-0041": "Authentication Enforced Only Locally Instead of on the Server-side", + "MASWE-0042": "Authorization Enforced Only Locally Instead of on the Server-side", + "MASWE-0043": "App Custom PIN Not Bound to Platform KeyStore", + "MASWE-0044": "Biometric Authentication Can Be Bypassed", + "MASWE-0045": "Fallback to Non-biometric Credentials Allowed for Sensitive Transactions", + "MASWE-0046": "Crypto Keys Not Invalidated on New Biometric Enrollment", + "MASWE-0047": "Insecure Identity Pinning", + "MASWE-0048": "Insecure Machine-to-Machine Communication", + "MASWE-0049": "Proven Networking APIs Not used", + "MASWE-0050": "Cleartext Traffic", + "MASWE-0051": "Unprotected Open Ports", + "MASWE-0052": "Insecure Certificate Validation", + "MASWE-0053": "Sensitive Data Leaked via the User Interface", + "MASWE-0054": "Sensitive Data Leaked via Notifications", + "MASWE-0055": "Sensitive Data Leaked via Screenshots or Screen Recordings", + "MASWE-0056": "Tapjacking Attacks", + "MASWE-0057": "StrandHogg Attack / Task Affinity Vulnerability", + "MASWE-0058": "Insecure Deep Links", + "MASWE-0059": "Use Of Unauthenticated Platform IPC", + "MASWE-0060": "Insecure Use of UIActivity", + "MASWE-0061": "Insecure Use of App Extensions", + "MASWE-0062": "Insecure Services", + "MASWE-0063": "Insecure Broadcast Receivers", + "MASWE-0064": "Insecure Content Providers", + "MASWE-0065": "Sensitive Data Permanently Shared with Other Apps", + "MASWE-0066": "Insecure Intents", + "MASWE-0067": "Debuggable Flag Not Disabled", + "MASWE-0068": "JavaScript Bridges in WebViews", + "MASWE-0069": "WebViews Allows Access to Local Resources", + "MASWE-0070": "JavaScript Loaded from Untrusted Sources", + "MASWE-0071": "WebViews Loading Content from Untrusted Sources", + "MASWE-0072": "Universal XSS", + "MASWE-0073": "Insecure WebResourceResponse Implementations", + "MASWE-0074": "Web Content Debugging Enabled", + "MASWE-0075": "Enforced Updating Not Implemented", + "MASWE-0076": "Dependencies with Known Vulnerabilities", + "MASWE-0077": "Running on a recent Platform Version Not Ensured", + "MASWE-0078": "Latest Platform Version Not Targeted", + "MASWE-0079": "Unsafe Handling of Data from the Network", + "MASWE-0080": "Unsafe Handling of Data from Backups", + "MASWE-0081": "Unsafe Handling Of Data From External Interfaces", + "MASWE-0082": "Unsafe Handling of Data From Local Storage", + "MASWE-0083": "Unsafe Handling of Data From The User Interface", + "MASWE-0084": "Unsafe Handling of Data from IPC", + "MASWE-0085": "Unsafe Dynamic Code Loading", + "MASWE-0086": "SQL Injection", + "MASWE-0087": "Insecure Parsing and Escaping", + "MASWE-0088": "Insecure Object Deserialization", + "MASWE-0089": "Code Obfuscation Not Implemented", + "MASWE-0090": "Resource Obfuscation Not Implemented", + "MASWE-0091": "Anti-Deobfuscation Techniques Not Implemented", + "MASWE-0092": "Static Analysis Tools Not Prevented", + "MASWE-0093": "Debugging Symbols Not Removed", + "MASWE-0094": "Non-Production Resources Not Removed", + "MASWE-0095": "Code That Disables Security Controls Not Removed", + "MASWE-0096": "Data Sent Unencrypted Over Encrypted Connections", + "MASWE-0097": "Root/Jailbreak Detection Not Implemented", + "MASWE-0098": "App Virtualization Environment Detection Not Implemented", + "MASWE-0099": "Emulator Detection Not Implemented", + "MASWE-0100": "Device Attestation Not Implemented", + "MASWE-0101": "Debugger Detection Not Implemented", + "MASWE-0102": "Dynamic Analysis Tools Detection Not Implemented", + "MASWE-0103": "RASP Techniques Not Implemented", + "MASWE-0104": "App Integrity Not Verified", + "MASWE-0105": "Integrity of App Resources Not Verified", + "MASWE-0106": "Official Store Verification Not Implemented", + "MASWE-0107": "Runtime Code Integrity Not Verified", + "MASWE-0108": "Sensitive Data in Network Traffic", + "MASWE-0109": "Lack of Anonymization or Pseudonymisation Measures", + "MASWE-0110": "Use of Unique Identifiers for User Tracking", + "MASWE-0111": "Inadequate Privacy Policy", + "MASWE-0112": "Inadequate Data Collection Declarations", + "MASWE-0113": "Lack of Proper Data Management Controls", + "MASWE-0114": "Inadequate Data Visibility Controls", + "MASWE-0115": "Inadequate or Ambiguous User Consent Mechanisms", + "MASWE-0116": "Compiler-Provided Security Features Not Used", + "MASWE-0117": "Inadequate Permission Management", + "MASWE-0118": "Sensitive Data Not Removed After Use", + "MASWE-0119": "Insecure Activities", +} -# ── Mapeo reglas vuln_scanner → MASVS ──────────────────────────────────────── +# ── Mapeo reglas vuln_scanner/native_scanner → MASVS + MASWE + CWE ────────── +# FIX (Fase 2, 2026-07-24) respecto a versiones previas de este módulo: +# - AUTH001 (token en logs) y DBG002/DBG003 (logs con datos sensibles): +# estaban en MASVS-CODE-2/AUTH-2, cuyo texto oficial no habla de logs. +# MASWE-0001 ("Insertion of Sensitive Data into Logs") está catalogada +# oficialmente bajo MASVS-STORAGE → movidas a MASVS-STORAGE-2, que +# además menciona "logs" explícitamente en su texto oficial. +# - INFO001 (android:debuggable=true): MASWE-0067 está catalogada bajo +# MASVS-RESILIENCE, no CODE → movida de CODE-2 a MASVS-RESILIENCE-4 +# (coincide con el texto oficial de R4: "anti-dynamic-analysis techniques"). +# - EXTRA001 (permisos peligrosos): movida de PLATFORM-1 a PRIVACY-1 +# ("minimiza acceso a datos y recursos sensibles"), que es el control +# real cuyo texto describe exactamente esto; MASWE-0117 confirma la +# categorización oficial bajo PRIVACY. RULE_TO_MASVS: dict[str, list[str]] = { "HC001": ["MASVS-STORAGE-2"], "HC002": ["MASVS-STORAGE-2"], @@ -80,11 +231,11 @@ "ST006": ["MASVS-STORAGE-2"], "NET001": ["MASVS-NETWORK-1"], "NET002": ["MASVS-NETWORK-1"], - "NET003": ["MASVS-NETWORK-2"], + "NET003": ["MASVS-NETWORK-1"], "NET004": ["MASVS-NETWORK-1"], "NET005": ["MASVS-NETWORK-2"], - "NET006": ["MASVS-NETWORK-1"], - "AUTH001": ["MASVS-AUTH-2"], + "NET006": ["MASVS-NETWORK-1", "MASVS-PLATFORM-2"], + "AUTH001": ["MASVS-STORAGE-2"], # FIX: era AUTH-2 "CRYPTO001": ["MASVS-CRYPTO-1"], "CRYPTO002": ["MASVS-CRYPTO-1"], "CRYPTO003": ["MASVS-CRYPTO-1"], @@ -98,21 +249,22 @@ "COMP005": ["MASVS-PLATFORM-2"], "COMP006": ["MASVS-PLATFORM-1"], # Activity exported sin permission "COMP007": ["MASVS-PLATFORM-1"], # Service exported sin permission - "INFO001": ["MASVS-CODE-2"], # debuggable=true + "COMP008": ["MASVS-PLATFORM-1"], # ContentProvider exported sin permission — antes sin mapear + "INFO001": ["MASVS-RESILIENCE-4"], # FIX: era CODE-2 — debuggable=true "INJ001": ["MASVS-CODE-4"], "INJ002": ["MASVS-CODE-4"], "INJ003": ["MASVS-CODE-4"], "INJ004": ["MASVS-PLATFORM-1"], - "DBG001": ["MASVS-CODE-2"], - "DBG002": ["MASVS-CODE-2"], - "DBG003": ["MASVS-CODE-2"], + "DBG001": ["MASVS-RESILIENCE-2"], # FIX: era CODE-2 — código solo-debug que puede desactivar controles + "DBG002": ["MASVS-STORAGE-2"], # FIX: era CODE-2 — log con datos sensibles + "DBG003": ["MASVS-STORAGE-2"], # FIX: era CODE-2 — printStackTrace expone info en logs "OBF001": ["MASVS-RESILIENCE-3"], "DESER001": ["MASVS-CODE-4"], - "EXTRA001": ["MASVS-PLATFORM-1"], + "EXTRA001": ["MASVS-PRIVACY-1"], # FIX: era PLATFORM-1 — permisos peligrosos # ── Reglas nativas (native_scanner) ────────────────────────────────────── "NAT001": ["MASVS-CODE-4"], "NAT002": ["MASVS-CODE-4"], - "NAT003": ["MASVS-RESILIENCE-2"], + "NAT003": ["MASVS-RESILIENCE-4"], # FIX: era RESILIENCE-2 — anti-debug es anti-dynamic-analysis "NAT004": ["MASVS-STORAGE-2"], "NAT005": ["MASVS-NETWORK-1"], "NAT006": ["MASVS-RESILIENCE-1"], @@ -120,18 +272,129 @@ "NAT008": ["MASVS-CRYPTO-1"], } -# ── Mapeo detectores → MASVS ────────────────────────────────────────────────── +# MASWE: solo se listan las reglas donde hay una debilidad MASWE con +# correspondencia semántica clara y verificable en MASWE_CATALOG. Reglas sin +# entrada aquí no tienen (todavía) un mapeo MASWE de confianza suficiente. +RULE_TO_MASWE: dict[str, list[str]] = { + "HC001": ["MASWE-0005"], # API Keys Hardcoded in the App Package + "HC002": ["MASWE-0002"], + "HC003": ["MASWE-0013"], # Hardcoded Cryptographic Keys in Use + "HC004": ["MASWE-0005"], + "HC005": ["MASWE-0005"], + "HC006": ["MASWE-0013"], + "ST001": ["MASWE-0006"], # Sensitive Data Stored Unencrypted in Private Storage + "ST002": ["MASWE-0002"], + "ST003": ["MASWE-0007"], # ...Unencrypted in Shared Storage + "ST004": ["MASWE-0006"], + "ST005": ["MASWE-0053"], # Sensitive Data Leaked via the UI + "NET001": ["MASWE-0050"], # Cleartext Traffic + "NET002": ["MASWE-0052"], # Insecure Certificate Validation + "NET003": ["MASWE-0052"], + "NET005": ["MASWE-0047"], # Insecure Identity Pinning + "NET006": ["MASWE-0052"], + "AUTH001": ["MASWE-0001"], # Insertion of Sensitive Data into Logs + "CRYPTO001": ["MASWE-0021"], # Improper Hashing (MD5) + "CRYPTO002": ["MASWE-0021"], # Improper Hashing (SHA-1) + "CRYPTO003": ["MASWE-0020"], # Improper Encryption (DES/3DES) + "CRYPTO004": ["MASWE-0020"], # Improper Encryption (AES/ECB) + "CRYPTO005": ["MASWE-0022"], # Predictable IVs + "CRYPTO006": ["MASWE-0027"], # Improper Random Number Generation + "COMP001": ["MASWE-0072"], # Universal XSS + "COMP002": ["MASWE-0069"], # WebViews Allows Access to Local Resources + "COMP003": ["MASWE-0068"], # JavaScript Bridges in WebViews + "COMP004": ["MASWE-0063"], # Insecure Broadcast Receivers + "COMP005": ["MASWE-0072"], + "COMP006": ["MASWE-0119"], # Insecure Activities + "COMP007": ["MASWE-0062"], # Insecure Services + "COMP008": ["MASWE-0064"], # Insecure Content Providers + "INFO001": ["MASWE-0067"], # Debuggable Flag Not Disabled + "INJ001": ["MASWE-0086"], # SQL Injection + "INJ002": ["MASWE-0081"], # Unsafe Handling Of Data From External Interfaces + "INJ003": ["MASWE-0081"], + "INJ004": ["MASWE-0058"], # Insecure Deep Links + "DBG001": ["MASWE-0095"], # Code That Disables Security Controls Not Removed + "DBG002": ["MASWE-0001"], + "DBG003": ["MASWE-0001"], + "DESER001": ["MASWE-0088"], # Insecure Object Deserialization + "EXTRA001": ["MASWE-0117"], # Inadequate Permission Management + "NAT003": ["MASWE-0101"], # Debugger Detection Not Implemented + "NAT004": ["MASWE-0002"], + "NAT005": ["MASWE-0052"], + "NAT007": ["MASWE-0047"], + "NAT008": ["MASWE-0020", "MASWE-0021"], +} +# CWE: solo los well-established, sin ambigüedad (misma prudencia que MASWE). +RULE_TO_CWE: dict[str, list[str]] = { + "HC001": ["CWE-798"], # Use of Hard-coded Credentials + "HC002": ["CWE-798"], + "HC003": ["CWE-798", "CWE-321"], + "HC004": ["CWE-798"], + "HC005": ["CWE-798"], + "HC006": ["CWE-321"], # Use of Hard-coded Cryptographic Key + "ST001": ["CWE-312"], # Cleartext Storage of Sensitive Information + "ST002": ["CWE-732"], # Incorrect Permission Assignment + "ST003": ["CWE-922"], # Insecure Storage of Sensitive Information + "ST004": ["CWE-312"], + "NET001": ["CWE-319"], # Cleartext Transmission of Sensitive Information + "NET002": ["CWE-295"], # Improper Certificate Validation + "NET003": ["CWE-295"], + "NET004": ["CWE-327"], # Broken/Risky Cryptographic Algorithm + "NET006": ["CWE-295"], + "AUTH001": ["CWE-532"], # Insertion of Sensitive Information into Log File + "CRYPTO001": ["CWE-328"], # Use of Weak Hash + "CRYPTO002": ["CWE-328"], + "CRYPTO003": ["CWE-327"], + "CRYPTO004": ["CWE-327"], + "CRYPTO005": ["CWE-329"], # Not Using a Random IV with CBC Mode + "CRYPTO006": ["CWE-330"], # Use of Insufficiently Random Values + "COMP001": ["CWE-79"], + "COMP002": ["CWE-200"], + "COMP003": ["CWE-749"], # Exposed Dangerous Method or Function + "COMP004": ["CWE-925"], # Improper Verification of Intent by Broadcast Receiver + "COMP005": ["CWE-79"], + "COMP006": ["CWE-926"], # Improper Export of Android Application Components + "COMP007": ["CWE-926"], + "COMP008": ["CWE-926"], + "INFO001": ["CWE-489"], # Active Debug Code + "INJ001": ["CWE-89"], + "INJ002": ["CWE-22"], # Path Traversal + "INJ003": ["CWE-78"], # OS Command Injection + "INJ004": ["CWE-926"], + "DBG001": ["CWE-489"], + "DBG002": ["CWE-532"], + "DBG003": ["CWE-209"], # Generation of Error Message Containing Sensitive Information + "DESER001": ["CWE-502"], # Deserialization of Untrusted Data + "EXTRA001": ["CWE-250"], # Execution with Unnecessary Privileges + "NAT001": ["CWE-120"], # Buffer Copy without Checking Size of Input + "NAT002": ["CWE-78"], + "NAT004": ["CWE-798"], + "NAT005": ["CWE-295"], + "NAT008": ["CWE-327"], +} + +# ── Peso base de cada categoría ─────────────────────────────────────────────── +# Nota: informativo/documental únicamente — build_masvs_report() usa +# penalizaciones planas desde 100 (_SEVERITY_PENALTY / _NO_PROTECT_PENALTY / +# _BYPASS_PENALTY), no una suma ponderada por categoría. + +# ── Mapeo detectores → MASVS ────────────────────────────────────────────────── +# FIX (Fase 2): "APK signature verification" estaba en RESILIENCE-4; el texto +# oficial de RESILIENCE-2 menciona literalmente "checking the application +# package signature" → movida a RESILIENCE-2. La detección de Frida (dentro +# de "Anti Magisk / SuperSU / KernelSU / Frida") es anti-dynamic-analysis +# (RESILIENCE-4), no anti-tampering (RESILIENCE-2) → se añadió RESILIENCE-4. DETECTOR_TO_MASVS: dict[str, list[str]] = { # Claves = detector.name tal como están definidas en cada clase detector "Known anti-root libraries": ["MASVS-RESILIENCE-1"], "SafetyNet / Play Integrity API": ["MASVS-RESILIENCE-1"], "Manual root checks": ["MASVS-RESILIENCE-1"], - "Anti Magisk / SuperSU / KernelSU / Frida": ["MASVS-RESILIENCE-1", "MASVS-RESILIENCE-2"], - "DexGuardDetector": ["MASVS-RESILIENCE-3", "MASVS-RESILIENCE-4"], + "Anti Magisk / SuperSU / KernelSU / Frida": ["MASVS-RESILIENCE-1", "MASVS-RESILIENCE-4"], + "DexGuardDetector": ["MASVS-RESILIENCE-2", "MASVS-RESILIENCE-3", + "MASVS-RESILIENCE-4"], "AppDome": ["MASVS-RESILIENCE-1", "MASVS-RESILIENCE-2", "MASVS-RESILIENCE-3", "MASVS-RESILIENCE-4"], - "APK signature verification": ["MASVS-RESILIENCE-4"], + "APK signature verification": ["MASVS-RESILIENCE-2"], # FIX: era RESILIENCE-4 "Certificate pinning": ["MASVS-NETWORK-2"], } @@ -142,29 +405,39 @@ }) # ── Mapeo misconfigs del manifest → MASVS ──────────────────────────────────── -# Cada entrada: (prefijo del título de Misconfiguration, [control_ids]) +# Cada entrada: (fragmento del título de Misconfiguration, [control_ids]). +# El fragmento se busca como substring en misconfig.title (no solo prefijo), +# para soportar títulos con contenido dinámico al inicio (p.ej. "3 high-risk +# permission(s)"). MISCONFIG_TO_MASVS: list[tuple[str, list[str]]] = [ # ── These titles are the same in both languages ─────────────────────────── - ('android:debuggable="true"', ["MASVS-CODE-2"]), - ('android:allowBackup="true"', ["MASVS-STORAGE-1"]), + ('android:debuggable="true"', ["MASVS-RESILIENCE-4"]), # FIX: era CODE-2 + ('android:allowBackup="true"', ["MASVS-STORAGE-2"]), # FIX: era STORAGE-1 ('android:usesCleartextTraffic="true"', ["MASVS-NETWORK-1"]), ('AWS Access Key', ["MASVS-STORAGE-2"]), ('Firebase Realtime DB URL', ["MASVS-STORAGE-2"]), ('Firebase API Key', ["MASVS-STORAGE-2"]), ('Google Maps API Key', ["MASVS-STORAGE-2"]), + # ── Nuevas coberturas reales (antes sin mapear) ──────────────────────────── + ('Low targetSdkVersion (', ["MASVS-CODE-1"]), + ('targetSdkVersion bajo (', ["MASVS-CODE-1"]), + ('high-risk permission(s)', ["MASVS-PRIVACY-1"]), + ('permiso(s) de alto riesgo', ["MASVS-PRIVACY-1"]), + ('No certificate pinning in Network Security Config', ["MASVS-NETWORK-2"]), + ('Sin certificate pinning en Network Security Config', ["MASVS-NETWORK-2"]), # ── Spanish ─────────────────────────────────────────────────────────────── ('Sin android:networkSecurityConfig', ["MASVS-NETWORK-1"]), ('Sin Network Security Config y cleartext', ["MASVS-NETWORK-1"]), - ('Conf\u00eda en CAs del usuario', ["MASVS-NETWORK-2"]), + ('Confía en CAs del usuario', ["MASVS-NETWORK-2"]), ('Cleartext permitido para dominio', ["MASVS-NETWORK-1"]), (' exportado sin permiso', ["MASVS-PLATFORM-1"]), (' exportado sin permiso', ["MASVS-PLATFORM-1"]), (' exportado sin permiso', ["MASVS-PLATFORM-1"]), (' exportado sin permiso', ["MASVS-PLATFORM-1"]), ('API Key hardcodeada', ["MASVS-STORAGE-2"]), - ('JWT Token hardcodeado', ["MASVS-STORAGE-2", "MASVS-AUTH-2"]), + ('JWT Token hardcodeado', ["MASVS-STORAGE-2"]), # FIX: quitado AUTH-2 (no aplica) ('IP privada hardcodeada', ["MASVS-NETWORK-1"]), - ('Posible contrase\u00f1a hardcodeada', ["MASVS-STORAGE-2"]), + ('Posible contraseña hardcodeada', ["MASVS-STORAGE-2"]), # ── English ─────────────────────────────────────────────────────────────── ('No android:networkSecurityConfig', ["MASVS-NETWORK-1"]), ('No Network Security Config and cleartext',["MASVS-NETWORK-1"]), @@ -175,7 +448,7 @@ (' exported without permission', ["MASVS-PLATFORM-1"]), (' exported without permission', ["MASVS-PLATFORM-1"]), ('Hardcoded API Key', ["MASVS-STORAGE-2"]), - ('Hardcoded JWT Token', ["MASVS-STORAGE-2", "MASVS-AUTH-2"]), + ('Hardcoded JWT Token', ["MASVS-STORAGE-2"]), # FIX: quitado AUTH-2 ('Hardcoded Private IP', ["MASVS-NETWORK-1"]), ('Possible hardcoded password', ["MASVS-STORAGE-2"]), ] @@ -225,6 +498,9 @@ class MASVSControlResult: detector_names: list[str] = field(default_factory=list) # Títulos cortos de misconfigs del manifest que afectan este control misconfig_titles: list[str] = field(default_factory=list) + # Debilidades MASWE y CWE agregadas de las reglas que dispararon este control + maswe_ids: list[str] = field(default_factory=list) + cwe_ids: list[str] = field(default_factory=list) def to_dict(self) -> dict: return { @@ -236,12 +512,14 @@ def to_dict(self) -> dict: "finding_rule_ids": self.finding_rule_ids, "detector_names": self.detector_names, "misconfig_titles": self.misconfig_titles, + "maswe_ids": self.maswe_ids, + "cwe_ids": self.cwe_ids, } @dataclass class MASVSReport: - """Reporte completo de cumplimiento MASVS v2.""" + """Reporte completo de cumplimiento MASVS v2.1.""" controls: list[MASVSControlResult] score: int # 0-100 grade: str # A / B / C / D / F @@ -340,6 +618,9 @@ def build_masvs_report( ) penalty_per = _SEVERITY_PENALTY.get(sev_order[worst_idx], 0) + maswe_ids = RULE_TO_MASWE.get(rule_id, []) + cwe_ids = RULE_TO_CWE.get(rule_id, []) + for cid in masvs_ids: ctrl = control_map.get(cid) if ctrl is None: @@ -348,6 +629,12 @@ def build_masvs_report( ctrl.finding_count += len(findings) if rule_id not in ctrl.finding_rule_ids: ctrl.finding_rule_ids.append(rule_id) + for mid in maswe_ids: + if mid not in ctrl.maswe_ids: + ctrl.maswe_ids.append(mid) + for cwe in cwe_ids: + if cwe not in ctrl.cwe_ids: + ctrl.cwe_ids.append(cwe) ctrl.penalty = min( ctrl.penalty + penalty_per * len(findings), _MAX_PENALTY_PER_CTRL, @@ -365,8 +652,8 @@ def build_masvs_report( if manifest is not None: for misconfig in manifest.misconfigurations: masvs_ids: list[str] = [] - for title_prefix, cids in MISCONFIG_TO_MASVS: - if misconfig.title.startswith(title_prefix): + for title_fragment, cids in MISCONFIG_TO_MASVS: + if title_fragment in misconfig.title: masvs_ids = cids break if not masvs_ids: @@ -409,8 +696,12 @@ def build_masvs_report( ctrl.status = "no_protection" ctrl.penalty = _NO_PROTECT_PENALTY.get(cid, 5) - # 4. Aplicar bypass si la protección fue rota en runtime - bypass_confirmed = analysis.protection_broken + # 4. Aplicar bypass si la protección fue rota en runtime. + # ROADMAP "Differentiate runtime bypass vs DEX extraction": protection_broken + # exige dex_count > 0 (extracción exitosa), pero el plugin aipwn puede + # confirmar un bypass runtime real (report_success del FridaAgent) sin + # llegar a volcar DEX — igual debe reflejarse aquí. + bypass_confirmed = analysis.protection_broken or analysis.aipwn_bypass_confirmed if bypass_confirmed: for cid, ctrl in control_map.items(): if not cid.startswith("MASVS-RESILIENCE"): diff --git a/nutcracker_core/orchestrator.py b/nutcracker_core/orchestrator.py index 996b120..799d4c2 100644 --- a/nutcracker_core/orchestrator.py +++ b/nutcracker_core/orchestrator.py @@ -454,6 +454,10 @@ def _print_verdict(result, vuln_scan) -> None: runtime_bypass = any(k in method.lower() for k in ("frida", "fart", "gadget")) was_bypassed = protected and runtime_bypass and dex_count > 0 + # ROADMAP "Differentiate runtime bypass vs DEX extraction": aipwn puede + # confirmar un bypass (report_success) sin haber volcado DEX — verdicto + # dinámico independiente de was_bypassed (que exige dex_count > 0). + aipwn_bypass_only = protected and bool(getattr(result, "aipwn_bypass_confirmed", False)) and not was_bypassed if not protected: color = "red" @@ -465,6 +469,11 @@ def _print_verdict(result, vuln_scan) -> None: icon = "⚡" title = t("cli_protection_broken_banner") detail = t("cli_bypassed_detail", method=method, dex_count=dex_count) + elif aipwn_bypass_only: + color = "yellow" + icon = "⚡" + title = t("cli_bypass_confirmed_banner") + detail = t("cli_bypass_confirmed_detail") else: color = "green" icon = "✔" diff --git a/nutcracker_core/store/hooks.py b/nutcracker_core/store/hooks.py index 1e51361..4528918 100644 --- a/nutcracker_core/store/hooks.py +++ b/nutcracker_core/store/hooks.py @@ -134,11 +134,13 @@ def _verdict_from_result(result: Any) -> str: def _to_finding_record(finding: Any, vuln_scan: Any) -> repository.FindingRecord: try: - from ..masvs import RULE_TO_MASVS + from ..masvs import RULE_TO_MASVS, RULE_TO_MASWE, RULE_TO_CWE masvs_ids = RULE_TO_MASVS.get(finding.rule_id, []) + maswe_ids = RULE_TO_MASWE.get(finding.rule_id, []) + cwe_ids = RULE_TO_CWE.get(finding.rule_id, []) except Exception: # noqa: BLE001 - masvs_ids = [] + masvs_ids, maswe_ids, cwe_ids = [], [], [] file_str = "" try: @@ -152,6 +154,8 @@ def _to_finding_record(finding: Any, vuln_scan: Any) -> repository.FindingRecord severity=getattr(finding, "severity", ""), category=getattr(finding, "category", ""), masvs=masvs_ids, + maswe=maswe_ids, + cwe=cwe_ids, file=file_str, line=getattr(finding, "line", 0) or 0, ) diff --git a/nutcracker_core/vuln_scanner.py b/nutcracker_core/vuln_scanner.py index 5a46c3b..9037f88 100644 --- a/nutcracker_core/vuln_scanner.py +++ b/nutcracker_core/vuln_scanner.py @@ -1154,6 +1154,18 @@ def _apply_apkleaks(scan_result: ScanResult) -> None: return result +# Reglas sintetizadas desde AndroidManifest.xml (no son VulnRule con regex: +# se generan directamente en scan_manifest_components a partir de atributos +# XML). A nivel de módulo para que checks/static/adapter.py (Fase 2.2 del +# plan) las exponga en el registry unificado sin duplicar esta tabla. +EXPORTED_COMPONENT_RULES: dict[str, tuple[str, str, str, str]] = { + "activity": ("COMP006", "Activity exported sin permission", "critical", "M6 - Componentes inseguros"), + "service": ("COMP007", "Service exported sin permission", "high", "M6 - Componentes inseguros"), + "receiver": ("COMP004", "BroadcastReceiver exported sin permission", "high", "M6 - Componentes inseguros"), + "provider": ("COMP008", "ContentProvider exported sin permission", "critical", "M6 - Componentes inseguros"), +} + + def scan_manifest_components(source_dir: Path) -> list[VulnFinding]: """ Parsea AndroidManifest.xml buscando: @@ -1213,15 +1225,8 @@ def scan_manifest_components(source_dir: Path) -> list[VulnFinding]: )) # ── Componentes exported sin permission ────────────────────────────────── - component_tags = { - "activity": ("COMP006", "Activity exported sin permission", "critical", "M6 - Componentes inseguros"), - "service": ("COMP007", "Service exported sin permission", "high", "M6 - Componentes inseguros"), - "receiver": ("COMP004", "BroadcastReceiver exported sin permission", "high", "M6 - Componentes inseguros"), - "provider": ("COMP008", "ContentProvider exported sin permission", "critical", "M6 - Componentes inseguros"), - } - if app_el is not None: - for tag, (rule_id, title, severity, category) in component_tags.items(): + for tag, (rule_id, title, severity, category) in EXPORTED_COMPONENT_RULES.items(): for el in app_el.findall(tag): exported = el.get(f"{{{ns}}}exported") permission = el.get(f"{{{ns}}}permission") diff --git a/plan.md b/plan.md index dd4cf79..ed13a23 100644 --- a/plan.md +++ b/plan.md @@ -62,7 +62,7 @@ Revisión del commit `90fad34 "advance"` (posterior al commit del plan) contra e |---|---| | **Fase 0** — Fundamentos + persistencia | ✅ **Completa** (con 2 huecos menores, ver abajo) | | **Fase 1** — Cola + scheduler | ✅ **Completa** (POC verificada con APK real, ver abajo) | -| **Fase 2** — OWASP MAS | ❌ No iniciada | +| **Fase 2** — OWASP MAS | ✅ **Completa** (investigación real + bugs de mapeo corregidos, ver abajo) | | **Fase 3** — Dashboard web | ❌ No iniciada | ### Fase 0 — detalle de lo entregado @@ -311,6 +311,102 @@ Implementar la separación **verdicto estático vs bypass dinámico** ya especif `ROADMAP.md` (`aipwn_bypass_confirmed` en `AnalysisResult`, ajuste en `build_masvs_report`), que encaja naturalmente con el modelo `runs.kind` de Fase 0. +### Fase 2 — estado: ✅ completa (2026-07-24) + +**Investigación previa a implementar** (obligatoria dado el riesgo de inventar datos de un +estándar): se extrajeron los **24 controles MASVS v2.1 reales** (8 categorías, incluyendo +PRIVACY) y el **catálogo completo de 119 debilidades MASWE** directamente de +`mas.owasp.org/MASVS/`, `github.com/OWASP/masvs` (`OWASP_MASVS.yaml`, fuente estructurada de +verdad) y `github.com/OWASP/maswe` — no se inventó ningún ID ni texto de control. + +**Hallazgo central: el repo tenía 3 bugs reales de mapeo MASVS**, encontrados al contrastar +el código contra el texto oficial de cada control (no eran solo "faltaban campos", eran +mapeos **incorrectos**): +1. **RESILIENCE-2 ↔ RESILIENCE-4 invertidos.** El texto oficial de R2 es "anti-tampering" + (firma del paquete, integridad DEX/nativo/recursos) y R4 es "anti-dynamic-analysis" + (debugger, Frida, hooking) — el código tenía exactamente al revés. Afectaba + `DETECTOR_TO_MASVS` ("APK signature verification" → R4 en vez de R2; detección de Frida → + R2 en vez de R4) y `RULE_TO_MASVS` (NAT003, anti-debug nativo → R2 en vez de R4). +2. **AUTH001/DBG002/DBG003/INFO001 en controles cuyo texto oficial no aplicaba.** "Token en + logs" estaba en `MASVS-AUTH-2` (cuyo texto real es autenticación local biométrica/PIN, no + logs) y `android:debuggable=true` estaba en `MASVS-CODE-2` (cuyo texto real es "mecanismo + de actualización forzada", no debuggable). El catálogo MASWE oficial resolvió la duda: + `MASWE-0001` ("Insertion of Sensitive Data into Logs") está catalogada bajo **STORAGE**, y + `MASWE-0067` ("Debuggable Flag Not Disabled") está catalogada bajo **RESILIENCE** → movidas + a `MASVS-STORAGE-2` y `MASVS-RESILIENCE-4` respectivamente (ambas además calzan con el + texto oficial de esos controles, que mencionan "logs" y "anti-dynamic-analysis" de forma + explícita). +3. **`COMP008` (ContentProvider exportado) no tenía ningún mapeo MASVS/MASWE/CWE** — se + generaba en runtime (`vuln_scanner.scan_manifest_components`) pero nunca se agregó a + `masvs.py` cuando se creó la regla. + +También se corrigió un **bug de implementación** encontrado por los tests del framework +nuevo (no de mapeo, de código): los 8 checks adaptados desde `analyzer.ALL_DETECTORS` +resolvían su metadata MASVS consultando `RULE_TO_MASVS` (por rule_id) en vez de +`DETECTOR_TO_MASVS` (por nombre de detector) — un dict distinto —, dejando **toda** la +metadata de los detectores de resiliencia vacía. Detectado por +`test_static_adapter_wraps_all_existing_rules` al generar `docs/owasp-mas-coverage.md` y ver +`MASVS-RESILIENCE-1` casi sin checks listados pese a tener 5 detectores activos. + +**Entregado:** +- `masvs.py` reescrito: 24 controles reales + `MASWE_CATALOG` (119 entradas) + `RULE_TO_MASWE` + + `RULE_TO_CWE` (mapeo prudente: solo reglas con correspondencia semántica clara y + verificable contra el catálogo oficial — no se forzó un MASWE/CWE para reglas ambiguas como + HC007/HC008/ST006/OBF001/NAT001/NAT006/NAT007, que se dejaron sin esa dimensión antes que + inventar una). +- `store/hooks.py`: cada `finding` persistido ahora trae `masvs` + `maswe` + `cwe` (los + campos ya existían en el schema desde Fase 0 pero nunca se poblaban). Verificado con APK + real: `AUTH001|high|MASVS-STORAGE-2|MASWE-0001|CWE-532`. +- `nutcracker_core/checks/`: framework unificado (`base.py`, `registry.py`) + + `checks/static/adapter.py` (envuelve los ~54 checks existentes de + `vuln_scanner.RULES`/`EXPORTED_COMPONENT_RULES`/`native_scanner._NATIVE_RULES`/ + `analyzer.ALL_DETECTORS` sin reescribirlos — riesgo/beneficio de portar ~50 reglas maduras a + archivos individuales no lo justificaba) + `checks/dynamic/` con **2 checks dinámicos reales + y testeados** (no simulados): `DebuggableDynamicCheck` (confirma `debuggable` en runtime vía + `run-as`/JDWP) y `CleartextTrafficDynamicCheck` (detecta HTTP en claro en logcat durante la + ejecución) — ambos con un `adb_run` inyectable, así que corren deterministas en CI sin + dispositivo. Se hizo un pequeño refactor seguro en `vuln_scanner.py`: `component_tags` (dict + local dentro de `scan_manifest_components`) se elevó a constante de módulo + `EXPORTED_COMPONENT_RULES` para que el adaptador la reuse sin duplicarla. +- `docs/owasp-mas-coverage.md`, generado por `tools/gen_owasp_coverage.py` desde el registry + (no escrito a mano — regenerar tras cualquier cambio en `masvs.py`/checks). Resultado + honesto: **14/24 controles MASVS con al menos un check**; los 10 sin cobertura + (AUTH-1/2/3, CRYPTO-2, CODE-1/2/3, PRIVACY-2/3/4) quedan listados explícitamente como gap, + no ocultos. +- ROADMAP "Differentiate runtime bypass vs DEX extraction": `AnalysisResult.aipwn_bypass_confirmed` + (nuevo campo, con roundtrip en `to_dict`/`from_dict`); `build_masvs_report()` ahora usa + `analysis.protection_broken or analysis.aipwn_bypass_confirmed`; banner de terminal nuevo + ("BYPASS CONFIRMED" / i18n EN+ES) en `orchestrator._print_verdict` cuando el bypass viene de + aipwn sin extracción de DEX. **Nota importante:** `nutcracker_core/plugins/aipwn/` es un + **repositorio git separado** (remoto propio `github.com/nutcracker-sh/aipwn`, invisible al + `git status` de este repo) — la parte de wiring en `plugins/aipwn/__init__.py` (marca + `aipwn_bypass_confirmed=True` y re-guarda el JSON tras un `report_success`) quedó modificada + ahí y **no se comiteó** (fuera del alcance de este repo; coordinar con el mantenedor de ese + repo aparte si se quiere subir). +- Actualización de la sección "PDF report con sección de dynamic analysis" (punto 3 del item + de ROADMAP) — **diferida**: el banner de terminal y el campo/score ya distinguen ambos + verdictos; el rediseño de la sección PDF es un cambio de mayor superficie en + `pdf_reporter.py` que se deja como follow-up explícito, no reclamado como hecho. + +**Deliberadamente fuera de alcance de esta fase** (para no sobre-extender el ya considerable +trabajo de investigación + implementación): +- Migrar cada regla existente a un archivo `.py` individual dentro de `checks/static/` (el + adaptador cubre la meta de "registry unificado" sin ese riesgo/costo). +- MASWE a nivel de `MISCONFIG_TO_MASVS` (hallazgos del manifest): se corrigieron los IDs + MASVS incorrectos ahí, pero no se añadió una dimensión MASWE porque esos hallazgos no pasan + hoy por `store/hooks.py::_to_finding_record` (no se persisten en la tabla `findings`) — + extenderlo requeriría antes persistir misconfigs, que es un cambio de alcance mayor. +- Checks dinámicos adicionales más allá de los 2 implementados (debuggable, cleartext) — + quedan como candidatos naturales para portar más lógica de + `plugins/aipwn/exploit_agent.py` cuando se retome esta fase. + +**Tests:** 19 nuevos (`tests/checks/test_registry.py` ×8, `tests/checks/test_dynamic_checks.py` +×7, `tests/test_masvs_bypass.py` ×4). Suite completa: **73/73 passing**. Verificado también +con el mismo APK real de Mobile Hacking Lab usado en la POC de Fase 1: coverage pasó de +15/24 a **24/24** controles reportados, `MASVS-STORAGE-2` ahora captura correctamente el +finding `AUTH001` (antes atado a un `AUTH-2` cuyo texto oficial no aplicaba), y +`MASVS-PRIVACY-1`/`MASVS-RESILIENCE-2` pasan a tener veredicto real (antes vacíos). + --- ## Fase 3 — Dashboard web local (plugin nuevo) diff --git a/tests/checks/__init__.py b/tests/checks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/checks/test_dynamic_checks.py b/tests/checks/test_dynamic_checks.py new file mode 100644 index 0000000..f4e9ec8 --- /dev/null +++ b/tests/checks/test_dynamic_checks.py @@ -0,0 +1,98 @@ +"""Tests de los checks dinámicos (Fase 2.2 del plan) — deterministas vía un +adb_run inyectado, sin necesitar un dispositivo real conectado.""" + +from __future__ import annotations + +from nutcracker_core.checks.dynamic.cleartext_traffic import CleartextTrafficDynamicCheck +from nutcracker_core.checks.dynamic.context import DynamicCheckContext +from nutcracker_core.checks.dynamic.debuggable import DebuggableDynamicCheck + + +def _ctx(fake_adb_run) -> DynamicCheckContext: + return DynamicCheckContext(package="com.example.app", serial="EMULATOR-TEST", adb_run=fake_adb_run) + + +# ── DebuggableDynamicCheck ──────────────────────────────────────────────────── + +def test_debuggable_detected_via_run_as(): + def fake_adb_run(args: list[str]) -> str: + if args[:2] == ["shell", "run-as"]: + return "uid=10123(com.example.app) gid=10123(com.example.app)\n" + if args == ["shell", "pidof", "com.example.app"]: + return "12345\n" + if args == ["jdwp"]: + return "" + return "" + + finding = DebuggableDynamicCheck().run(_ctx(fake_adb_run))[0] + assert finding.detected is True + assert finding.severity == "high" + assert finding.masvs == ["MASVS-RESILIENCE-4"] + assert finding.maswe == ["MASWE-0067"] + assert finding.cwe == ["CWE-489"] + + +def test_debuggable_detected_via_jdwp_only(): + def fake_adb_run(args: list[str]) -> str: + if args[:2] == ["shell", "run-as"]: + return "run-as: Package 'com.example.app' is not debuggable\n" + if args == ["shell", "pidof", "com.example.app"]: + return "999\n" + if args == ["jdwp"]: + return "888\n999\n" + return "" + + finding = DebuggableDynamicCheck().run(_ctx(fake_adb_run))[0] + assert finding.detected is True + + +def test_debuggable_not_detected(): + def fake_adb_run(args: list[str]) -> str: + if args[:2] == ["shell", "run-as"]: + return "run-as: Package 'com.example.app' is not debuggable\n" + if args == ["shell", "pidof", "com.example.app"]: + return "" + if args == ["jdwp"]: + return "" + return "" + + finding = DebuggableDynamicCheck().run(_ctx(fake_adb_run))[0] + assert finding.detected is False + assert finding.severity == "info" + + +# ── CleartextTrafficDynamicCheck ────────────────────────────────────────────── + +def test_cleartext_traffic_detected(): + def fake_adb_run(args: list[str]) -> str: + if args == ["shell", "logcat", "-c"]: + return "" + if args[:3] == ["shell", "logcat", "-d"]: + return "I/OkHttp: --> GET http://api.example.com/login\nD/Something: unrelated\n" + return "" + + check = CleartextTrafficDynamicCheck(settle_seconds=0) + finding = check.run(_ctx(fake_adb_run))[0] + assert finding.detected is True + assert "api.example.com" in finding.detail + assert finding.masvs == ["MASVS-NETWORK-1"] + assert finding.maswe == ["MASWE-0050"] + + +def test_cleartext_traffic_not_detected_when_only_https(): + def fake_adb_run(args: list[str]) -> str: + if args[:3] == ["shell", "logcat", "-d"]: + return "I/OkHttp: --> GET https://api.example.com/login\n" + return "" + + check = CleartextTrafficDynamicCheck(settle_seconds=0) + finding = check.run(_ctx(fake_adb_run))[0] + assert finding.detected is False + assert finding.severity == "info" + + +def test_default_adb_runner_used_when_none_injected(): + """Sin adb_run explícito, DynamicCheckContext debe construir uno real + (default_adb_runner) en vez de quedar None — evita AttributeError en uso real.""" + ctx = DynamicCheckContext(package="com.example.app") + assert callable(ctx.adb_run) diff --git a/tests/checks/test_registry.py b/tests/checks/test_registry.py new file mode 100644 index 0000000..80c7e1a --- /dev/null +++ b/tests/checks/test_registry.py @@ -0,0 +1,113 @@ +"""Tests del framework de checks (Fase 2.2 del plan): registry + adaptador +estático + metadata OWASP MAS.""" + +from __future__ import annotations + +import pytest + +from nutcracker_core import checks +from nutcracker_core.checks import registry + + +@pytest.fixture(autouse=True) +def _clean_registry(): + """Cada test arranca con el registry vacío para no depender de orden de + ejecución ni de si otro test ya llamó load_registry().""" + registry.reset() + yield + registry.reset() + + +def test_load_registry_is_idempotent(): + checks.load_registry() + n1 = len(checks.all_checks()) + checks.load_registry() + n2 = len(checks.all_checks()) + assert n1 == n2 > 0 + + +def test_static_adapter_wraps_all_existing_rules(): + from nutcracker_core.vuln_scanner import RULES, EXPORTED_COMPONENT_RULES + from nutcracker_core.native_scanner import _NATIVE_RULES + from nutcracker_core.analyzer import ALL_DETECTORS + + checks.load_registry() + static_ids = {c.meta.id for c in checks.static_checks()} + + # COMP004 colisiona a propósito entre RULES y EXPORTED_COMPONENT_RULES + # (dos hallazgos distintos comparten id preexistente) — el adaptador solo + # lo registra una vez, vía RULES. + manifest_synth_ids = {rid for rid, *_ in EXPORTED_COMPONENT_RULES.values()} | {"INFO001"} + expected = ( + {r.rule_id for r in RULES} + | {r.rule_id for r in _NATIVE_RULES} + | {d.name for d in ALL_DETECTORS} + | manifest_synth_ids + ) + assert static_ids == expected + + +def test_no_duplicate_check_ids(): + checks.load_registry() + ids = [c.meta.id for c in checks.all_checks()] + assert len(ids) == len(set(ids)), "hay IDs de check duplicados en el registry" + + +def test_static_checks_have_static_kind_and_dynamic_have_dynamic_kind(): + checks.load_registry() + assert all(c.meta.kind == "static" for c in checks.static_checks()) + assert all(c.meta.kind == "dynamic" for c in checks.dynamic_checks()) + + +def test_dynamic_checks_are_registered(): + checks.load_registry() + dynamic_ids = {c.meta.id for c in checks.dynamic_checks()} + assert "DYN-DEBUGGABLE" in dynamic_ids + assert "DYN-CLEARTEXT-TRAFFIC" in dynamic_ids + + +def test_known_rule_carries_correct_masvs_maswe_cwe_metadata(): + """INFO001 es el caso que motivó el fix de Fase 2 (estaba mal mapeado a + CODE-2; el control real es RESILIENCE-4 según MASWE-0067).""" + checks.load_registry() + info001 = next(c for c in checks.static_checks() if c.meta.id == "INFO001") + assert info001.meta.masvs == ["MASVS-RESILIENCE-4"] + assert info001.meta.maswe == ["MASWE-0067"] + assert info001.meta.cwe == ["CWE-489"] + + +def test_static_check_run_raises_not_implemented(): + """Los checks estáticos adaptados no ejecutan vía Check.run() — corren por + su pipeline original (scan_directory/etc); esto debe fallar explícito, no + en silencio, si alguien intenta invocarlo directamente.""" + checks.load_registry() + any_static = checks.static_checks()[0] + with pytest.raises(NotImplementedError): + any_static.run(None) + + +def test_detector_checks_carry_masvs_from_detector_to_masvs_not_rule_to_masvs(): + """Regresión: los detectores usan DETECTOR_TO_MASVS (mapeo separado de + RULE_TO_MASVS por nombre, no por rule_id); un bug previo dejaba esta + metadata vacía para los 8 detectores de resiliencia.""" + from nutcracker_core.masvs import DETECTOR_TO_MASVS + + checks.load_registry() + detector_checks = [c for c in checks.static_checks() if c.meta.source == "detector"] + assert len(detector_checks) == len(DETECTOR_TO_MASVS) + for c in detector_checks: + assert c.meta.masvs == DETECTOR_TO_MASVS[c.meta.id] + assert c.meta.masvs, f"{c.meta.id}: detector sin MASVS asociado" + + +def test_masvs_maswe_cwe_ids_referenced_actually_exist_in_catalogs(): + """Sanity check anti-typo: todo MASVS/MASWE id usado en la metadata de un + check debe existir en los catálogos oficiales (masvs.py).""" + from nutcracker_core.masvs import MASVS_CONTROLS, MASWE_CATALOG + + checks.load_registry() + for check in checks.all_checks(): + for cid in check.meta.masvs: + assert cid in MASVS_CONTROLS, f"{check.meta.id}: {cid} no existe en MASVS_CONTROLS" + for mid in check.meta.maswe: + assert mid in MASWE_CATALOG, f"{check.meta.id}: {mid} no existe en MASWE_CATALOG" diff --git a/tests/test_masvs_bypass.py b/tests/test_masvs_bypass.py new file mode 100644 index 0000000..fc8cd8c --- /dev/null +++ b/tests/test_masvs_bypass.py @@ -0,0 +1,62 @@ +"""Tests del item de ROADMAP cerrado en Fase 2.4: separar el veredicto de +bypass estático (protection_broken, exige DEX extraído) del bypass dinámico +confirmado por el plugin aipwn (aipwn_bypass_confirmed, sin esa exigencia).""" + +from __future__ import annotations + +from nutcracker_core.analyzer import AnalysisResult +from nutcracker_core.detectors.base import DetectionResult +from nutcracker_core.masvs import build_masvs_report + + +def _protected_result(aipwn_bypass_confirmed: bool = False, dex_count: int = 0) -> AnalysisResult: + result = AnalysisResult( + package="com.example.app", + version_name="1.0", + version_code="1", + min_sdk="21", + target_sdk="33", + analyzed_at="2026-07-24T00:00:00Z", + results=[DetectionResult(name="Known anti-root libraries", detected=True, strength="high")], + ) + if dex_count: + result.decompilation_info = {"method": "frida-dexdump", "dex_count": dex_count} + result.aipwn_bypass_confirmed = aipwn_bypass_confirmed + return result + + +def test_aipwn_bypass_confirmed_flips_resilience_controls_without_dex(): + """Caso central del ROADMAP: bypass confirmado por aipwn SIN dex_count > 0 + (app con anti-root nativo agresivo que mata el proceso antes de FART) + igual debe marcar bypass_confirmed=True y aplicar la penalización.""" + result = _protected_result(aipwn_bypass_confirmed=True, dex_count=0) + assert result.protection_broken is False # exige dex_count > 0 + + report = build_masvs_report(result) + assert report.bypass_confirmed is True + r1 = next(c for c in report.controls if c.control_id == "MASVS-RESILIENCE-1") + assert r1.status == "bypass" + + +def test_protection_broken_alone_still_flips_bypass_confirmed(): + """No regresionar el camino existente: DEX extraído sin aipwn también + debe seguir marcando bypass_confirmed=True.""" + result = _protected_result(aipwn_bypass_confirmed=False, dex_count=3) + assert result.protection_broken is True + + report = build_masvs_report(result) + assert report.bypass_confirmed is True + + +def test_no_bypass_when_neither_confirmed(): + result = _protected_result(aipwn_bypass_confirmed=False, dex_count=0) + report = build_masvs_report(result) + assert report.bypass_confirmed is False + r1 = next(c for c in report.controls if c.control_id == "MASVS-RESILIENCE-1") + assert r1.status == "pass" + + +def test_analysis_result_roundtrips_aipwn_bypass_confirmed_through_dict(): + result = _protected_result(aipwn_bypass_confirmed=True) + restored = AnalysisResult.from_dict(result.to_dict()) + assert restored.aipwn_bypass_confirmed is True diff --git a/tools/gen_owasp_coverage.py b/tools/gen_owasp_coverage.py new file mode 100644 index 0000000..34c6571 --- /dev/null +++ b/tools/gen_owasp_coverage.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Genera docs/owasp-mas-coverage.md desde el registry de checks (Fase 2.3 del +plan): matriz MASVS × MASWE × CWE cubierta / pendiente por nutcracker. + +Uso: + python tools/gen_owasp_coverage.py +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from nutcracker_core import checks +from nutcracker_core.masvs import MASVS_CONTROLS, MASWE_CATALOG + +OUTPUT_PATH = Path(__file__).parent.parent / "docs" / "owasp-mas-coverage.md" + + +def main() -> None: + checks.load_registry() + all_checks = checks.all_checks() + + # control_id -> lista de checks que lo cubren + by_control: dict[str, list] = {cid: [] for cid in MASVS_CONTROLS} + maswe_covered: set[str] = set() + for check in all_checks: + for cid in check.meta.masvs: + by_control.setdefault(cid, []).append(check) + maswe_covered.update(check.meta.maswe) + + covered_controls = sorted(cid for cid, checks_ in by_control.items() if checks_) + uncovered_controls = sorted(cid for cid in MASVS_CONTROLS if not by_control.get(cid)) + + lines: list[str] = [] + lines.append("# Cobertura OWASP MAS de nutcracker") + lines.append("") + lines.append( + "Generado automáticamente por `tools/gen_owasp_coverage.py` desde " + "`nutcracker_core/checks/` (Fase 2 del plan). No editar a mano — " + "regenerar tras cambios en `masvs.py` o en los checks." + ) + lines.append("") + lines.append( + f"**Resumen:** {len(covered_controls)}/{len(MASVS_CONTROLS)} controles MASVS v2.1 " + f"con al menos un check · {len(all_checks)} checks totales " + f"({len(checks.static_checks())} estáticos, {len(checks.dynamic_checks())} dinámicos) · " + f"{len(maswe_covered)}/{len(MASWE_CATALOG)} debilidades MASWE referenciadas." + ) + lines.append("") + + lines.append("## Cobertura por control MASVS") + lines.append("") + lines.append("| Control | Descripción | Checks | MASWE |") + lines.append("|---|---|---|---|") + for cid, desc in MASVS_CONTROLS.items(): + checks_for_ctrl = by_control.get(cid, []) + check_ids = ", ".join(f"`{c.meta.id}`" for c in checks_for_ctrl) or "—" + maswe_ids = sorted({m for c in checks_for_ctrl for m in c.meta.maswe}) + maswe_str = ", ".join(maswe_ids) or "—" + lines.append(f"| `{cid}` | {desc} | {check_ids} | {maswe_str} |") + lines.append("") + + lines.append("## Controles sin cobertura hoy (gap honesto, no una promesa)") + lines.append("") + if uncovered_controls: + for cid in uncovered_controls: + lines.append(f"- `{cid}` — {MASVS_CONTROLS[cid]}") + else: + lines.append("(ninguno)") + lines.append("") + + lines.append("## Checks registrados") + lines.append("") + lines.append("| ID | Kind | Fuente | Severidad | MASVS | MASWE | CWE |") + lines.append("|---|---|---|---|---|---|---|") + for c in sorted(all_checks, key=lambda c: (c.meta.kind, c.meta.source, c.meta.id)): + masvs_str = ", ".join(c.meta.masvs) or "—" + maswe_str = ", ".join(c.meta.maswe) or "—" + cwe_str = ", ".join(c.meta.cwe) or "—" + lines.append( + f"| `{c.meta.id}` | {c.meta.kind} | {c.meta.source} | {c.meta.severity} " + f"| {masvs_str} | {maswe_str} | {cwe_str} |" + ) + lines.append("") + + OUTPUT_PATH.write_text("\n".join(lines), encoding="utf-8") + print(f"✔ Escrito {OUTPUT_PATH} ({len(covered_controls)}/{len(MASVS_CONTROLS)} controles cubiertos)") + + +if __name__ == "__main__": + main() From 42d73306155b160912ce16dc1533a86950916303 Mon Sep 17 00:00:00 2001 From: Hiteek Date: Fri, 24 Jul 2026 14:42:31 -0500 Subject: [PATCH 18/30] dashboard --- nutcracker_core/queue/engine.py | 27 +++++- plan.md | 91 +++++++++++++++++++- tests/dashboard/__init__.py | 0 tests/dashboard/test_api.py | 147 ++++++++++++++++++++++++++++++++ tests/dashboard/test_events.py | 59 +++++++++++++ tests/dashboard/test_ws.py | 62 ++++++++++++++ tests/test_queue_engine.py | 45 ++++++++++ 7 files changed, 429 insertions(+), 2 deletions(-) create mode 100644 tests/dashboard/__init__.py create mode 100644 tests/dashboard/test_api.py create mode 100644 tests/dashboard/test_events.py create mode 100644 tests/dashboard/test_ws.py diff --git a/nutcracker_core/queue/engine.py b/nutcracker_core/queue/engine.py index 99e5c46..ee7e72a 100644 --- a/nutcracker_core/queue/engine.py +++ b/nutcracker_core/queue/engine.py @@ -72,6 +72,12 @@ def __init__( self._pending: list[Job] = [] self._device_locks: dict[str, threading.Lock] = {} self._locks_guard = threading.Lock() + # Hook opcional (Fase 3, dashboard): si se asigna, cada línea de stdout + # del subproceso de un job se publica aquí en vivo, línea a línea, + # en vez de esperar a que el job termine. None (default) preserva el + # camino original — subprocess.run() bloqueante, sin cambios de + # comportamiento para quien no lo use (CLI, scheduler, tests de Fase 1). + self.on_line: Callable[[int, str], None] | None = None # ── Encolado ───────────────────────────────────────────────────────────── @@ -176,7 +182,10 @@ def _run_job(self, job: Job) -> JobOutcome: env["NUTCRACKER_QUEUE_JOB_ID"] = str(job.db_id) _log.info("job #%s: %s", job.db_id, " ".join(cmd)) - proc = subprocess.run(cmd, env=env, capture_output=True, text=True) + if self.on_line is not None: + proc = self._run_streaming(job.db_id, cmd, env) + else: + proc = subprocess.run(cmd, env=env, capture_output=True, text=True) ok = proc.returncode == 0 tail = (proc.stderr or proc.stdout or "").strip()[-2000:] error = "" if ok else tail @@ -197,6 +206,22 @@ def _run_job(self, job: Job) -> JobOutcome: return JobOutcome(job=job, ok=ok, returncode=proc.returncode, error=error, run_id=run_id, package=package) + def _run_streaming(self, job_id: int, cmd: list[str], env: dict) -> subprocess.CompletedProcess: + """Como subprocess.run(), pero publica cada línea de salida a + self.on_line(job_id, line) en cuanto se produce (stdout+stderr + combinados, orden real de aparición) en vez de solo al terminar.""" + proc = subprocess.Popen( + cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, bufsize=1, + ) + lines: list[str] = [] + assert proc.stdout is not None + for line in proc.stdout: + lines.append(line) + self.on_line(job_id, line.rstrip("\n")) # type: ignore[misc] + proc.wait() + return subprocess.CompletedProcess(cmd, proc.returncode, stdout="".join(lines), stderr="") + def _run_dynamic(self, job: Job) -> JobOutcome: serial = job.serial or "default" with self._device_lock(serial): diff --git a/plan.md b/plan.md index ed13a23..f8094cc 100644 --- a/plan.md +++ b/plan.md @@ -63,7 +63,7 @@ Revisión del commit `90fad34 "advance"` (posterior al commit del plan) contra e | **Fase 0** — Fundamentos + persistencia | ✅ **Completa** (con 2 huecos menores, ver abajo) | | **Fase 1** — Cola + scheduler | ✅ **Completa** (POC verificada con APK real, ver abajo) | | **Fase 2** — OWASP MAS | ✅ **Completa** (investigación real + bugs de mapeo corregidos, ver abajo) | -| **Fase 3** — Dashboard web | ❌ No iniciada | +| **Fase 3** — Dashboard web | ✅ **Completa** (alcance ajustado en 2 puntos, ver abajo) | ### Fase 0 — detalle de lo entregado - ✅ **0.1 Persistencia SQLite**: `nutcracker_core/store/{db,repository,hooks}.py` + `schema.sql`. @@ -442,6 +442,95 @@ Layout: - `nutcracker serve --dashboard` (o siempre-on) levanta API+WS+estáticos en `127.0.0.1:`. - Config `dashboard:` en `config.yaml` (`port`, `bind`, `enable_chat`, `scrcpy: true`). +### Fase 3 — estado: ✅ completa (2026-07-24), con 2 ajustes de alcance honestos + +**Ajuste de arquitectura respecto al texto original:** en vez de un flag `--dashboard` sobre el +comando core `serve` (que obligaría a `nutcracker_core/cli/serve.py`, core, a conocer detalles de +un plugin — rompe la frontera), el dashboard es su **propio comando** registrado por el plugin: +`nutcracker dashboard`. Arranca su propia `QueueEngine` + `NutcrackerScheduler` (reusa las clases +de Fase 1 tal cual) además de la API — mismo resultado ("un comando levanta todo"), pero el core +sigue sin saber que el plugin existe. Mismo patrón que ya usa `aipwn` (comando propio, no un flag +de un comando core). + +**Dos alcances deliberadamente reducidos frente al texto original** (documentados como tal, no +reclamados como hechos): +1. **Video del dispositivo:** el plan original pedía scrcpy embebido vía ws-scrcpy (WebSocket/WebRTC, + H.264 en vivo). Este entorno **no tiene un dispositivo Android conectado** (`adb devices` vacío + durante toda la sesión) y ws-scrcpy es un proyecto Node/TypeScript de varios miles de líneas — + integrarlo sin poder probarlo contra hardware real habría sido reclamar un feature no verificado. + Se implementó en su lugar `GET /api/device/screenshot` (captura real vía `adb exec-out screencap`) + que el frontend refresca por polling cada 2.5s — no es video de baja latencia, pero es una vista + *real y funcional* del dispositivo, con degradación honesta ("sin dispositivo") si no hay uno + conectado (verificado: la POC real correctamente devolvió 503 al no haber ningún device). +2. **Razonamiento del agente en vivo / chat consumido por el agente:** el plan pedía streamear + `print_agent_thinking`, cada `tool_call`, y que el chat inyectara mensajes en el loop del agente. + El agente de bypass (`aipwn`) solo corre con un dispositivo físico conectado — inalcanzable aquí + — y además **es un repositorio git separado** (`github.com/nutcracker-sh/aipwn`, ver nota de + Fase 2), así que wire-up adicional ahí tampoco sería verificable en este entorno. Lo que sí se + construyó y quedó **real y probado**: `GET /api/agent/prompt` (lee el `_SYSTEM_PROMPT` real de + `aipwn.frida_agent` si el plugin está instalado — transparencia sin necesitar una corrida en vivo) + y `/ws/chat/{package}` (mensaje real operador→WebSocket→bus→todos los suscriptores, con historial). + El consumo automático de esos mensajes por un agente en ejecución queda como follow-up explícito. + + En cambio, el streaming de **logs en vivo de los jobs de la cola SÍ es real y de punta a punta** + (no un sustituto reducido): se agregó un modo streaming opcional a `QueueEngine` + (`engine.on_line`, usa `subprocess.Popen` en vez de `subprocess.run` solo cuando está asignado — + el camino sin callback, usado por Fase 1/CLI/scheduler, queda exactamente igual) y el dashboard lo + conecta a un `EventBus` en memoria que cada `/ws/jobs/{id}` consume. La POC real lo confirmó con + **282 líneas de log reales** recibidas por WebSocket durante un análisis real del mismo APK de + Mobile Hacking Lab usado en Fases 1-2. + +**Entregado:** +- `nutcracker_core/queue/engine.py`: `on_line` callback opcional + `_run_streaming()` (Popen + + lectura línea a línea). Default `None` preserva el camino de Fase 1 sin cambios (tests de Fase 1 + siguen monkeypatcheando `subprocess.run` sin tocar nada). +- `nutcracker_core/plugins/dashboard/`: `events.py` (EventBus pub-sub en memoria, con historial por + canal), `store_reader.py` (queries de lectura con forma de dashboard sobre el store de Fase 0 — + `list_apps`, `list_runs`, `run_detail`, `masvs_trend`, `summary_counts`), `device.py` (screenshot + + listado de seriales vía adb), `api.py` (REST: `/api/summary`, `/api/apps`, `/api/apps/{pkg}/trend`, + `/api/runs`, `/api/runs/{id}`, `/api/schedule` GET/POST, `/api/queue` GET/POST, `/api/queue/drain`, + `/api/device`, `/api/device/screenshot`, `/api/agent/prompt`), `ws.py` (`/ws/jobs/{id}`, + `/ws/chat/{package}`), `server.py` (`create_app()`), `__init__.py` (comando `nutcracker dashboard`), + `static/index.html` (SPA self-contained: apps, cola, logs en vivo, dispositivo, prompt del agente, + chat — sin CDN, dark/light aware). +- El encolado vía `/api/queue` (`run_now=True`) dispara `engine.drain()` en un hilo de fondo + (no bloquea la respuesta HTTP — un job real puede tardar minutos) — el progreso se sigue por WS. + +**Bug real encontrado y corregido (crítico, no de este plugin):** `.gitignore` tenía una regla +`plugins/` sin anclar que también atrapaba `nutcracker_core/plugins/` — el directorio del **sistema +de plugins del core**, no solo plugins externos. Cualquier plugin de primera parte nuevo (como este +dashboard) quedaba invisible para git por completo (`git add -A` lo saltea en silencio, sin error). +Confirmado que el usuario ya había comiteado Fases 1 y 2 en commits separados (`053b4e4 "fase 1"`, +`23052d0 "Fase 2"`) — de seguir el mismo patrón para Fase 3, todo este plugin se habría perdido +silenciosamente. Corregido reestructurando la regla a `nutcracker_core/plugins/*` + +allow-list explícito de los plugins de primera parte (`aireview`, `dashboard`), preservando el +comportamiento original para plugins externos (siguen ignorados por defecto; `aipwn` además queda +protegido por tener su propio `.git` anidado, con o sin esta regla). Un efecto secundario de la +negación (reincluía también `__pycache__/` dentro de esos plugins) se corrigió reforzando esa regla +al final del archivo. Verificado con `git check-ignore` y `git add -A --dry-run`: los 12 archivos +nuevos del plugin dashboard ahora sí quedan listos para `git add`. + +**Tests:** 24 nuevos (`tests/dashboard/test_events.py` ×6, `test_api.py` ×10 vía FastAPI TestClient +sin red real, `test_ws.py` ×4 vía `websocket_connect`, más 2 en `tests/test_queue_engine.py` para el +streaming). Se encontró y corrigió un problema de aislamiento entre tests propio: un test que dispara +`run_now=True` dejaba un hilo de fondo corriendo tras terminar la función de test, contaminando el +monkeypatch de `subprocess.run` de un test posterior (flakiness dependiente de timing/orden) — +arreglado esperando explícitamente a que el hilo de fondo termine antes de que el test retorne. Suite +completa estable en 3 corridas consecutivas: **95/95 passing**. + +**POC real ejecutada** (no simulada): `nutcracker dashboard --config ... --port 8765 --no-scheduler` +levantado como proceso real; un cliente Python (`httpx` + `websockets`) encoló el mismo APK real de +Mobile Hacking Lab vía `POST /api/queue`, se conectó a `/ws/jobs/{id}` *antes* de que terminara el +análisis, y recibió **282 líneas de log reales en vivo** (banner, tabla de vulnerabilidades, tabla +MASVS completa) seguidas del evento de estado final. Tras terminar: `GET /api/apps` mostró el +resultado real (`protected`, score 80, grade B, `next_due_at` auto-agendado a 30 días — Fase 1); +`GET /api/runs/{id}` mostró el finding `AUTH001` con **MASVS-STORAGE-2 + MASWE-0001 + CWE-532** +completos (Fase 2, fluyendo íntegro por la API del dashboard); `GET /api/device` y +`/api/device/screenshot` se degradaron correctamente a "sin dispositivo" (503); `GET /api/agent/prompt` +reportó honestamente `available: false` (el plugin aipwn no tiene sus dependencias — `any-llm-sdk` — +instaladas en este entorno, nunca se llegó a invocar `nutcracker aipwn ` de verdad, solo +`--help`, que no dispara el auto-install del plugin loader). + --- ## Ideas transversales de mantenibilidad (a lo largo de las fases) diff --git a/tests/dashboard/__init__.py b/tests/dashboard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/dashboard/test_api.py b/tests/dashboard/test_api.py new file mode 100644 index 0000000..d768330 --- /dev/null +++ b/tests/dashboard/test_api.py @@ -0,0 +1,147 @@ +"""Tests de la API REST del dashboard (Fase 3 del plan), vía TestClient de +FastAPI contra una QueueEngine + SQLite reales (archivo temporal) — sin +levantar un servidor de verdad ni tocar la red.""" + +from __future__ import annotations + +import subprocess +import time + +import pytest +from fastapi.testclient import TestClient + +from nutcracker_core.plugins.dashboard.server import create_app +from nutcracker_core.queue.engine import QueueEngine +from nutcracker_core.store import db, repository + + +@pytest.fixture +def db_path(tmp_path): + return str(tmp_path / "dash_test.db") + + +@pytest.fixture +def engine(db_path): + return QueueEngine(config_path="config.yaml", db_path=db_path, static_workers=2) + + +@pytest.fixture +def client(db_path, engine): + app = create_app(db_path=db_path, engine=engine) + return TestClient(app) + + +def _seed_app_with_run(db_path: str) -> None: + conn = db.connect(db_path) + try: + run_id = repository.insert_run(conn, "com.example.app", kind="full", status="done") + repository.update_run_status(conn, run_id, status="done", verdict="protected", + masvs_score=80, grade="B") + repository.record_findings(conn, run_id, [ + repository.FindingRecord(rule_id="AUTH001", title="Token en logs", severity="high", + masvs=["MASVS-STORAGE-2"], maswe=["MASWE-0001"], cwe=["CWE-532"]), + ]) + repository.touch_app_run(conn, "com.example.app") + finally: + conn.close() + + +def test_summary_empty_db(client): + r = client.get("/api/summary") + assert r.status_code == 200 + assert r.json() == {"apps": 0, "runs": 0, "findings": 0, "active_jobs": 0} + + +def test_apps_and_runs_reflect_seeded_data(db_path, client): + _seed_app_with_run(db_path) + + apps = client.get("/api/apps").json() + assert len(apps) == 1 + assert apps[0]["package"] == "com.example.app" + assert apps[0]["last_verdict"] == "protected" + assert apps[0]["masvs_score"] == 80 + + summary = client.get("/api/summary").json() + assert summary == {"apps": 1, "runs": 1, "findings": 1, "active_jobs": 0} + + +def test_run_detail_by_id(db_path, client): + _seed_app_with_run(db_path) + run_id = client.get("/api/runs").json()[0]["id"] + + detail = client.get(f"/api/runs/{run_id}").json() + assert detail["package"] == "com.example.app" + assert len(detail["findings"]) == 1 + assert detail["findings"][0]["rule_id"] == "AUTH001" + assert detail["findings"][0]["maswe"] == "MASWE-0001" + + +def test_run_detail_404_for_missing_run(client): + r = client.get("/api/runs/9999") + assert r.status_code == 404 + + +def test_schedule_set_and_get(client): + r = client.post("/api/schedule/com.example.app", json={"interval_days": 15, "enabled": True}) + assert r.status_code == 200 + assert r.json()["interval_days"] == 15 + + schedules = client.get("/api/schedule").json() + assert schedules[0]["package"] == "com.example.app" + assert schedules[0]["interval_days"] == 15 + + +def test_queue_add_local_apk_and_run_now(monkeypatch, tmp_path, client, engine): + """run_now dispara el drenado en un hilo de fondo (fire-and-forget: la + respuesta HTTP no espera al job — el progreso se sigue por WS). Para que + el test sea determinista y no deje un hilo colgando que contamine el + monkeypatch de tests posteriores, se espera aquí explícitamente a que + termine antes de retornar.""" + apk = tmp_path / "app.apk" + apk.write_bytes(b"PK\x03\x04") + + def fake_run(cmd, env=None, capture_output=True, text=True): # noqa: ANN001 + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr("nutcracker_core.queue.engine.subprocess.run", fake_run) + + r = client.post("/api/queue", json={"target": str(apk), "kind": "static", "run_now": True}) + assert r.status_code == 200 + assert r.json()["job_id"] is not None + + deadline = time.monotonic() + 5 + while getattr(engine, "_dashboard_draining", False) and time.monotonic() < deadline: + time.sleep(0.05) + assert not getattr(engine, "_dashboard_draining", False), "el job de fondo no terminó a tiempo" + + r = client.get("/api/queue") + assert len(r.json()) == 1 + + +def test_queue_add_rejects_dynamic_without_local_apk(client): + r = client.post("/api/queue", json={"target": "com.example.app", "kind": "dynamic"}) + assert r.status_code == 400 + + +def test_device_endpoint_reports_no_devices_without_adb(monkeypatch, client): + monkeypatch.setattr( + "nutcracker_core.plugins.dashboard.device.subprocess.run", + lambda *a, **kw: (_ for _ in ()).throw(FileNotFoundError()), + ) + r = client.get("/api/device") + assert r.status_code == 200 + assert r.json() == {"serials": []} + + +def test_device_screenshot_503_when_unavailable(monkeypatch, client): + monkeypatch.setattr("nutcracker_core.plugins.dashboard.device.screenshot_png", lambda serial=None: None) + r = client.get("/api/device/screenshot") + assert r.status_code == 503 + + +def test_agent_prompt_response_shape(client): + # No forzamos el import a fallar (aipwn puede o no estar instalado en el + # entorno de test) — solo verificamos la forma de la respuesta en ambos casos. + r = client.get("/api/agent/prompt") + assert r.status_code == 200 + assert "available" in r.json() diff --git a/tests/dashboard/test_events.py b/tests/dashboard/test_events.py new file mode 100644 index 0000000..5880ef7 --- /dev/null +++ b/tests/dashboard/test_events.py @@ -0,0 +1,59 @@ +"""Tests del EventBus del dashboard (Fase 3 del plan).""" + +from __future__ import annotations + +import queue + +import pytest + +from nutcracker_core.plugins.dashboard.events import EventBus + + +def test_publish_without_subscribers_does_not_raise(): + bus = EventBus() + bus.publish("job-1", "log", "hola") # no debe lanzar aunque no haya suscriptores + + +def test_subscriber_receives_published_event(): + bus = EventBus() + q = bus.subscribe("job-1") + bus.publish("job-1", "log", "línea 1") + event = q.get(timeout=1) + assert event.channel == "job-1" + assert event.kind == "log" + assert event.data == "línea 1" + + +def test_subscribers_on_different_channels_are_isolated(): + bus = EventBus() + q1 = bus.subscribe("job-1") + q2 = bus.subscribe("job-2") + bus.publish("job-1", "log", "solo para job-1") + assert q1.get(timeout=1).data == "solo para job-1" + with pytest.raises(queue.Empty): + q2.get(timeout=0.2) + + +def test_history_replays_past_events_to_new_subscribers(): + bus = EventBus() + bus.publish("job-1", "log", "antes de suscribirse") + hist = bus.history("job-1") + assert len(hist) == 1 + assert hist[0].data == "antes de suscribirse" + + +def test_history_size_is_capped(): + bus = EventBus(history_size=3) + for i in range(10): + bus.publish("job-1", "log", str(i)) + hist = bus.history("job-1") + assert len(hist) == 3 + assert [e.data for e in hist] == ["7", "8", "9"] + + +def test_unsubscribe_stops_delivery(): + bus = EventBus() + q = bus.subscribe("job-1") + bus.unsubscribe("job-1", q) + bus.publish("job-1", "log", "no debería llegar") + assert q.empty() diff --git a/tests/dashboard/test_ws.py b/tests/dashboard/test_ws.py new file mode 100644 index 0000000..48166ad --- /dev/null +++ b/tests/dashboard/test_ws.py @@ -0,0 +1,62 @@ +"""Tests de los WebSockets del dashboard (Fase 3 del plan) — verifica que un +evento publicado en el EventBus llega de verdad al cliente WS, incluyendo el +historial para quien se conecta tarde.""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from nutcracker_core.plugins.dashboard.events import bus as global_bus +from nutcracker_core.plugins.dashboard.server import create_app +from nutcracker_core.queue.engine import QueueEngine + + +@pytest.fixture(autouse=True) +def _clean_bus(): + # events.bus es una instancia de proceso compartida (la usa el comando + # `dashboard` real); en tests hay que limpiarla entre casos. + global_bus._subscribers.clear() + global_bus._history.clear() + yield + global_bus._subscribers.clear() + global_bus._history.clear() + + +@pytest.fixture +def client(tmp_path): + engine = QueueEngine(config_path="config.yaml", db_path=str(tmp_path / "ws_test.db")) + app = create_app(db_path=str(tmp_path / "ws_test.db"), engine=engine) + return TestClient(app) + + +def test_job_ws_receives_live_published_events(client): + with client.websocket_connect("/ws/jobs/42") as ws: + global_bus.publish("42", "log", "línea en vivo") + msg = ws.receive_json() + assert msg["kind"] == "log" + assert msg["data"] == "línea en vivo" + + +def test_job_ws_replays_history_on_connect(client): + global_bus.publish("42", "log", "línea anterior a la conexión") + with client.websocket_connect("/ws/jobs/42") as ws: + msg = ws.receive_json() + assert msg["data"] == "línea anterior a la conexión" + + +def test_job_ws_channels_are_isolated(client): + global_bus.publish("other-job", "log", "no debería aparecer en el 42") + global_bus.publish("42", "log", "sí debería aparecer") + with client.websocket_connect("/ws/jobs/42") as ws: + msg = ws.receive_json() + assert msg["data"] == "sí debería aparecer" + + +def test_chat_ws_echoes_message_to_subscribers(client): + with client.websocket_connect("/ws/chat/com.example.app") as ws: + ws.send_text("hola agente") + msg = ws.receive_json() + assert msg["kind"] == "chat" + assert msg["data"]["from"] == "operator" + assert msg["data"]["text"] == "hola agente" diff --git a/tests/test_queue_engine.py b/tests/test_queue_engine.py index fc81f78..0e5f039 100644 --- a/tests/test_queue_engine.py +++ b/tests/test_queue_engine.py @@ -253,6 +253,51 @@ def fake_run(cmd, env=None, capture_output=True, text=True): # noqa: ANN001 assert run_count["n"] == 1 +# ── Streaming en vivo (Fase 3: dashboard) ─────────────────────────────────── + +def test_on_line_streams_output_and_does_not_affect_outcome(monkeypatch, tmp_path, engine): + """engine.on_line (usado por el plugin dashboard para WS en vivo) debe + recibir cada línea a medida que se produce, sin cambiar el resultado final + del job frente al camino no-streaming (subprocess.run).""" + + class _FakePopen: + def __init__(self, cmd, env=None, stdout=None, stderr=None, text=None, bufsize=None): + self.stdout = iter(["primera línea\n", "segunda línea\n"]) + self.returncode = 0 + + def wait(self): + return self.returncode + + monkeypatch.setattr("nutcracker_core.queue.engine.subprocess.Popen", _FakePopen) + + received: list[tuple[int, str]] = [] + engine.on_line = lambda job_id, line: received.append((job_id, line)) + + job = engine.submit(str(_touch_apk(tmp_path, "stream.apk")), kind="static") + outcomes = engine.drain() + + assert outcomes[0].ok is True + assert received == [(job.db_id, "primera línea"), (job.db_id, "segunda línea")] + + +def test_without_on_line_uses_subprocess_run_unchanged(monkeypatch, tmp_path, engine): + """Sin on_line asignado (default), debe seguir usando subprocess.run tal + cual (camino de Fase 1, ya probado) — Popen no debe ni importarse/llamarse.""" + + def _fail_if_called(*a, **kw): + raise AssertionError("Popen no debería llamarse cuando on_line es None") + + monkeypatch.setattr("nutcracker_core.queue.engine.subprocess.Popen", _fail_if_called) + monkeypatch.setattr( + "nutcracker_core.queue.engine.subprocess.run", + lambda cmd, env=None, capture_output=True, text=True: subprocess.CompletedProcess(cmd, 0, "", ""), + ) + + engine.submit(str(_touch_apk(tmp_path, "nostream.apk")), kind="static") + outcomes = engine.drain() + assert outcomes[0].ok is True + + def test_enqueue_due_apps_only_queues_overdue_packages(tmp_path, engine): import datetime From b696af0f95b3646c5cb358adb3d074b4cdd1cc0b Mon Sep 17 00:00:00 2001 From: Hiteek Date: Sun, 26 Jul 2026 23:44:51 -0500 Subject: [PATCH 19/30] improves --- README.md | 234 +++++++- ROADMAP.md | 24 +- config.yaml.example | 16 + docs/owasp-mas-coverage.md | 18 +- nutcracker_core/checks/dynamic/context.py | 9 +- nutcracker_core/checks/static/adapter.py | 17 +- nutcracker_core/cli/__init__.py | 2 +- nutcracker_core/cli/analyze.py | 14 +- nutcracker_core/cli/batch.py | 296 +++++----- nutcracker_core/cli/queue_cmd.py | 10 +- nutcracker_core/i18n.py | 4 +- nutcracker_core/masvs.py | 20 + nutcracker_core/orchestrator.py | 142 ++++- nutcracker_core/queue/engine.py | 76 ++- nutcracker_core/vuln_scanner.py | 53 ++ plan.md | 629 +++++++++++++++++++++- pyproject.toml | 36 ++ tests/checks/test_dynamic_checks.py | 28 + tests/checks/test_registry.py | 5 +- tests/dashboard/test_api.py | 45 ++ tests/dashboard/test_chat_mailbox.py | 33 ++ tests/dashboard/test_scrcpy_video.py | 257 +++++++++ tests/dashboard/test_ws.py | 14 + tests/test_aipwn_chat_wiring.py | 101 ++++ tests/test_batch.py | 213 ++++++++ tests/test_orchestrator.py | 228 ++++++++ tests/test_pdf_reporter_batch.py | 30 ++ tests/test_queue_engine.py | 141 ++++- tests/test_vuln_scanner_split.py | 72 +++ 29 files changed, 2527 insertions(+), 240 deletions(-) create mode 100644 pyproject.toml create mode 100644 tests/dashboard/test_chat_mailbox.py create mode 100644 tests/dashboard/test_scrcpy_video.py create mode 100644 tests/test_aipwn_chat_wiring.py create mode 100644 tests/test_batch.py create mode 100644 tests/test_orchestrator.py create mode 100644 tests/test_pdf_reporter_batch.py diff --git a/README.md b/README.md index 13be778..c53df0d 100644 --- a/README.md +++ b/README.md @@ -60,9 +60,11 @@ All results are consolidated into a technical PDF report ready for reporting. - Optional OSINT module: subdomains via crt.sh, public leaks on GitHub/Postman/FOFA/Shodan/Wayback, false-positive filter and optional web searches via DuckDuckGo - **AI Review** (`ai-review`): LLM-powered false positive filter — reviews each finding, tags FPs with `_fp: true` (preserved in JSON for audit), downgrades low-confidence findings severity; auto-regenerates PDF - AndroidManifest.xml analysis: dangerous permissions, exported components, `network security config` and insecure configurations -- MASVS v2 compliance scoring: 24-control evaluation with numeric pass/fail count (e.g. `10/24 controls`) +- MASVS v2.1 compliance scoring, backed by a deterministic check registry mapped to **MASVS + MASWE + CWE** (see [OWASP MAS Alignment](#owasp-mas-alignment-masvs--maswe--cwe)) - Complete PDF report: cover page, MASVS compliance, protections, misconfigurations, OSINT, leaks, and SAST vulnerabilities -- Batch mode to scan multiple apps in sequence +- Batch mode to scan multiple apps, backed by a **job queue** with configurable static parallelism and per-device serialization for dynamic jobs (see [Mass Execution](#mass-execution-queue--scheduler)) +- Built-in **scheduler** that re-queues every app for periodic re-review (default ≥1/month), driven by `nutcracker serve` +- Local **web dashboard** (`nutcracker dashboard`): apps overview, live job logs, device screenshot, MASVS trend per app, and an inline schedule editor — SQLite-backed, no external services (see [Web Dashboard](#web-dashboard)) - Modules controllable via feature flags in `config.yaml` - `decompilation: jadx` pipeline option forces static-only analysis — disables Frida/emulator even when DexGuard is detected @@ -143,6 +145,15 @@ source .venv/bin/activate pip install -r requirements.txt ``` +Every command in this README also works as `python nutcracker.py ` (script mode, no +install needed). Alternatively, install it as a package to get a `nutcracker` console command: + +```bash +pip install -e . # editable install; entry point: nutcracker = nutcracker_core.cli:cli +pip install -e ".[dashboard]" # + fastapi/uvicorn for `nutcracker dashboard` +nutcracker --help +``` + ### Python Dependencies (requirements.txt) | Package | Purpose | @@ -287,6 +298,61 @@ The command: --- +## Mass Execution: Queue & Scheduler + +`batch` (and every job submitted through `queue add`) runs on a shared job queue instead of an +in-process loop: **static** analyses (decompile, SAST, OSINT) run in parallel across +`queue.static_workers` threads, while **dynamic** analyses (Frida/ADB on a physical device) are +always serialized per device serial — two dynamic jobs never touch the same phone at once, even +if `queue.dynamic_workers > 1` (that setting controls parallelism *across different* devices). +Every job runs as an isolated subprocess (the same `analyze`/`scan` CLI path), so a crash in one +target can never corrupt another's in-process state. + +```bash +# Enqueue a single target (path, URL, package id) and run the queue immediately: +python nutcracker.py queue add com.example.app --run + +# Enqueue a dynamic job (requires a local .apk and a connected device): +python nutcracker.py queue add downloads/app.apk --dynamic --serial emulator-5554 --run + +# List recent jobs: +python nutcracker.py queue ls +python nutcracker.py queue ls --status error --limit 50 + +# Schedule a periodic review (default: every 30 days, i.e. ≥1/month): +python nutcracker.py schedule set com.example.app --every 30 +python nutcracker.py schedule ls +python nutcracker.py schedule set com.example.app --disable + +# Long-running daemon: re-queues every app whose schedule is due, on a poll +# interval (config.yaml → scheduler.poll_interval_minutes, default 60min). +python nutcracker.py serve +``` + +Any app that goes through `batch`, `queue add`, or `analyze`/`scan` is auto-scheduled for +periodic re-review — the "≥1 review/month" guarantee applies without extra setup, using +`scheduler.default_interval_days` (default `30`) unless overridden per app via `schedule set`. + +Configure parallelism and cadence in `config.yaml`: + +```yaml +queue: + static_workers: 4 # parallel static analyses at once + dynamic_workers: 2 # concurrent *devices* for dynamic jobs (never same serial) + +scheduler: + enabled: true + poll_interval_minutes: 60 # how often `serve` checks for due apps + default_interval_days: 30 # ≥1 review/month per app unless overridden +``` + +State (queued/running/done/error, per-app schedule, run history and findings) is persisted to a +local SQLite database (`store.db_path` in `config.yaml`, default `./nutcracker.db`) — this is +what both `serve` and the dashboard read from; it does not replace the existing JSON/PDF reports +in `reports//`, it complements them. + +--- + ## Configuration (`config.yaml` / `config.yaml.example`) Use [config.yaml.example](config.yaml.example) as the source of truth. @@ -382,6 +448,23 @@ auto: batch: list_file: "" # Optional list file for batch mode stop_on_error: false + +store: # SQLite persistence (queue/scheduler/dashboard state) + enabled: true # set false to skip SQLite entirely + db_path: "" # empty = ./nutcracker.db at the project root + +queue: # see "Mass Execution: Queue & Scheduler" + static_workers: 4 + dynamic_workers: 2 + +scheduler: # see "Mass Execution: Queue & Scheduler" + enabled: true + poll_interval_minutes: 60 + default_interval_days: 30 + +dashboard: # see "Web Dashboard" — only used by `nutcracker dashboard` + bind: "127.0.0.1" + port: 8765 ``` --- @@ -499,6 +582,110 @@ When the app doesn't start with `monkey` (native-level anti-tampering, emulator --- +## OWASP MAS Alignment (MASVS + MASWE + CWE) + +Every finding-producing rule (regex, semgrep, native-lib heuristics, manifest analysis, and a +handful of deterministic on-device checks) is registered as a `Check` in +`nutcracker_core/checks/` and mapped to the official OWASP taxonomy: a **MASVS v2.1** control, the +relevant **MASWE** weakness id(s) (`MASWE-XXXX`), and a **CWE** id where one genuinely applies. +There is no in-process framework separate from the existing scanners — `checks/static/adapter.py` +wraps the existing rule registries (`vuln_scanner`, `native_scanner`, detectors, manifest +analysis) so every rule gets taxonomy metadata without being rewritten. + +```bash +# Regenerate the coverage matrix from the check registry (source of truth, not hand-edited): +python tools/gen_owasp_coverage.py # writes docs/owasp-mas-coverage.md +``` + +Current coverage (regenerated, not aspirational): **18/24 MASVS v2.1 controls** have at least one +check, across **68 checks** (66 static, 2 dynamic — ADB-only, no LLM), referencing **33/119 MASWE** +weaknesses. The 6 uncovered controls (`MASVS-AUTH-1`, `AUTH-3`, `CODE-2`, `CODE-3`, `PRIVACY-3`, +`PRIVACY-4`) are documented as **deliberately out of scope**, not missing work: they require live +backend behavior, business-logic understanding, or a real CVE database — none of which can be +verified deterministically by analyzing an APK alone. See +[docs/owasp-mas-coverage.md](docs/owasp-mas-coverage.md) for the full per-control breakdown. + +Dynamic checks (`checks/dynamic/`) run headless over ADB against a connected device/emulator — +no Frida REPL, no manual interaction — via: + +```bash +python nutcracker.py analyze downloads/app.apk --dynamic-checks --serial emulator-5554 +``` + +--- + +## Web Dashboard + +`nutcracker dashboard` starts a local web UI (FastAPI + WebSocket, self-contained — no CDN +dependencies, dark/light theme aware) backed by the same SQLite store and job queue used by +`serve`/`batch`/`queue add`: + +```bash +python nutcracker.py dashboard +# → http://127.0.0.1:8765 + +python nutcracker.py dashboard --port 8080 --host 0.0.0.0 # expose on the LAN +python nutcracker.py dashboard --no-scheduler # if `nutcracker serve` already runs elsewhere +``` + +It shows: +- **Apps overview** — verdict, MASVS score/grade, next scheduled review; click a row for a + detail view with the MASVS score trend over time, the MASVS controls affected, and the full + findings table (rule, severity, MASVS/MASWE/CWE, location) for the latest run. +- **Analysis queue** — enqueue a target (path/URL/package id/list file) and watch it run, + including `aipwn` runs (see below). +- **Live logs** — real job output streamed line-by-line over WebSocket as it happens. +- **Device** — real live video via your own [scrcpy](https://github.com/Genymobile/scrcpy) + installation (see below), with automatic fallback to a polling screenshot + (`adb exec-out screencap`) if scrcpy isn't configured or the stream drops mid-session. +- **Agent / Chat** — the real system prompt of the `aipwn` bypass agent (if installed), and a + WebSocket chat channel that a running `aipwn` job actually consumes (see below). +- **Inline schedule editor** — change an app's review interval without touching the CLI. + +The dashboard is itself a plugin (`nutcracker_core/plugins/dashboard/`) — it only *reads* the +store and *drives* the queue through their public APIs, following the same core/plugin boundary +as every other plugin in this project. + +### Live device video (scrcpy) + +The dashboard does not reimplement the scrcpy wire protocol — it drives your own +[scrcpy](https://github.com/Genymobile/scrcpy) client binary (any recent version; validated +against 3.1) headlessly (`scrcpy --no-window --record=.mkv`) and re-reads the growing +recording with PyAV to serve the latest frame as `multipart/x-mixed-replace`, which any browser +renders natively in an `` — no CDN, no WebRTC/H.264-in-JS. Point nutcracker at your own +scrcpy install: + +```yaml +dashboard: + scrcpy_path: '/path/to/scrcpy' # Linux/macOS + # scrcpy_path: '/mnt/c/.../scrcpy.exe' # Windows scrcpy, from WSL +``` + +Leave it empty to search `scrcpy`/`scrcpy.exe` on `PATH`. Without a working scrcpy, the "Device" +tab falls back to the screenshot-polling view automatically — nothing breaks either way. Install +PyAV in the dashboard environment (`pip install .[dashboard]` already includes it, or +`pip install av` manually). + +### `aipwn` in the queue + chat wiring + +`aipwn` (the LLM-powered bypass agent) can run as a queue job like any other target — its live +reasoning ("Nutcracker thinking", each tool call) streams to the same live-logs WebSocket as +`analyze`/`scan` jobs, and it shares the per-device lock with dynamic jobs (never runs +concurrently with another job on the same phone): + +```bash +python nutcracker.py queue add com.example.app --aipwn --serial emulator-5554 --run +``` + +The dashboard's chat (`/ws/chat/{package}`) is genuinely consumed by a running `aipwn` job: every +operator message is also written to a small pull-based mailbox +(`GET /api/chat/{package}/pending`); the agent polls it once per ReAct iteration (before calling +the LLM) and injects any pending message as a real conversation turn — no dashboard running means +no polling and zero overhead, this is entirely opt-in via the `NUTCRACKER_DASHBOARD_URL` +environment variable the queue sets for the job's subprocess. + +--- + ## Roadmap See [ROADMAP.md](ROADMAP.md) for pending tasks: OSINT improvements, iOS/IPA support and partial migration to Go. @@ -523,6 +710,7 @@ git clone https://github.com// nutcracker_core/plugins/ |---|---|---| | `aipwn` | `nutcracker aipwn ` | Autonomous LLM-powered Frida bypass agent | | `aireview` | `nutcracker ai-review ` | LLM-powered false positive filter | +| `dashboard` | `nutcracker dashboard` | Local web dashboard over the queue + SQLite store (see [Web Dashboard](#web-dashboard)) | > **`aipwn` native library analysis** — The agent can disassemble and patch `.so` files > (native RASP checks). This requires a cross-compiler `objdump` for ARM64: @@ -672,42 +860,72 @@ def register(cli: click.Group) -> None: ``` nutcracker/ -├── nutcracker.py # Main CLI (click) +├── nutcracker.py # Thin entrypoint shim → nutcracker_core.cli.cli ├── config.yaml # Local configuration ├── config.yaml.example # Configuration template ├── setup.sh # Quick install script ├── requirements.txt # Python dependencies ├── docker-compose.yml # Docker environment for hybrid execution ├── Dockerfile # Project base image -├── docs/assets/ # Logo and README assets +├── docs/ +│ ├── assets/ # Logo and README assets +│ └── owasp-mas-coverage.md # Generated MASVS×MASWE coverage matrix (tools/gen_owasp_coverage.py) ├── downloads/ # Downloaded APKs ├── decompiled/ # Code decompiled by jadx / frida-dexdump ├── frida_scripts/ # Generated Frida bypass scripts ├── reports/ # Generated PDFs and JSON reports +├── nutcracker.db # SQLite store (queue/scheduler/dashboard state, gitignored) ├── semgrep_rules_android/ # OWASP MASTG rules -├── tools/ # Auxiliary utilities +├── tools/ # Auxiliary utilities (incl. gen_owasp_coverage.py) └── nutcracker_core/ ├── __init__.py # Main package ├── analyzer.py # Main static analysis (androguard) ├── apk_tools.py # APK manipulation and installation utilities - ├── config.py # config.yaml loading and access + ├── config.py # config.yaml loading and access (supports ${ENV_VAR}) ├── device.py # Devices, SDK, Frida and adb utilities ├── downloader.py # Download APKs (Google Play / APKPure / direct URL) ├── decompiler.py # jadx interface ├── deobfuscator.py # FART flow for physical device ├── frida_bypass.py # Frida scripts (bypass, FART) ├── manifest_analyzer.py # AndroidManifest.xml and insecure configuration analysis - ├── masvs.py # MASVS v2 compliance scoring (24 controls, numeric pass/fail) + ├── masvs.py # MASVS v2.1 taxonomy: controls, RULE_TO_MASVS/MASWE/CWE + ├── orchestrator.py # Shared orchestration used by CLI, queue jobs and dashboard ├── osint.py # Subdomains, public leaks, Wayback and optional web searches ├── pdf_reporter.py # PDF report generation (fpdf2) ├── pipeline.py # End-to-end analysis pipeline ├── reporter.py # JSON reports and console output ├── runtime.py # Dynamic analysis orchestration + ├── scan_types.py # Shared finding/scan dataclasses + ├── scheduler.py # APScheduler-based periodic re-review (used by `serve`/dashboard) ├── string_extractor.py # APK string extraction - ├── vuln_scanner.py # Semgrep + regex + apkleaks + gitleaks + ├── vuln_scanner.py # Regex + semgrep vulnerability rules + ├── leak_scanner.py # apkleaks + gitleaks secret scanning + ├── native_scanner.py # Native (.so) library heuristics + ├── cli/ # Click commands (one module per command) + │ ├── __init__.py # Root click.Group + plugin loading + banner + │ ├── scan.py / analyze.py / launch.py / batch.py + │ ├── queue_cmd.py # `queue add`/`queue ls` + │ ├── schedule_cmd.py # `schedule set`/`schedule ls` + │ ├── serve.py # `serve` daemon (queue + scheduler, no UI) + │ ├── setup_token.py / regen_pdf.py + ├── store/ # SQLite persistence (Fase 0) + │ ├── db.py # Connection + WAL mode + versioned migrations + │ ├── repository.py # Typed CRUD (apps, runs, findings, schedule, queue_jobs) + │ ├── hooks.py # after_analysis post-hook → double, non-destructive write + │ └── schema.sql + ├── queue/ # Job queue engine (Fase 1) + │ ├── engine.py # Static thread pool + per-device lock for dynamic jobs + │ └── job.py + ├── checks/ # OWASP MAS-aligned deterministic check registry (Fase 2) + │ ├── base.py # Check / CheckMeta / CheckFinding + │ ├── registry.py # register_static / register_dynamic / load_all + │ ├── static/adapter.py # Wraps vuln_scanner/native_scanner/detectors/manifest as Checks + │ └── dynamic/ # Headless ADB-only checks (debuggable, cleartext traffic, ...) ├── plugins/ │ ├── __init__.py # Plugin loader + post-hook registry │ ├── aireview/ # ai-review plugin: LLM-powered false positive filter + │ ├── aipwn/ # Autonomous LLM-powered Frida bypass agent + │ └── dashboard/ # Web dashboard (Fase 3) — FastAPI + WS + self-contained SPA └── detectors/ ├── __init__.py # Detectors subpackage export ├── appdome.py # Appdome detector diff --git a/ROADMAP.md b/ROADMAP.md index 7a742f6..46735cf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -9,16 +9,24 @@ ## Platforms - [ ] **iOS / IPA support** — download with `ipatool`, static Mach-O analysis with `jtool2`/`class-dump`, secrets in `.plist`/`.strings`, `Info.plist` analysis (permissions, ATS), jailbreak check detection. MVP static only; dynamic requires jailbreak. +## aipwn +- [ ] **Multimodal message format incompatible with z.ai/GLM (and possibly other OpenAI-compatible providers)** — found live 2026-07-27 while validating the dashboard chat wiring: after the first `take_screenshot()` tool call, the LLM call fails with `messages.content.type is invalid, allowed values: ['text']`. `frida_agent.py` builds the image message in a format OpenAI itself accepts but this provider's OpenAI-compatible endpoint rejects — needs investigating the exact expected content-array shape for z.ai, or falling back to text-only description when the provider doesn't support images. + ## Reporting -- [ ] **Differentiate runtime bypass vs DEX extraction in reports** — The current verdict only distinguishes `PROTECTED` from `PROTECTION BROKEN`, where `PROTECTION BROKEN` requires a successful in-memory DEX dump (`dex_count > 0`). This is misleading: the FridaAgent can bypass all anti-root protections and run the app on a rooted device without ever dumping DEX (e.g. apps with aggressive native anti-root that kill the process before FART hooks run). Proposed fix — two independent verdicts: +- [~] **Differentiate runtime bypass vs DEX extraction in reports** — The current verdict only distinguishes `PROTECTED` from `PROTECTION BROKEN`, where `PROTECTION BROKEN` requires a successful in-memory DEX dump (`dex_count > 0`). This is misleading: the FridaAgent can bypass all anti-root protections and run the app on a rooted device without ever dumping DEX (e.g. apps with aggressive native anti-root that kill the process before FART hooks run). Proposed fix — two independent verdicts: - **Static protection** (`PROTECTED` / `UNPROTECTED`): driven by the existing static detectors. - - **Dynamic analysis** (`BYPASS CONFIRMED` / `DEX EXTRACTED` / `NOT ATTEMPTED`): fed by the FridaAgent result (`report_success`) and the FART/dexdump pipeline respectively. + - **Dynamic analysis** (`BYPASS CONFIRMED` / `DEX EXTRACTED` / `NOT ATTEMPTED`): fed by the FridaAgent result (`report_success`) and the FART/dexdump pipeline respectively. Implementation touch-points: - 1. Add `aipwn_bypass_confirmed: bool` field to `AnalysisResult` (set by `aipwn.py` after `report_success`). - 2. Change `build_masvs_report()` in `masvs.py`: replace `bypass_confirmed = analysis.protection_broken` with `bypass_confirmed = analysis.protection_broken or analysis.aipwn_bypass_confirmed`. This makes `MASVS-RESILIENCE-*` controls flip to `bypass` status and apply the `_BYPASS_PENALTY` even when DEX extraction failed. - 3. Update `reporter.py` to show a separate dynamic analysis banner alongside the static verdict. - 4. Update the PDF report with a new dynamic analysis section. + 1. [x] Add `aipwn_bypass_confirmed: bool` field to `AnalysisResult` (set by `aipwn.py` after `report_success`). + 2. [x] Change `build_masvs_report()` in `masvs.py`: `bypass_confirmed = analysis.protection_broken or analysis.aipwn_bypass_confirmed`. `MASVS-RESILIENCE-*` controls now flip to `bypass` status and apply the `_BYPASS_PENALTY` even when DEX extraction failed. Done 2026-07-24, see `plan.md` Fase 2.4. + 3. [ ] Update `reporter.py` to show a separate dynamic analysis banner alongside the static verdict. + 4. [ ] Update the PDF report with a new dynamic analysis section. ## Architecture -- [ ] **Split `vuln_scanner.py`** — 1400+ lines with three responsibilities. Split into `scan_types.py` (dataclasses), `vuln_scanner.py` (regex + semgrep) and `leak_scanner.py` (apkleaks, gitleaks). Do this before adding new leak sources or porting to Go. -- [ ] **Port secret scanner to Go** — `string_extractor.py` and the HC* rules are the slowest steps. Go binary `nutcracker-strings` that takes a directory and patterns and returns JSON. Python invokes it as a subprocess, the same way it does semgrep. Prerequisite: split modules first. +- [x] **Split `vuln_scanner.py`** — 1400+ lines with three responsibilities. Split into `scan_types.py` (dataclasses), `vuln_scanner.py` (regex + semgrep) and `leak_scanner.py` (apkleaks, gitleaks). Done 2026-07 (`plan.md` Fase 0.3) — `vuln_scanner.py` is now 456 lines. +- [ ] **Port secret scanner to Go** — `string_extractor.py` and the HC* rules are the slowest steps. Go binary `nutcracker-strings` that takes a directory and patterns and returns JSON. Python invokes it as a subprocess, the same way it does semgrep. Prerequisite: split modules first (done, see above). +- [x] **`pyproject.toml` packaging** — `pip install -e .` gives a `nutcracker` console command (entry point `nutcracker_core.cli:cli`); `.[dashboard]` extra installs FastAPI/uvicorn. Done 2026-07-26. +- [x] **Live device video (scrcpy) in the dashboard** — not `ws-scrcpy` (a full protocol reimplementation attempt was decompiled/tried and hit an unresolved handshake hang against Magisk-hardened hardware, see `plan.md`) — instead the dashboard drives the user's own real `scrcpy` binary headlessly (`--no-window --record=.mkv`, re-read via `container.seek()` near the end with PyAV instead of full re-decode — measured 0.5s→0.07s per poll, see `plan.md`) and falls back to screenshot polling automatically. Done 2026-07-27, validated live against a real device (`nutcracker_core/plugins/dashboard/scrcpy_video.py`). +- [ ] **Device video is still ~1.3fps, not fluid** — even after the seek optimization above, live-measured effective rate is ~1-2 frames/second, far from real video (15-30fps like ws-scrcpy/webadb.com). Suspected cause (unconfirmed): scrcpy's own mkv muxer likely batches multiple encoded frames per on-disk "cluster" before they become newly-decodable, capping update frequency independent of decode cost. See the next item for the real fix. +- [ ] **WebUSB + WebCodecs device video (Tango/yume-chan)** — proposed and scoped in `plan.md` Fase 4, not implemented. Replicates app.webadb.com: the browser talks ADB/scrcpy directly over WebUSB (no server-side `adb`/`scrcpy` process at all for the video path), decoding raw H.264 natively via WebCodecs — would resolve the ~1.3fps ceiling above at its root. Real packages verified: `@yume-chan/adb`, `@yume-chan/adb-daemon-webusb`, `@yume-chan/scrcpy`, `@yume-chan/adb-scrcpy`, `@yume-chan/scrcpy-decoder-webcodecs`, `@yume-chan/fetch-scrcpy-server` (see [tangoadb.dev/scrcpy](https://tangoadb.dev/scrcpy/)). Real trade-offs to weigh before building: Chromium-only (no Firefox/Safari — WebUSB isn't supported there at all), requires the phone on USB on the same machine as the browser (won't work over network adb), needs a secure context (fine on `localhost`, not on a plain-HTTP LAN exposure), unconfirmed whether the browser's WebUSB claim conflicts with the OS-level `adb`/`scrcpy` running concurrently, and it would be the project's first JS build step (Vite/esbuild + npm, bundled and committed — the served page stays self-contained/no-CDN, but authoring it needs Node). +- [x] **`aipwn` agent reasoning + chat wired into the dashboard** — `aipwn` is now a queue job kind (`queue add --aipwn`), reusing the existing live-logs WebSocket for its reasoning/tool-call stream; the chat mailbox (`chat_mailbox.py`) is polled by `FridaAgent._check_operator_chat()` once per ReAct iteration and injects pending operator messages as real conversation turns. Done 2026-07-27, validated live with a real LLM call against `com.example.tapjacking` — confirmed an injected chat message reaching the agent's actual conversation. (Found in passing, not fixed — out of scope: the configured provider, z.ai/GLM, rejects the multimodal screenshot message format `frida_agent.py` sends, a pre-existing bug unrelated to this wiring.) diff --git a/config.yaml.example b/config.yaml.example index 69b4239..833e7d5 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -167,3 +167,19 @@ scheduler: enabled: true poll_interval_minutes: 60 # how often the daemon checks for due apps default_interval_days: 30 # ≥1 review/month per app unless overridden + +# ── Web dashboard (plugin — plan.md Fase 3) ──────────────────────────────────── +# `nutcracker dashboard`: starts its own queue + scheduler + a local web UI +# (apps, live job logs, device screenshot, MASVS trend, schedule editor). +dashboard: + bind: '127.0.0.1' + port: 8765 + # Ruta al binario cliente de scrcpy (https://github.com/Genymobile/scrcpy) + # para video en vivo real del dispositivo en la pestaña "Dispositivo". + # nutcracker NO reimplementa el protocolo scrcpy ni vendorea el binario — + # usa tu propia instalación. Vacío = busca "scrcpy"/"scrcpy.exe" en PATH; + # si no se encuentra, la pestaña cae automáticamente al polling de + # screenshots existente (ver plan.md). Ejemplo en WSL apuntando a una + # instalación de scrcpy en Windows: + # scrcpy_path: '/mnt/c/Users//scrcpy-win64-v3.1/scrcpy.exe' + scrcpy_path: '' diff --git a/docs/owasp-mas-coverage.md b/docs/owasp-mas-coverage.md index df890d8..7cd2641 100644 --- a/docs/owasp-mas-coverage.md +++ b/docs/owasp-mas-coverage.md @@ -2,7 +2,7 @@ Generado automáticamente por `tools/gen_owasp_coverage.py` desde `nutcracker_core/checks/` (Fase 2 del plan). No editar a mano — regenerar tras cambios en `masvs.py` o en los checks. -**Resumen:** 14/24 controles MASVS v2.1 con al menos un check · 64 checks totales (62 estáticos, 2 dinámicos) · 29/119 debilidades MASWE referenciadas. +**Resumen:** 18/24 controles MASVS v2.1 con al menos un check · 68 checks totales (66 estáticos, 2 dinámicos) · 33/119 debilidades MASWE referenciadas. ## Cobertura por control MASVS @@ -11,16 +11,16 @@ Generado automáticamente por `tools/gen_owasp_coverage.py` desde `nutcracker_co | `MASVS-STORAGE-1` | La app almacena de forma segura los datos sensibles, sin importar la ubicación (privada o pública) | `ST001`, `ST002`, `ST003`, `ST004` | MASWE-0002, MASWE-0006, MASWE-0007 | | `MASVS-STORAGE-2` | La app evita la fuga no intencional de datos sensibles (por ejemplo vía backups o logs) | `HC001`, `HC002`, `HC003`, `HC004`, `HC005`, `HC007`, `HC008`, `AUTH001`, `DBG002`, `DBG003`, `ST006`, `NAT004` | MASWE-0001, MASWE-0002, MASWE-0005, MASWE-0013 | | `MASVS-CRYPTO-1` | La app usa criptografía fuerte y actual según las mejores prácticas de la industria | `HC006`, `CRYPTO001`, `CRYPTO002`, `CRYPTO003`, `CRYPTO004`, `CRYPTO005`, `CRYPTO006`, `NAT008` | MASWE-0013, MASWE-0020, MASWE-0021, MASWE-0022, MASWE-0027 | -| `MASVS-CRYPTO-2` | La app gestiona las claves criptográficas (generación, almacenamiento, rotación) según las mejores prácticas | — | — | +| `MASVS-CRYPTO-2` | La app gestiona las claves criptográficas (generación, almacenamiento, rotación) según las mejores prácticas | `CRYPTO007` | MASWE-0010 | | `MASVS-AUTH-1` | La app usa protocolos de autenticación y autorización seguros y sigue las mejores prácticas relevantes | — | — | -| `MASVS-AUTH-2` | La app realiza la autenticación local (biometría, PIN) de forma segura según las mejores prácticas de la plataforma | — | — | +| `MASVS-AUTH-2` | La app realiza la autenticación local (biometría, PIN) de forma segura según las mejores prácticas de la plataforma | `AUTH002` | MASWE-0032 | | `MASVS-AUTH-3` | La app protege las operaciones sensibles con autenticación adicional (step-up) | — | — | | `MASVS-NETWORK-1` | La app protege todo el tráfico de red según las mejores prácticas actuales (TLS, validación de certificados) | `HC008`, `NET001`, `NET002`, `NET003`, `NET004`, `NET006`, `NAT005`, `NAT007`, `DYN-CLEARTEXT-TRAFFIC` | MASWE-0047, MASWE-0050, MASWE-0052 | | `MASVS-NETWORK-2` | La app implementa identity pinning (certificate/public key pinning) para los endpoints bajo control del desarrollador | `NET005`, `NAT007`, `Certificate pinning` | MASWE-0047 | | `MASVS-PLATFORM-1` | La app usa los mecanismos de IPC de forma segura (intents, deep links, componentes exportados) | `COMP004`, `INJ004`, `COMP006`, `COMP007`, `COMP008` | MASWE-0058, MASWE-0062, MASWE-0063, MASWE-0064, MASWE-0119 | | `MASVS-PLATFORM-2` | La app usa WebViews de forma segura (sin JS innecesario, sin acceso a archivos, sin puentes JS expuestos) | `COMP001`, `COMP002`, `COMP003`, `COMP005`, `NET006` | MASWE-0052, MASWE-0068, MASWE-0069, MASWE-0072 | | `MASVS-PLATFORM-3` | La app usa la interfaz de usuario de forma segura (evita fugas vía capturas de pantalla, clipboard, etc.) | `ST005` | MASWE-0053 | -| `MASVS-CODE-1` | La app requiere una versión de plataforma actualizada | — | — | +| `MASVS-CODE-1` | La app requiere una versión de plataforma actualizada | `MANIFEST-LOW-TARGET-SDK` | MASWE-0078 | | `MASVS-CODE-2` | La app tiene un mecanismo para forzar actualizaciones ante vulnerabilidades críticas | — | — | | `MASVS-CODE-3` | La app solo usa componentes de software sin vulnerabilidades conocidas | — | — | | `MASVS-CODE-4` | La app valida y sanitiza toda entrada no confiable (SQL, IPC, deserialización, comandos) | `INJ001`, `INJ002`, `INJ003`, `DESER001`, `NAT001`, `NAT002` | MASWE-0081, MASWE-0086, MASWE-0088 | @@ -29,20 +29,16 @@ Generado automáticamente por `tools/gen_owasp_coverage.py` desde `nutcracker_co | `MASVS-RESILIENCE-3` | La app implementa mecanismos anti-análisis-estático (ofuscación de código, recursos y strings) | `OBF001`, `DexGuardDetector`, `AppDome` | — | | `MASVS-RESILIENCE-4` | La app implementa técnicas anti-análisis-dinámico (detección de debugger, Frida, hooking) | `INFO001`, `NAT003`, `Anti Magisk / SuperSU / KernelSU / Frida`, `DexGuardDetector`, `AppDome`, `DYN-DEBUGGABLE` | MASWE-0067, MASWE-0101 | | `MASVS-PRIVACY-1` | La app minimiza el acceso a datos y recursos sensibles (principio de mínimo privilegio en permisos) | `EXTRA001` | MASWE-0117 | -| `MASVS-PRIVACY-2` | La app evita la identificación del usuario (anonimización/pseudonimización) | — | — | +| `MASVS-PRIVACY-2` | La app evita la identificación del usuario (anonimización/pseudonimización) | `PRIVACY001` | MASWE-0110 | | `MASVS-PRIVACY-3` | La app es transparente sobre la recolección y uso de datos | — | — | | `MASVS-PRIVACY-4` | La app ofrece al usuario control sobre sus datos | — | — | ## Controles sin cobertura hoy (gap honesto, no una promesa) - `MASVS-AUTH-1` — La app usa protocolos de autenticación y autorización seguros y sigue las mejores prácticas relevantes -- `MASVS-AUTH-2` — La app realiza la autenticación local (biometría, PIN) de forma segura según las mejores prácticas de la plataforma - `MASVS-AUTH-3` — La app protege las operaciones sensibles con autenticación adicional (step-up) -- `MASVS-CODE-1` — La app requiere una versión de plataforma actualizada - `MASVS-CODE-2` — La app tiene un mecanismo para forzar actualizaciones ante vulnerabilidades críticas - `MASVS-CODE-3` — La app solo usa componentes de software sin vulnerabilidades conocidas -- `MASVS-CRYPTO-2` — La app gestiona las claves criptográficas (generación, almacenamiento, rotación) según las mejores prácticas -- `MASVS-PRIVACY-2` — La app evita la identificación del usuario (anonimización/pseudonimización) - `MASVS-PRIVACY-3` — La app es transparente sobre la recolección y uso de datos - `MASVS-PRIVACY-4` — La app ofrece al usuario control sobre sus datos @@ -60,6 +56,7 @@ Generado automáticamente por `tools/gen_owasp_coverage.py` desde `nutcracker_co | `Known anti-root libraries` | static | detector | high | MASVS-RESILIENCE-1 | — | — | | `Manual root checks` | static | detector | medium | MASVS-RESILIENCE-1 | — | — | | `SafetyNet / Play Integrity API` | static | detector | high | MASVS-RESILIENCE-1 | — | — | +| `MANIFEST-LOW-TARGET-SDK` | static | manifest_analyzer | medium | MASVS-CODE-1 | MASWE-0078 | — | | `NAT001` | static | native_scanner | high | MASVS-CODE-4 | — | CWE-120 | | `NAT002` | static | native_scanner | critical | MASVS-CODE-4 | — | CWE-78 | | `NAT003` | static | native_scanner | info | MASVS-RESILIENCE-4 | MASWE-0101 | — | @@ -69,6 +66,7 @@ Generado automáticamente por `tools/gen_owasp_coverage.py` desde `nutcracker_co | `NAT007` | static | native_scanner | medium | MASVS-NETWORK-1, MASVS-NETWORK-2 | MASWE-0047 | — | | `NAT008` | static | native_scanner | high | MASVS-CRYPTO-1 | MASWE-0020, MASWE-0021 | CWE-327 | | `AUTH001` | static | vuln_scanner | high | MASVS-STORAGE-2 | MASWE-0001 | CWE-532 | +| `AUTH002` | static | vuln_scanner | medium | MASVS-AUTH-2 | MASWE-0032 | CWE-477 | | `COMP001` | static | vuln_scanner | medium | MASVS-PLATFORM-2 | MASWE-0072 | CWE-79 | | `COMP002` | static | vuln_scanner | high | MASVS-PLATFORM-2 | MASWE-0069 | CWE-200 | | `COMP003` | static | vuln_scanner | critical | MASVS-PLATFORM-2 | MASWE-0068 | CWE-749 | @@ -83,6 +81,7 @@ Generado automáticamente por `tools/gen_owasp_coverage.py` desde `nutcracker_co | `CRYPTO004` | static | vuln_scanner | high | MASVS-CRYPTO-1 | MASWE-0020 | CWE-327 | | `CRYPTO005` | static | vuln_scanner | high | MASVS-CRYPTO-1 | MASWE-0022 | CWE-329 | | `CRYPTO006` | static | vuln_scanner | medium | MASVS-CRYPTO-1 | MASWE-0027 | CWE-330 | +| `CRYPTO007` | static | vuln_scanner | high | MASVS-CRYPTO-2 | MASWE-0010 | CWE-916 | | `DBG001` | static | vuln_scanner | medium | MASVS-RESILIENCE-2 | MASWE-0095 | CWE-489 | | `DBG002` | static | vuln_scanner | medium | MASVS-STORAGE-2 | MASWE-0001 | CWE-532 | | `DBG003` | static | vuln_scanner | low | MASVS-STORAGE-2 | MASWE-0001 | CWE-209 | @@ -108,6 +107,7 @@ Generado automáticamente por `tools/gen_owasp_coverage.py` desde `nutcracker_co | `NET005` | static | vuln_scanner | medium | MASVS-NETWORK-2 | MASWE-0047 | — | | `NET006` | static | vuln_scanner | critical | MASVS-NETWORK-1, MASVS-PLATFORM-2 | MASWE-0052 | CWE-295 | | `OBF001` | static | vuln_scanner | info | MASVS-RESILIENCE-3 | — | — | +| `PRIVACY001` | static | vuln_scanner | medium | MASVS-PRIVACY-2 | MASWE-0110 | CWE-359 | | `ST001` | static | vuln_scanner | medium | MASVS-STORAGE-1 | MASWE-0006 | CWE-312 | | `ST002` | static | vuln_scanner | high | MASVS-STORAGE-1 | MASWE-0002 | CWE-732 | | `ST003` | static | vuln_scanner | high | MASVS-STORAGE-1 | MASWE-0007 | CWE-922 | diff --git a/nutcracker_core/checks/dynamic/context.py b/nutcracker_core/checks/dynamic/context.py index 47e4665..2dda4cd 100644 --- a/nutcracker_core/checks/dynamic/context.py +++ b/nutcracker_core/checks/dynamic/context.py @@ -18,7 +18,14 @@ def default_adb_runner(serial: str | None, adb_bin: str = "adb", timeout: int = def _run(args: list[str]) -> str: cmd = [adb_bin] + (["-s", serial] if serial else []) + args try: - result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + # FIX (prueba con dispositivo físico real, 2026-07-24): capturar + # solo stdout descartaba el mensaje real de comandos como `run-as` + # (p.ej. "run-as: package not debuggable: "), que adb escribe + # a stderr — quedaba en detail/logs solo un string vacío en vez + # del diagnóstico útil. stderr=STDOUT los combina en orden real. + result = subprocess.run( + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=timeout, + ) return result.stdout except (subprocess.TimeoutExpired, FileNotFoundError): return "" diff --git a/nutcracker_core/checks/static/adapter.py b/nutcracker_core/checks/static/adapter.py index 85fafb5..9908d8b 100644 --- a/nutcracker_core/checks/static/adapter.py +++ b/nutcracker_core/checks/static/adapter.py @@ -101,8 +101,22 @@ def _load_detector_checks() -> None: register_static(_AdaptedCheck(_detector_meta(det.name, det.strength))) +def _load_manifest_analyzer_checks() -> None: + """Checks detectados por manifest_analyzer.py que no pasan por vuln_scanner + (producen `Misconfiguration`, no `VulnFinding` — no tienen un rule_id real + de origen). Representados aquí con un id sintético solo para que el + registry/docs/owasp-mas-coverage.md reflejen cobertura que YA existe en el + código, en vez de mostrarla como un gap (cierre de cobertura, 2026-07-25). + """ + from ..registry import register_static + + register_static(_AdaptedCheck(_rule_meta( + "MANIFEST-LOW-TARGET-SDK", "targetSdkVersion desactualizado", "medium", "manifest_analyzer", + ))) + + def load_all() -> None: - """Puebla el registry con los ~50 checks estáticos existentes. + """Puebla el registry con los checks estáticos existentes. No es idempotente por sí sola (llamarla dos veces duplica entradas); la idempotencia real vive en checks.load_registry(), que solo la invoca si @@ -111,3 +125,4 @@ def load_all() -> None: _load_vuln_scanner_checks() _load_native_scanner_checks() _load_detector_checks() + _load_manifest_analyzer_checks() diff --git a/nutcracker_core/cli/__init__.py b/nutcracker_core/cli/__init__.py index f285cfa..c99f80f 100644 --- a/nutcracker_core/cli/__init__.py +++ b/nutcracker_core/cli/__init__.py @@ -123,7 +123,7 @@ def _print_banner() -> None: # ── Grupo de comandos ────────────────────────────────────────────────────────── @click.group(invoke_without_command=True) -@click.version_option("0.1.0", prog_name="nutcracker") +@click.version_option(_VERSION, prog_name="nutcracker") @click.pass_context def cli(ctx: click.Context) -> None: """nutcracker: detects anti-root protections in Android applications (APK).""" diff --git a/nutcracker_core/cli/analyze.py b/nutcracker_core/cli/analyze.py index 423f22c..2caaea9 100644 --- a/nutcracker_core/cli/analyze.py +++ b/nutcracker_core/cli/analyze.py @@ -37,7 +37,7 @@ "--serial", "-s", default=None, metavar="SERIAL", - help="ADB device serial for --launch (default: first available).", + help="ADB device serial for --launch / --dynamic-checks (default: first available).", ) @click.option( "--static-only", @@ -46,11 +46,21 @@ help="Force jadx-only decompilation and skip Frida/device runtime steps " "(used by queued/scheduled batch runs; see `nutcracker queue`/`serve`).", ) +@click.option( + "--dynamic-checks", + is_flag=True, + default=False, + help="After static analysis, run headless dynamic checks against --serial " + "(ADB-based, no Frida REPL — safe for automation; used by queued dynamic " + "jobs). Unlike --launch, never hands the process to an interactive shell.", +) def analyze(apk_path: str, config_path: str, report: str | None, launch: bool, serial: str | None, - static_only: bool) -> None: + static_only: bool, dynamic_checks: bool) -> None: """Analyze a local APK for anti-root protections.""" orch._LAUNCH_APP = launch orch._LAUNCH_SERIAL = serial + orch._RUN_DYNAMIC_CHECKS = dynamic_checks + orch._DYNAMIC_CHECKS_SERIAL = serial config = load_config(config_path) if static_only: orch.apply_static_only_override(config) diff --git a/nutcracker_core/cli/batch.py b/nutcracker_core/cli/batch.py index b4d570f..3b0a22a 100644 --- a/nutcracker_core/cli/batch.py +++ b/nutcracker_core/cli/batch.py @@ -1,4 +1,16 @@ -"""Comando `nutcracker batch`: escanea una lista de APKs/URLs.""" +"""Comando `nutcracker batch`: escanea una lista de APKs/URLs. + +Migrado a usar el motor de cola de Fase 1 (`nutcracker_core.queue.engine`) en +vez de su bucle secuencial propio original: cada target ahora corre como el +mismo subproceso aislado (`analyze`/`scan`) que usan `queue add` y `serve`, +en paralelo según `queue.static_workers` (config.yaml) — o estrictamente +secuencial con `static_workers: 1`. El resumen consolidado (severidades, top +findings, categorías por app) se reconstruye desde SQLite en vez de desde +objetos en memoria, porque cada job corrió en su propio proceso. + +Efecto colateral bienvenido: como cada target pasa por QueueEngine, también +queda auto-agendado para revisión periódica (Fase 1.2, `schedule`/`serve`). +""" from __future__ import annotations @@ -6,24 +18,76 @@ from pathlib import Path import click -from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, DownloadColumn, TransferSpeedColumn - -from nutcracker_core.analyzer import APKAnalyzer -from nutcracker_core.config import load_config, get as cfg_get -from nutcracker_core.downloader import ( - APKPureDownloader, - GooglePlayDownloader, - DirectURLDownloader, - APKDownloadError, - is_direct_apk_url, -) -from nutcracker_core.i18n import t -from nutcracker_core.reporter import save_analysis_json + from nutcracker_core import orchestrator as orch +from nutcracker_core.config import get as cfg_get, load_config +from nutcracker_core.i18n import t from nutcracker_core.orchestrator import console +from nutcracker_core.queue.engine import JobOutcome, QueueEngine +from nutcracker_core.store import db, repository +from nutcracker_core.store.hooks import db_path_from_config from . import cli +_SEV_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4} +_VERDICT_TO_STATUS = { + "protection_broken": "protected_broken", + "protected": "protected", + "not_protected": "unprotected", +} + + +def _summary_for_outcome(db_path: str | None, outcome: JobOutcome) -> dict: + """Reconstruye la fila de resumen del batch (severidades, top findings, + categorías) desde SQLite — cada job corrió en su propio subproceso, así + que sus hallazgos ya están persistidos para cuando drain() retorna.""" + base: dict = {"target": outcome.job.target, "package": outcome.package} + + if not outcome.ok or outcome.run_id is None: + base["status"] = "error" + base["error"] = outcome.error or "análisis fallido" + return base + + conn = db.connect(db_path) + try: + run = repository.get_run(conn, outcome.run_id) + findings = repository.findings_for_run(conn, outcome.run_id) + finally: + conn.close() + + base["status"] = _VERDICT_TO_STATUS.get(run["verdict"] if run else None, "unprotected") + + sev_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0} + leaks_count = 0 + seen_rules: set[str] = set() + top_findings: list[tuple[str, str, str]] = [] + cat_max_sev: dict[str, str] = {} + for f in findings: + sev = (f["severity"] or "info").lower() + if sev in sev_counts: + sev_counts[sev] += 1 + rid = f["rule_id"] or "" + cat = f["category"] or "" + if cat: + prev = cat_max_sev.get(cat, "info") + if _SEV_ORDER.get(sev, 5) < _SEV_ORDER.get(prev, 5): + cat_max_sev[cat] = sev + if rid.upper().startswith(("AL-", "HC", "GL-")): + leaks_count += 1 + if rid not in seen_rules: + seen_rules.add(rid) + top_findings.append((rid, f["title"] or "", sev)) + top_findings.sort(key=lambda x: _SEV_ORDER.get(x[2], 5)) + + base.update({ + "findings": len(findings), + **sev_counts, + "leaks": leaks_count, + "top_findings": top_findings[:5], + "categories": cat_max_sev, + }) + return base + @cli.command() @click.argument("list_file", type=click.Path(dir_okay=False), required=False, default=None) @@ -38,30 +102,32 @@ @click.option( "--output-dir", "-o", default=None, - help="Directory to save PDF/JSON reports.", -) -@click.option( - "--keep-apk", - is_flag=True, - default=False, - help="Keep downloaded APKs after each analysis.", + help="Directory for the consolidated batch_report.pdf (individual per-app " + "reports always use reports.output_dir from config.yaml, same as " + "`analyze`/`scan`).", ) @click.option( "--stop-on-error", is_flag=True, default=False, - help="Stop on first error (default: continue).", + help="Stop queueing further targets on the first error. Only strictly " + "honored in sequential mode (queue.static_workers: 1) — in parallel " + "mode, jobs already dispatched to the pool still finish.", ) def batch( list_file: str | None, config_path: str, output_dir: str | None, - keep_apk: bool, stop_on_error: bool, ) -> None: """ Scan a list of APKs or URLs in batch mode. + Runs via the queue (Fase 1 del plan): in parallel according to + queue.static_workers in config.yaml (default 4), or sequentially with + static_workers: 1. Each target also gets auto-scheduled for periodic + re-review (see `nutcracker schedule`). + LIST_FILE is a text file with one entry per line: - Google Play URLs (https://play.google.com/...) - Package IDs (com.example.app) @@ -72,17 +138,13 @@ def batch( """ started_at = time.perf_counter() config = load_config(config_path) - orch._CFG = config orch._init_i18n(config) - batch_cfg = cfg_get(config, "batch") or {} - _keep_apk = keep_apk or bool(batch_cfg.get("keep_apk", cfg_get(config, "downloader", "keep_apk", default=False))) + batch_cfg = cfg_get(config, "batch") or {} _stop_on_err = stop_on_error or bool(batch_cfg.get("stop_on_error", False)) reports_dir = output_dir or batch_cfg.get("reports_dir") or cfg_get(config, "reports", "output_dir") or "./reports" - dl_dir = batch_cfg.get("download_dir") or cfg_get(config, "downloader", "output_dir") or "./downloads" - save_pdf = cfg_get(config, "reports", "save_pdf", default=True) + save_pdf = cfg_get(config, "reports", "save_pdf", default=True) - # Resolver list_file: CLI > config batch.list_file resolved_list_file = list_file or batch_cfg.get("list_file") if not resolved_list_file: console.print(f"[red]✘[/red] {t('cli_batch_list_missing')}") @@ -92,151 +154,60 @@ def batch( console.print(f"[red]✘[/red] {t('cli_batch_file_not_found', path=resolved_list_file)}") raise SystemExit(1) - # Leer lista de targets raw_lines = resolved_list_file.read_text(encoding="utf-8").splitlines() targets = [l.strip() for l in raw_lines if l.strip() and not l.strip().startswith("#")] - if not targets: console.print(f"[yellow]{t('cli_batch_empty_list')}[/yellow]") return - console.print(f"[bold cyan]Batch scan:[/bold cyan] {t('cli_batch_scan_header', count=len(targets), file=list_file)}") + static_workers = int(cfg_get(config, "queue", "static_workers", default=4)) + engine = QueueEngine( + config_path=config_path, + db_path=db_path_from_config(config), + static_workers=static_workers, + ) + # Streamea la salida de cada job en vivo, prefijada por job id — con + # varios jobs corriendo en paralelo, sin esto la terminal quedaría muda + # hasta que cada uno termine. + engine.on_line = lambda job_id, line: console.print(f"[dim]\\[job #{job_id}][/dim] {line}") + + console.print( + f"[bold cyan]Batch scan:[/bold cyan] " + f"{t('cli_batch_scan_header', count=len(targets), file=str(resolved_list_file))}" + f" [dim](static_workers={static_workers})[/dim]" + ) console.rule() results_summary: list[dict] = [] - - for idx, target in enumerate(targets, 1): - console.print(f"\n[bold][[{idx}/{len(targets)}]][/bold] {target}") - - apk_path: Path | None = None - is_local = Path(target).exists() and target.lower().endswith(".apk") - - # ── Descarga si no es local ─────────────────────────────────────────── - if not is_local: - try: - if is_direct_apk_url(target): - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - BarColumn(), - DownloadColumn(), - TransferSpeedColumn(), - console=console, - transient=True, - ) as progress: - task = progress.add_task(t("cli_batch_downloading"), total=None) - def _on_chunk(dl: int, tot: int | None) -> None: - progress.update(task, completed=dl, total=tot) - apk_path = DirectURLDownloader(dl_dir).download(target, progress_callback=_on_chunk, use_cache=_keep_apk) - else: - # Google Play / APKPure - email = cfg_get(config, "google_play", "email") - aas_token = cfg_get(config, "google_play", "aas_token") - if email and aas_token: - dl = GooglePlayDownloader(email, aas_token, dl_dir) - else: - dl = APKPureDownloader(dl_dir) - with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), - console=console, transient=True) as progress: - progress.add_task(t("cli_batch_downloading"), total=None) - apk_path = dl.download(target) - console.print(f" [green]✔[/green] {t('cli_batch_downloaded', name=apk_path.name)}") - except APKDownloadError as exc: - console.print(f" [red]{t('cli_batch_download_error')}[/red] {exc}") - results_summary.append({"target": target, "status": "error_download", "error": str(exc)}) - if _stop_on_err: - break - continue - else: - apk_path = Path(target) - - # ── Análisis ────────────────────────────────────────────────────────── - try: - with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), - console=console, transient=True) as progress: - task = progress.add_task(t("cli_batch_analyzing"), total=None) - def on_prog(msg: str) -> None: - progress.update(task, description=msg) - analyzer = APKAnalyzer(progress_callback=on_prog) - result = analyzer.analyze(apk_path) - - # PDF individual por app - pkg = result.package - pdf_path: Path | None = None - if save_pdf: - pkg_dir = Path(reports_dir) / pkg - pkg_dir.mkdir(parents=True, exist_ok=True) - pdf_dest = pkg_dir / f"nutcracker_{pkg}_report.pdf" - scan_result = orch._post_analysis_flow(result, apk_path) - # Guardar JSON una vez que todos los datos están completos - save_analysis_json(result, scan_result=scan_result, manifest=orch._MANIFEST_ANALYSIS) - from nutcracker_core.pdf_reporter import generate_pdf_report - pdf_path = generate_pdf_report(result, pdf_dest, scan=scan_result, manifest=orch._MANIFEST_ANALYSIS) - console.print(f" [green]✔[/green] PDF: [bold]{pdf_path}[/bold]") - else: - scan_result = orch._post_analysis_flow(result, apk_path) - # Guardar JSON una vez que todos los datos están completos - save_analysis_json(result, scan_result=scan_result, manifest=orch._MANIFEST_ANALYSIS) - - status = "protected_broken" if result.protection_broken \ - else ("protected" if result.protected else "unprotected") - - # Enriquecer datos para reporte batch consolidado - sev_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0} - leaks_count = 0 - top_findings: list[tuple[str, str, str]] = [] - cat_max_sev: dict[str, str] = {} # categoría → peor severidad - if scan_result: - seen_rules: set[str] = set() - sev_order = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4} - for f in scan_result.findings: - sev = getattr(f, "severity", "info").lower() - if sev in sev_counts: - sev_counts[sev] += 1 - rid = getattr(f, "rule_id", "") - cat = getattr(f, "category", "") - # Peor severidad por categoría - if cat: - prev = cat_max_sev.get(cat, "info") - if sev_order.get(sev, 5) < sev_order.get(prev, 5): - cat_max_sev[cat] = sev - # Contar leaks - if rid.upper().startswith(("AL-", "HC", "GL-")): - leaks_count += 1 - # Top findings (únicos por rule_id, ordenados por severidad) - if rid not in seen_rules: - seen_rules.add(rid) - top_findings.append((rid, getattr(f, "title", ""), sev)) - top_findings.sort(key=lambda x: sev_order.get(x[2], 5)) - top_findings = top_findings[:5] - - results_summary.append({ - "target": target, - "package": pkg, - "status": status, - "pdf": str(pdf_path) if pdf_path else None, - "findings": len(scan_result.findings) if scan_result else 0, - **sev_counts, - "leaks": leaks_count, - "top_findings": top_findings, - "categories": cat_max_sev, - }) - except Exception as exc: # noqa: BLE001 - console.print(f" [red]{t('cli_batch_analysis_error')}[/red] {exc}") - results_summary.append({"target": target, "status": "error_analysis", "error": str(exc)}) - if _stop_on_err: + stop_requested = {"flag": False} + + def _on_result(outcome: JobOutcome) -> None: + icon = "[green]✔[/green]" if outcome.ok else "[red]✘[/red]" + console.print(f"{icon} {outcome.package or outcome.job.target}") + results_summary.append(_summary_for_outcome(engine.db_path, outcome)) + if not outcome.ok and _stop_on_err: + stop_requested["flag"] = True + + if static_workers <= 1: + # Secuencial de verdad: encola y drena de a un target por vez para + # que --stop-on-error pueda cortar antes del siguiente. + for target in targets: + if stop_requested["flag"]: break + engine.submit(target, kind="static") + engine.drain(on_result=_on_result) + else: + # Paralelo: se encolan todos de una — --stop-on-error ya no puede + # cancelar jobs que el pool ya empezó a correr (QueueEngine no + # soporta cancelación a mitad de camino). + engine.submit_many(targets, kind="static") + engine.drain(on_result=_on_result) - finally: - if not _keep_apk and apk_path and apk_path.exists() and not is_local: - apk_path.unlink() - - # ── Resumen final ───────────────────────────────────────────────────────── console.rule() console.print(f"\n[bold]{t('cli_batch_summary_header', count=len(results_summary))}[/bold]\n") - ok = [r for r in results_summary if "error" not in r["status"]] - errors = [r for r in results_summary if "error" in r["status"]] - broken = [r for r in ok if r["status"] == "protected_broken"] + ok = [r for r in results_summary if "error" not in r["status"]] + errors = [r for r in results_summary if "error" in r["status"]] + broken = [r for r in ok if r["status"] == "protected_broken"] console.print(f" [green]{t('cli_batch_ok_label')}[/green] {len(ok)}") console.print(f" [red]{t('cli_batch_errors_label')}[/red] {len(errors)}") console.print(f" [yellow]{t('cli_batch_broken_label')}[/yellow] {len(broken)}") @@ -244,9 +215,8 @@ def on_prog(msg: str) -> None: if errors: console.print(f"\n[dim]{t('cli_batch_error_targets')}[/dim]") for r in errors: - console.print(f" - {r['target']} ({r['error'][:80]})") + console.print(f" - {r['target']} ({(r.get('error') or '')[:80]})") - # ── Reporte batch consolidado ───────────────────────────────────────────── if save_pdf and len(results_summary) > 1: from nutcracker_core.pdf_reporter import generate_batch_report batch_pdf = Path(reports_dir) / "batch_report.pdf" diff --git a/nutcracker_core/cli/queue_cmd.py b/nutcracker_core/cli/queue_cmd.py index d78805f..deda149 100644 --- a/nutcracker_core/cli/queue_cmd.py +++ b/nutcracker_core/cli/queue_cmd.py @@ -45,15 +45,19 @@ def queue() -> None: @click.option("--config", "-c", "config_path", default="config.yaml", show_default=True) @click.option("--dynamic", is_flag=True, default=False, help="Job dinámico (Frida/ADB sobre dispositivo). Requiere .apk local.") -@click.option("--serial", default=None, help="Serial ADB para job dinámico.") +@click.option("--aipwn", is_flag=True, default=False, + help="Job del agente de bypass aipwn (Frida+LLM). TARGET es un package id " + "ya analizado previamente, no una ruta/URL de APK.") +@click.option("--serial", default=None, help="Serial ADB para job dinámico/aipwn.") @click.option("--run", "run_now", is_flag=True, default=False, help="Ejecutar la cola inmediatamente tras encolar (bloqueante).") -def queue_add(target: str, config_path: str, dynamic: bool, serial: str | None, run_now: bool) -> None: +def queue_add(target: str, config_path: str, dynamic: bool, aipwn: bool, + serial: str | None, run_now: bool) -> None: """Encola TARGET: ruta a .apk local, URL, package id, o un list_file con una entrada por línea (mismo formato que `nutcracker batch`).""" engine = _build_engine(config_path) targets = _read_targets(target) - kind = "dynamic" if dynamic else "static" + kind = "aipwn" if aipwn else ("dynamic" if dynamic else "static") jobs = [] for t in targets: diff --git a/nutcracker_core/i18n.py b/nutcracker_core/i18n.py index 5f45eea..a2bb385 100644 --- a/nutcracker_core/i18n.py +++ b/nutcracker_core/i18n.py @@ -278,7 +278,7 @@ def register(strings: dict[str, dict[str, str]]) -> None: "batch_medium_count": "{count} medium", "batch_low_count": "{count} low", "batch_leaks_count": "{count} leaks", - "batch_comparative_title": "Comparative Table \u2014 Protection Status", + "batch_comparative_title": "Comparative Table - Protection Status", "batch_unprotected_label": "Unprotected", "batch_protection_broken_label": "Protection broken", "batch_with_protection": "With protection", @@ -1322,7 +1322,7 @@ def register(strings: dict[str, dict[str, str]]) -> None: "batch_medium_count": "{count} medios", "batch_low_count": "{count} bajos", "batch_leaks_count": "{count} leaks", - "batch_comparative_title": "Tabla Comparativa \u2014 Estado de Proteccion", + "batch_comparative_title": "Tabla Comparativa - Estado de Proteccion", "batch_unprotected_label": "Sin proteccion", "batch_protection_broken_label": "Proteccion rota", "batch_with_protection": "Con proteccion", diff --git a/nutcracker_core/masvs.py b/nutcracker_core/masvs.py index cd6ab4d..3aa0d05 100644 --- a/nutcracker_core/masvs.py +++ b/nutcracker_core/masvs.py @@ -270,6 +270,15 @@ "NAT006": ["MASVS-RESILIENCE-1"], "NAT007": ["MASVS-NETWORK-1", "MASVS-NETWORK-2"], "NAT008": ["MASVS-CRYPTO-1"], + # ── Nuevas reglas: cierre de gap de cobertura OWASP MAS (2026-07-25) ────── + "CRYPTO007": ["MASVS-CRYPTO-2"], # clave derivada de password sin KDF + "AUTH002": ["MASVS-AUTH-2"], # FingerprintManager deprecado + "PRIVACY001": ["MASVS-PRIVACY-2"], # identificador de hardware para tracking + # Sintético (Fase 2.5): representa en el registry el check ya existente en + # manifest_analyzer.py (low_target_sdk), no un VulnRule real — ver + # checks/static/adapter.py. No aparece en hallazgos persistidos (los + # Misconfiguration de manifest_analyzer no pasan por store/hooks.py hoy). + "MANIFEST-LOW-TARGET-SDK": ["MASVS-CODE-1"], } # MASWE: solo se listan las reglas donde hay una debilidad MASWE con @@ -322,6 +331,10 @@ "NAT005": ["MASWE-0052"], "NAT007": ["MASWE-0047"], "NAT008": ["MASWE-0020", "MASWE-0021"], + "CRYPTO007": ["MASWE-0010"], # Improper Cryptographic Key Derivation + "AUTH002": ["MASWE-0032"], # Platform-provided Authentication APIs Not Used + "PRIVACY001": ["MASWE-0110"], # Use of Unique Identifiers for User Tracking + "MANIFEST-LOW-TARGET-SDK": ["MASWE-0078"], # Latest Platform Version Not Targeted } # CWE: solo los well-established, sin ambigüedad (misma prudencia que MASWE). @@ -371,6 +384,13 @@ "NAT004": ["CWE-798"], "NAT005": ["CWE-295"], "NAT008": ["CWE-327"], + "CRYPTO007": ["CWE-916"], # Use of Password Hash With Insufficient Computational Effort + "AUTH002": ["CWE-477"], # Use of Obsolete Function + "PRIVACY001": ["CWE-359"], # Exposure of Private Personal Information to an Unauthorized Actor + # MANIFEST-LOW-TARGET-SDK sin CWE: "no targetear la última versión de + # plataforma" no tiene un CWE específico razonable (distinto de CODE-3/SCA, + # que sí lo tendría vía CWE-1104) — se deja sin forzar, mismo criterio que + # el resto de reglas sin match claro (ver nota de Fase 2 sobre prudencia). } # ── Peso base de cada categoría ─────────────────────────────────────────────── diff --git a/nutcracker_core/orchestrator.py b/nutcracker_core/orchestrator.py index 799d4c2..1dc9bff 100644 --- a/nutcracker_core/orchestrator.py +++ b/nutcracker_core/orchestrator.py @@ -31,9 +31,12 @@ decompile_dumps, ) from nutcracker_core.device import ( + download_frida_server, find_sdk_tools, + frida_arch_for_device, get_frida_version, list_avds, + setup_frida_server, ) from nutcracker_core.frida_bypass import ( fart_run_instructions, @@ -67,6 +70,15 @@ _OSINT_RESULT = None # OsintResult del último scan _LAUNCH_APP: bool = False # --launch: lanzar app con bypass script tras el análisis _LAUNCH_SERIAL: str | None = None # --serial para --launch +# --dynamic-checks: corre los checks dinámicos de Fase 2 (ADB puro, sin Frida, +# sin REPL interactivo) tras el análisis estático — ver _run_dynamic_checks_for(). +# Introducido junto al fix de --launch (2026-07-24, prueba con dispositivo físico +# real): --launch reemplaza el proceso Python con un REPL de frida interactivo, +# pensado para un humano en terminal — un job automatizado de la cola (Fase 1) +# que lo invocaba se quedaría esperando input que nunca llega. Los jobs +# dinámicos de la cola usan este flag en vez de --launch. +_RUN_DYNAMIC_CHECKS: bool = False +_DYNAMIC_CHECKS_SERIAL: str | None = None def apply_static_only_override(config: dict) -> None: @@ -87,8 +99,9 @@ def build_job_cmd( is_local_apk: bool, config_path: str = "config.yaml", static_only: bool = True, - launch: bool = False, + dynamic_checks: bool = False, serial: str | None = None, + aipwn: bool = False, ) -> list[str]: """Construye el argv para ejecutar un job de análisis como subproceso aislado. @@ -98,13 +111,32 @@ def build_job_cmd( `_MANIFEST_ANALYSIS`, `_OSINT_RESULT`, ...) que no es seguro entre análisis concurrentes de APKs distintas. El aislamiento por proceso evita ese riesgo sin reescribir el orquestador, y de paso da paralelismo real (sin GIL). + + Los jobs dinámicos usan ``--dynamic-checks`` (checks/dynamic/ de Fase 2, ADB + puro) en vez de ``--launch``: ``--launch`` reemplaza el proceso con un REPL + de frida interactivo pensado para un humano en terminal — un job automatizado + de la cola que lo usara se quedaría esperando input que nunca llega + (encontrado en prueba con dispositivo físico real, 2026-07-24; ver plan.md). + + ``aipwn=True`` (Fase 3, wiring del dashboard con el agente de bypass) corre + ``nutcracker aipwn `` en vez de analyze/scan — ``target`` es un + package id, no una ruta/URL de APK (aipwn requiere que ya exista un + análisis previo del package, igual que en uso manual por CLI). El comando + ``aipwn`` no acepta ``--config`` (siempre lee ``config.yaml`` por defecto), + así que ``config_path``/``static_only``/``dynamic_checks`` se ignoran en + este modo. """ entry = str(Path(__file__).resolve().parent.parent / "nutcracker.py") cmd = [sys.executable, entry] + if aipwn: + cmd += ["aipwn", target] + if serial: + cmd += ["--serial", serial] + return cmd if is_local_apk: cmd += ["analyze", target, "--config", config_path] - if launch: - cmd.append("--launch") + if dynamic_checks: + cmd.append("--dynamic-checks") if serial: cmd += ["--serial", serial] else: @@ -114,6 +146,34 @@ def build_job_cmd( return cmd +def _run_dynamic_checks_for(result, serial: str | None) -> None: + """Corre los checks dinámicos de Fase 2 (ADB puro, sin Frida) contra + ``result.package`` en el dispositivo ``serial`` e imprime los hallazgos. + + A diferencia de --launch, nunca cede el proceso a un REPL interactivo: + apto para automatización headless (jobs de la cola, Fase 1). + """ + from .checks import dynamic_checks, load_registry + from .checks.dynamic.context import DynamicCheckContext + + load_registry() + checks = dynamic_checks() + if not checks: + return + + console.print(f"\n[bold cyan]Checks dinámicos[/bold cyan] ({len(checks)}) — serial={serial or '(auto)'}") + ctx = DynamicCheckContext(package=result.package, serial=serial) + for check in checks: + try: + findings = check.run(ctx) + except Exception as exc: # noqa: BLE001 + console.print(f" [red]✘[/red] {check.meta.id}: error ejecutando el check — {exc}") + continue + for finding in findings: + icon = "[yellow]⚠[/yellow]" if finding.detected else "[dim]–[/dim]" + console.print(f" {icon} {finding.check_id} {finding.detail}") + + def _init_i18n(config: dict) -> None: """Initialize the i18n module from the loaded config.""" language = str(cfg_get(config, "language", default="en")).strip().lower() @@ -179,14 +239,50 @@ def _launch_frida_bypass( adb_args = [adb] + (["-s", serial] if serial else []) console.print(f"[dim] {t('cli_restarting_frida')}[/dim]") - subprocess.run(adb_args + ["shell", "killall frida-server 2>/dev/null; true"], capture_output=True) - subprocess.run(adb_args + ["root"], capture_output=True) - _time.sleep(2) - subprocess.run( - adb_args + ["shell", "nohup /data/local/tmp/frida-server > /dev/null 2>&1 &"], - capture_output=True, - ) - _time.sleep(2) + # FIX (prueba con dispositivo físico real, 2026-07-24): antes reiniciaba + # frida-server con un simple "nohup .../frida-server &" — sin -l 0.0.0.0 + # (nunca escuchaba en red, solo en 127.0.0.1: --launch con frida_host + # configurado no podía conectar aunque el ruteo de red funcionara), sin + # respetar strategies.frida_server_version (riesgo de mismatch de versión + # con la librería Python), y sin el "unset LD_PRELOAD" que setup_frida_server + # sí hace — necesario en devices con Magisk Zygisk/LSPosed (hma_oss_zygisk, + # zygisksu, etc., que setean LD_PRELOAD y rompen el attach/spawn de Frida). + # Reusa el mismo mecanismo robusto que ya usa el flujo FART (pipeline.py). + if serial: + try: + tools = find_sdk_tools() + tools.setdefault("adb", adb) + cfg_ver = str(cfg_get(_CFG, "strategies", "frida_server_version") or "").strip() + frida_ver = cfg_ver or get_frida_version() + if frida_ver: + arch = frida_arch_for_device(serial, tools) + server_bin = download_frida_server(frida_ver, arch) + setup_frida_server( + serial, tools, server_bin, + listen_all=bool(frida_host), + force_restart=True, + ) + else: + console.print(f"[yellow]⚠[/yellow] {t('pipe_frida_not_found')}") + except Exception as exc: # noqa: BLE001 + console.print( + f"[yellow]⚠[/yellow] No se pudo reiniciar frida-server automáticamente " + f"({exc}); intentando con el binario que ya esté en el device." + ) + else: + # Sin --serial explícito (un único device/emulador conectado): no hay + # forma fiable de resolverle arquitectura/tools a setup_frida_server, + # así que se conserva el restart simple de antes como fallback — sin + # -l 0.0.0.0 (frida_host sin --serial es un caso raro) pero al menos + # asegura que frida-server esté corriendo para -U. + subprocess.run(adb_args + ["shell", "killall frida-server 2>/dev/null; true"], capture_output=True) + subprocess.run(adb_args + ["root"], capture_output=True) + _time.sleep(2) + subprocess.run( + adb_args + ["shell", "nohup /data/local/tmp/frida-server > /dev/null 2>&1 &"], + capture_output=True, + ) + _time.sleep(2) subprocess.run(adb_args + ["shell", f"am force-stop {package}"], capture_output=True) _time.sleep(1) @@ -198,7 +294,18 @@ def _launch_frida_bypass( frida_cmd = [frida_bin, "-U", "-f", package, "-l", str(script_path)] console.print(f"[green]▶[/green] [bold cyan]{' '.join(frida_cmd)}[/bold cyan]") - os.execvp(frida_cmd[0], frida_cmd) + # FIX (encontrado en prueba con dispositivo físico real, 2026-07-24): antes + # usaba os.execvp(), que REEMPLAZA el proceso Python actual por el de frida. + # Esto hacía que todo el código posterior a _post_analysis_flow() en + # _run_analysis() (save_analysis_json, PDF, reporte MASVS, post-hooks de + # persistencia SQLite) nunca se ejecutara — el análisis estático, ya + # completo en ese punto, se perdía sin dejar rastro en disco ni en la BD, + # sin importar si frida lograba conectar o no. subprocess.run() preserva + # el mismo comportamiento interactivo (hereda stdin/stdout/stderr, así que + # un humano en terminal sigue viendo el REPL de frida en vivo) pero, a + # diferencia de execvp, retorna cuando frida termina — permitiendo que el + # resto del pipeline complete su trabajo de guardado. + subprocess.run(frida_cmd) def _auto(key: str) -> "bool | None": @@ -407,6 +514,17 @@ def on_progress(msg: str) -> None: config=_CFG, ) + # ── Checks dinámicos (--dynamic-checks) ───────────────────────────── + # Corre después de guardar todo lo estático a propósito: si algo falla + # acá (dispositivo desconectado, ADB sin permisos, etc.) el análisis + # estático ya quedó persistido — nunca se pierde por un paso opcional + # posterior (ver fix de --launch/execvp más arriba en este archivo). + if _RUN_DYNAMIC_CHECKS: + try: + _run_dynamic_checks_for(result, _DYNAMIC_CHECKS_SERIAL) + except Exception as exc: # noqa: BLE001 + console.print(f"[red]{t('cli_error_unexpected')}[/red] {exc}") + if not keep_apk and apk_path and apk_path.exists(): apk_path.unlink() console.print(f"[dim]{t('cli_apk_deleted', path=apk_path)}[/dim]") diff --git a/nutcracker_core/queue/engine.py b/nutcracker_core/queue/engine.py index ee7e72a..bd7de14 100644 --- a/nutcracker_core/queue/engine.py +++ b/nutcracker_core/queue/engine.py @@ -25,6 +25,7 @@ import datetime import logging import os +import re import subprocess import threading from concurrent.futures import ThreadPoolExecutor, as_completed @@ -55,6 +56,50 @@ def _is_local_apk(target: str) -> bool: return Path(target).exists() and target.lower().endswith(".apk") +# Marcadores usados por _extract_error_summary para reconocer líneas que +# probablemente son la causa raíz real de un fallo. +_ERROR_MARKERS = ("error", "failed", "traceback", "exception", "no route to host", + "permission denied", "timed out", "timeout", "refused") + +# Defensa en profundidad: aunque _run_job fuerza NO_COLOR=1 en el subproceso, +# una herramienta de terceros (semgrep, androguard, ...) podría ignorar esa +# convención y forzar color igual (p.ej. flags propios tipo --color=always). +# Sin esto, esos códigos ANSI crudos terminan como texto literal en el panel +# de logs en vivo del dashboard (visto en vivo: el banner ASCII-art coloreado +# de nutcracker se volcaba como un bloque de texto ilegible en el navegador). +_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)") + + +def _strip_ansi(text: str) -> str: + return _ANSI_RE.sub("", text) + + +def _extract_error_summary(output: str, max_len: int = 2000) -> str: + """Extrae un resumen útil del output de un job fallido. + + FIX (prueba con dispositivo físico real, 2026-07-24): un simple + output[-max_len:] pierde la causa raíz real cuando el proceso sigue + imprimiendo (tablas de hallazgos, banners) después del error — se vio en + vivo con un fallo de conexión Frida ("No route to host") tapado por una + tabla de vulnerabilidades posterior, dejando en `error` solo un fragmento + ilegible de esa tabla. Prioriza líneas que parecen errores reales, y + completa con el tail normal como contexto. + """ + output = output.strip() + if not output: + return "" + lines = output.splitlines() + flagged = list(dict.fromkeys( + line.strip() for line in lines if any(m in line.lower() for m in _ERROR_MARKERS) + )) + tail = output[-max_len:] + if not flagged: + return tail + flagged_text = "\n".join(flagged)[: max_len // 2] + remaining = max_len - len(flagged_text) - len("\n---\n") + return f"{flagged_text}\n---\n{tail[-remaining:]}" + + class QueueEngine: """Encola y ejecuta jobs de análisis de APKs.""" @@ -78,6 +123,12 @@ def __init__( # camino original — subprocess.run() bloqueante, sin cambios de # comportamiento para quien no lo use (CLI, scheduler, tests de Fase 1). self.on_line: Callable[[int, str], None] | None = None + # Variables de entorno extra (Fase 3, dashboard) fusionadas en el env + # de cada subproceso de job -- p.ej. NUTCRACKER_DASHBOARD_URL, que el + # agente aipwn usa para el polling del mailbox de chat (ver + # plugins/dashboard/__init__.py y plugins/aipwn/frida_agent.py). Vacío + # (default) preserva el env tal cual para quien no lo use. + self.extra_env: dict[str, str] = {} # ── Encolado ───────────────────────────────────────────────────────────── @@ -175,11 +226,21 @@ def _run_job(self, job: Job) -> JobOutcome: is_local_apk=job.is_local_apk, config_path=self.config_path, static_only=(job.kind == "static"), - launch=(job.kind == "dynamic"), + dynamic_checks=(job.kind == "dynamic"), serial=job.serial, + aipwn=(job.kind == "aipwn"), ) env = dict(os.environ) env["NUTCRACKER_QUEUE_JOB_ID"] = str(job.db_id) + # El job corre desapegado de cualquier terminal (stdout va a un pipe que + # termina en el panel de logs del dashboard). Si el proceso padre heredó + # FORCE_COLOR/COLORTERM (p.ej. terminal de VS Code), rich igual emite + # ANSI truecolor real hacia el pipe -- NO_COLOR es lo único que rich + # respeta por encima de FORCE_COLOR, y evita volcar códigos de color + # crudos como texto en el navegador. + env["NO_COLOR"] = "1" + env.pop("FORCE_COLOR", None) + env.update(self.extra_env) _log.info("job #%s: %s", job.db_id, " ".join(cmd)) if self.on_line is not None: @@ -187,8 +248,7 @@ def _run_job(self, job: Job) -> JobOutcome: else: proc = subprocess.run(cmd, env=env, capture_output=True, text=True) ok = proc.returncode == 0 - tail = (proc.stderr or proc.stdout or "").strip()[-2000:] - error = "" if ok else tail + error = "" if ok else _extract_error_summary(_strip_ansi(proc.stderr or proc.stdout or "")) conn = db.connect(self.db_path) try: @@ -216,9 +276,9 @@ def _run_streaming(self, job_id: int, cmd: list[str], env: dict) -> subprocess.C ) lines: list[str] = [] assert proc.stdout is not None - for line in proc.stdout: - lines.append(line) - self.on_line(job_id, line.rstrip("\n")) # type: ignore[misc] + for raw_line in proc.stdout: + lines.append(raw_line) + self.on_line(job_id, _strip_ansi(raw_line.rstrip("\n"))) # type: ignore[misc] proc.wait() return subprocess.CompletedProcess(cmd, proc.returncode, stdout="".join(lines), stderr="") @@ -233,7 +293,9 @@ def drain(self, on_result: Callable[[JobOutcome], None] | None = None) -> list[J resultados. Bloqueante: vuelve cuando no queda ningún job por correr.""" self._load_queued_from_db() static_jobs = [j for j in self._pending if j.kind == "static"] - dynamic_jobs = [j for j in self._pending if j.kind == "dynamic"] + # "aipwn" corre exclusivo sobre el mismo dispositivo físico que los + # checks dinámicos (Frida) -- comparte el lock por serial con "dynamic". + dynamic_jobs = [j for j in self._pending if j.kind in ("dynamic", "aipwn")] self._pending = [] outcomes: list[JobOutcome] = [] diff --git a/nutcracker_core/vuln_scanner.py b/nutcracker_core/vuln_scanner.py index 9037f88..d1d5df1 100644 --- a/nutcracker_core/vuln_scanner.py +++ b/nutcracker_core/vuln_scanner.py @@ -702,6 +702,59 @@ recommendation="Seguir el principio de mínimo privilegio: solicitar solo los permisos " "imprescindibles y justificar cada uno en la ficha de Play Store.", ), + + # ── M5: Gestión de claves (MASVS-CRYPTO-2) ──────────────────────────────── + VulnRule( + rule_id="CRYPTO007", + title="Clave criptográfica derivada directamente de un password/PIN", + severity="high", + category="M5 - Criptografía débil", + pattern=re.compile( + r'new\s+SecretKeySpec\s*\(\s*\w*(?:password|passwd|pin|secret)\w*\.getBytes\s*\(', + re.IGNORECASE, + ), + description="La clave simétrica se construye directamente desde los bytes de un " + "password/PIN (SecretKeySpec), sin una función de derivación de claves " + "(KDF). Quien conoce el password reconstruye la clave trivialmente.", + recommendation="Derivar la clave con SecretKeyFactory (p.ej. PBKDF2WithHmacSHA256) " + "usando un salt aleatorio e iteraciones suficientes, no los bytes " + "crudos del password.", + ignore_if_contains=["test", "mock", "example"], + ), + + # ── M4: Autenticación local (MASVS-AUTH-2) ──────────────────────────────── + VulnRule( + rule_id="AUTH002", + title="FingerprintManager deprecado en vez de BiometricPrompt", + severity="medium", + category="M4 - Autenticación insegura", + pattern=re.compile(r'android\.hardware\.fingerprint\.FingerprintManager'), + description="Uso de la API FingerprintManager (deprecada desde Android 9/API 28). " + "No soporta biometría distinta a huella (rostro, iris) ni la lógica de " + "presentación unificada, y su manejo de CryptoObject es más propenso a " + "errores de implementación.", + recommendation="Migrar a androidx.biometric.BiometricPrompt, que además permite " + "vincular la autenticación a una clave del Android Keystore de forma " + "estándar.", + ), + + # ── M9: Privacidad (MASVS-PRIVACY-2) ────────────────────────────────────── + VulnRule( + rule_id="PRIVACY001", + title="Identificador de hardware usado para tracking", + severity="medium", + category="Privacidad", + pattern=re.compile( + r'\.(?:getDeviceId|getImei|getMeid|getSimSerialNumber|getSubscriberId)\s*\(' + ), + description="Lectura de un identificador de hardware persistente (IMEI/MEID, serial " + "de SIM, IMSI) vía TelephonyManager. Estos identificadores no se pueden " + "resetear por el usuario y permiten trackearlo de forma permanente entre " + "instalaciones/factory resets.", + recommendation="Usar un identificador propio, regenerable y específico de la instalación " + "(p.ej. un UUID generado al primer arranque), o el Advertising ID " + "respetando la preferencia de 'Limit Ad Tracking' del usuario.", + ), ] diff --git a/plan.md b/plan.md index f8094cc..75eafd2 100644 --- a/plan.md +++ b/plan.md @@ -220,14 +220,56 @@ esta fase a la realidad del código): siempre a `drain()` (barato si no hay nada pendiente) en vez de solo cuando hay apps vencidas — así recoge jobs encolados manualmente entre ticks. Regresión cubierta por `tests/test_queue_engine.py::test_drain_picks_up_jobs_queued_by_a_different_engine_instance`. -- **`batch` NO se migró internamente al motor de cola** (a diferencia de lo previsto en el - texto original de 1.3). Se mantiene tal cual (secuencial, in-process, ya probado) por - relación riesgo/beneficio: reescribirlo para reconstruir su resumen consolidado - (severidades, top findings, categorías) desde SQLite en vez de desde objetos en memoria - era un cambio de mayor superficie sin beneficio claro sobre simplemente ofrecer el nuevo - camino recomendado (`queue add ` + `serve`/`--run`), que cubre el mismo - entregable (revisión masiva con paralelo/secuencial + reintentable + persistida). Pendiente - como mejora futura opcional, no bloqueante. +- **`batch` migrado al motor de cola** (2026-07-25, ver detalle más abajo — cerraba el único + ítem pendiente de Fase 1). Ya no es secuencial/in-process: corre en paralelo según + `queue.static_workers`, con resumen consolidado reconstruido desde SQLite. + +### `batch` migrado al motor de cola (2026-07-25) + +Cerrado el único pendiente de Fase 1. `cli/batch.py` ya no duplica su propio bucle de +descarga+análisis in-process (`APKAnalyzer` + `_post_analysis_flow` directos, con su propio +`results_summary` armado en memoria) — ahora cada target pasa por el mismo +`QueueEngine.submit()`/`drain()` que usan `queue add` y `serve`: subproceso aislado +(`analyze`/`scan`), paralelo real según `queue.static_workers` (default 4) o estrictamente +secuencial con `static_workers: 1`. + +- **Resumen reconstruido desde SQLite**, no desde objetos en memoria: nueva función + `_summary_for_outcome()` en `cli/batch.py` — como cada job corrió en su propio subproceso, no + hay `ScanResult` en memoria para armar severidades/top-findings/categorías; se leen de + `repository.findings_for_run()` usando el `run_id` que `JobOutcome` ya trae enlazado (Fase 1 + original). El PDF consolidado (`generate_batch_report`) no cambió — sigue recibiendo la misma + forma de `dict` de siempre. +- **`--stop-on-error` solo se garantiza estrictamente en modo secuencial** + (`static_workers: 1`): ahí se encola y drena un target a la vez, cortando antes del + siguiente. En paralelo no hay forma de cancelar jobs que el pool ya empezó (QueueEngine no + soporta cancelación a mitad de camino) — documentado en el `--help` del comando. + `--keep-apk` se eliminó del CLI de `batch`: `build_job_cmd` ya fuerza `--keep-apk` en todo + job de tipo `scan` (decisión ya tomada en Fase 1 para depuración), así que el flag propio de + `batch` había quedado inalcanzable de todas formas. +- **Efecto colateral bienvenido**: cada app que pasa por `batch` ahora también queda + auto-agendada para revisión periódica (Fase 1.2) — confirmado en la prueba real + (`next_due_at` se fijó a 30 días). +- **Bug preexistente encontrado y arreglado al validar con hardware real (no relacionado con + esta migración)**: `generate_batch_report()` crasheaba con `FPDFUnicodeEncodingException` al + renderizar el título "Comparative Table — Protection Status" — el em-dash (`—`) no es + soportable por la fuente Helvetica no-Unicode que usa fpdf2 para `section_title()`. Bug + presente desde antes (mismo string, mismo problema con el `batch` viejo), simplemente nunca + se había ejercitado con >1 resultado exitoso en un test real. Fix: reemplazado por un guion + simple en `i18n.py` (EN + ES) — único string con em-dash de los 43 que existen en el archivo + que efectivamente se usa en `pdf_reporter.py`. Test de regresión: + `tests/test_pdf_reporter_batch.py`. +- **Validado con APKs reales contra el dispositivo físico** (no solo con la QueueEngine + mockeada de los tests): 3 targets (2 válidos + 1 corrupto a propósito) en paralelo + (`static_workers=3`) → resumen correcto (2 OK, 1 error con el mensaje real "End of central + directory record (EOCD) signature not found"), PDF consolidado generado; y en secuencial + (`static_workers=1`) con `--stop-on-error` → se detuvo correctamente antes del tercer target + tras el error en el segundo. + +**Tests:** 9 nuevos (`tests/test_batch.py` — reconstrucción de resumen desde SQLite con +severidades/leaks/categorías/mapeo de verdicto, más el flujo del comando vía `CliRunner` con +una `QueueEngine` falsa, incluida la detención en modo secuencial) + 1 +(`tests/test_pdf_reporter_batch.py`, regresión del em-dash). Suite completa: **119/119 +passing**. **Archivos nuevos:** `nutcracker_core/queue/{__init__,job,engine}.py`, `nutcracker_core/scheduler.py`, `nutcracker_core/cli/{serve,queue_cmd,schedule_cmd}.py`, @@ -407,6 +449,54 @@ con el mismo APK real de Mobile Hacking Lab usado en la POC de Fase 1: coverage finding `AUTH001` (antes atado a un `AUTH-2` cuyo texto oficial no aplicaba), y `MASVS-PRIVACY-1`/`MASVS-RESILIENCE-2` pasan a tener veredicto real (antes vacíos). +### Cierre de cobertura OWASP MAS: 14/24 → 18/24 (2026-07-25) + +De los 10 controles sin ningún check, se cerraron 4 con checks reales y de alta confianza — el +resto se dejó **honestamente sin cobertura** en vez de forzar checks de baja confianza solo para +subir el número (mismo criterio de prudencia que el resto de Fase 2). + +- **`MASVS-CODE-1` (versión de plataforma actualizada) — "victoria gratis"**: el check ya + existía (`manifest_analyzer.py` detecta `targetSdkVersion` bajo y ya estaba mapeado a CODE-1 + en `MISCONFIG_TO_MASVS` desde la propia Fase 2), pero no aparecía en el registry/coverage doc + porque el adaptador solo envolvía `vuln_scanner`/`native_scanner`/detectores. Nueva función + `_load_manifest_analyzer_checks()` en `checks/static/adapter.py` lo representa con un id + sintético `MANIFEST-LOW-TARGET-SDK` (no es un `VulnRule` real — no aparece en hallazgos + persistidos, esos `Misconfiguration` no pasan por `store/hooks.py` hoy, mismo gap ya + documentado arriba). +- **`MASVS-CRYPTO-2` (gestión de claves)** — `CRYPTO007`: detecta `SecretKeySpec` construido + directamente desde los bytes de un password/PIN (`getBytes()`), sin una función de derivación + de claves. Patrón real y específico (exige que el nombre de variable contenga + password/passwd/pin/secret), bajo riesgo de falso positivo. `MASWE-0010` (Improper + Cryptographic Key Derivation), `CWE-916`. +- **`MASVS-AUTH-2` (autenticación local)** — `AUTH002`: detecta uso de + `android.hardware.fingerprint.FingerprintManager`, deprecada desde Android 9/API 28 en favor + de `androidx.biometric.BiometricPrompt`. Nombre de clase completo, sin ambigüedad. `MASWE-0032` + (Platform-provided Authentication APIs Not Used), `CWE-477`. +- **`MASVS-PRIVACY-2` (prevenir identificación del usuario)** — `PRIVACY001`: detecta lectura de + identificadores de hardware persistentes (`getImei`/`getDeviceId`/`getMeid`/ + `getSimSerialNumber`/`getSubscriberId`) usados típicamente para tracking entre instalaciones. + `MASWE-0110` (Use of Unique Identifiers for User Tracking), `CWE-359`. + +**Quedan sin cobertura, a propósito** (no son verificables de forma determinista analizando solo +el APK — necesitarían tráfico en vivo, backend, o entendimiento de lógica de negocio/UX): +`MASVS-AUTH-1` (seguridad de protocolos remotos de auth), `MASVS-AUTH-3` (autenticación adicional +para operaciones sensibles — requiere saber qué es "sensible" en la lógica de la app), +`MASVS-CODE-2` (mecanismo de actualización forzada — comportamiento servidor/backend, no +verificable desde el APK), `MASVS-CODE-3` (SCA — componentes sin vulnerabilidades conocidas; +requeriría una base de datos de CVEs real, no una lista de patrones regex; queda como follow-up +explícito, no forzado con datos inventados), `MASVS-PRIVACY-3`/`MASVS-PRIVACY-4` (transparencia y +control del usuario sobre sus datos — políticas/UX, no analizables estáticamente con confianza). + +Se descartó deliberadamente un candidato de baja confianza: detectar `KeyGenerator`/ +`KeyPairGenerator` sin proveedor `AndroidKeyStore` explícito — riesgo real de falso positivo alto +(muchas apps generan claves simétricas efímeras de un solo uso legítimamente sin necesitar +respaldo de hardware), se prefirió no incluirlo antes que meter ruido. + +**Tests:** 8 nuevos (`tests/test_vuln_scanner_split.py` ×6: positivo/negativo por cada regla +nueva; `tests/checks/test_registry.py` actualizado para el nuevo id sintético). Suite completa: +**125/125 passing**. `docs/owasp-mas-coverage.md` regenerado: **18/24 controles**, 68 checks +totales (66 estáticos + 2 dinámicos), 33/119 debilidades MASWE referenciadas. + --- ## Fase 3 — Dashboard web local (plugin nuevo) @@ -496,19 +586,20 @@ reclamados como hechos): - El encolado vía `/api/queue` (`run_now=True`) dispara `engine.drain()` en un hilo de fondo (no bloquea la respuesta HTTP — un job real puede tardar minutos) — el progreso se sigue por WS. -**Bug real encontrado y corregido (crítico, no de este plugin):** `.gitignore` tenía una regla -`plugins/` sin anclar que también atrapaba `nutcracker_core/plugins/` — el directorio del **sistema -de plugins del core**, no solo plugins externos. Cualquier plugin de primera parte nuevo (como este -dashboard) quedaba invisible para git por completo (`git add -A` lo saltea en silencio, sin error). -Confirmado que el usuario ya había comiteado Fases 1 y 2 en commits separados (`053b4e4 "fase 1"`, -`23052d0 "Fase 2"`) — de seguir el mismo patrón para Fase 3, todo este plugin se habría perdido -silenciosamente. Corregido reestructurando la regla a `nutcracker_core/plugins/*` + -allow-list explícito de los plugins de primera parte (`aireview`, `dashboard`), preservando el -comportamiento original para plugins externos (siguen ignorados por defecto; `aipwn` además queda -protegido por tener su propio `.git` anidado, con o sin esta regla). Un efecto secundario de la -negación (reincluía también `__pycache__/` dentro de esos plugins) se corrigió reforzando esa regla -al final del archivo. Verificado con `git check-ignore` y `git add -A --dry-run`: los 12 archivos -nuevos del plugin dashboard ahora sí quedan listos para `git add`. +**Nota de `.gitignore` (encontrada, no corregida a pedido explícito del usuario):** `.gitignore` +tiene una regla `plugins/` sin anclar que también atrapa `nutcracker_core/plugins/` — el directorio +del **sistema de plugins del core**, no solo plugins externos. Cualquier plugin de primera parte +nuevo (como este dashboard) queda invisible para git por completo (`git add -A` lo saltea en +silencio, sin error) — confirmado con `git check-ignore -v`. Se probó y verificó un fix (reestructurar +la regla a `nutcracker_core/plugins/*` + allow-list explícito de `aireview`/`dashboard`), pero el +usuario pidió explícitamente no tocar `.gitignore` y dejar el plugin donde está — **decisión +consciente, no un problema pendiente**: el dashboard queda intencionalmente fuera del tracking de +git de este repo, igual que ya trataba a `aipwn`. Esto no afecta en nada la funcionalidad (gitignore +no tiene efecto en tiempo de ejecución) — reconfirmado corriendo la suite completa después de revertir +el intento de fix: **95/95 passing**. Si en el futuro se quiere trackear el dashboard, el fix ya +probado es: `nutcracker_core/plugins/*` + `!nutcracker_core/plugins/dashboard/` + +`!nutcracker_core/plugins/dashboard/**` (cuidado: esa negación reincluye también `__pycache__/` +dentro del plugin — hay que reforzar `**/__pycache__/` después en el archivo). **Tests:** 24 nuevos (`tests/dashboard/test_events.py` ×6, `test_api.py` ×10 vía FastAPI TestClient sin red real, `test_ws.py` ×4 vía `websocket_connect`, más 2 en `tests/test_queue_engine.py` para el @@ -568,3 +659,499 @@ instaladas en este entorno, nunca se llegó a invocar `nutcracker aipwn ` d (taxonomía + verdicto dual), `analyzer.py` (`aipwn_bypass_confirmed`). - Nuevos (plugin): `nutcracker_core/plugins/dashboard/` (FastAPI + WS + SPA + ws-scrcpy). Sink de streaming opcional en `plugins/aipwn/frida_agent.py` / `frida_capture.py`. + +--- + +## Sesión de pruebas con dispositivo físico real (2026-07-24) + +Prueba real contra un Moto G23 (Android 14) rooteado con Magisk, con módulos de anti-detección +avanzados instalados (`hma_oss_zygisk`, `playintegrityfix`, `tricky_store`, `specter`, +`zygisksu`) — un banco de pruebas realista pensado específicamente para evadir detectores de +root/RASP. Apps de prueba usadas: `com.example.tapjacking` (sin protección) y `owasp.sat.agoat` +(AndroGoat, la app oficial de OWASP para práctica de seguridad móvil — protegida). Se evitó +deliberadamente tocar las apps bancarias reales instaladas en el device (BCP, Yape, Tenpo). + +### Hallazgos y fixes aplicados + +**🔴 Bug crítico de core (preexistente, no introducido en este plan) — pérdida total de +resultados con `--launch`.** `orchestrator._launch_frida_bypass()` usaba `os.execvp()`, que +**reemplaza el proceso Python actual** por el de `frida` (documentado en el propio código: +"reemplaza el proceso"). Como consecuencia, cualquier `analyze --launch` / `scan --launch` +contra una app protegida perdía el análisis estático completo — sin JSON, sin PDF, sin fila en +`runs`/`findings` de SQLite — **sin importar si Frida lograba conectar o no**, porque todo el +código de persistencia (`save_analysis_json`, PDF, reporte MASVS, post-hooks) vive *después* de +esa llamada en `_run_analysis()`, y `execvp` nunca retorna. Reproducido en vivo con AndroGoat: +el análisis estático corrió completo (banner PROTECTED, tabla de hallazgos con certificate +pinning real) pero `reports/owasp.sat.agoat/` no llegó a existir. +- **Fix:** `os.execvp(frida_cmd[0], frida_cmd)` → `subprocess.run(frida_cmd)` + (`orchestrator.py`). Preserva el uso interactivo original (hereda stdin/stdout/stderr, un + humano en terminal sigue viendo el REPL de frida en vivo) pero retorna control al llamador + cuando frida termina, permitiendo que el resto del pipeline guarde sus resultados. +- **Validado en vivo tras el fix:** mismo AndroGoat, mismo dispositivo → `status: done`, + `package: "owasp.sat.agoat"`, `run_id: 3`, con `reports/owasp.sat.agoat/` conteniendo JSON + + PDF + osint.json + vuln.json, y hallazgos reales persistidos con MASVS+MASWE+CWE completos + (p.ej. `HC005 Hardcoded AWS credentials` → `MASVS-STORAGE-2` / `MASWE-0005` / `CWE-798`, + encontrado de verdad en `CloudServicesActivity.java` de AndroGoat). + +**🔴 Bug de diseño en Fase 1 — jobs dinámicos de la cola dependían de un REPL interactivo.** +`QueueEngine`/`build_job_cmd` usaban `--launch` para los jobs `kind="dynamic"` — un flag pensado +para ceder el control a un humano en una sesión interactiva de Frida, incompatible con +automatización headless (un job de cola sin humano delante quedaría esperando un REPL que nunca +recibe input). +- **Fix:** nuevo flag `--dynamic-checks` en `analyze` (core, `cli/analyze.py`) que corre los + checks dinámicos de Fase 2 (`checks/dynamic/` — ADB puro, sin Frida, sin REPL) contra + `--serial` tras el análisis estático, vía la función nueva + `orchestrator._run_dynamic_checks_for()`. `build_job_cmd` renombró su parámetro `launch` → + `dynamic_checks`; los jobs dinámicos de la cola ya no invocan `--launch` en absoluto. +- **Validado en vivo:** el mismo re-run de AndroGoat mostró en el log en vivo del dashboard: + `DYN-DEBUGGABLE` **detectado** (`run-as` funcionó — AndroGoat resultó ser debuggable en su + instalación real en el device, un hallazgo genuino) y `DYN-CLEARTEXT-TRAFFIC` no detectado + (app no lanzada activamente durante la ventana del check) — ambos corrieron sin colgarse. + +**🟡 Menor — `default_adb_runner` descartaba `stderr`.** Comandos como `run-as` escriben su +mensaje real de error a stderr (p.ej. `"run-as: package not debuggable: "`); capturar solo +`stdout` lo perdía en silencio, dejando el campo `detail` de los checks dinámicos sin +diagnóstico útil. **Fix:** `stderr=subprocess.STDOUT` en `checks/dynamic/context.py`. Validado: +tras el fix, `DYN-DEBUGGABLE` mostró el detalle real (`"run-as funcionó; JDWP no confirma"`) en +vez de un string vacío. + +**🟡 Menor — `QueueEngine` truncaba a ciegas el campo `error`.** `error = output[-2000:]` perdía +la causa raíz real cuando el proceso seguía imprimiendo (tablas de hallazgos, banners) después +del error — se vio en vivo con `"Failed to spawn: ... No route to host"` tapado por una tabla de +vulnerabilidades posterior, dejando en `error` solo un fragmento ilegible de esa tabla. **Fix:** +`_extract_error_summary()` nueva en `queue/engine.py` — prioriza líneas con marcadores de error +conocidos (`error`, `failed`, `no route to host`, `timeout`, `refused`, ...) sobre el tail ciego. + +### Confirmado funcionando correctamente contra hardware real (sin cambios necesarios) +- Detección de protecciones (AndroGoat → `PROTECTED`, MASVS score 70/grade C). +- Cola con device-lock real (Fase 1) — job dinámico sobre `tapjacking.apk`, sin protección, + corrió limpio de punta a punta antes de tocar ningún código. +- Dashboard completo (Fase 3): API REST, WebSocket de logs en vivo (282+ líneas reales + streameadas durante los distintos runs), y **`/api/device/screenshot` capturando la pantalla + real del device** (confirmado con una imagen PNG real de 720×1600, no simulada). +- Fallback automático existente en el código: cuando USB no está disponible, nutcracker ya + intenta conectar a frida-server vía la IP WiFi del propio device (`frida -H :27042`) — un + mecanismo inteligente preexistente que solo no funcionó por la limitación de red del entorno + de prueba (ver abajo), no por un bug de nutcracker. + +### Corrección de diagnóstico: no era un bloqueo de red WSL↔Windows +El primer diagnóstico de esta sesión (Frida no conectaba: "adb.exe de Windows vs WSL, dos stacks +de red separados, necesita `wsl --shutdown` + mirrored mode o `usbipd-win`") **resultó +incorrecto** — quedaba documentado así más arriba antes de esta corrección. La causa real, mucho +más simple, era una combinación de dos cosas: +1. **IP equivocada**: `config.yaml` ya tenía `frida_host: '192.168.1.4:27042'` de una sesión + anterior — pero la IP real del teléfono (confirmada con la propia captura de pantalla del + dashboard, sección "Detalles de la red") era `192.168.1.42`. `.4` simplemente no era un host + vivo en esa LAN → "No route to host". +2. **frida-server sin `-l 0.0.0.0`**: el binario, iniciado sin ese flag, solo escuchaba en + `127.0.0.1` — inalcanzable por TCP desde cualquier otro host, WSL incluido. + +Con la IP corregida en `config.yaml` y frida-server reiniciado con `-l 0.0.0.0` (vía el fix de +`_launch_frida_bypass()`/`setup_frida_server` de más abajo), la conexión funcionó **directamente +desde WSL, sin `wsl --shutdown` ni cambios de `networkingMode`** — confirmado con +`frida-ps -H 192.168.1.42:27042` listando el proceso real del device. La guía de mirrored +mode/usbipd-win de más abajo queda como referencia general (útil si algún día SÍ hay un problema +de ruteo real), pero no era necesaria en este caso — buena lección: verificar la IP y el bind +antes de asumir un problema de red más profundo. + +**🔴 Bug adicional encontrado al preparar la conexión por red — `--launch` nunca escuchaba en +red.** Al revisar `config.yaml` con `strategies.frida_host`/`frida_server_version` recién +configurados, se encontró que `_launch_frida_bypass()` reiniciaba frida-server con un simple +`nohup .../frida-server &` — **sin el flag `-l 0.0.0.0`**, así que el server solo escuchaba en +`127.0.0.1` sin importar qué tan bien rutee la red. Además ignoraba por completo +`frida_server_version` (riesgo de mismatch con la librería Python) y no hacía el +`unset LD_PRELOAD` que sí hace `setup_frida_server()` — necesario específicamente en devices con +módulos Magisk Zygisk/LSPosed (`hma_oss_zygisk`, `zygisksu`, exactamente los que tiene el +dispositivo de prueba), que rompen el attach/spawn de Frida si no se limpia esa variable. +- **Fix:** `_launch_frida_bypass()` ahora reusa `setup_frida_server()` (el mismo mecanismo que ya + usa el flujo FART en `pipeline.py`) cuando se pasa `--serial`: descarga/sincroniza la versión de + `frida_server_version`, detecta arquitectura del device, y pasa `listen_all=bool(frida_host)`. + Sin `--serial` (un único device/emulador, sin forma fiable de resolver arquitectura) se conserva + el restart simple de antes como fallback, para no dejar de reiniciar frida-server en absoluto. + Un fallo al reiniciar (red caída, etc.) se loggea como warning y no aborta el intento de launch. + +### Tests añadidos (34 nuevos, 110/110 en la suite completa) +`tests/test_orchestrator.py` (9 tests: `execvp`→`subprocess.run`, uso de `setup_frida_server` con +`listen_all`/`frida_server_version` cuando hay `--serial`, fallback al restart simple sin +`--serial`, resiliencia si `setup_frida_server` lanza una excepción, `build_job_cmd` con +`dynamic_checks`, `_run_dynamic_checks_for` incluyendo manejo de checks que fallan), +`tests/test_queue_engine.py` (+4: `_extract_error_summary`), `tests/checks/test_dynamic_checks.py` +(+2: `default_adb_runner` combina stderr / retorna vacío si el binario no existe). Todos +deterministas — no requieren un dispositivo conectado para correr en CI. + +## Mejora del dashboard: vista de detalle por app (2026-07-26) + +El dashboard de Fase 3 mostraba únicamente una tabla plana de apps (package, verdict, score, +próxima revisión) — toda la taxonomía OWASP MAS construida en Fase 2 (MASVS/MASWE/CWE por +hallazgo) y el historial de runs en SQLite existían en el backend pero nunca se exponían en la +UI. No era un bug: la Fase 3 se había enfocado en logs en vivo + cola, y la Fase 2 llegó después +sin volver a tocar el frontend. Se cerró ese hueco. + +- **Modal de detalle por app** (`nutcracker_core/plugins/dashboard/static/index.html`, sin + cambios de backend — `/api/runs/{id}`, `/api/apps/{pkg}/trend` y `/api/schedule` ya existían): + clic en cualquier fila de la tabla de apps abre un modal con: + - **KPIs** del último run: veredicto, score MASVS + grade, próxima revisión. + - **Gráfico de tendencia MASVS** (SVG propio, sin librerías externas): score por run a lo + largo del tiempo, un punto por run coloreado según el grade (A/B verde, C ámbar, D naranja, + F rojo), gridlines en 0/25/50/75/100, tooltip al hacer hover sobre cada punto + (`fecha — Score N (grade)`), labels de fecha en los extremos. + - **Editor de revisión periódica** inline (días entre revisiones + botón Guardar → `POST + /api/schedule/{package}`), en vez de requerir la CLI para reagendar una app puntual. + - **Controles MASVS afectados**: hallazgos agrupados por control MASVS, con la severidad más + alta de cada grupo y el conteo. + - **Tabla de hallazgos** del último run: regla, severidad (badge con ícono + color, nunca solo + color), MASVS, MASWE, CWE, ubicación (archivo:línea). +- **Paleta de estado**: colores fijos validados vía el skill `dataviz` (good `#0ca30c`, warning + `#fab219`, serious `#ec835a`, critical `#d03b3b`) — nunca reciclados como colores categóricos, + siempre acompañados de texto/ícono, nunca el único portador del significado. +- **Verificación visual real**: se sembraron datos de ejemplo (`com.example.bankapp`, 5 runs con + progresión de score 45→80, 4 hallazgos con metadata MASVS/MASWE/CWE completa) en una copia + local de `nutcracker.db` (gitignorado, jamás commiteado), se levantó el dashboard + (`nutcracker.py dashboard --port 8765`) y se capturaron screenshots reales con Playwright + (`chromium-headless-shell`) de: la tabla principal, el modal abierto, y el tooltip del gráfico + en hover. Cero errores de JS/consola; layout, colores y geometría del gráfico verificados + visualmente sin colisiones ni overflow. La base de datos de prueba se descartó después + (`rm nutcracker.db*`) para no dejar datos sintéticos en el entorno del usuario. +- **Sin cambios de backend**: toda la funcionalidad se apoyó en endpoints ya existentes de + Fase 3 — confirma que el diseño API-first de esa fase era suficiente para una UI más rica sin + tocar `server.py`. +- Suite completa: **125/125 tests pasan** (sin tests nuevos — cambio puramente de frontend, sin + lógica Python nueva que testear; la verificación fue visual vía Playwright, no unitaria). + +## Fix: panel "Logs en vivo" mostraba basura coloreada + layout no responsivo (2026-07-26) + +El usuario reportó "la web no es responsive" adjuntando dos screenshots: una del panel +"Dispositivo" (bien) y una del panel "Logs en vivo" mostrando el banner ASCII-art de nutcracker +como un bloque ilegible de píxeles de colores en vez de texto. + +**🔴 Causa raíz del bloque de colores — códigos ANSI truecolor crudos volcados al navegador.** +`QueueEngine._run_job()` construye el entorno del subproceso con `env = dict(os.environ)`, sin +tocar nada relacionado a color. Si el proceso que arrancó el dashboard heredó `FORCE_COLOR` +(algunas terminales/integraciones, p.ej. la terminal integrada de VS Code, lo exportan), rich +detecta `is_terminal=True` **incluso cuando stdout es un pipe no interactivo** (confirmado +directamente: `Console().is_terminal` da `True` con solo `FORCE_COLOR=1` en el entorno, sin +importar `isatty()`) y emite ANSI truecolor real. Ese texto crudo (códigos de escape + +caracteres de medio-bloque `▄`/`▀` del logo pixel-art) se streamea línea por línea al WebSocket +y se inserta tal cual en `
` — de ahí el bloque de "píxeles" de colores.
+- **Fix:** `_run_job()` ahora fuerza `env["NO_COLOR"] = "1"` y elimina `FORCE_COLOR` del entorno
+  del subproceso — confirmado que `NO_COLOR` manda por encima de `FORCE_COLOR` en rich (con
+  ambos presentes, 0 códigos de color, solo quedan 2 secuencias OSC8 de hipervínculo inofensivas
+  e invisibles en el navegador). Defensa adicional: nueva `_strip_ansi()` en `queue/engine.py`
+  aplicada a cada línea antes de publicarla via `on_line` (streaming) y al resumen de error
+  (`_extract_error_summary`), por si alguna herramienta de terceros (semgrep, etc.) ignora
+  `NO_COLOR` y fuerza color con sus propios flags.
+- **Validado en vivo:** se reprodujo el bug exacto (servidor arrancado con
+  `FORCE_COLOR=1 COLORTERM=truecolor` forzados) y se confirmó que, tras el fix, el mismo banner
+  se ve en texto monocromo limpio y legible en el panel de logs — captura real vía Playwright.
+
+**🟡 Menor — layout no protegido contra overflow horizontal en viewports angostos.**
+`index.html` (dashboard) no envolvía las tablas de "Apps" y "Cola de análisis" en un contenedor
+con scroll horizontal propio, así que en pantallas angostas (móvil) el ancho de la tabla podía
+forzar scroll horizontal de toda la página. El header (`.stats`) tampoco hacía wrap. La imagen
+`#shot` (captura del device) combinaba `width:100%` (CSS) con `height="360"` (atributo HTML fijo),
+lo que podía distorsionar el aspect ratio de una captura de teléfono real (720×1600) en vez de
+escalarla proporcionalmente.
+- **Fix:** nueva clase `.table-scroll{overflow-x:auto}` envolviendo ambas tablas; `header`/`.stats`
+  con `flex-wrap:wrap`; nuevo breakpoint `@media (max-width:600px)` con paddings/gaps más
+  compactos; `#shot` cambiado a `max-height:70vh;object-fit:contain` sin altura fija, para que la
+  captura del device escale manteniendo su proporción real.
+- **Validado:** Playwright a 375×812 (viewport móvil real) — `document.documentElement.scrollWidth
+  - clientWidth === 0` (cero overflow horizontal), sin errores de JS, captura real revisada
+  visualmente.
+
+**⚠️ Nota operacional durante la verificación:** para reproducir el bug con `FORCE_COLOR` forzado
+se levantó un dashboard de prueba en el puerto 8766 apuntando al `nutcracker.db` real del
+repositorio (mismo `cwd`) — se descubrió ahí un job real preexistente (`#1,
+pe.indigital.tunki.user, running`, el mismo que aparece en la captura que envió el usuario), no
+generado por esta sesión. Se detuvo el servidor de prueba de inmediato y se borraron **solo** las
+filas insertadas por la prueba (`apps`/`schedule`/`queue_jobs` para `com.example.bankapp`),
+dejando el job real #1 completamente intacto sin tocarlo.
+
+Tests añadidos (5, en `tests/test_queue_engine.py`): `_strip_ansi` (colores, hipervínculos OSC8,
+no-op en texto plano), `_run_job` fuerza `NO_COLOR`/limpia `FORCE_COLOR` en el entorno del
+subproceso, `_run_streaming` limpia ANSI de cada línea antes de publicarla. **130/130 tests
+pasan.**
+
+## Documentación + empaquetado (2026-07-26)
+
+Ítem de mantenibilidad transversal, pendiente desde Fase 0 (ver huecos abiertos al inicio de este
+documento): `README.md` no mencionaba en absoluto la cola, el scheduler, el dashboard, ni la
+taxonomía MASWE/CWE — todo el trabajo de las Fases 1-3 era invisible para quien solo lee el
+README. Tampoco existía `pyproject.toml`.
+
+- **`README.md`**: nuevas secciones "Mass Execution: Queue & Scheduler" (`queue add`/`queue
+  ls`/`schedule set`/`schedule ls`/`serve`, con ejemplos reales verificados contra el `--help`
+  actual de cada comando), "OWASP MAS Alignment (MASVS + MASWE + CWE)" (cómo se genera
+  `docs/owasp-mas-coverage.md`, cobertura real 18/24 controles honestamente indicada, no
+  aspiracional), y "Web Dashboard" (`nutcracker dashboard`, qué muestra cada panel, límites
+  conocidos ya documentados en Fase 3). Tabla de plugins actualizada con `dashboard`. Árbol de
+  "Project Structure" reescrito para reflejar el estado real (`cli/`, `store/`, `queue/`,
+  `checks/`, `orchestrator.py`, `scheduler.py`, `plugins/dashboard/` — antes solo listaba los
+  módulos de Fase -1). Bloque de ejemplo de `config.yaml` ampliado con `store:`/`queue:`/
+  `scheduler:`/`dashboard:`.
+- **`config.yaml.example`**: nuevo bloque `dashboard: {bind, port}` — existía en el código
+  (`cfg_get(config, "dashboard", "bind"/"port")`) pero no estaba documentado en ningún lado.
+- **`ROADMAP.md`**: marcados como hechos (con fecha y referencia a la sección de `plan.md`
+  correspondiente) dos ítems que ya estaban completos — "Split `vuln_scanner.py`" (Fase 0.3) y,
+  parcialmente (`[~]`), "Differentiate runtime bypass vs DEX extraction" (los pasos 1-2 de 4 están
+  hechos desde Fase 2.4; los pasos 3-4, banner en `reporter.py` y sección nueva en el PDF, siguen
+  pendientes — documentado así, no reclamado como completo). Añadidos como ítems nuevos: scrcpy
+  real y wiring del chat/razonamiento de `aipwn` en el dashboard (los 2 recortes de alcance
+  honestos ya documentados en Fase 3, ahora también visibles en el roadmap de cara afuera).
+- **`pyproject.toml`** (nuevo): empaqueta `nutcracker_core` (auto-descubrimiento de subpaquetes)
+  con entry point `nutcracker = nutcracker_core.cli:cli` y extra opcional `[dashboard]`
+  (fastapi/uvicorn). `python nutcracker.py ` en modo script sigue funcionando exactamente
+  igual — el paquete es un modo de instalación adicional, no un reemplazo. Validado con
+  `pip install -e .` real: `nutcracker --help`/`--version` funcionan como comando instalado
+  (`.venv/bin/nutcracker`), y `pip install -e ".[dashboard]"` instala fastapi/uvicorn
+  correctamente. `nutcracker.egg-info/` (artefacto de build, no cubierto por `.gitignore`) se
+  borró manualmente tras cada verificación en vez de tocar `.gitignore`.
+- **Fix menor encontrado de paso**: `@click.version_option("0.1.0", ...)` en `cli/__init__.py`
+  tenía la versión hardcodeada y desincronizada de `nutcracker_core.__version__` (`0.2.0`, la que
+  sí se usa correctamente en el banner). Cambiado a `@click.version_option(_VERSION, ...)`.
+  `nutcracker --version` ahora reporta `0.2.0` en vez de `0.1.0`.
+
+Sin cambios de lógica de negocio — puramente documentación + empaquetado. **130/130 tests
+pasan** (sin tests nuevos: nada de esto tiene comportamiento en tiempo de ejecución que testear
+más allá de lo que ya cubre `test_queue_engine.py`/CLI existente, verificado manualmente con
+`pip install -e .` real en vez de con un test unitario).
+
+## Cierre de los 2 recortes de alcance de Fase 3: scrcpy real + wiring de aipwn (2026-07-27)
+
+El usuario pidió implementar los dos últimos ítems documentados como "alcance reducido" en Fase
+3. Ambos se completaron y **se validaron en vivo contra el Moto G23 real** (dispositivo rooteado
+con Magisk usado en la sesión de pruebas del 2026-07-24), no solo con mocks.
+
+### scrcpy real (video en vivo del dispositivo)
+
+**Investigación previa (documentada por transparencia, no descartada sin más):** se decompiló con
+`jadx` el `scrcpy-server.jar` oficial de Genymobile en 3 versiones (v1.25 vía `apt-get download`
+sin root; v2.4 y v3.1 vía descarga directa de GitHub Releases) para entender el wire protocol
+exacto (socket abstracto `scrcpy`/`scrcpy_`, header de 68 bytes de device-meta, header de 12
+bytes de frame-meta, modo `raw_video_stream`/flags individuales sin ningún header). Con el
+protocolo ya verificado por lectura de fuente (no adivinado), una reimplementación propia en
+Python (`socket` + `PyAV` para decodificar H.264) **se probó exhaustivamente contra el device real
+y nunca logró completar el handshake** — el servidor se queda colgado indefinidamente dentro de
+`Device()` (antes de aceptar la conexión), sin excepción ni AVC denial visible en `logcat` extenso,
+reproducido igual con 3 versiones distintas del server. Se descartó como enfoque.
+
+**Lo que sí funcionó, y es la implementación final:** el usuario indicó usar su propio scrcpy 3.1
+de Windows (`scrcpy-win64-v3.1`), accedido desde WSL vía interop (`/mnt/c/...scrcpy.exe`,
+ejecutable directamente como ".exe" gracias al interop de WSL2). El binario **real** sí completa el
+handshake contra el mismo device sin problema — confirma que el cuelgue era específico de mi
+reimplementación del protocolo (probablemente algún detalle fino del handshake del cliente real que
+no se pudo reproducir en el tiempo disponible), no una limitación irresoluble del hardware/Magisk.
+
+Como `scrcpy` no ofrece un modo de salida por stdout/pipe (solo GUI o `--record=`, y `--record`
+requiere un archivo real, no `-` /stdout), la solución final es:
+- `nutcracker_core/plugins/dashboard/scrcpy_video.py` (nuevo): lanza
+  `scrcpy --no-window --record=` sin ventana; un hilo de fondo relee ese archivo
+  periódicamente con **PyAV** (nueva dependencia del plugin dashboard, `av>=11.0`) y se queda con el
+  último frame decodificado como JPEG — **confirmado empíricamente que el .mkv crece de forma
+  incremental en disco mientras scrcpy sigue grabando** (con actividad real de pantalla; con
+  pantalla estática/bloqueada no se generan frames nuevos, por el optimize de "repeat-previous-frame"
+  de scrcpy — comportamiento esperado, no un bug). La grabación se reinicia cada 20s a un archivo
+  nuevo para acotar el costo de releer un archivo cada vez más grande.
+- **Bug real encontrado y arreglado durante la validación en vivo**: matar el proceso `adb
+  shell`/`scrcpy` LOCAL (`SIGKILL`, o incluso `SIGTERM` a través de la interop WSL↔Windows) **no
+  siempre mata el `app_process` REMOTO en el device** — queda escuchando el socket "scrcpy" para
+  siempre, y cada intento posterior falla con "Address already in use" (server) / "Server
+  connection failed" (cliente). Fix: `ScrcpyVideoSession._kill_orphaned_remote_server()` corre
+  `adb shell pkill -f com.genymobile.scrcpy.Server` (best-effort) antes del primer ciclo y tras
+  cada ciclo fallido.
+- `GET /api/device/video` (nuevo, `api.py`): `StreamingResponse` multipart/x-mixed-replace — el
+  navegador lo reproduce nativo en un ``, sin JS ni librerías adicionales. `GET
+  /api/device/video/status` para que el frontend decida sin arrancar nada. 503 + degradación
+  honesta si no hay binario configurado.
+- `config.yaml` (`dashboard.scrcpy_path`): ruta explícita del usuario a su propia instalación de
+  scrcpy — nutcracker **no vendorea el binario** (¿por qué reimplementar/empaquetar un proyecto de
+  terceros cuando el usuario ya lo tiene?). Vacío = busca `scrcpy`/`scrcpy.exe` en PATH.
+- Frontend (`index.html`): pestaña "Dispositivo" ahora intenta video en vivo primero
+  (`toggleDeviceView()`), con fallback automático y transparente al polling de screenshots
+  existente si no hay binario configurado o si el stream falla a mitad de sesión (`onerror` del
+  ``).
+- **Validado en vivo con Playwright**: captura real mostrando la pantalla actual del device
+  (pantalla "Detalles de la red" con la IP real `192.168.1.42`) dentro del panel del dashboard, nota
+  confirmando "● Video en vivo real (scrcpy) — no es polling", cero errores JS.
+- Tests: `tests/dashboard/test_scrcpy_video.py` (16, todo mockeado — subprocess/PyAV/tiempo — no
+  depende de hardware ni de scrcpy real instalado) + 3 en `test_api.py` para los endpoints nuevos.
+
+### Wiring del agente aipwn en el dashboard
+
+**Streaming del razonamiento en vivo:** en vez de reimplementar un mecanismo de streaming nuevo
+para aipwn, se le dio a `aipwn` un **kind de job nuevo en la cola** (`"aipwn"`, junto a
+`"static"`/`"dynamic"`) que reusa el streaming de logs ya construido en Fase 3 para
+`queue add`/`batch` — `orchestrator.build_job_cmd(..., aipwn=True)` genera
+`nutcracker aipwn  [--serial ]` como subproceso aislado (igual que todo lo demás
+en la cola), y su stdout (que ya incluye "Nutcracker thinking", "Nutcracker says", cada tool call)
+se streamea línea por línea al mismo `/ws/jobs/{id}` que ya usan los jobs estáticos/dinámicos — cero
+plumbing nuevo del lado del streaming, solo una nueva forma de construir el comando. Los jobs
+`aipwn` comparten el lock por device-serial con los jobs `"dynamic"` (`QueueEngine.drain()`), ya que
+ambos usan el mismo teléfono físico en exclusiva.
+
+**Chat operador→agente:** el WebSocket `/ws/chat/{package}` de Fase 3 ya publicaba mensajes a un
+bus en memoria, pero nada los consumía — un job `aipwn` corre como **subproceso aislado** de la
+cola (por diseño, ver nota de Fase 1 sobre estado mutuo de `orchestrator.py`), así que no puede ser
+un suscriptor WebSocket. Se agregó un **mailbox pull-based** separado del bus
+(`nutcracker_core/plugins/dashboard/chat_mailbox.py`): cada mensaje de operador también se guarda
+ahí; `FridaAgent._check_operator_chat()` (nuevo método en `frida_agent.py`) hace polling HTTP
+best-effort de `GET /api/chat/{package}/pending` **al inicio de cada iteración de su loop ReAct**
+(antes de llamar al LLM), y si hay mensajes pendientes los inyecta como un turno `user` real en
+`self.messages` — el LLM los ve genuinamente en su próxima respuesta. La URL del dashboard llega al
+subproceso vía la variable de entorno `NUTCRACKER_DASHBOARD_URL`, que solo se define cuando
+`QueueEngine.extra_env` la trae puesta (el
+comando `dashboard` la fija tras calcular host/puerto reales; `serve`/CLI/tests no la definen, así
+que `_check_operator_chat()` es un no-op inmediato y gratis para el 100% de las corridas normales
+por CLI sin dashboard).
+
+**Validado en vivo, con LLM real (aprobado explícitamente por el usuario, gasta tokens reales de su
+`llm.api_key`):**
+- Job `aipwn` #1 contra `com.example.tapjacking` (app de prueba aprobada, no una app bancaria real)
+  encolado vía `POST /api/queue {"kind":"aipwn","serial":"ZY22GPM27J"}` — el razonamiento real del
+  agente (`glm-5.1` vía z.ai) se vio en vivo por `/ws/jobs/{id}`: "Nutcracker thinking", "Nutcracker
+  says", tool calls reales (`get_app_analysis()`, `probe_security_violations()`,
+  `get_heuristic_bypass_script()`, `enumerate_runtime_classes()`, `take_screenshot()`).
+- Job #2: se envió un mensaje real por `/ws/chat/com.example.tapjacking` a los pocos segundos de
+  iniciado. El log en vivo mostró la línea inyectada
+  `[PRUEBA DE VALIDACION] Operador: detente y confirma que recibiste este mensaje antes de
+  seguir.` apareciendo exactamente antes de "Calling LLM (iteration 3)" — confirma el recorrido
+  completo operador→WebSocket→mailbox→subproceso aipwn→turno real de la conversación del LLM.
+- **Hallazgo colateral, no arreglado (fuera de alcance de esta tarea, pre-existente y ajeno al
+  wiring):** ambos jobs terminaron en `LLM error: ... "messages.content.type is invalid, allowed
+  values: ['text']"` tras el primer `take_screenshot()` — el proveedor configurado (z.ai/GLM vía
+  endpoint OpenAI-compatible) rechaza el formato de mensaje multimodal (imagen) que
+  `frida_agent.py` construye para enviar el screenshot al LLM. Bug real de compatibilidad
+  proveedor↔formato de mensaje en `aipwn`, no relacionado con la cola/streaming/chat — no se tocó,
+  documentado aquí para que quede registrado.
+
+Tests: `tests/test_aipwn_chat_wiring.py` (7 — `FridaAgent._check_operator_chat` construido con
+`__new__` para testear el método sin pagar el costo del constructor completo: no-op sin
+`NUTCRACKER_DASHBOARD_URL`, inyecta mensajes reales, url-encoding del package, nunca propaga
+excepciones de red), `tests/dashboard/test_chat_mailbox.py` (4), 2 nuevos en `test_api.py`
+(`/api/chat/{package}/pending`), 1 nuevo en `test_ws.py` (el WS de chat también escribe al
+mailbox), 3 en `test_orchestrator.py`/`test_queue_engine.py` (`build_job_cmd(aipwn=True)`, jobs
+`aipwn` comparten device-lock con `dynamic`).
+
+**Suite completa: 167/167 tests pasan.** Entorno (device + `nutcracker.db` de prueba) limpiado
+después de cada corrida de validación en vivo.
+
+### Follow-up post-entrega: 2 bugs reales encontrados por el usuario en uso normal (2026-07-27)
+
+El usuario reportó "no se puede ver el dispositivo" al usar la pestaña Dispositivo recién
+entregada. Investigación en vivo (no reproducible solo con mocks) encontró dos problemas reales:
+
+**🔴 Bug — limpieza de huérfanos no cubría el caso normal de "detener la vista".**
+`_kill_orphaned_remote_server()` solo corría al inicio de una sesión y tras un ciclo fallido — el
+caso más común (el usuario cierra la pestaña, o el ciclo llega a su fin normal tras
+`_RESTART_INTERVAL_S`) no la ejecutaba. Cada vez que eso pasaba, un `app_process` remoto quedaba
+vivo para siempre ocupando el socket "scrcpy", haciendo fallar en cascada el siguiente intento
+("Server connection failed"). **Fix:** la limpieza ahora corre incondicionalmente después de matar
+el proceso local, sin importar por qué terminó el ciclo.
+
+**🟡 UX — sin feedback visual durante los ~10-15s que tarda en aparecer el primer frame.**
+Confirmado con capturas Playwright reales: a los 8s de clic en "ver dispositivo" seguía en blanco
+(el `` sin `src` cargado colapsa a 0px de alto), recién a los ~12s mostró contenido real.
+Sin ningún indicador, esto es indistinguible de "no funciona". **Fix:** `#shot` con
+`min-height:280px` (caja negra visible de inmediato) + mensaje explícito "Conectando con scrcpy…
+puede tardar varios segundos" hasta que el evento `onload` del `` confirma el primer frame
+real, momento en que cambia a "● Video en vivo real".
+
+**Hallazgo adicional del propio usuario, en forma de pregunta ("¿no puede ser más fluido como
+webadb.com/scrcpy?")** que llevó a una investigación de rendimiento real:
+- Medido contra una grabación real de scrcpy: **decodificar el archivo `.mkv` completo en cada
+  poll cuesta 0.5s cuando el archivo lleva ~18s grabando (~5MB)** — muy por encima de
+  `_POLL_INTERVAL_S` (0.35s original), y el costo **crece** cuanto más dura el ciclo de grabación
+  — la vista se ponía cada vez más entrecortada según pasaba el tiempo dentro de un mismo ciclo de
+  20s.
+- **Fix (validado empíricamente, no solo razonado):** se agregó `--video-codec-options=i-frame-
+  interval=1` al comando de scrcpy (keyframe cada ~1s en vez del default de 10s) y
+  `decode_last_frame_jpeg()` ahora hace `container.seek()` a ~1.5s antes del final del archivo en
+  vez de decodificar desde el principio. Medido: **0.5s/poll → 0.07s/poll (7x)**, con costo
+  prácticamente constante sin importar cuánto lleve grabando — esto permitió subir
+  `_RESTART_INTERVAL_S` de 20s a 120s (menos interrupciones de reconexión) sin volver a degradar.
+- **Techo real, sin resolver — reportado con honestidad, no oculto:** incluso con el fix, la tasa
+  efectiva medida en vivo fue **~1.3 fps** (frames nuevos por segundo), lejos de un video fluido
+  real (15-30fps como ws-scrcpy/webadb.com). Sospecha, no confirmada: el propio muxer mkv de
+  scrcpy probablemente agrupa varios frames por "cluster" antes de volverlos visibles/legibles en
+  disco, imponiendo un techo independiente del costo de decode ya optimizado. Lograr fluidez real
+  requeriría el enfoque original (stream H.264 crudo por socket + decode en el navegador vía
+  WebCodecs) — exactamente lo que se intentó y no se pudo completar contra este hardware endurecido
+  (ver sección "scrcpy real" más arriba). Queda documentado como limitación conocida del enfoque
+  actual, no resuelta en esta sesión.
+
+Tests: +2 en `tests/dashboard/test_scrcpy_video.py` (seek cerca del final cuando `container.duration`
+lo permite; fallback a decode completo si el seek lanza excepción). **169/169 tests pasan.**
+
+## Fase 4 (propuesta, no implementada) — video realmente fluido vía WebUSB + WebCodecs
+
+El usuario preguntó por qué el video no es tan fluido como
+[app.webadb.com](https://app.webadb.com) y si se podía replicar ese enfoque. Investigado y
+**confirmado técnicamente factible** (verificado contra la documentación real del proyecto, no
+contra el primer texto pegado por el usuario — algunos nombres de paquete ahí ya estaban
+desactualizados). Se documenta aquí como plan concreto, **sin implementar todavía** — es un cambio
+de arquitectura real (primer build step de JS del proyecto), no un ajuste rápido.
+
+### Qué es y por qué sería mejor
+app.webadb.com usa **Tango** (antes `ya-webadb`, de yume-chan) — una reimplementación completa y
+madura del protocolo ADB **en TypeScript, corriendo en el navegador vía WebUSB**. El navegador se
+conecta directo al teléfono por USB (sin pasar por ningún `adb`/proceso del lado servidor), empuja
+el `scrcpy-server` real, y decodifica el H.264 crudo con la **WebCodecs API** nativa del navegador,
+dibujando en un ``. Esto es fundamentalmente distinto al enfoque actual
+(`scrcpy_video.py`): no hay archivo intermedio, no hay proceso `scrcpy` del lado del servidor, no
+hay polling — es el mismo tipo de decodificación continua que hace un reproductor de video nativo.
+Resolvería de raíz el techo de ~1.3fps documentado arriba.
+
+### Paquetes reales (verificados, no supuestos)
+```bash
+npm install @yume-chan/adb @yume-chan/adb-daemon-webusb @yume-chan/scrcpy \
+            @yume-chan/adb-scrcpy @yume-chan/scrcpy-decoder-webcodecs \
+            @yume-chan/fetch-scrcpy-server
+```
+(`adb-daemon-webusb`, no `adb-backend-webusb` — el nombre cambió en algún punto de la evolución del
+proyecto; `fetch-scrcpy-server` resuelve en build-time la descarga del `scrcpy-server.jar` con la
+versión exacta que espera `@yume-chan/scrcpy`, evitando el mismatch de versión que fue tan
+problemático en la investigación de "scrcpy real" de más arriba). Documentación:
+[tangoadb.dev/scrcpy](https://tangoadb.dev/scrcpy/).
+
+### Restricciones reales a documentar honestamente (no ocultar)
+- **Solo navegadores basados en Chromium** (Chrome, Edge, Opera, Samsung Internet) — WebUSB no
+  existe en Firefox ni Safari (macOS/iOS), ninguna versión. ~76% de soporte global. Debe
+  degradarse con gracia: feature-detect `"usb" in navigator`, y si no está disponible caer al
+  `scrcpy_video.py` actual (que a su vez cae a polling de screenshots) — la misma cadena de
+  degradación honesta ya establecida, con un escalón nuevo arriba.
+- **Requiere "secure context"**: `http://127.0.0.1`/`http://localhost` cuentan como seguros sin
+  necesitar TLS (el uso local típico del dashboard sigue funcionando tal cual), pero si el
+  dashboard se expone en la LAN (`--host 0.0.0.0`) y se accede por `http://:puerto` desde
+  otra máquina, **WebUSB no estaría disponible** en ese caso sin HTTPS.
+- **El teléfono debe estar conectado por USB a la MISMA máquina que corre el navegador.** Esto es
+  una restricción más estrecha que el enfoque actual: `scrcpy_video.py` funciona con cualquier
+  device alcanzable por `adb` (USB o red, vía el `adb`/`scrcpy` que ya tenga el usuario) — WebUSB
+  no puede alcanzar un device conectado a una máquina remota ni reenviado por red.
+- **Posible conflicto de exclusividad de USB**: mientras el navegador tiene el device reclamado
+  vía WebUSB, no está confirmado si el `adb`/`scrcpy` del sistema (WSL, Windows, o el propio
+  `scrcpy_video.py`) puede seguir usándolo en simultáneo — a validar en vivo antes de dar esto por
+  cerrado; podría requerir que el usuario libere la pestaña de video antes de correr un job
+  dinámico/aipwn, o viceversa.
+- **Primer build step de JS del proyecto.** El dashboard (Fase 3) es deliberadamente un único
+  HTML self-contained, sin CDN ni dependencias externas. Estos paquetes de Tango son sustanciales y
+  necesitan bundling (Vite/esbuild) — la propuesta es un subproyecto npm nuevo
+  (`nutcracker_core/plugins/dashboard/webusb/`, con su propio `package.json`) cuyo `npm run build`
+  produce un único archivo `.js` bundleado que se commitea al repo y se sirve como estático más —
+  el HTML servido en runtime sigue siendo self-contained (nada se descarga de un CDN en tiempo de
+  ejecución), pero el *desarrollo* de ese archivo sí requiere Node/npm, algo que hoy el proyecto no
+  necesita para nada del dashboard.
+
+### Integración propuesta (no implementada)
+- Nuevo escalón en la cadena de degradación de la pestaña "Dispositivo": **WebUSB+WebCodecs**
+  (mejor, si `navigator.usb` existe y el usuario autoriza el device) → `scrcpy_video.py` actual
+  (si hay `dashboard.scrcpy_path` configurado) → polling de screenshots (siempre disponible).
+- El backend Python **no necesita lógica nueva para el video en sí** — todo el streaming ocurre
+  en el navegador, directo al USB. El backend solo serviría el bundle `.js` compilado y el
+  `scrcpy-server.jar` correcto como estáticos (ya resueltos en build-time por
+  `@yume-chan/fetch-scrcpy-server`).
+- Verificación honesta pendiente antes de implementar: confirmar en vivo (contra el mismo Moto G23)
+  que el WebUSB del navegador puede reclamar el device sin conflicto con `adb`/`scrcpy` del
+  sistema, y medir el fps real resultante contra la promesa de fluidez.
+
+**Estado: documentado y factible, no implementado.** Requiere decisión explícita del usuario para
+proceder (introduce Node/npm al proyecto por primera vez) antes de escribir código.
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..8373d6b
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,36 @@
+[build-system]
+requires = ["setuptools>=68"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "nutcracker"
+version = "0.2.0"
+description = "Android mobile security testing: anti-root/RASP bypass, static/dynamic analysis, OWASP MAS (MASVS+MASWE+CWE) alignment, mass execution queue/scheduler, and a local web dashboard."
+readme = "README.md"
+requires-python = ">=3.11"
+license = { text = "MIT" }
+dependencies = [
+    "androguard>=3.3.5",
+    "click>=8.1.0",
+    "rich>=13.7.0",
+    "pyyaml>=6.0.1",
+    "fpdf2>=2.7.9",
+    "loguru>=0.7.0",
+    "frida-tools>=13.7.0",
+    "frida-dexdump>=2.0.1",
+    "requests>=2.31.0",
+    "apscheduler>=3.10",
+]
+
+[project.optional-dependencies]
+# `nutcracker dashboard` — installed automatically on first use if missing
+# (same auto-install-on-import pattern as every other plugin), listed here
+# too so `pip install .[dashboard]` works for a non-interactive setup.
+dashboard = ["fastapi>=0.110", "uvicorn[standard]>=0.29", "av>=11.0"]
+dev = ["pytest>=8.0.0"]
+
+[project.scripts]
+nutcracker = "nutcracker_core.cli:cli"
+
+[tool.setuptools.packages.find]
+include = ["nutcracker_core*"]
diff --git a/tests/checks/test_dynamic_checks.py b/tests/checks/test_dynamic_checks.py
index f4e9ec8..9b6c522 100644
--- a/tests/checks/test_dynamic_checks.py
+++ b/tests/checks/test_dynamic_checks.py
@@ -3,6 +3,8 @@
 
 from __future__ import annotations
 
+import sys
+
 from nutcracker_core.checks.dynamic.cleartext_traffic import CleartextTrafficDynamicCheck
 from nutcracker_core.checks.dynamic.context import DynamicCheckContext
 from nutcracker_core.checks.dynamic.debuggable import DebuggableDynamicCheck
@@ -96,3 +98,29 @@ def test_default_adb_runner_used_when_none_injected():
     (default_adb_runner) en vez de quedar None — evita AttributeError en uso real."""
     ctx = DynamicCheckContext(package="com.example.app")
     assert callable(ctx.adb_run)
+
+
+def test_default_adb_runner_combines_stderr_with_stdout():
+    """FIX (prueba con dispositivo físico real, 2026-07-24): comandos como
+    `run-as` escriben su mensaje real de error a stderr (p.ej. 'run-as:
+    package not debuggable: '); capturar solo stdout lo perdía
+    silenciosamente, dejando detail/logs sin información útil."""
+    from nutcracker_core.checks.dynamic.context import default_adb_runner
+
+    # adb_bin apunta a un script de prueba que simula stdout+stderr reales,
+    # en vez de necesitar un `adb` de verdad.
+    runner = default_adb_runner(serial=None, adb_bin=sys.executable)
+    out = runner([
+        "-c",
+        "import sys; sys.stdout.write('linea-stdout\\n'); "
+        "sys.stderr.write('linea-stderr\\n')",
+    ])
+    assert "linea-stdout" in out
+    assert "linea-stderr" in out
+
+
+def test_default_adb_runner_returns_empty_string_on_missing_binary():
+    from nutcracker_core.checks.dynamic.context import default_adb_runner
+
+    runner = default_adb_runner(serial=None, adb_bin="/no/existe/adb-binario-inventado")
+    assert runner(["shell", "id"]) == ""
diff --git a/tests/checks/test_registry.py b/tests/checks/test_registry.py
index 80c7e1a..6a6c7ad 100644
--- a/tests/checks/test_registry.py
+++ b/tests/checks/test_registry.py
@@ -37,7 +37,10 @@ def test_static_adapter_wraps_all_existing_rules():
     # COMP004 colisiona a propósito entre RULES y EXPORTED_COMPONENT_RULES
     # (dos hallazgos distintos comparten id preexistente) — el adaptador solo
     # lo registra una vez, vía RULES.
-    manifest_synth_ids = {rid for rid, *_ in EXPORTED_COMPONENT_RULES.values()} | {"INFO001"}
+    manifest_synth_ids = (
+        {rid for rid, *_ in EXPORTED_COMPONENT_RULES.values()}
+        | {"INFO001", "MANIFEST-LOW-TARGET-SDK"}
+    )
     expected = (
         {r.rule_id for r in RULES}
         | {r.rule_id for r in _NATIVE_RULES}
diff --git a/tests/dashboard/test_api.py b/tests/dashboard/test_api.py
index d768330..7a7e61f 100644
--- a/tests/dashboard/test_api.py
+++ b/tests/dashboard/test_api.py
@@ -139,6 +139,51 @@ def test_device_screenshot_503_when_unavailable(monkeypatch, client):
     assert r.status_code == 503
 
 
+def test_device_video_status_unavailable_without_scrcpy(monkeypatch, client):
+    monkeypatch.setattr(
+        "nutcracker_core.plugins.dashboard.scrcpy_video.shutil.which", lambda *a, **kw: None,
+    )
+    r = client.get("/api/device/video/status")
+    assert r.status_code == 200
+    assert r.json() == {"available": False, "scrcpy_bin": None}
+
+
+def test_device_video_status_available_when_scrcpy_on_path(monkeypatch, client):
+    monkeypatch.setattr(
+        "nutcracker_core.plugins.dashboard.scrcpy_video.shutil.which",
+        lambda name, **kw: "/usr/bin/scrcpy" if name == "scrcpy" else None,
+    )
+    r = client.get("/api/device/video/status")
+    assert r.status_code == 200
+    assert r.json() == {"available": True, "scrcpy_bin": "/usr/bin/scrcpy"}
+
+
+def test_device_video_503_when_scrcpy_unavailable(monkeypatch, client):
+    monkeypatch.setattr(
+        "nutcracker_core.plugins.dashboard.scrcpy_video.shutil.which", lambda *a, **kw: None,
+    )
+    r = client.get("/api/device/video")
+    assert r.status_code == 503
+
+
+def test_chat_pending_empty_by_default(client):
+    r = client.get("/api/chat/com.example.app/pending")
+    assert r.status_code == 200
+    assert r.json() == {"messages": []}
+
+
+def test_chat_pending_drains_mailbox(client):
+    from nutcracker_core.plugins.dashboard import chat_mailbox
+    chat_mailbox.add("com.example.app", "hola desde el operador")
+
+    r = client.get("/api/chat/com.example.app/pending")
+    assert r.json() == {"messages": ["hola desde el operador"]}
+
+    # Segunda lectura -- ya se drenó, debe venir vacío.
+    r2 = client.get("/api/chat/com.example.app/pending")
+    assert r2.json() == {"messages": []}
+
+
 def test_agent_prompt_response_shape(client):
     # No forzamos el import a fallar (aipwn puede o no estar instalado en el
     # entorno de test) — solo verificamos la forma de la respuesta en ambos casos.
diff --git a/tests/dashboard/test_chat_mailbox.py b/tests/dashboard/test_chat_mailbox.py
new file mode 100644
index 0000000..e2eef08
--- /dev/null
+++ b/tests/dashboard/test_chat_mailbox.py
@@ -0,0 +1,33 @@
+"""Tests de nutcracker_core/plugins/dashboard/chat_mailbox.py (Fase 3, wiring
+del chat operador→agente aipwn)."""
+
+from __future__ import annotations
+
+from nutcracker_core.plugins.dashboard import chat_mailbox
+
+
+def setup_function():
+    chat_mailbox._pending.clear()
+
+
+def test_drain_empty_package_returns_empty_list():
+    assert chat_mailbox.drain("com.example.app") == []
+
+
+def test_add_then_drain_returns_messages_in_order():
+    chat_mailbox.add("com.example.app", "primero")
+    chat_mailbox.add("com.example.app", "segundo")
+    assert chat_mailbox.drain("com.example.app") == ["primero", "segundo"]
+
+
+def test_drain_consumes_messages_only_once():
+    chat_mailbox.add("com.example.app", "mensaje único")
+    assert chat_mailbox.drain("com.example.app") == ["mensaje único"]
+    assert chat_mailbox.drain("com.example.app") == []
+
+
+def test_packages_are_isolated():
+    chat_mailbox.add("com.example.a", "para A")
+    chat_mailbox.add("com.example.b", "para B")
+    assert chat_mailbox.drain("com.example.a") == ["para A"]
+    assert chat_mailbox.drain("com.example.b") == ["para B"]
diff --git a/tests/dashboard/test_scrcpy_video.py b/tests/dashboard/test_scrcpy_video.py
new file mode 100644
index 0000000..2d8bed4
--- /dev/null
+++ b/tests/dashboard/test_scrcpy_video.py
@@ -0,0 +1,257 @@
+"""Tests de nutcracker_core/plugins/dashboard/scrcpy_video.py (video en vivo
+real vía el cliente scrcpy oficial — ver el docstring del módulo para el
+porqué de invocar el binario real en vez de reimplementar el protocolo).
+
+Todo mockeado: ni un subprocess real ni una decodificación H.264 real corren
+en estos tests — lo que se ejercita es la mecánica propia (localización del
+binario, construcción del comando, ciclo de reinicio, generador multipart),
+no el pipeline de video real (verificado a mano contra hardware, ver plan.md).
+"""
+
+from __future__ import annotations
+
+import time
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+from nutcracker_core.plugins.dashboard import scrcpy_video
+
+
+# ── find_scrcpy_binary / _find_adjacent_adb ─────────────────────────────────
+
+def test_find_scrcpy_binary_prefers_configured_path(tmp_path):
+    fake_bin = tmp_path / "scrcpy.exe"
+    fake_bin.write_text("fake")
+    config = {"dashboard": {"scrcpy_path": str(fake_bin)}}
+    assert scrcpy_video.find_scrcpy_binary(config) == str(fake_bin)
+
+
+def test_find_scrcpy_binary_falls_back_to_path_when_configured_missing(tmp_path):
+    config = {"dashboard": {"scrcpy_path": str(tmp_path / "nope")}}
+    with patch("shutil.which", return_value="/usr/bin/scrcpy"):
+        assert scrcpy_video.find_scrcpy_binary(config) == "/usr/bin/scrcpy"
+
+
+def test_find_scrcpy_binary_none_when_nothing_found():
+    with patch("shutil.which", return_value=None):
+        assert scrcpy_video.find_scrcpy_binary({}) is None
+        assert scrcpy_video.find_scrcpy_binary(None) is None
+
+
+def test_find_adjacent_adb_returns_sibling_when_present(tmp_path):
+    scrcpy_bin = tmp_path / "scrcpy.exe"
+    scrcpy_bin.write_text("fake")
+    adb_bin = tmp_path / "adb.exe"
+    adb_bin.write_text("fake")
+    assert scrcpy_video._find_adjacent_adb(str(scrcpy_bin)) == str(adb_bin)
+
+
+def test_find_adjacent_adb_none_when_absent(tmp_path):
+    scrcpy_bin = tmp_path / "scrcpy"
+    scrcpy_bin.write_text("fake")
+    assert scrcpy_video._find_adjacent_adb(str(scrcpy_bin)) is None
+
+
+# ── decode_last_frame_jpeg ───────────────────────────────────────────────────
+
+def test_decode_last_frame_jpeg_missing_file(tmp_path):
+    assert scrcpy_video.decode_last_frame_jpeg(tmp_path / "nope.mkv") is None
+
+
+def test_decode_last_frame_jpeg_empty_file(tmp_path):
+    p = tmp_path / "empty.mkv"
+    p.write_bytes(b"")
+    assert scrcpy_video.decode_last_frame_jpeg(p) is None
+
+
+def test_decode_last_frame_jpeg_unreadable_container(tmp_path):
+    p = tmp_path / "garbage.mkv"
+    p.write_bytes(b"not a real mkv file")
+    # av.open lanzará sobre bytes basura -- debe devolver None, no propagar.
+    assert scrcpy_video.decode_last_frame_jpeg(p) is None
+
+
+def test_decode_last_frame_jpeg_returns_last_frame_as_jpeg(tmp_path):
+    p = tmp_path / "clip.mkv"
+    p.write_bytes(b"fake but non-empty")
+
+    fake_image = MagicMock()
+
+    def _fake_save(buf, format=None, quality=None):
+        buf.write(b"\xff\xd8\xff\xd9")  # marcadores JPEG start/end, contenido irrelevante
+
+    fake_image.save.side_effect = _fake_save
+
+    frame1, frame2 = MagicMock(), MagicMock()
+    frame1.to_image.return_value = fake_image
+    frame2.to_image.return_value = fake_image
+
+    fake_container = MagicMock()
+    fake_container.duration = None  # archivo "recién creado" -- sin seek, decode desde el principio
+    fake_container.decode.return_value = [frame1, frame2]
+
+    with patch("av.open", return_value=fake_container):
+        result = scrcpy_video.decode_last_frame_jpeg(p)
+
+    assert result == b"\xff\xd8\xff\xd9"
+    fake_container.close.assert_called_once()
+    fake_container.seek.assert_not_called()
+    # Debe quedarse con el ÚLTIMO frame decodificado, no el primero.
+    frame2.to_image.assert_called_once()
+    frame1.to_image.assert_not_called()
+
+
+def test_decode_last_frame_jpeg_seeks_near_end_on_long_recording(tmp_path):
+    """Con un archivo lo bastante largo (container.duration disponible y
+    mayor al lookback), debe hacer seek cerca del final en vez de decodificar
+    desde el principio -- ver docstring del módulo para la medición real que
+    motiva esto (0.5s/poll sin seek vs 0.07s/poll con seek)."""
+    p = tmp_path / "clip.mkv"
+    p.write_bytes(b"fake but non-empty")
+
+    fake_image = MagicMock()
+    fake_image.save.side_effect = lambda buf, format=None, quality=None: buf.write(b"\xff\xd8\xff\xd9")
+    frame = MagicMock()
+    frame.to_image.return_value = fake_image
+
+    fake_container = MagicMock()
+    fake_container.duration = 30_000_000  # 30s, bien por encima del lookback de 1.5s
+    fake_container.decode.return_value = [frame]
+
+    with patch("av.open", return_value=fake_container):
+        result = scrcpy_video.decode_last_frame_jpeg(p)
+
+    assert result == b"\xff\xd8\xff\xd9"
+    fake_container.seek.assert_called_once_with(
+        30_000_000 - scrcpy_video._SEEK_LOOKBACK_US, any_frame=False,
+    )
+
+
+def test_decode_last_frame_jpeg_falls_back_when_seek_raises(tmp_path):
+    """Si el seek falla (formato inesperado, versión de ffmpeg, etc.), debe
+    seguir intentando decode() en vez de propagar la excepción."""
+    p = tmp_path / "clip.mkv"
+    p.write_bytes(b"fake but non-empty")
+
+    fake_image = MagicMock()
+    fake_image.save.side_effect = lambda buf, format=None, quality=None: buf.write(b"\xff\xd8\xff\xd9")
+    frame = MagicMock()
+    frame.to_image.return_value = fake_image
+
+    fake_container = MagicMock()
+    fake_container.duration = 30_000_000
+    fake_container.seek.side_effect = RuntimeError("seek no soportado")
+    fake_container.decode.return_value = [frame]
+
+    with patch("av.open", return_value=fake_container):
+        result = scrcpy_video.decode_last_frame_jpeg(p)
+
+    assert result == b"\xff\xd8\xff\xd9"
+
+
+# ── ScrcpyVideoSession._build_cmd ────────────────────────────────────────────
+
+def test_build_cmd_includes_serial_when_given(tmp_path):
+    session = scrcpy_video.ScrcpyVideoSession("scrcpy", serial="ZY22GPM27J")
+    cmd = session._build_cmd(tmp_path / "clip_1.mkv")
+    assert cmd[0] == "scrcpy"
+    assert "--no-window" in cmd
+    assert "-s" in cmd and cmd[cmd.index("-s") + 1] == "ZY22GPM27J"
+    assert any(c.startswith("--record=") for c in cmd)
+    session.stop()
+
+
+def test_build_cmd_omits_serial_when_none(tmp_path):
+    session = scrcpy_video.ScrcpyVideoSession("scrcpy", serial=None)
+    cmd = session._build_cmd(tmp_path / "clip_1.mkv")
+    assert "-s" not in cmd
+    session.stop()
+
+
+# ── ScrcpyVideoSession._run (ciclo de fondo, todo mockeado) ──────────────────
+
+def test_session_sets_latest_jpeg_when_frames_decode(monkeypatch, tmp_path):
+    monkeypatch.setattr(scrcpy_video, "_RESTART_INTERVAL_S", 0.3)
+    monkeypatch.setattr(scrcpy_video, "_POLL_INTERVAL_S", 0.05)
+
+    fake_proc = MagicMock()
+    fake_proc.poll.return_value = None  # "vivo" durante todo el ciclo
+    fake_proc.stdout = None
+
+    with patch("subprocess.Popen", return_value=fake_proc), \
+         patch("subprocess.run"), \
+         patch.object(scrcpy_video, "decode_last_frame_jpeg", return_value=b"jpegdata"):
+        session = scrcpy_video.ScrcpyVideoSession("scrcpy", serial=None)
+        session.start()
+        time.sleep(0.5)
+        assert session.latest_jpeg() == b"jpegdata"
+        assert session.error() is None
+        session.stop()
+
+
+def test_session_sets_error_when_no_frames_ever_decode(monkeypatch, tmp_path):
+    monkeypatch.setattr(scrcpy_video, "_RESTART_INTERVAL_S", 0.2)
+    monkeypatch.setattr(scrcpy_video, "_POLL_INTERVAL_S", 0.05)
+    monkeypatch.setattr(scrcpy_video, "_RETRY_BACKOFF_S", 0.05)
+
+    fake_proc = MagicMock()
+    fake_proc.poll.return_value = None
+    fake_stdout = MagicMock()
+    fake_stdout.read.return_value = "[server] ERROR: something broke"
+    fake_proc.stdout = fake_stdout
+
+    with patch("subprocess.Popen", return_value=fake_proc), \
+         patch("subprocess.run"), \
+         patch.object(scrcpy_video, "decode_last_frame_jpeg", return_value=None):
+        session = scrcpy_video.ScrcpyVideoSession("scrcpy", serial=None)
+        session.start()
+        time.sleep(0.4)
+        assert session.latest_jpeg() is None
+        assert session.error() is not None
+        assert "something broke" in session.error()
+        session.stop()
+
+
+def test_session_reports_error_when_binary_not_found(tmp_path):
+    with patch("subprocess.Popen", side_effect=FileNotFoundError("no such file")):
+        session = scrcpy_video.ScrcpyVideoSession("scrcpy-inexistente", serial=None)
+        session.start()
+        time.sleep(0.2)
+        assert session.error() is not None
+        session.stop()
+
+
+# ── mjpeg_multipart ──────────────────────────────────────────────────────────
+
+def test_mjpeg_multipart_yields_frame_with_correct_headers():
+    session = MagicMock()
+    session.latest_jpeg.return_value = b"abc123"
+
+    gen = scrcpy_video.mjpeg_multipart(session, boundary="testboundary", fps=1000)
+    chunk = next(gen)
+
+    assert b"--testboundary\r\n" in chunk
+    assert b"Content-Type: image/jpeg\r\n" in chunk
+    assert b"Content-Length: 6\r\n" in chunk
+    assert chunk.endswith(b"abc123\r\n")
+    gen.close()
+
+
+def test_mjpeg_multipart_skips_resending_same_frame(monkeypatch):
+    frame_a = b"frame-a"
+    frame_b = b"frame-b"
+    session = MagicMock()
+    # Tres lecturas seguidas del mismo objeto frame_a, luego cambia a frame_b.
+    session.latest_jpeg.side_effect = [frame_a, frame_a, frame_a, frame_b]
+    monkeypatch.setattr(scrcpy_video.time, "sleep", lambda _: None)
+
+    gen = scrcpy_video.mjpeg_multipart(session, fps=1000)
+    first = next(gen)   # frame_a es nuevo -> yield
+    second = next(gen)  # frame_b tras dos repeticiones de frame_a -> yield
+    gen.close()
+
+    assert first.endswith(b"frame-a\r\n")
+    assert second.endswith(b"frame-b\r\n")
+    # Se llamó a latest_jpeg() 4 veces (3 de frame_a + 1 de frame_b) para
+    # producir solo 2 yields -- confirma que las repeticiones no re-emiten.
+    assert session.latest_jpeg.call_count == 4
diff --git a/tests/dashboard/test_ws.py b/tests/dashboard/test_ws.py
index 48166ad..a56d815 100644
--- a/tests/dashboard/test_ws.py
+++ b/tests/dashboard/test_ws.py
@@ -60,3 +60,17 @@ def test_chat_ws_echoes_message_to_subscribers(client):
         assert msg["kind"] == "chat"
         assert msg["data"]["from"] == "operator"
         assert msg["data"]["text"] == "hola agente"
+
+
+def test_chat_ws_also_writes_to_mailbox_for_aipwn_polling(client):
+    """Fase 3 follow-up: además de hacer eco por WS, el mensaje debe quedar
+    disponible para que un job aipwn (subproceso, no cliente WS) lo recoja
+    vía GET /api/chat/{package}/pending -- ver chat_mailbox.py."""
+    from nutcracker_core.plugins.dashboard import chat_mailbox
+    chat_mailbox._pending.clear()
+
+    with client.websocket_connect("/ws/chat/com.example.app") as ws:
+        ws.send_text("toma un screenshot")
+        ws.receive_json()  # esperar el eco antes de leer el mailbox (evita carrera)
+
+    assert chat_mailbox.drain("com.example.app") == ["toma un screenshot"]
diff --git a/tests/test_aipwn_chat_wiring.py b/tests/test_aipwn_chat_wiring.py
new file mode 100644
index 0000000..efa8adf
--- /dev/null
+++ b/tests/test_aipwn_chat_wiring.py
@@ -0,0 +1,101 @@
+"""Tests del wiring del chat operador→agente (Fase 3, follow-up de plan.md):
+FridaAgent._check_operator_chat hace polling best-effort de
+NUTCRACKER_DASHBOARD_URL/api/chat/{package}/pending e inyecta lo que
+encuentra en la conversación real del agente.
+
+FridaAgent.__init__ requiere un LLM real, ToolContext, decompiled dir, etc. —
+para testear un único método en aislamiento se construye la instancia con
+__new__ (sin __init__) y se setean a mano los atributos que el método usa
+(self.package, self.messages) — técnica estándar para testear un método sin
+pagar el costo/dependencias del constructor completo de una clase grande.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from nutcracker_core.plugins.aipwn.frida_agent import FridaAgent
+
+
+def _make_agent(package: str = "com.example.tapjacking") -> FridaAgent:
+    agent = FridaAgent.__new__(FridaAgent)
+    agent.package = package
+    agent.messages = []
+    return agent
+
+
+def test_noop_without_dashboard_url_env(monkeypatch):
+    monkeypatch.delenv("NUTCRACKER_DASHBOARD_URL", raising=False)
+    agent = _make_agent()
+    with patch("requests.get") as mock_get:
+        agent._check_operator_chat()
+    mock_get.assert_not_called()
+    assert agent.messages == []
+
+
+def test_injects_pending_messages_as_user_turn(monkeypatch):
+    monkeypatch.setenv("NUTCRACKER_DASHBOARD_URL", "http://127.0.0.1:8765")
+    agent = _make_agent("com.example.tapjacking")
+
+    fake_resp = MagicMock()
+    fake_resp.status_code = 200
+    fake_resp.json.return_value = {"messages": ["toma un screenshot", "prueba el hook de SSL pinning"]}
+
+    with patch("requests.get", return_value=fake_resp) as mock_get:
+        agent._check_operator_chat()
+
+    called_url = mock_get.call_args[0][0]
+    assert called_url == "http://127.0.0.1:8765/api/chat/com.example.tapjacking/pending"
+    assert len(agent.messages) == 2
+    assert agent.messages[0]["role"] == "user"
+    assert "toma un screenshot" in agent.messages[0]["content"]
+    assert "prueba el hook de SSL pinning" in agent.messages[1]["content"]
+
+
+def test_url_encodes_package_with_special_characters(monkeypatch):
+    monkeypatch.setenv("NUTCRACKER_DASHBOARD_URL", "http://127.0.0.1:8765")
+    agent = _make_agent("com.example.app+test")
+    fake_resp = MagicMock()
+    fake_resp.status_code = 200
+    fake_resp.json.return_value = {"messages": []}
+    with patch("requests.get", return_value=fake_resp) as mock_get:
+        agent._check_operator_chat()
+    called_url = mock_get.call_args[0][0]
+    assert "com.example.app+test" not in called_url  # el '+' debe quedar codificado
+    assert "com.example.app%2Btest" in called_url
+
+
+def test_strips_trailing_slash_from_dashboard_url(monkeypatch):
+    monkeypatch.setenv("NUTCRACKER_DASHBOARD_URL", "http://127.0.0.1:8765/")
+    agent = _make_agent()
+    fake_resp = MagicMock()
+    fake_resp.status_code = 200
+    fake_resp.json.return_value = {"messages": []}
+    with patch("requests.get", return_value=fake_resp) as mock_get:
+        agent._check_operator_chat()
+    called_url = mock_get.call_args[0][0]
+    assert "//api" not in called_url
+
+
+@pytest.mark.parametrize("side_effect", [
+    ConnectionError("dashboard caído"),
+    TimeoutError("timeout"),
+])
+def test_never_raises_when_dashboard_unreachable(monkeypatch, side_effect):
+    monkeypatch.setenv("NUTCRACKER_DASHBOARD_URL", "http://127.0.0.1:8765")
+    agent = _make_agent()
+    with patch("requests.get", side_effect=side_effect):
+        agent._check_operator_chat()  # no debe propagar la excepción
+    assert agent.messages == []
+
+
+def test_ignores_non_200_response(monkeypatch):
+    monkeypatch.setenv("NUTCRACKER_DASHBOARD_URL", "http://127.0.0.1:8765")
+    agent = _make_agent()
+    fake_resp = MagicMock()
+    fake_resp.status_code = 503
+    with patch("requests.get", return_value=fake_resp):
+        agent._check_operator_chat()
+    assert agent.messages == []
diff --git a/tests/test_batch.py b/tests/test_batch.py
new file mode 100644
index 0000000..761298c
--- /dev/null
+++ b/tests/test_batch.py
@@ -0,0 +1,213 @@
+"""Tests de `nutcracker batch` migrado al motor de cola (Fase 1 del plan).
+
+`_summary_for_outcome` se prueba contra una SQLite real (reconstruye el
+resumen consolidado desde `runs`/`findings`, ya no desde objetos en memoria);
+el comando en sí se prueba con CliRunner + una QueueEngine falsa, sin correr
+subprocesos ni tocar red/disco de verdad.
+"""
+
+from __future__ import annotations
+
+import pytest
+from click.testing import CliRunner
+
+from nutcracker_core.cli.batch import _summary_for_outcome
+from nutcracker_core.queue.engine import JobOutcome
+from nutcracker_core.queue.job import Job
+from nutcracker_core.store import db, repository
+
+
+@pytest.fixture
+def db_path(tmp_path):
+    return str(tmp_path / "batch_test.db")
+
+
+def _seed_run(db_path: str, package: str, verdict: str, findings: list[dict] | None = None) -> int:
+    conn = db.connect(db_path)
+    try:
+        run_id = repository.insert_run(conn, package, kind="full", status="done")
+        repository.update_run_status(conn, run_id, status="done", verdict=verdict, masvs_score=70, grade="C")
+        if findings:
+            repository.record_findings(conn, run_id, [
+                repository.FindingRecord(
+                    rule_id=f["rule_id"], title=f.get("title", ""), severity=f.get("severity", "info"),
+                    category=f.get("category", ""),
+                )
+                for f in findings
+            ])
+        return run_id
+    finally:
+        conn.close()
+
+
+# ── _summary_for_outcome ─────────────────────────────────────────────────────
+
+def test_summary_for_failed_outcome_has_error_status(db_path):
+    job = Job(target="com.broken.app", kind="static")
+    job.db_id = 1
+    outcome = JobOutcome(job=job, ok=False, returncode=1, error="algo falló", run_id=None, package=None)
+
+    summary = _summary_for_outcome(db_path, outcome)
+
+    assert summary["status"] == "error"
+    assert summary["error"] == "algo falló"
+    assert summary["target"] == "com.broken.app"
+
+
+def test_summary_for_successful_outcome_reconstructs_severities_from_db(db_path):
+    run_id = _seed_run(db_path, "com.example.app", "protected", findings=[
+        {"rule_id": "HC001", "title": "API key hardcodeada", "severity": "high", "category": "M1"},
+        {"rule_id": "HC001", "title": "API key hardcodeada", "severity": "high", "category": "M1"},
+        {"rule_id": "CRYPTO001", "title": "MD5 en uso", "severity": "medium", "category": "M5"},
+        {"rule_id": "GL-001", "title": "Leak de gitleaks", "severity": "critical", "category": "M1"},
+    ])
+    job = Job(target="com.example.app", kind="static")
+    job.db_id = 2
+    outcome = JobOutcome(job=job, ok=True, returncode=0, run_id=run_id, package="com.example.app")
+
+    summary = _summary_for_outcome(db_path, outcome)
+
+    assert summary["status"] == "protected"
+    assert summary["findings"] == 4
+    assert summary["high"] == 2
+    assert summary["medium"] == 1
+    assert summary["critical"] == 1
+    assert summary["leaks"] == 3  # se cuenta por hallazgo, no por rule_id único: 2x HC001 + 1x GL-001
+    assert summary["categories"]["M1"] == "critical"  # peor severidad de esa categoría
+    # top_findings único por rule_id, ordenado por severidad (critical primero)
+    rule_ids = [t[0] for t in summary["top_findings"]]
+    assert rule_ids[0] == "GL-001"
+    assert rule_ids.count("HC001") == 1
+
+
+@pytest.mark.parametrize("verdict,expected_status", [
+    ("protection_broken", "protected_broken"),
+    ("protected", "protected"),
+    ("not_protected", "unprotected"),
+])
+def test_summary_verdict_mapping(db_path, verdict, expected_status):
+    run_id = _seed_run(db_path, "com.example.app", verdict)
+    job = Job(target="com.example.app", kind="static")
+    job.db_id = 3
+    outcome = JobOutcome(job=job, ok=True, returncode=0, run_id=run_id, package="com.example.app")
+
+    summary = _summary_for_outcome(db_path, outcome)
+    assert summary["status"] == expected_status
+
+
+# ── comando `batch` end-to-end (QueueEngine mockeada) ────────────────────────
+
+class _FakeEngine:
+    """Doble de QueueEngine: no corre subprocesos — cada submit() produce un
+    JobOutcome predefinido, entregado a on_result cuando drain() corre."""
+
+    def __init__(self, outcomes_by_target: dict[str, JobOutcome], db_path=None, **kw):
+        self.db_path = db_path
+        self._outcomes_by_target = outcomes_by_target
+        self._pending: list[str] = []
+        self.on_line = None
+
+    def submit(self, target, kind="static", **kw):
+        self._pending.append(target)
+        job = Job(target=target, kind=kind)
+        job.db_id = len(self._pending)
+        return job
+
+    def submit_many(self, targets, kind="static"):
+        return [self.submit(t, kind=kind) for t in targets]
+
+    def drain(self, on_result=None):
+        outcomes = []
+        for target in self._pending:
+            outcome = self._outcomes_by_target[target]
+            outcomes.append(outcome)
+            if on_result:
+                on_result(outcome)
+        self._pending = []
+        return outcomes
+
+
+def _fake_outcome(target: str, ok: bool = True, package: str | None = None) -> JobOutcome:
+    job = Job(target=target)
+    return JobOutcome(job=job, ok=ok, returncode=0 if ok else 1,
+                       run_id=None, package=package or (target if ok else None),
+                       error="" if ok else "boom")
+
+
+def test_batch_command_missing_list_file_exits_nonzero(tmp_path, monkeypatch):
+    from nutcracker_core.cli import batch as batch_mod
+
+    monkeypatch.chdir(tmp_path)
+    runner = CliRunner()
+    result = runner.invoke(batch_mod.batch, ["no_existe.txt"])
+    assert result.exit_code != 0
+
+
+def test_batch_command_runs_targets_through_queue_engine(tmp_path, monkeypatch):
+    from nutcracker_core.cli import batch as batch_mod
+
+    list_file = tmp_path / "targets.txt"
+    list_file.write_text("com.app.one\ncom.app.two\n# comentario\n\n")
+
+    config_path = tmp_path / "config.yaml"
+    config_path.write_text("queue:\n  static_workers: 4\nreports:\n  save_pdf: false\n")
+
+    outcomes = {
+        "com.app.one": _fake_outcome("com.app.one", ok=True),
+        "com.app.two": _fake_outcome("com.app.two", ok=False),
+    }
+    monkeypatch.setattr(
+        batch_mod, "QueueEngine",
+        lambda **kw: _FakeEngine(outcomes, **kw),
+    )
+    monkeypatch.setattr(batch_mod, "_summary_for_outcome",
+                         lambda db_path, outcome: {"target": outcome.job.target,
+                                                    "status": "protected" if outcome.ok else "error",
+                                                    "error": outcome.error})
+
+    runner = CliRunner()
+    result = runner.invoke(batch_mod.batch, [str(list_file), "--config", str(config_path)])
+
+    assert result.exit_code == 0, result.output
+    assert "com.app.one" in result.output
+    assert "com.app.two" in result.output
+
+
+def test_batch_sequential_mode_stops_on_error(tmp_path, monkeypatch):
+    from nutcracker_core.cli import batch as batch_mod
+
+    list_file = tmp_path / "targets.txt"
+    list_file.write_text("com.app.one\ncom.app.two\ncom.app.three\n")
+
+    config_path = tmp_path / "config.yaml"
+    config_path.write_text("queue:\n  static_workers: 1\nreports:\n  save_pdf: false\n")
+
+    outcomes = {
+        "com.app.one": _fake_outcome("com.app.one", ok=True),
+        "com.app.two": _fake_outcome("com.app.two", ok=False),
+        "com.app.three": _fake_outcome("com.app.three", ok=True),
+    }
+    engines: list[_FakeEngine] = []
+
+    def _make_engine(**kw):
+        eng = _FakeEngine(outcomes, **kw)
+        engines.append(eng)
+        return eng
+
+    monkeypatch.setattr(batch_mod, "QueueEngine", _make_engine)
+    monkeypatch.setattr(batch_mod, "_summary_for_outcome",
+                         lambda db_path, outcome: {"target": outcome.job.target,
+                                                    "status": "protected" if outcome.ok else "error",
+                                                    "error": outcome.error})
+
+    runner = CliRunner()
+    result = runner.invoke(
+        batch_mod.batch,
+        [str(list_file), "--config", str(config_path), "--stop-on-error"],
+    )
+
+    assert result.exit_code == 0, result.output
+    assert "com.app.one" in result.output
+    assert "com.app.two" in result.output
+    # com.app.three nunca debió encolarse: nos detuvimos tras el error en two.
+    assert "com.app.three" not in result.output
diff --git a/tests/test_orchestrator.py b/tests/test_orchestrator.py
new file mode 100644
index 0000000..ba98c83
--- /dev/null
+++ b/tests/test_orchestrator.py
@@ -0,0 +1,228 @@
+"""Tests de los fixes encontrados en la prueba con dispositivo físico real
+(Magisk, 2026-07-24): --launch perdía el análisis estático (os.execvp
+reemplazaba el proceso) y los jobs dinámicos de la cola dependían de un REPL
+interactivo de Frida en vez de un modo headless."""
+
+from __future__ import annotations
+
+from nutcracker_core import orchestrator as orch
+
+
+# ── _launch_frida_bypass: subprocess.run en vez de os.execvp ───────────────
+
+def _mock_frida_server_setup(monkeypatch, setup_calls):
+    """Mockea la cadena find_sdk_tools/get_frida_version/download_frida_server/
+    frida_arch_for_device/setup_frida_server usada por _launch_frida_bypass
+    cuando se pasa --serial, para no tocar red/adb de verdad en los tests."""
+    monkeypatch.setattr(orch, "find_sdk_tools", lambda: {"adb": "adb"})
+    monkeypatch.setattr(orch, "get_frida_version", lambda: "17.16.4")
+    monkeypatch.setattr(orch, "frida_arch_for_device", lambda serial, tools: "arm64")
+    monkeypatch.setattr(orch, "download_frida_server", lambda ver, arch: f"/tmp/frida-server-{ver}-{arch}")
+    monkeypatch.setattr(
+        orch, "setup_frida_server",
+        lambda serial, tools, server_bin, **kw: setup_calls.append((serial, server_bin, kw)) or True,
+    )
+
+
+def test_launch_frida_bypass_uses_subprocess_run_not_execvp(monkeypatch, tmp_path):
+    """os.execvp reemplaza el proceso Python — nunca vuelve, así que todo el
+    código posterior (guardar JSON/PDF, post-hooks) nunca se ejecutaba. Debe
+    usar subprocess.run, que sí retorna control al llamador."""
+    execvp_called = []
+    run_called = []
+    setup_calls: list = []
+
+    monkeypatch.setattr(orch.os, "execvp", lambda *a: execvp_called.append(a))
+    monkeypatch.setattr(orch.subprocess, "run", lambda *a, **kw: run_called.append((a, kw)))
+    _mock_frida_server_setup(monkeypatch, setup_calls)
+
+    script = tmp_path / "bypass.js"
+    script.write_text("// noop")
+
+    orch._launch_frida_bypass("com.example.app", script, serial="ABC123")
+
+    assert execvp_called == [], "no debe usar os.execvp (reemplaza el proceso y pierde resultados)"
+    assert len(run_called) >= 1
+    frida_cmd = run_called[-1][0][0]
+    assert frida_cmd[0].endswith("frida")
+    assert "-D" in frida_cmd and "ABC123" in frida_cmd
+
+
+def test_launch_frida_bypass_with_serial_uses_setup_frida_server_not_manual_restart(monkeypatch, tmp_path):
+    """FIX (prueba con dispositivo físico real, 2026-07-24): el restart manual
+    ('nohup .../frida-server &', sin argumentos) nunca escuchaba en red y no
+    manejaba Magisk/Zygisk (LD_PRELOAD) ni versión. Con --serial debe reusar
+    setup_frida_server, el mismo mecanismo robusto del flujo FART."""
+    run_called = []
+    setup_calls: list = []
+
+    monkeypatch.setattr(orch.subprocess, "run", lambda *a, **kw: run_called.append((a, kw)))
+    _mock_frida_server_setup(monkeypatch, setup_calls)
+
+    script = tmp_path / "bypass.js"
+    script.write_text("// noop")
+
+    orch._launch_frida_bypass("com.example.app", script, serial="ABC123", frida_host="192.168.1.4:27042")
+
+    assert len(setup_calls) == 1, "debe llamar a setup_frida_server exactamente una vez"
+    serial, server_bin, kwargs = setup_calls[0]
+    assert serial == "ABC123"
+    assert kwargs["listen_all"] is True, "frida_host configurado → debe escuchar en 0.0.0.0"
+    assert kwargs["force_restart"] is True
+
+    # No debe quedar ningún restart manual "nohup .../frida-server &" (el
+    # camino viejo, sin -l 0.0.0.0) cuando hay --serial.
+    manual_restarts = [c for c in run_called if "nohup" in " ".join(str(x) for x in c[0][0])]
+    assert manual_restarts == []
+
+
+def test_launch_frida_bypass_respects_configured_frida_server_version(monkeypatch, tmp_path):
+    setup_calls: list = []
+    download_calls: list = []
+
+    monkeypatch.setattr(orch.subprocess, "run", lambda *a, **kw: None)
+    monkeypatch.setattr(orch, "find_sdk_tools", lambda: {"adb": "adb"})
+    monkeypatch.setattr(orch, "get_frida_version", lambda: "17.16.4")  # versión del entorno Python
+    monkeypatch.setattr(orch, "frida_arch_for_device", lambda serial, tools: "arm64")
+    monkeypatch.setattr(orch, "download_frida_server", lambda ver, arch: download_calls.append(ver) or "/tmp/bin")
+    monkeypatch.setattr(
+        orch, "setup_frida_server",
+        lambda serial, tools, server_bin, **kw: setup_calls.append(kw) or True,
+    )
+    orch._CFG = {"strategies": {"frida_server_version": "17.15.4"}}  # pineada por el usuario
+
+    script = tmp_path / "bypass.js"
+    script.write_text("// noop")
+    orch._launch_frida_bypass("com.example.app", script, serial="ABC123")
+
+    assert download_calls == ["17.15.4"], "debe usar la versión de config.yaml, no la del entorno Python"
+    orch._CFG = {}
+
+
+def test_launch_frida_bypass_without_serial_falls_back_to_manual_restart(monkeypatch, tmp_path):
+    """Sin --serial no hay forma fiable de resolver arch/tools para
+    setup_frida_server — debe conservar el restart simple de antes en vez de
+    no reiniciar frida-server en absoluto."""
+    run_called = []
+    setup_calls: list = []
+
+    monkeypatch.setattr(orch.subprocess, "run", lambda *a, **kw: run_called.append((a, kw)))
+    _mock_frida_server_setup(monkeypatch, setup_calls)
+
+    script = tmp_path / "bypass.js"
+    script.write_text("// noop")
+    orch._launch_frida_bypass("com.example.app", script, serial=None)
+
+    assert setup_calls == [], "sin serial no debe intentar setup_frida_server"
+    manual_restarts = [c for c in run_called if "nohup" in " ".join(str(x) for x in c[0][0])]
+    assert len(manual_restarts) == 1
+
+
+def test_launch_frida_bypass_survives_setup_frida_server_exception(monkeypatch, tmp_path):
+    """Un fallo al reiniciar frida-server (red caída, etc.) no debe tumbar la
+    función entera — debe seguir e intentar lanzar frida igual."""
+    run_called = []
+
+    monkeypatch.setattr(orch.subprocess, "run", lambda *a, **kw: run_called.append((a, kw)))
+    monkeypatch.setattr(orch, "find_sdk_tools", lambda: {"adb": "adb"})
+    monkeypatch.setattr(orch, "get_frida_version", lambda: "17.16.4")
+    monkeypatch.setattr(orch, "frida_arch_for_device", lambda serial, tools: "arm64")
+
+    def _boom(*a, **kw):
+        raise RuntimeError("no hay red")
+
+    monkeypatch.setattr(orch, "download_frida_server", _boom)
+
+    script = tmp_path / "bypass.js"
+    script.write_text("// noop")
+    orch._launch_frida_bypass("com.example.app", script, serial="ABC123")  # no debe lanzar
+
+    frida_cmd = run_called[-1][0][0]
+    assert frida_cmd[0].endswith("frida")
+
+
+# ── build_job_cmd: dynamic_checks en vez de launch ──────────────────────────
+
+def test_build_job_cmd_dynamic_uses_dynamic_checks_flag_not_launch():
+    cmd = orch.build_job_cmd(
+        "/tmp/app.apk", is_local_apk=True, static_only=False,
+        dynamic_checks=True, serial="ABC123",
+    )
+    assert "--dynamic-checks" in cmd
+    assert "--serial" in cmd and "ABC123" in cmd
+    assert "--launch" not in cmd, "un job automatizado de la cola nunca debe usar --launch (REPL interactivo)"
+
+
+def test_build_job_cmd_static_has_neither_flag():
+    cmd = orch.build_job_cmd("/tmp/app.apk", is_local_apk=True, static_only=True, dynamic_checks=False)
+    assert "--dynamic-checks" not in cmd
+    assert "--launch" not in cmd
+    assert "--static-only" in cmd
+
+
+def test_build_job_cmd_aipwn_runs_aipwn_subcommand_with_package():
+    cmd = orch.build_job_cmd(
+        "com.example.tapjacking", is_local_apk=False, aipwn=True, serial="ZY22GPM27J",
+    )
+    assert "aipwn" in cmd
+    assert "com.example.tapjacking" in cmd
+    assert "--serial" in cmd and "ZY22GPM27J" in cmd
+    # aipwn no acepta --config; nada de lo que analyze/scan usan debe colarse.
+    assert "--config" not in cmd
+    assert "--static-only" not in cmd
+    assert "analyze" not in cmd and "scan" not in cmd
+
+
+def test_build_job_cmd_aipwn_without_serial_omits_flag():
+    cmd = orch.build_job_cmd("com.example.tapjacking", is_local_apk=False, aipwn=True)
+    assert "--serial" not in cmd
+    assert cmd[-1] == "com.example.tapjacking"
+
+
+# ── _run_dynamic_checks_for: headless, nunca cede el proceso ────────────────
+
+class _FakeResult:
+    package = "com.example.app"
+
+
+def test_run_dynamic_checks_for_executes_registered_checks(monkeypatch):
+    from nutcracker_core.checks import registry
+
+    registry.reset()
+    calls = []
+
+    class _FakeCheck:
+        class meta:
+            id = "DYN-FAKE"
+
+        def run(self, ctx):
+            calls.append((ctx.package, ctx.serial))
+            from nutcracker_core.checks.base import CheckFinding
+            return [CheckFinding(check_id="DYN-FAKE", title="fake", detected=True, detail="ok")]
+
+    monkeypatch.setattr("nutcracker_core.checks.load_registry", lambda: registry.register_dynamic(_FakeCheck()))
+
+    orch._run_dynamic_checks_for(_FakeResult(), "SERIAL123")
+
+    assert calls == [("com.example.app", "SERIAL123")]
+    registry.reset()
+
+
+def test_run_dynamic_checks_for_survives_a_failing_check(monkeypatch):
+    """Un check que lanza una excepción no debe tumbar el análisis completo
+    (a diferencia del bug de --launch, que sí perdía todo)."""
+    from nutcracker_core.checks import registry
+
+    registry.reset()
+
+    class _BrokenCheck:
+        class meta:
+            id = "DYN-BROKEN"
+
+        def run(self, ctx):
+            raise RuntimeError("dispositivo desconectado a mitad de camino")
+
+    monkeypatch.setattr("nutcracker_core.checks.load_registry", lambda: registry.register_dynamic(_BrokenCheck()))
+
+    orch._run_dynamic_checks_for(_FakeResult(), "SERIAL123")  # no debe lanzar
+    registry.reset()
diff --git a/tests/test_pdf_reporter_batch.py b/tests/test_pdf_reporter_batch.py
new file mode 100644
index 0000000..12e2224
--- /dev/null
+++ b/tests/test_pdf_reporter_batch.py
@@ -0,0 +1,30 @@
+"""Regresión: generate_batch_report() crasheaba con FPDFUnicodeEncodingException
+al renderizar 'Comparative Table — Protection Status' (em-dash \\u2014, fuente
+Helvetica no-Unicode) — encontrado corriendo `nutcracker batch` real contra
+hardware (2026-07-25), no relacionado con la migración de batch.py al motor
+de cola, pero bloqueaba su paso final (el PDF consolidado)."""
+
+from __future__ import annotations
+
+from nutcracker_core.pdf_reporter import generate_batch_report
+
+
+def test_generate_batch_report_does_not_crash_on_unicode_font_encoding(tmp_path):
+    apps = [
+        {
+            "target": "com.example.one", "package": "com.example.one",
+            "status": "protected", "findings": 2,
+            "critical": 0, "high": 1, "medium": 1, "low": 0,
+            "leaks": 0, "top_findings": [("HC001", "API key", "high")],
+            "categories": {"M1": "high"},
+        },
+        {
+            "target": "com.example.two", "package": "com.example.two",
+            "status": "unprotected", "findings": 0,
+            "critical": 0, "high": 0, "medium": 0, "low": 0,
+            "leaks": 0, "top_findings": [], "categories": {},
+        },
+    ]
+    out = generate_batch_report(apps, tmp_path / "batch_report.pdf")
+    assert out.exists()
+    assert out.stat().st_size > 0
diff --git a/tests/test_queue_engine.py b/tests/test_queue_engine.py
index 0e5f039..cd6701f 100644
--- a/tests/test_queue_engine.py
+++ b/tests/test_queue_engine.py
@@ -14,7 +14,7 @@
 
 import pytest
 
-from nutcracker_core.queue.engine import QueueEngine
+from nutcracker_core.queue.engine import QueueEngine, _extract_error_summary, _strip_ansi
 from nutcracker_core.store import db, repository
 
 
@@ -110,6 +110,52 @@ def fake_run(cmd, env=None, capture_output=True, text=True):  # noqa: ANN001
     assert e1 <= s2, "dos jobs dinámicos con el mismo serial se solaparon (el lock por device falló)"
 
 
+def test_aipwn_jobs_share_device_lock_with_dynamic_jobs(monkeypatch, tmp_path, engine):
+    """aipwn (agente de bypass) usa el mismo dispositivo físico que los checks
+    dinámicos -- deben serializarse entre sí por serial, igual que dos jobs
+    'dynamic' entre sí (Fase 3: wiring del agente en la cola)."""
+    intervals: list[tuple[float, float]] = []
+    lock = threading.Lock()
+
+    def fake_run(cmd, env=None, capture_output=True, text=True):  # noqa: ANN001
+        start = time.monotonic()
+        time.sleep(0.2)
+        end = time.monotonic()
+        with lock:
+            intervals.append((start, end))
+        return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
+
+    monkeypatch.setattr("nutcracker_core.queue.engine.subprocess.run", fake_run)
+
+    engine.submit(str(_touch_apk(tmp_path, "dyn.apk")), kind="dynamic", serial="SAME-SERIAL")
+    engine.submit("com.example.app", kind="aipwn", serial="SAME-SERIAL")
+
+    outcomes = engine.drain()
+    assert len(outcomes) == 2 and all(o.ok for o in outcomes)
+
+    (s1, e1), (s2, e2) = sorted(intervals)
+    assert e1 <= s2, "un job aipwn y uno dinámico con el mismo serial se solaparon"
+
+
+def test_aipwn_job_builds_aipwn_command(monkeypatch, engine):
+    seen_cmds = []
+
+    def fake_run(cmd, env=None, capture_output=True, text=True):  # noqa: ANN001
+        seen_cmds.append(cmd)
+        return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
+
+    monkeypatch.setattr("nutcracker_core.queue.engine.subprocess.run", fake_run)
+
+    engine.submit("com.example.tapjacking", kind="aipwn", serial="ZY22GPM27J")
+    outcomes = engine.drain()
+
+    assert len(outcomes) == 1 and outcomes[0].ok
+    cmd = seen_cmds[0]
+    assert "aipwn" in cmd
+    assert "com.example.tapjacking" in cmd
+    assert "--serial" in cmd and "ZY22GPM27J" in cmd
+
+
 def test_dynamic_jobs_different_serials_run_in_parallel(monkeypatch, tmp_path, engine):
     intervals: list[tuple[float, float]] = []
     lock = threading.Lock()
@@ -316,3 +362,96 @@ def test_enqueue_due_apps_only_queues_overdue_packages(tmp_path, engine):
     assert n == 1
     assert len(engine._pending) == 1
     assert engine._pending[0].target == "com.overdue.app"
+
+
+# ── _extract_error_summary (fix encontrado en prueba con dispositivo real) ──
+
+def test_extract_error_summary_surfaces_error_line_buried_by_later_output():
+    """Reproduce el bug real: 'Failed to spawn: ... No route to host' seguido
+    de una tabla larga de hallazgos — un tail[-2000:] ciego lo perdía."""
+    noise = "\n".join(f"│ tabla de hallazgos línea {i:03d} sin relación │" for i in range(200))
+    output = (
+        "Failed to spawn: unable to connect to remote frida-server: "
+        "Could not connect to 192.168.1.4: No route to host\n" + noise
+    )
+    summary = _extract_error_summary(output, max_len=500)
+    assert "No route to host" in summary
+
+
+def test_extract_error_summary_falls_back_to_tail_without_error_markers():
+    output = "línea 1\nlínea 2\nlínea 3 sin nada especial"
+    assert _extract_error_summary(output) == output
+
+
+def test_extract_error_summary_empty_output():
+    assert _extract_error_summary("") == ""
+
+
+def test_extract_error_summary_respects_max_len():
+    output = "Error: algo salió mal\n" + ("x" * 5000)
+    summary = _extract_error_summary(output, max_len=500)
+    assert len(summary) <= 500 + len("\n---\n")  # margen del separador
+
+
+# ── ANSI/color (fix encontrado en el dashboard: banner coloreado de rich ──
+# volcado como texto crudo en el panel de logs cuando el entorno padre tiene
+# FORCE_COLOR/COLORTERM, p.ej. terminal integrada de VS Code) ───────────────
+
+def test_strip_ansi_removes_color_codes_but_keeps_text():
+    raw = "\x1b[38;2;68;68;68m▄\x1b[0m normal \x1b[31mrojo\x1b[0m"
+    assert _strip_ansi(raw) == "▄ normal rojo"
+
+
+def test_strip_ansi_removes_osc8_hyperlinks():
+    raw = "\x1b]8;;https://nutcracker.sh\x1b\\nutcracker.sh\x1b]8;;\x1b\\"
+    assert _strip_ansi(raw) == "nutcracker.sh"
+
+
+def test_strip_ansi_noop_on_plain_text():
+    assert _strip_ansi("sin nada especial\nsegunda línea") == "sin nada especial\nsegunda línea"
+
+
+def test_run_job_forces_no_color_and_clears_force_color(monkeypatch, tmp_path, engine):
+    """El subproceso de un job nunca escribe a una terminal real (su stdout
+    termina en un pipe leído por el motor de la cola) — si el proceso padre
+    heredó FORCE_COLOR/COLORTERM=truecolor, rich igual emitiría ANSI real
+    hacia ese pipe. NO_COLOR es lo único que rich respeta por encima de
+    FORCE_COLOR, así que _run_job debe forzarlo y limpiar FORCE_COLOR."""
+    seen_env = {}
+
+    def fake_run(cmd, env=None, capture_output=True, text=True):  # noqa: ANN001
+        seen_env.update(env or {})
+        return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
+
+    monkeypatch.setattr("nutcracker_core.queue.engine.subprocess.run", fake_run)
+    monkeypatch.setenv("FORCE_COLOR", "1")
+    monkeypatch.setenv("COLORTERM", "truecolor")
+
+    engine.submit(str(_touch_apk(tmp_path, "color.apk")), kind="static")
+    outcomes = engine.drain()
+
+    assert outcomes[0].ok is True
+    assert seen_env.get("NO_COLOR") == "1"
+    assert "FORCE_COLOR" not in seen_env
+    # COLORTERM por sí solo no fuerza is_terminal en rich sin isatty(); no hace
+    # falta limpiarlo, pero NO_COLOR ya manda por encima de cualquier combinación.
+
+
+def test_run_streaming_strips_ansi_before_publishing_lines(monkeypatch, tmp_path, engine):
+    class _FakePopen:
+        def __init__(self, cmd, env=None, stdout=None, stderr=None, text=None, bufsize=None):
+            self.stdout = iter(["\x1b[31mlínea con color\x1b[0m\n", "línea normal\n"])
+            self.returncode = 0
+
+        def wait(self):
+            return self.returncode
+
+    monkeypatch.setattr("nutcracker_core.queue.engine.subprocess.Popen", _FakePopen)
+
+    received: list[str] = []
+    engine.on_line = lambda job_id, line: received.append(line)
+
+    engine.submit(str(_touch_apk(tmp_path, "streamcolor.apk")), kind="static")
+    engine.drain()
+
+    assert received == ["línea con color", "línea normal"]
diff --git a/tests/test_vuln_scanner_split.py b/tests/test_vuln_scanner_split.py
index b1a2be3..fce9cd0 100644
--- a/tests/test_vuln_scanner_split.py
+++ b/tests/test_vuln_scanner_split.py
@@ -83,3 +83,75 @@ def test_scan_with_gitleaks_returns_empty_without_binary(tmp_path, monkeypatch):
     findings = vuln_scanner.scan_with_gitleaks(tmp_path)
 
     assert findings == []
+
+
+# ── Reglas nuevas: cierre de cobertura OWASP MAS (2026-07-25) ────────────────
+
+def test_crypto007_detects_key_derived_directly_from_password(tmp_path):
+    java_file = tmp_path / "Crypto.java"
+    java_file.write_text(
+        'public class Crypto {\n'
+        '    SecretKeySpec key = new SecretKeySpec(userPassword.getBytes(), "AES");\n'
+        '}\n',
+        encoding="utf-8",
+    )
+    result = vuln_scanner.scan_directory(tmp_path)
+    assert "CRYPTO007" in {f.rule_id for f in result.findings}
+
+
+def test_crypto007_ignores_key_from_secure_source(tmp_path):
+    java_file = tmp_path / "Crypto.java"
+    java_file.write_text(
+        'public class Crypto {\n'
+        '    SecretKeySpec key = new SecretKeySpec(derivedKeyBytes, "AES");\n'
+        '}\n',
+        encoding="utf-8",
+    )
+    result = vuln_scanner.scan_directory(tmp_path)
+    assert "CRYPTO007" not in {f.rule_id for f in result.findings}
+
+
+def test_auth002_detects_deprecated_fingerprint_manager(tmp_path):
+    java_file = tmp_path / "Auth.java"
+    java_file.write_text(
+        'import android.hardware.fingerprint.FingerprintManager;\n'
+        'public class Auth {}\n',
+        encoding="utf-8",
+    )
+    result = vuln_scanner.scan_directory(tmp_path)
+    assert "AUTH002" in {f.rule_id for f in result.findings}
+
+
+def test_auth002_ignores_biometric_prompt(tmp_path):
+    java_file = tmp_path / "Auth.java"
+    java_file.write_text(
+        'import androidx.biometric.BiometricPrompt;\n'
+        'public class Auth {}\n',
+        encoding="utf-8",
+    )
+    result = vuln_scanner.scan_directory(tmp_path)
+    assert "AUTH002" not in {f.rule_id for f in result.findings}
+
+
+def test_privacy001_detects_imei_tracking(tmp_path):
+    java_file = tmp_path / "Device.java"
+    java_file.write_text(
+        'public class Device {\n'
+        '    String id = telephonyManager.getImei();\n'
+        '}\n',
+        encoding="utf-8",
+    )
+    result = vuln_scanner.scan_directory(tmp_path)
+    assert "PRIVACY001" in {f.rule_id for f in result.findings}
+
+
+def test_privacy001_ignores_unrelated_getters(tmp_path):
+    java_file = tmp_path / "Device.java"
+    java_file.write_text(
+        'public class Device {\n'
+        '    String id = UUID.randomUUID().toString();\n'
+        '}\n',
+        encoding="utf-8",
+    )
+    result = vuln_scanner.scan_directory(tmp_path)
+    assert "PRIVACY001" not in {f.rule_id for f in result.findings}

From 456174a948b63ad458387dfa3eb5a85c4c24b4f2 Mon Sep 17 00:00:00 2001
From: Hiteek 
Date: Mon, 27 Jul 2026 01:55:41 -0500
Subject: [PATCH 20/30] improves 2

---
 docs/aipwn-flow.md              | 479 ++++++++++++++++++++++++++++++++
 docs/static-analysis-flow.md    | 320 +++++++++++++++++++++
 nutcracker_core/orchestrator.py |  17 ++
 nutcracker_core/queue/engine.py |  67 ++++-
 nutcracker_core/store/hooks.py  |  13 +
 plan.md                         |  47 ++++
 tests/test_orchestrator.py      |  63 +++++
 tests/test_queue_engine.py      | 123 +++++++-
 tests/test_store_hooks.py       |  42 +++
 9 files changed, 1167 insertions(+), 4 deletions(-)
 create mode 100644 docs/aipwn-flow.md
 create mode 100644 docs/static-analysis-flow.md

diff --git a/docs/aipwn-flow.md b/docs/aipwn-flow.md
new file mode 100644
index 0000000..78bb3d1
--- /dev/null
+++ b/docs/aipwn-flow.md
@@ -0,0 +1,479 @@
+
+> Documento generado a partir del análisis del código en
+> `nutcracker_core/plugins/aipwn/`.
+> Última revisión: 2026-07-27.
+
+## Resumen
+
+`python3 nutcracker.py aipwn ` ejecuta un **agente autónomo ReAct con
+LLM** que bypasea protecciones Android (root detection, SSL pinning, anti-Frida,
+emulator detection, signature verification) usando Frida, y luego opcionalmente
+confirma vulnerabilidades en runtime mediante un ExploitAgent.
+
+## Diagrama general
+
+```
+┌─────────────────────────────────────────────────────────────────────┐
+│  nutcracker aipwn             [__init__.py::register()]     │
+│  --serial / --max-runs / --capture-sec / --force / --only-bypass    │
+│  --report                                                            │
+└──────────────────────────────────────────────────────────────────────┘
+                                   ▼
+┌─────────────────────────────────────────────────────────────────────┐
+│  1. Carga de contexto              [__init__.py::aipwn_cmd]          │
+│     ── _load_analysis_json(package) → AnalysisResult previo          │
+│     ── Busca decompiled// o runtime_dump_/        │
+│     ── _load_scan_result(package) → ScanResult (vuln.json)           │
+│         ├─ Si no existe vuln.json → live scan (scan_directory)       │
+│         └─ Enriquece con manifest components                        │
+│     ── _init_i18n(config)                                            │
+└──────────────────────────────────────────────────────────────────────┘
+                                   ▼
+┌─────────────────────────────────────────────────────────────────────┐
+│  2. run_aipwn()                    [aipwn.py]                        │
+│     ── Setup TeeWriter (log a logs/__aipwn.txt)            │
+│     ── Crea aipwn_memory/                                           │
+│     ── _run_aipwn_inner():                                          │
+│        ├─ Paso 0: ¿app instalada? → _auto_download_apk + install    │
+│        ├─ Paso 1: script previo (si no --force)                     │
+│        ├─ Paso 2: FridaAgent.run() ← EL CEREBRO                     │
+│        └─ Modo exploit (si bypass OK y scan_result): ExploitAgent   │
+└──────────────────────────────────────────────────────────────────────┘
+                                   ▼
+┌─────────────────────────────────────────────────────────────────────┐
+│  3. Paso 1 — Script previo         [aipwn.py::_find_latest_script]  │
+│     Busca frida_scripts/bypass___agent.js              │
+│     ── Si existe y no --force:                                      │
+│        ├─ launch_frida_capture(script)                              │
+│        └─ Si success → retorna inmediatamente (sin llamar al LLM)   │
+│     ── Si falla o no existe → continúa al agente LLM                │
+└──────────────────────────────────────────────────────────────────────┘
+                                   ▼
+┌─────────────────────────────────────────────────────────────────────┐
+│  4. FridaAgent — Loop ReAct        [frida_agent.py]                  │
+│     System prompt (fase 1-3, reglas de terminación)                 │
+│     + Contexto inicial (package, protecciones, memoria pasada)      │
+│                                                                      │
+│     while not terminated and iter < max_llm_iterations:              │
+│       1. _check_operator_chat() ← poll dashboard mailbox (Fase 3)    │
+│       2. Anti-bucle pressure (si N iters sin run_frida_script)       │
+│       3. LLMClient.chat(messages, tools=TOOL_SCHEMAS)                │
+│          ├─ any-llm SDK (OpenAI/Anthropic/Ollama)                    │
+│          ├─ Retry con backoff (429, context too long)                │
+│          └─ Auto-fallback: sin imágenes si vision no soportado       │
+│       4. Mostrar thinking + "Nutcracker says"                       │
+│       5. Por cada tool_call en response.tool_calls:                  │
+│          ├─ dispatch_tool(ctx, name, args)                           │
+│          ├─ Inyecta hints dinámicos según el resultado               │
+│          ├─ Detecta señales Phase 2b (SIGSEGV, watchdog, etc.)      │
+│          ├─ Detecta ClassLoader integrity fail → inyecta prompt      │
+│          └─ Si report_success/report_failure → terminated=True       │
+│       6. _prune_messages() si contexto > 60KB                       │
+│                                                                      │
+│     Al terminar:                                                    │
+│     ── _write_bypass_result() → reports//bypass_result.json    │
+│     ── _save_memory() → aipwn_memory/_memory.json              │
+│     → AgentResult(success, script_path, explanation, ...)           │
+└──────────────────────────────────────────────────────────────────────┘
+                                   ▼
+┌─────────────────────────────────────────────────────────────────────┐
+│  5. 27 Herramientas del agente    [frida_agent_tools.py]            │
+│     (definidas en TOOL_SCHEMAS, dispatchadas por dispatch_tool)     │
+│                                                                      │
+│     Análisis estático (no consumen Frida run):                       │
+│     ── get_app_analysis: protecciones del AnalysisResult             │
+│     ── get_heuristic_bypass_script: script base cacheado servidor   │
+│     ── read_decompiled_class: lee .java decompilado                  │
+│     ── search_in_decompiled: grep sobre código fuente                │
+│     ── list_classes_matching: busca clases por keyword               │
+│     ── enumerate_runtime_classes: clases reales en runtime (Frida)   │
+│     ── get_class_methods: firmas exactas de métodos                  │
+│     ── get_certificate_pins: lee network_security_config.xml         │
+│                                                                      │
+│     Ejecución Frida (consumen run slot):                             │
+│     ── run_frida_script: ejecuta JS en device (on_frida_run cb)     │
+│     ── relaunch_with_gadget: inyecta gadget y reinstala              │
+│     ── patch_native_lib: parchea .so, repaqueta, firma, reinstala    │
+│                                                                      │
+│     Diagnóstico (no consumen run):                                   │
+│     ── probe_security_violations: captura stack trace del bloqueo    │
+│     ── take_screenshot: captura pantalla → mensaje visual al LLM     │
+│     ── sniff_network_calls: captura HTTP fallido (SSL, 4xx)          │
+│     ── trace_method_execution: Stalker sobre una clase/método        │
+│     ── get_frida_output_history: stdout+logcat de runs anteriores    │
+│                                                                      │
+│     Native (no consumen run):                                        │
+│     ── get_loaded_native_libs: lista .so cargados                    │
+│     ── enumerate_native_exports: exports de un .so                   │
+│     ── strings_native_lib: strings de un .so con filtro              │
+│     ── resolve_native_symbol: dirección de un símbolo                │
+│     ── disassemble_native_lib: objdump/r2 a un offset                │
+│     ── pull_apk_from_device: extrae base+splits del device           │
+│     ── get_apk_signature: firma original del APK                     │
+│                                                                      │
+│     Terminación:                                                    │
+│     ── report_success: marca bypass OK, guarda script                │
+│     ── report_failure: marca fallo con razón                        │
+└──────────────────────────────────────────────────────────────────────┘
+                                   ▼
+┌─────────────────────────────────────────────────────────────────────┐
+│  6. frida_capture                  [frida_capture.py]                │
+│     launch_frida_capture(package, script_js, serial, duration):     │
+│     ── setup_frida_server() si hace falta (push + chmod + run)      │
+│     ── frida -U -s script.js -f package (spawn) o attach            │
+│     ── Captura stdout del script + logcat por N segundos             │
+│     ── _parse_result(): analiza outputs y construye FridaRunResult  │
+│        ├─ success/bypass_confirmed                                  │
+│        ├─ security_blocked (custom RASP detectó bypass)             │
+│        ├─ app_running/app_crashed                                   │
+│        ├─ emulator_detected                                         │
+│        ├─ hooks_failed[] (ClassNotFoundException, etc.)             │
+│        ├─ crash_lines[] (FATAL EXCEPTION, stack trace)              │
+│        ├─ sigsegv_in_native_lib                                     │
+│        ├─ terminated_on_detach (watchdog nativo)                    │
+│        └─ classloader_integrity_failed                              │
+└──────────────────────────────────────────────────────────────────────┘
+                                   ▼
+┌─────────────────────────────────────────────────────────────────────┐
+│  7. ExploitAgent (opcional)        [exploit_agent.py]                │
+│     Solo si: bypass OK + scan_result + exploit_mode=True             │
+│                                                                      │
+│     Recibe ScanResult.findings → por cada VulnFinding:               │
+│     ── EXPLOIT_STRATEGY[rule_id] → tool a llamar                     │
+│        ├─ send_deeplink (INJ004)                                    │
+│        ├─ send_broadcast (COMP004)                                  │
+│        ├─ start_exported_component (COMP006/7, COMP001/2/3/5, INFO)  │
+│        ├─ read_shared_prefs (ST001)                                 │
+│        ├─ read_app_file (ST002/3/4)                                 │
+│        ├─ confirm_cleartext_traffic (NET001/2)                      │
+│        ├─ inject_sql_payload (INJ001, COMP008)                      │
+│        ├─ read_native_secret (NAT004)                               │
+│        └─ None → report directo (HC*, NAT sin confirmación)         │
+│     ── Genera PoC, ejecuta, observa (screenshot/logcat/file)        │
+│     ── ExploitReport con PoCResults (confirmed, poc, evidence)      │
+└──────────────────────────────────────────────────────────────────────┘
+                                   ▼
+┌─────────────────────────────────────────────────────────────────────┐
+│  8. Persistencia + Reporte                                         │
+│     ── aipwn_memory/_memory.json: sesión persistente            │
+│     ── reports//bypass_result.json: resultado del bypass        │
+│     ── reports//exploit_report_.json: exploit (si --report)│
+│     ── reports//exploit_report_.pdf: PDF standalone        │
+│     ── reports//nutcracker__full_report.pdf: fusionado    │
+│     ── frida_scripts/bypass___agent.js: script reutilizable│
+│     ── logs/__aipwn.txt: log completo de la sesión         │
+│     ── AnalysisResult.aipwn_bypass_confirmed=True (para MASVS)      │
+└─────────────────────────────────────────────────────────────────────┘
+```
+
+## Detalle por componente
+
+### 1. Entrada CLI (`plugins/aipwn/__init__.py`)
+
+El comando `nutcracker aipwn ` acepta:
+
+| Opción | Descripción |
+|---|---|
+| `--serial`, `-s` | Serial ADB del dispositivo (default: USB) |
+| `--max-runs` | Máximo de ejecuciones Frida (default: `aipwn.max_frida_runs`) |
+| `--capture-sec` | Segundos de observación por run (default: `aipwn.capture_seconds`) |
+| `--force` | Omite validación de script previo, va directo al LLM |
+| `--only-bypass` | Solo bypass, sin ExploitAgent |
+| `--report` | Guarda exploit results como JSON + PDF |
+
+Antes de llamar a `run_aipwn()`, carga contexto:
+
+- `_load_analysis_json(package)` → lee el último `AnalysisResult` de
+  `reports//`. Si no existe, el agente opera sin contexto de
+  protecciones detectadas estáticamente.
+- `_load_scan_result(package)` → lee el `vuln.json` para alimentar al
+  ExploitAgent. Si no existe, cae a **live-scan** (`scan_directory()` sobre el
+  directorio decompilado). Esto produce el mensaje:
+  `[aipwn] no vuln JSON found — running live scan on decompiled/`.
+- `_init_i18n(config)` → inicializa traducciones.
+
+Si el bypass se confirma (`agent_result.success`), se persiste el flag
+`aipwn_bypass_confirmed=True` en el `AnalysisResult` (re-escrito a disco) para
+que `build_masvs_report()` lo recoja en futuras corridas — un bypass confirmado
+en runtime es evidencia válida de `RESILIENCE` roto aunque no se haya volcado
+DEX en memoria.
+
+### 2. Orquestador (`aipwn.py::run_aipwn`)
+
+`run_aipwn()` envuelve la lógica en un `_TeeWriter` que duplica toda la salida
+ANSI del terminal a `logs/__aipwn.txt` (sin códigos ANSI). Aplica a
+todas las consolas (`aipwn.py`, `frida_agent.py`, `frida_capture.py`,
+`frida_agent_tools.py`) para que nada se pierda.
+
+`_run_aipwn_inner()` hace:
+
+1. **Paso 0 — App instalada:** si `check_app_installed()` devuelve `False`,
+   intenta `_auto_download_apk()` (Google Play con AAS token o APKPure) e
+   `_adb_install()` (maneja splits, firma incompatible, abis).
+2. **Paso 1 — Script previo:** si no `--force`, busca el último
+   `bypass__*.js`. Si existe y pasa `launch_frida_capture()`, retorna
+   éxito inmediatamente **sin llamar al LLM** (ahorro de tokens).
+3. **Paso 2 — FridaAgent:** crea el agente con todo el contexto y llama
+   `agent.run()`.
+4. **Modo exploit:** si `exploit_mode=True`, `result.success=True` y hay
+   `scan_result`, lanza `ExploitAgent`.
+
+### 3. FridaAgent (`frida_agent.py`)
+
+El corazón del módulo. Es un **loop ReAct** (Reason + Act) con function calling.
+
+#### System prompt
+
+Define una estrategia de escalada en **3 fases obligatorias**:
+
+- **Fase 1 — Java-level bypass:**
+  1. `get_app_analysis` → entiende qué protecciones hay.
+  2. `get_heuristic_bypass_script` → script base probado (cacheado en servidor)
+     con hooks para File.exists, Runtime.exec, PackageManager, RootBeer,
+     Build fields spoof, Frida detection hiding, etc.
+  3. `get_certificate_pins` si hay SSL pinning (lee
+     `network_security_config.xml`).
+  4. `probe_security_violations` o `read_decompiled_class` para encontrar RASP
+     custom.
+  5. `list_classes_matching` / `enumerate_runtime_classes` para clases
+     relevantes.
+  6. `get_class_methods` para firmas exactas.
+  7. Escribe solo los hooks adicionales necesarios + siempre incluye
+     `FLAG_SECURE` bypass.
+  8. `run_frida_script` con `extend_heuristic_base=true`.
+
+- **Fase 2 — Native-level escalation (obligatoria si Java falla):**
+  - Señales de activación: `security_blocked=True`, `app_running=False`,
+    `app_crashed=True`, `hooks_failed` de SSL.
+  - `get_loaded_native_libs` → `enumerate_native_exports` → `Interceptor.attach`
+    sobre `SSL_CTX_set_verify`, `SSL_do_handshake`, `CONSCRYPT_checkServerTrusted`,
+    etc.
+
+- **Fase 3 — Universal fallback (obligatoria si Fase 2 falla):**
+  - Reflection-based SSL bypass (`HttpsURLConnection`,
+    `OkHttpClient.Builder`).
+  - `Java.deoptimizeEverything()`.
+  - Spoof de signature (`PackageManager.getPackageInfo`).
+
+#### Sub-prompts condicionales
+
+Se **inyectan dinámicamente** cuando el loop detecta señales específicas:
+
+- **`_PHASE2B_PROMPT`** — RASP self-integrity bypass. Se activa cuando:
+  `sigsegv_in_native_lib=True`, o muerte instantánea con
+  `extend_heuristic_base=True` (DT_PREINIT_ARRAY), o `terminated_on_detach`
+  (watchdog nativo). Estrategias: spawn gating, `Memory.patchCode()`, binary
+  patch permanente.
+
+- **`_CLASSLOADER_INTEGRITY_PROMPT`** — DexGuard / APK tamper detection. Se
+  activa cuando el crash contiene "tamper"/"signature",
+  `INSTALL_FAILED_TEST_ONLY`, o crash tras `relaunch_with_gadget`. Estrategias:
+  spoof de signature in-memory, hook ClassLoader con spawn gating, patch nativo
+  del check.
+
+#### LLMClient
+
+Usa `any-llm-sdk` (`from any_llm import completion`) que abstrae OpenAI,
+Anthropic y Ollama con una interfaz unificada. Maneja:
+
+- **API key vía env var** (mecanismo más fiable): `OPENAI_API_KEY`,
+  `ANTHROPIC_API_KEY`.
+- **Thinking blocks:** extrae `reasoning_content` (DeepSeek thinking mode) o
+  bloques `thinking` (Anthropic extended thinking).
+- **Auto-fallback de visión:** si el error indica que el proveedor no soporta
+  imágenes (`_is_vision_unsupported_error`), reintenta sin los bloques de imagen
+  (`_strip_image_blocks`).
+- **Retry con backoff:** 3 intentos. Backoff exponencial para 429/rate limit,
+  poda agresiva de contexto para "prompt too long", sin reintento para
+  quota/credits exhausted.
+
+#### Mecanismos anti-bucle
+
+- **Pressure message:** si pasan `max_llm_iterations // 3` iteraciones sin
+  `run_frida_script`, se inyecta un mensaje `[SYSTEM]` forzando al agente a
+  actuar. Se repite cada iteración hasta que ejecute Frida.
+- **Bloqueo de duplicados:** `enumerate_runtime_classes` con el mismo patrón
+  devuelve un mensaje `[SYSTEM]` prohibiendo repetir la búsqueda.
+- **Context pruning:** `_prune_messages()` recorta resultados de herramientas
+  antiguas (`role=tool`) a 300 chars cuando el historial supera 60KB. Protege
+  system prompt, primer user, y últimos 4 mensajes.
+
+#### Terminación inteligente (anti-alucinación)
+
+El system prompt tiene **reglas estrictas** para evitar falsos positivos de
+`report_success`:
+
+1. Tras cualquier `run_frida_script` con `bypass_confirmed=true`, el LLM debe
+   llamar `take_screenshot` PRIMERO.
+2. Debe describir explícitamente qué ve:
+   - Elemento interactivo (botón, campo, label, icono), o
+   - Color de marca + panel de contenido no vacío.
+3. **Patrones de razonamiento prohibidos** (hardcodeados):
+   - "bypass_confirmed=true por tanto éxito"
+   - "app_running=true por tanto funciona" (splash atascado también tiene
+     `app_running=true`)
+   - "sesiones pasadas mostraron éxito" (alucinación — no hay memoria in-LLM)
+   - "pantalla oscura es el theme"
+   - "tomemos otro screenshot para verificar" (si ya hay colores de marca +
+     panel, está confirmado)
+
+#### Wiring del dashboard (`_check_operator_chat`)
+
+Si la corrida viene de un job de la cola lanzado por el dashboard, la variable
+`NUTCRACKER_DASHBOARD_URL` apunta a la API local. Antes de cada turno del LLM,
+se hace poll best-effort de `/api/chat//pending`. Si hay mensajes del
+operador, se inyectan como `role: user` reales — el LLM los ve y puede actuar en
+consecuencia. Sin dashboard (uso CLI normal), es un no-op inmediato. Cualquier
+fallo de red se ignora en silencio.
+
+### 4. Herramientas (`frida_agent_tools.py`)
+
+27 herramientas definidas en `TOOL_SCHEMAS` (JSON Schema para function calling),
+dispatchadas por `dispatch_tool(ctx, name, args, next_run_idx)`.
+
+**`ToolContext`** es un dataclass que lleva todo el estado compartido: package,
+`decompiled_dir`, `analysis_result`, serial, `frida_host`, `capture_seconds`,
+`scripts_dir`, callback `on_frida_run`, `runtime_dump_dir`.
+
+| Categoría | Herramientas | Consume run |
+|---|---|---|
+| Análisis estático | `get_app_analysis`, `get_heuristic_bypass_script`, `read_decompiled_class`, `search_in_decompiled`, `list_classes_matching`, `enumerate_runtime_classes`, `get_class_methods`, `get_certificate_pins` | No |
+| Ejecución Frida | `run_frida_script`, `relaunch_with_gadget`, `patch_native_lib` | Sí |
+| Diagnóstico | `probe_security_violations`, `take_screenshot`, `sniff_network_calls`, `trace_method_execution`, `get_frida_output_history` | No |
+| Native | `get_loaded_native_libs`, `enumerate_native_exports`, `strings_native_lib`, `resolve_native_symbol`, `disassemble_native_lib`, `pull_apk_from_device`, `get_apk_signature` | No |
+| Terminación | `report_success`, `report_failure` | — |
+
+Cada herramienta devuelve un string JSON que el LLM ve como `role: tool`.
+Además, tras cada `run_frida_script`, el loop inyecta **hints dinámicos** según
+el resultado: `security_blocked` → instrucciones de RASP custom,
+`ClassNotFoundException` → patrón de ClassLoader isolation, `app_crashed` →
+"analiza crash_lines, no reportes fallo todavía".
+
+### 5. Captura Frida (`frida_capture.py`)
+
+`launch_frida_capture(package, script_js, serial, duration)`:
+
+1. `setup_frida_server()` si hace falta — push del `frida-server` matching arch,
+   chmod +x, run en background.
+2. Ejecuta `frida -U -s script.js -f package` (spawn) o attach.
+3. Captura stdout del script + logcat por `duration` segundos.
+4. `_parse_result()` analiza los outputs y construye `FridaRunResult`:
+
+| Campo | Significado |
+|---|---|
+| `success` / `bypass_confirmed` | Heurísticas indican bypass exitoso |
+| `security_blocked` | Custom RASP detectó el bypass |
+| `app_running` | La app sigue viva tras el run |
+| `app_crashed` | FATAL EXCEPTION o SIGKILL |
+| `emulator_detected` | Logcat muestra detección de emulador |
+| `hooks_failed[]` | Hooks que fallaron (ClassNotFoundException, etc.) |
+| `crash_lines[]` | Stack trace del crash |
+| `sigsegv_in_native_lib` | SIGSEGV dentro de lib de seguridad |
+| `terminated_on_detach` | Watchdog nativo mata al desatachar |
+| `classloader_integrity_failed` | ClassLoader integrity check |
+
+### 6. ExploitAgent (`exploit_agent.py`)
+
+Segundo agente LLM que corre **solo si** bypass OK + `scan_result` +
+`exploit_mode=True`. Recibe los `VulnFinding` del análisis estático y, por cada
+uno, decide una estrategia vía `EXPLOIT_STRATEGY[rule_id]`:
+
+| rule_id | Estrategia |
+|---|---|
+| `INJ004` | `send_deeplink` |
+| `COMP004` | `send_broadcast` |
+| `COMP006/7`, `COMP001/2/3/5` | `start_exported_component` |
+| `INFO001` | `start_exported_component` (debuggable) |
+| `ST001` | `read_shared_prefs` |
+| `ST002/3/4` | `read_app_file` |
+| `NET001/2` | `confirm_cleartext_traffic` |
+| `INJ001`, `COMP008` | `inject_sql_payload` |
+| `NAT004` | `read_native_secret` |
+| `HC*`, `NAT*` (otros) | `None` — report directo (no requiere runtime) |
+
+Genera un PoC, lo ejecuta, observa (screenshot/logcat/file), y produce
+`ExploitReport` con `PoCResults` (confirmed, poc, evidence).
+
+### 7. Memoria persistente (`agent_memory.py`)
+
+`aipwn_memory/_memory.json` guarda las últimas 10 sesiones:
+
+- **Hooks que funcionaron** (reutilizar).
+- **Hooks que fallaron** (evitar).
+- **Clases confirmadas en runtime** (seguras para `Java.use`).
+- **Clases de análisis estático** (pueden diferir del runtime; verificar con
+  `enumerate_runtime_classes` antes de `Java.use`).
+- Notas del outcome.
+
+`build_memory_context()` construye un bloque de texto que se inyecta en el
+primer mensaje del agente para que **no repita errores** en corridas futuras.
+
+### 8. Persistencia + Reporte
+
+| Salida | Ruta |
+|---|---|
+| Script reutilizable | `frida_scripts/bypass___agent.js` |
+| Memoria de sesión | `aipwn_memory/_memory.json` |
+| Resultado del bypass | `reports//bypass_result.json` |
+| Log completo | `logs/__aipwn.txt` |
+| Exploit JSON | `reports//exploit_report_.json` |
+| Exploit PDF standalone | `reports//exploit_report_.pdf` |
+| PDF fusionado | `reports//nutcracker__full_report.pdf` |
+| Flag MASVS | `AnalysisResult.aipwn_bypass_confirmed=True` |
+
+## Configuración (`config.yaml`)
+
+```yaml
+llm:
+  provider: openai        # openai | anthropic | ollama
+  model: gpt-4o
+  api_key: sk-...
+  base_url: ...           # opcional, para endpoints OpenAI-compatible
+  max_tokens: 4096
+  timeout: 120
+
+aipwn:
+  max_frida_runs: 5
+  max_llm_iterations: 30
+  capture_seconds: 30
+  scripts_dir: frida_scripts
+  show_thinking: true
+```
+
+## Limitación conocida — Mensajes multimodales con z.ai/GLM
+
+El proveedor OpenAI-compatible z.ai/GLM rechaza el formato de mensaje
+multimodal (imagen) que `frida_agent.py` construye para `take_screenshot`:
+
+```
+LLM error: ... "messages.content.type is invalid, allowed values: ['text']"
+```
+
+`LLMClient.chat()` tiene un fallback automático
+(`_is_vision_unsupported_error` → reintenta sin imágenes), pero esto significa
+que el agente pierde la capacidad de ver la pantalla — crítica para la regla de
+terminación que exige confirmación visual antes de `report_success`. Ver
+`ROADMAP.md` sección *aipwn*.
+
+## Integración con el dashboard
+
+Cuando `aipwn` se lanza como job de la cola (`queue add --aipwn`):
+
+- El dashboard setea `NUTCRACKER_DASHBOARD_URL`.
+- `FridaAgent._check_operator_chat()` hace poll del mailbox
+  (`/api/chat//pending`) antes de cada turno del LLM.
+- Los mensajes del operador se inyectan como `role: user` reales.
+- El razonamiento del agente se streamea por el WebSocket de live-logs.
+
+El motor de colas (`queue/engine.py`) usa `orchestrator.build_job_cmd(aipwn=True)`
+para lanzar el análisis como subproceso aislado, reusando todo el flujo descrito
+aquí.
+
+## Referencias cruzadas
+
+- `docs/static-analysis-flow.md` — flujo del análisis estático que alimenta a
+  aipwn.
+- `docs/owasp-mas-coverage.md` — cobertura MASVS (incluye `RESILIENCE-*` que
+  aipwn puede confirmar).
+- `ROADMAP.md` — limitación multimodal, verdict dual.
+- `plan.md` — Fase 3 (wiring del dashboard con el agente).
\ No newline at end of file
diff --git a/docs/static-analysis-flow.md b/docs/static-analysis-flow.md
new file mode 100644
index 0000000..716c4ea
--- /dev/null
+++ b/docs/static-analysis-flow.md
@@ -0,0 +1,320 @@
+# Flujo de análisis estático de nutcracker
+
+> Documento generado a partir del análisis del código en `nutcracker_core/`.
+> Última revisión: 2026-07-27.
+
+## Resumen
+
+El flujo está centralizado en `orchestrator.py::_run_analysis()` (línea 446),
+que orquesta toda la cadena desde la APK hasta el reporte final. Esto es lo que
+pasa, paso a paso, cuando se corre `nutcracker analyze ` o
+`nutcracker scan `.
+
+## Diagrama general
+
+```
+┌─────────────────────────────────────────────────────────────────────┐
+│  nutcracker analyze  / scan                               │
+│  (CLI en cli/analyze.py o cli/scan.py → orchestrator._run_analysis) │
+└──────────────────────────────────────────────────────────────────────┘
+                                   ▼
+┌─────────────────────────────────────────────────────────────────────┐
+│  1. APKAnalyzer.analyze(apk_path)              [analyzer.py]        │
+│     ── Androguard: AnalyzeAPK() → apk, dex, dx                     │
+│     ── Extrae metadata: package, version, min/target SDK            │
+│     ── Detectores anti-root (ALL_DETECTORS, 8 en total):            │
+│        KnownLibraries, SafetyNet, ManualChecks, Magisk,             │
+│        DexGuard, Appdome, SignatureCheck, CertificatePinning        │
+│        (engine "native" usa los builtin; "apkid" usa APKiD)         │
+│     → AnalysisResult (protected?, confidence, detections[])         │
+└──────────────────────────────────────────────────────────────────────┘
+                                   ▼
+┌─────────────────────────────────────────────────────────────────────┐
+│  2. _post_analysis_flow(result, apk_path)      [orchestrator.py:615]│
+│     Decide el método de decompilación según protección detectada:   │
+│                                                                     │
+│     ├─ ¿DexGuard? → _do_dexguard_deobf():                           │
+│     │     frida-dexdump (memoria) → FART fallback → jadx fallback   │
+│     │     → _decompile_and_scan()                                   │
+│     │                                                               │
+│     └─ Sin DexGuard → _do_decompile():                              │
+│           jadx directo sobre el APK                                 │
+│           → _do_manifest_scan + _do_vuln_scan + _do_osint_scan      │
+└──────────────────────────────────────────────────────────────────────┘
+                                   ▼
+┌─────────────────────────────────────────────────────────────────────┐
+│  3. Decompilación                               [decompiler.py]     │
+│     get_available_tool() → jadx (preferido) o apktool (fallback)    │
+│     decompile(apk, ./decompiled/)                                   │
+│     → ./decompiled//sources/*.java                         │
+│     → ./decompiled//resources/AndroidManifest.xml          │
+└──────────────────────────────────────────────────────────────────────┘
+                                   ▼
+┌─────────────────────────────────────────────────────────────────────┐
+│  4. Manifest scan                   [manifest_analyzer.py]          │
+│     _do_manifest_scan() → analyze_decompiled_dir()                 │
+│     Busca misconfigs: debuggable, allowBackup, cleartext traffic,   │
+│     exported components, targetSdk bajo, network security config    │
+│     → Misconfiguration[] (no se persisten en findings, gap Fase 2)  │
+└──────────────────────────────────────────────────────────────────────┘
+                                   ▼
+┌─────────────────────────────────────────────────────────────────────┐
+│  5. Vuln + Leak scan                [vuln_scanner.py, leak_scanner] │
+│     _do_vuln_scan() → auto_scan(source_dir, engine, leak_engine)    │
+│                                                                     │
+│     auto_scan() elige motor:                                        │
+│     ├─ engine="semgrep" → scan_with_semgrep (p/android p/owasp)     │
+│     └─ engine="regex"   → scan_directory() con RULES (~54 reglas)   │
+│                                                                     │
+│     scan_directory() aplica las VulnRule regex sobre .java/.kt/.xml │
+│     (filtra SDK/terceros: Android, Firebase, MSAL, okhttp, etc.)    │
+│                                                                     │
+│     Leaks (paralelo, según config leak_scan):                       │
+│     ├─ native (HC*)  → scan_directory con reglas hardcoded          │
+│     ├─ apkleaks      → scan_with_apkleaks(apk) [subproceso]         │
+│     └─ gitleaks      → scan_with_gitleaks(dir) [subproceso]         │
+│                                                                     │
+│     + _scan_xml_resources_for_secrets() → keys en strings.xml       │
+│     + scan_native_libs() → .so (anti-debug, hardcoded, symbols)     │
+│     + scan_manifest_components() → exported providers/receivers     │
+│     → ScanResult(findings: VulnFinding[])                           │
+└──────────────────────────────────────────────────────────────────────┘
+                                   ▼
+┌─────────────────────────────────────────────────────────────────────┐
+│  6. OSINT                          [osint.py]                       │
+│     _do_osint_scan() → run_osint()                                 │
+│     Alimentado por los leak_findings del paso anterior              │
+│     Busca: crt.sh, GitHub, grep.app, FOFA, Shodan, Postman,         │
+│            Google dorks, Wayback Machine                            │
+│     → OsintResult (secrets, subdomains, public_leaks, auth_flows)   │
+└──────────────────────────────────────────────────────────────────────┘
+                                   ▼
+┌─────────────────────────────────────────────────────────────────────┐
+│  7. Persistencia + Reporte                                        │
+│     save_analysis_json() → reports//.json       │
+│     _save_vuln_json()    → reports//vuln.json              │
+│                           + decompiled/vuln_.json (legacy) │
+│     _save_osint_json()   → reports//osint.json             │
+│     build_masvs_report()→ MASVS score (24 controles v2.1)           │
+│     _generate_pdf()      → reports//.pdf        │
+│     fire_post_hooks("after_analysis") → store/hooks.py (SQLite)     │
+│                                          + plugins/aireview (IA)    │
+└─────────────────────────────────────────────────────────────────────┘
+```
+
+## Detalle por componente
+
+### 1. APKAnalyzer (`nutcracker_core/analyzer.py`)
+
+`APKAnalyzer.analyze(apk_path)` es el punto de entrada al análisis de
+protecciones anti-root. Usa **Androguard** (`AnalyzeAPK`) para cargar el APK y
+obtener tres objetos: `apk` (manifest + metadata), `dex` (código Dalvik) y `dx`
+(índice de análisis).
+
+- `_extract_metadata()`: package, versionName, versionCode, minSdk, targetSdk.
+- `_build_string_set(dx)`: todas las strings del DEX.
+- `_build_class_set(dx)`: todos los nombres de clase.
+- `_run_builtin_detectors()`: corre los 8 detectores de `detectors/` sobre los
+  sets de strings/clases.
+- `_run_apkid_detector()`: alternativa con APKiD (subproceso), traduce las
+  categorías de APKiD a `DetectionResult`.
+
+Salida: `AnalysisResult` con los siguientes campos derivados:
+- `protected`: al menos un detector con `detected=True`.
+- `protection_broken`: protección detectada Y bypass runtime exitoso con
+  volcado de DEX (`dex_count > 0`).
+- `aipwn_bypass_confirmed`: bypass confirmado por aipwn sin volcado de DEX
+  (campo nuevo del ROADMAP, verificación dual del veredicto).
+- `confidence`: `none` / `low` / `medium` / `high` según cantidad de
+  detectores positivos.
+
+Detectores disponibles (`nutcracker_core/detectors/`):
+
+| Detector | Archivo | Qué busca |
+|---|---|---|
+| `KnownLibrariesDetector` | `libraries.py` | SDKs de protección (DexGuard, Appdome, Promon, Arxan...) |
+| `SafetyNetDetector` | `safetynet.py` | Google Play Integrity / SafetyNet API |
+| `ManualChecksDetector` | `manual_checks.py` | Heurísticas varias (RootBeer, detection de Frida, etc.) |
+| `MagiskDetector` | `magisk.py` | Hooks anti-Magisk |
+| `DexGuardDetector` | `dexguard.py` | DexGuard específico (strings, clase Runtime) |
+| `AppdomeDetector` | `appdome.py` | Appdome (runtime application shielding) |
+| `SignatureCheckDetector` | `signature_check.py` | Verificación de firma en runtime |
+| `CertificatePinningDetector` | `certificate_pinning.py` | SSL pinning nativo/Js |
+
+### 2. `_post_analysis_flow` (`orchestrator.py:615`)
+
+Decide **cómo** decompilar según lo que detectó el paso 1:
+
+- **App sin DexGuard:** ruta simple → `_do_decompile()` → jadx directo.
+- **App con DexGuard detectado:** `_do_dexguard_deobf()` intenta:
+  1. `frida-dexdump` (volcado de DEX en memoria con Frida + device conectado).
+  2. Fallback a FART (volcado en memoria con hooks propios).
+  3. Último fallback a jadx (aunque la ofuscación hará que el scan sea pobre).
+  Los `.dex` resultantes se deobfuscan con `deobfuscator.decompile_dumps()` y se
+  pasan a `_decompile_and_scan()`.
+
+`_pipeline_decompilation_mode(protected)` define el modo: `none` / `jadx` /
+`frida-dexdump` / `FART`.
+
+### 3. Decompilación (`nutcracker_core/decompiler.py`)
+
+`get_available_tool()` devuelve `"jadx"` si está en `PATH`, si no `"apktool"`
+como fallback. `decompile()` corre la herramienta sobre el APK y genera:
+
+```
+./decompiled//
+├── sources/        # .java decompilado (jadx)
+└── resources/      # AndroidManifest.xml, strings.xml, layout/, etc.
+```
+
+### 4. Manifest scan (`nutcracker_core/manifest_analyzer.py`)
+
+`analyze_decompiled_dir()` parsea `AndroidManifest.xml` y busca
+misconfiguraciones:
+
+- `debuggable=true`
+- `allowBackup=true`
+- `usesCleartextTraffic=true`
+- Componentes `exported` sin permisos (activities, services, providers,
+  receivers)
+- `targetSdkVersion` bajo
+- Network security config permisiva
+
+Cada misconfig se mapea a MASVS vía `MISCONFIG_TO_MASVS`. **Limitación conocida**
+(Fase 2): estos hallazgos no se persisten en la tabla `findings` porque no pasan
+por `store/hooks.py` — viven solo en memoria y en el JSON del análisis.
+
+### 5. Vuln + Leak scan (`nutcracker_core/vuln_scanner.py`, `leak_scanner.py`)
+
+`_do_vuln_scan()` → `auto_scan(source_dir, engine, leak_engine)`. `auto_scan` es
+el **punto de entrada unificado** del SAST:
+
+**Motor de código (`engine`):**
+- `"semgrep"`: `scan_with_semgrep()` con `p/android p/owasp-top-ten` por defecto.
+- `"regex"`: `scan_directory()` con `RULES` (~54 reglas regex de
+  `scan_types.py`).
+- `"auto"`: usa semgrep si está instalado, si no regex.
+
+`scan_directory` aplica cada `VulnRule` sobre `.java`/`.kt`/`.xml`, filtrando
+archivos de SDK/terceros (`Android`, `Firebase`, `MSAL`, `okhttp`, etc.) para
+reducir falsos positivos.
+
+**Motores de leaks (`leak_engine`):**
+- `native` (reglas `HC*`): `scan_directory` con reglas hardcoded de secretos.
+- `apkleaks`: `scan_with_apkleaks(apk)` — subproceso externo.
+- `gitleaks`: `scan_with_gitleaks(dir)` — subproceso externo.
+
+**Scans adicionales integrados:**
+- `_scan_xml_resources_for_secrets()`: keys en `strings.xml` / XML de recursos.
+- `scan_native_libs()`: `.so` del APK — anti-debug, hardcoded secrets, símbolos
+  sospechosos, entropía.
+- `scan_manifest_components()`: providers/receivers exported con handler
+  peligrosos.
+
+Salida: `ScanResult(findings: list[VulnFinding])`. Cada `VulnFinding` incluye
+`masvs`, `maswe` y `cwe` mapeados desde `RULE_TO_MASVS` / `RULE_TO_MASWE` /
+`RULE_TO_CWE` en `masvs.py`.
+
+### 6. OSINT (`nutcracker_core/osint.py`)
+
+`run_osint()` toma los leak_findings del paso anterior y busca exposición pública:
+- `crt.sh` — subdominios vía Certificate Transparency.
+- GitHub — código fuente filtrado (scraping HTML; ROADMAP: migrar a REST API con
+  `GITHUB_TOKEN`).
+- `grep.app` — código público.
+- FOFA / Shodan — exposición de infra.
+- Postman — colecciones públicas.
+- Google dorks, Wayback Machine.
+
+Salida: `OsintResult(secrets, subdomains, public_leaks, auth_flows)`.
+
+### 7. Persistencia + Reporte
+
+**Archivos planos:**
+```
+reports//
+├── .json    # AnalysisResult completo
+├── .pdf     # PDF con secciones: findings, OSINT, MASVS
+├── vuln.json           # ScanResult (lo que lee aipwn)
+└── osint.json          # OsintResult
+```
+Más una copia legacy en `decompiled/vuln_.json` (para `aireview`).
+
+**SQLite (`nutcracker.db`)** vía `store/hooks.py` (post-hook `after_analysis`):
+- `apps`: una fila por package (última corrida, veredicto).
+- `runs`: una fila por corrida.
+- `findings`: todos los `VulnFinding` con su taxonomía MASVS/MASWE/CWE.
+- `artifacts`: rutas a APK, reportes, etc.
+
+**MASVS:** `build_masvs_report(result, vuln_scan, manifest_analysis)` calcula el
+score sobre los 24 controles MASVS v2.1. Cada control está `pass`, `fail` o
+`warn` según los hallazgos. Cobertura actual: 18/24 controles con check
+(`docs/owasp-mas-coverage.md`).
+
+**Post-hooks:** `fire_post_hooks("after_analysis")` dispara:
+- `store/hooks.py` (SQLite, siempre).
+- `plugins/aireview` (análisis con IA del JSON, si está instalado).
+
+## Framework de checks (Fase 2, paralelo)
+
+`nutcracker_core/checks/` es un framework unificado con auto-descubrimiento que
+**envuelve** los 3 registros manuales vía `checks/static/adapter.py`:
+
+- `vuln_scanner.RULES` (~54 reglas regex)
+- `native_scanner._NATIVE_RULES`
+- `analyzer.ALL_DETECTORS` (8 detectores anti-root)
+- `manifest_analyzer` (misconfigs)
+
+Más 2 checks dinámicos (`debuggable`, `cleartext_traffic`) que corren en el
+device. El registry se usa para generar `docs/owasp-mas-coverage.md` (matriz de
+cobertura 18/24 controles).
+
+**Limitación conocida** (Fase 2): el objetivo era migrar cada regla a un `.py`
+individual en `checks/static/`, pero se dejó un adaptador por costo/beneficio.
+
+## Comando `scan` (URL/Play Store)
+
+Es idéntico al `analyze`, pero arranca con `downloader.py` (descarga el APK
+desde Google Play con token AAS, o desde una URL directa) antes de entrar al
+mismo `_run_analysis()`. Lleva `--keep-apk` siempre (la cola lo fuerza para
+re-usos).
+
+## Persistencia dual (Fase 0)
+
+Todo análisis queda en **dos lugares**:
+1. **Archivos planos** (igual que antes): `reports//*.json` + `.pdf`
+2. **SQLite** (`nutcracker.db`): tablas `apps`, `runs`, `findings`, `artifacts`,
+   `schedule` — vía el post-hook `after_analysis` en `store/hooks.py`.
+
+## Integración con el dashboard
+
+El dashboard (`plugins/dashboard/`) lee de la base SQLite (no de los JSON
+planos):
+- `GET /api/apps` — lista de apps analizadas.
+- `GET /api/apps/{package}` — detalle + último run.
+- `GET /api/apps/{package}/findings` — hallazgos paginados.
+- `GET /api/queue` — jobs encolados (static / dynamic / aipwn).
+
+El motor de colas (`queue/engine.py`) usa `orchestrator.build_job_cmd()` para
+lanzar análisis como subprocesos aislados, reusando todo el flujo descrito aquí.
+
+## Integración con aipwn
+
+`plugins/aipwn/__init__.py::_load_scan_result()` lee el `vuln.json` del paso 5
+para alimentar al ExploitAgent. Si no existe (porque el análisis estático nunca
+completó el paso 7, o porque alguien decompiló manualmente), cae a un fallback:
+corre `scan_directory()` en vivo sobre `decompiled//` y construye los
+hallazgos al momento. Este fallback es lo que produce el mensaje:
+
+```
+[aipwn] no vuln JSON found — running live scan on decompiled/
+```
+
+No es un error — es degradación esperada. El agente funciona igual con el
+live-scan.
+
+## Referencias cruzadas
+
+- `ROADMAP.md` — ítems pendientes (iOS, port a Go, verdict dual, GitHub token).
+- `docs/owasp-mas-coverage.md` — matriz de cobertura MASVS.
+- `plan.md` — historial detallado de las Fases 0-4.
\ No newline at end of file
diff --git a/nutcracker_core/orchestrator.py b/nutcracker_core/orchestrator.py
index 1dc9bff..1a74ae7 100644
--- a/nutcracker_core/orchestrator.py
+++ b/nutcracker_core/orchestrator.py
@@ -503,6 +503,23 @@ def on_progress(msg: str) -> None:
             _generate_pdf(result, vuln_scan,
                           vuln_scan_enabled=_feature_enabled("sast_scan", default=True))
 
+        # FIX (reportado en vivo por el usuario, 2026-07-27): el dashboard
+        # asumía que "re-analizar" siempre podía volver a *descargar* la app
+        # por su package id (scan), pero una app analizada desde un .apk local
+        # (analyze , o un job local vía la cola) puede no estar
+        # publicada en ninguna store -- el intento de re-descarga fallaba con
+        # "APK no encontrada en 'downloads' tras la descarga" para apps que
+        # nunca vinieron de ahí en primer lugar. store/hooks.py no puede
+        # recibir apk_path directo (la firma de after_analysis es compartida
+        # con otros post-hooks sin **kwargs, ver comentario abajo) -- se pasa
+        # por env var, mismo patrón que NUTCRACKER_QUEUE_JOB_ID. Solo se fija
+        # si el archivo va a seguir existiendo tras esta función (keep_apk) --
+        # si se va a borrar, no hay nada reutilizable que ofrecer.
+        if keep_apk and apk_path and apk_path.exists():
+            os.environ["NUTCRACKER_APK_SOURCE"] = str(apk_path.resolve())
+        else:
+            os.environ.pop("NUTCRACKER_APK_SOURCE", None)
+
         # ── Post-hooks de plugins ──────────────────────────────────────────
         # Firma estable (package, result, vuln_scan, config): otros plugins (p.ej. aireview)
         # ya registran hooks con esta firma exacta y no aceptan **kwargs adicionales.
diff --git a/nutcracker_core/queue/engine.py b/nutcracker_core/queue/engine.py
index bd7de14..ee92316 100644
--- a/nutcracker_core/queue/engine.py
+++ b/nutcracker_core/queue/engine.py
@@ -56,6 +56,52 @@ def _is_local_apk(target: str) -> bool:
     return Path(target).exists() and target.lower().endswith(".apk")
 
 
+def _resolve_local_apk(target: str) -> str | None:
+    """Si ``target`` es un package ID con un APK ya descargado en
+    ``downloads//``, devuelve la ruta al APK base (reutilizando la
+    misma heurística que ``downloader._find_downloaded_apk``).
+
+    Esto permite que un job encolado desde el dashboard con un package ID
+    (no una ruta de archivo) reutilice un APK descargado previamente en vez
+    de forzar una nueva descarga que puede fallar (credenciales, región,
+    app retirada de Google Play, etc.).
+
+    Retorna ``None`` si no hay APK local reusable.
+    """
+    # Caso 1: target ya es una ruta a un .apk existente → ya es local
+    if _is_local_apk(target):
+        return target
+
+    # Caso 2: target no parece un package ID → no hay nada que resolver
+    if "/" in target or "\\" in target or target.lower().endswith(".apk"):
+        return None
+    # Debe verse como un package ID (ej. com.example.app)
+    if not target or "." not in target:
+        return None
+
+    downloads_dir = Path("downloads") / target
+    if not downloads_dir.is_dir():
+        return None
+
+    # APK con nombre del paquete (formato apkeep >= 0.18)
+    pkg_apk = downloads_dir / f"{target}.apk"
+    if pkg_apk.exists():
+        return str(pkg_apk)
+
+    # base.apk (formato App Bundle clásico)
+    base_apk = downloads_dir / "base.apk"
+    if base_apk.exists():
+        return str(base_apk)
+
+    # Cualquier .apk que NO sea split/config (heurística)
+    for apk in sorted(downloads_dir.glob("*.apk"), key=os.path.getmtime, reverse=True):
+        name_lower = apk.name.lower()
+        if "config." not in name_lower and "split_" not in name_lower:
+            return str(apk)
+
+    return None
+
+
 # Marcadores usados por _extract_error_summary para reconocer líneas que
 # probablemente son la causa raíz real de un fallo.
 _ERROR_MARKERS = ("error", "failed", "traceback", "exception", "no route to host",
@@ -221,9 +267,24 @@ def _run_job(self, job: Job) -> JobOutcome:
         finally:
             conn.close()
 
+        # FIX (2026-07-27): si el target es un package ID (no una ruta .apk),
+        # intenta resolverlo a un APK ya descargado en downloads//
+        # antes de construir el comando. Esto evita que el dashboard fuerce
+        # una nueva descarga (que falla si no hay credenciales o la app no
+        # está disponible) cuando el APK ya existe localmente.
+        effective_target = job.target
+        effective_is_local = job.is_local_apk
+        if not effective_is_local and job.kind != "aipwn":
+            resolved = _resolve_local_apk(job.target)
+            if resolved:
+                _log.info("job #%s: reusing local APK %s for package %s",
+                          job.db_id, resolved, job.target)
+                effective_target = resolved
+                effective_is_local = True
+
         cmd = orch.build_job_cmd(
-            job.target,
-            is_local_apk=job.is_local_apk,
+            effective_target,
+            is_local_apk=effective_is_local,
             config_path=self.config_path,
             static_only=(job.kind == "static"),
             dynamic_checks=(job.kind == "dynamic"),
@@ -318,4 +379,4 @@ def drain(self, on_result: Callable[[JobOutcome], None] | None = None) -> list[J
                     if on_result:
                         on_result(outcome)
 
-        return outcomes
+        return outcomes
\ No newline at end of file
diff --git a/nutcracker_core/store/hooks.py b/nutcracker_core/store/hooks.py
index 4528918..b710cf2 100644
--- a/nutcracker_core/store/hooks.py
+++ b/nutcracker_core/store/hooks.py
@@ -55,6 +55,19 @@ def _persist_after_analysis(
     try:
         run_id = repository.insert_run(conn, package, kind="full", status="running")
 
+        # FIX (reportado en vivo, 2026-07-27): registrar de dónde salió el
+        # .apk analizado -- sin esto, "re-analizar" en el dashboard siempre
+        # asumía que la app se podía volver a *descargar* por package id,
+        # fallando para apps analizadas desde un .apk local que nunca estuvo
+        # publicado en ninguna store. orchestrator._run_analysis deja la ruta
+        # en NUTCRACKER_APK_SOURCE (solo si el archivo sobrevive al análisis,
+        # ver ahí) -- misma técnica de env var que NUTCRACKER_QUEUE_JOB_ID,
+        # necesaria porque esta función comparte firma con otros post-hooks
+        # (aireview) que no aceptan kwargs extra en fire_post_hooks.
+        apk_source = os.environ.get("NUTCRACKER_APK_SOURCE")
+        if apk_source:
+            repository.upsert_app(conn, package, source=f"local:{apk_source}")
+
         masvs_report = None
         try:
             from ..masvs import build_masvs_report
diff --git a/plan.md b/plan.md
index 75eafd2..e6a53aa 100644
--- a/plan.md
+++ b/plan.md
@@ -1155,3 +1155,50 @@ problemático en la investigación de "scrcpy real" de más arriba). Documentaci
 
 **Estado: documentado y factible, no implementado.** Requiere decisión explícita del usuario para
 proceder (introduce Node/npm al proyecto por primera vez) antes de escribir código.
+
+## Fix: "re-analizar" fallaba para apps analizadas desde un .apk local (2026-07-27)
+
+El usuario reportó, al usar el dashboard real: clic en "re-analizar" para `sh.nutcracker.nutbank`
+(su propia app de prueba, no una app bancaria real) devolvía
+`Download error: APK no encontrada en 'downloads' tras la descarga`.
+
+**Causa raíz:** `reQueue(pkg)` en el frontend siempre reencola usando el **package id** como
+target. Cuando el target no es una ruta local (`_is_local_apk()` da False), `build_job_cmd` arma
+un job `scan `, que intenta *descargar* la app desde Google Play/APKPure. Esto funciona
+para apps reales publicadas en una store, pero `sh.nutcracker.nutbank` es la app de demo propia del
+usuario (`downloads/nutbank.apk`, analizada originalmente vía `analyze `) — nunca
+estuvo publicada en ninguna store, así que la re-descarga por package id no puede funcionar nunca,
+sin importar credenciales.
+
+Investigando más a fondo: el esquema de `apps` ya modelaba justo esta distinción
+(`source TEXT -- apkpure | google-play | local | url`, ver `schema.sql`) pero **la columna nunca se
+escribía en ningún lado del código** — un campo muerto desde Fase 0, no algo que esta sesión rompió.
+Sin ese dato, el dashboard no tenía forma de saber "esta app se puede re-analizar desde tal ruta
+local" en vez de "hay que volver a descargarla".
+
+**Fix:**
+- `orchestrator._run_analysis()`: justo antes de disparar el post-hook `after_analysis`, si
+  `keep_apk=True` (el archivo va a seguir existiendo después de esta función) deja la ruta
+  resuelta en la variable de entorno `NUTCRACKER_APK_SOURCE`; si no, la limpia. No se pudo pasar
+  como kwarg directo al hook porque `fire_post_hooks("after_analysis", ...)` comparte firma fija
+  con otros post-hooks (p.ej. `aireview`) que no aceptan kwargs extra — mismo patrón ya usado para
+  `NUTCRACKER_QUEUE_JOB_ID`.
+- `store/hooks.py::_persist_after_analysis()`: si esa env var está presente, llama
+  `repository.upsert_app(conn, package, source=f"local:{ruta}")` (el `UPSERT` de `upsert_app` ya
+  soportaba actualizar `source` en un run posterior, vía `COALESCE(excluded.source, apps.source)`
+  — solo faltaba que alguien lo llamara con un valor real).
+- Frontend (`reQueue()`): si `app.source` empieza con `"local:"`, usa esa ruta como target en vez
+  del package id — la app se re-analiza desde el mismo `.apk` que la primera vez, sin intentar
+  descargar nada.
+- **Dato ya existente corregido a mano** en la `nutcracker.db` real del usuario (no una prueba):
+  `UPDATE apps SET source = 'local:/home/hiteek/nutcracker2/nutcracker/downloads/nutbank.apk'
+  WHERE package = 'sh.nutcracker.nutbank'` — la única fila de `apps` en su base, analizada antes
+  de que este fix existiera, así que "re-analizar" funciona ya mismo sin esperar un ciclo nuevo.
+- **No requiere reiniciar el dashboard**: cada job de la cola corre como subproceso aislado
+  (`python nutcracker.py analyze/scan ...`), que importa el código actualizado desde disco en cada
+  invocación — solo el cambio de frontend necesita recargar la página del navegador.
+
+Tests: 2 en `tests/test_store_hooks.py` (con/sin `NUTCRACKER_APK_SOURCE` en el entorno), 2 en
+`tests/test_orchestrator.py` (`_run_analysis` fija/limpia la env var según `keep_apk`, con todas
+las dependencias pesadas mockeadas — `APKAnalyzer`, `_post_analysis_flow`,
+`save_analysis_json`, `fire_post_hooks`, etc.). **181/181 tests pasan.**
diff --git a/tests/test_orchestrator.py b/tests/test_orchestrator.py
index ba98c83..628fcfc 100644
--- a/tests/test_orchestrator.py
+++ b/tests/test_orchestrator.py
@@ -5,6 +5,8 @@
 
 from __future__ import annotations
 
+import os
+
 from nutcracker_core import orchestrator as orch
 
 
@@ -179,6 +181,67 @@ def test_build_job_cmd_aipwn_without_serial_omits_flag():
     assert cmd[-1] == "com.example.tapjacking"
 
 
+# ── _run_analysis: NUTCRACKER_APK_SOURCE (fix reportado en vivo, 2026-07-27) ─
+# El dashboard asumía que "re-analizar" siempre podía re-descargar la app por
+# package id -- fallaba para apps analizadas desde un .apk local nunca
+# publicado en ninguna store. _run_analysis debe dejar la ruta real en esta
+# env var solo cuando el archivo sobrevive al análisis (keep_apk=True).
+
+class _FakeAnalyzer:
+    def __init__(self, progress_callback=None, engine=None):
+        pass
+
+    def analyze(self, apk_path):
+        result = type("FakeResult", (), {})()
+        result.package = "com.example.fake"
+        result.protected = False
+        result.protection_broken = False
+        result.elapsed_seconds = 0.0
+        return result
+
+
+def _mock_run_analysis_deps(monkeypatch):
+    monkeypatch.setattr(orch, "APKAnalyzer", _FakeAnalyzer)
+    monkeypatch.setattr(orch, "print_report", lambda result: None)
+    monkeypatch.setattr(orch, "_post_analysis_flow", lambda result, apk_path: None)
+    monkeypatch.setattr(orch, "save_analysis_json", lambda *a, **kw: None)
+    monkeypatch.setattr(orch, "print_masvs_summary", lambda *a, **kw: None)
+    monkeypatch.setattr(orch, "_print_verdict", lambda *a, **kw: None)
+    monkeypatch.setattr(orch, "_print_elapsed", lambda *a, **kw: None)
+    monkeypatch.setattr(orch, "_generate_pdf", lambda *a, **kw: None)
+    monkeypatch.setattr(orch, "_RUN_DYNAMIC_CHECKS", False)
+
+    seen_hook_calls = []
+    monkeypatch.setattr(orch, "fire_post_hooks", lambda event, **kw: seen_hook_calls.append((event, kw)))
+    return seen_hook_calls
+
+
+def test_run_analysis_sets_apk_source_env_when_keep_apk_true(monkeypatch, tmp_path):
+    _mock_run_analysis_deps(monkeypatch)
+    monkeypatch.delenv("NUTCRACKER_APK_SOURCE", raising=False)
+
+    apk_path = tmp_path / "nutbank.apk"
+    apk_path.write_bytes(b"PK\x03\x04")
+
+    orch._run_analysis(apk_path, report_path=None, keep_apk=True, gen_pdf=False)
+
+    assert os.environ.get("NUTCRACKER_APK_SOURCE") == str(apk_path.resolve())
+    assert apk_path.exists(), "keep_apk=True no debe borrar el archivo"
+
+
+def test_run_analysis_clears_apk_source_env_when_keep_apk_false(monkeypatch, tmp_path):
+    _mock_run_analysis_deps(monkeypatch)
+    monkeypatch.setenv("NUTCRACKER_APK_SOURCE", "/stale/path/from/a/previous/run.apk")
+
+    apk_path = tmp_path / "downloaded.apk"
+    apk_path.write_bytes(b"PK\x03\x04")
+
+    orch._run_analysis(apk_path, report_path=None, keep_apk=False, gen_pdf=False)
+
+    assert "NUTCRACKER_APK_SOURCE" not in os.environ
+    assert not apk_path.exists(), "keep_apk=False debe borrar el archivo como antes"
+
+
 # ── _run_dynamic_checks_for: headless, nunca cede el proceso ────────────────
 
 class _FakeResult:
diff --git a/tests/test_queue_engine.py b/tests/test_queue_engine.py
index cd6701f..6734461 100644
--- a/tests/test_queue_engine.py
+++ b/tests/test_queue_engine.py
@@ -14,7 +14,12 @@
 
 import pytest
 
-from nutcracker_core.queue.engine import QueueEngine, _extract_error_summary, _strip_ansi
+from nutcracker_core.queue.engine import (
+    QueueEngine,
+    _extract_error_summary,
+    _resolve_local_apk,
+    _strip_ansi,
+)
 from nutcracker_core.store import db, repository
 
 
@@ -455,3 +460,119 @@ def wait(self):
     engine.drain()
 
     assert received == ["línea con color", "línea normal"]
+
+
+# ── _resolve_local_apk (fix: dashboard no reutilizaba APK descargado) ───────
+
+def test_resolve_local_apk_finds_package_named_apk(tmp_path, monkeypatch):
+    """downloads//.apk (formato apkeep >= 0.18)."""
+    pkg = "com.example.app"
+    dl_dir = tmp_path / "downloads" / pkg
+    dl_dir.mkdir(parents=True)
+    apk = dl_dir / f"{pkg}.apk"
+    apk.write_bytes(b"PK\x03\x04")
+
+    monkeypatch.chdir(tmp_path)
+    import os; result = _resolve_local_apk(pkg); assert result is not None and os.path.basename(result) == f"{pkg}.apk"
+
+
+def test_resolve_local_apk_finds_base_apk(tmp_path, monkeypatch):
+    """downloads//base.apk (formato App Bundle clásico)."""
+    pkg = "com.example.bundle"
+    dl_dir = tmp_path / "downloads" / pkg
+    dl_dir.mkdir(parents=True)
+    apk = dl_dir / "base.apk"
+    apk.write_bytes(b"PK\x03\x04")
+    # Splits presentes — no deben elegirse
+    (dl_dir / "split_config.arm64_v8a.apk").write_bytes(b"PK\x03\x04")
+
+    monkeypatch.chdir(tmp_path)
+    import os; result = _resolve_local_apk(pkg); assert result is not None and os.path.basename(result) == "base.apk"
+
+
+def test_resolve_local_apk_skips_split_and_config_apks(tmp_path, monkeypatch):
+    """Si solo hay splits/configs en downloads// (sin base ni
+    package.apk), la heurística no debe elegirlos — retorna None."""
+    pkg = "com.example.splitsonly"
+    dl_dir = tmp_path / "downloads" / pkg
+    dl_dir.mkdir(parents=True)
+    (dl_dir / "split_config.arm64_v8a.apk").write_bytes(b"PK\x03\x04")
+    (dl_dir / "split_config.xxhdpi.apk").write_bytes(b"PK\x03\x04")
+
+    monkeypatch.chdir(tmp_path)
+    assert _resolve_local_apk(pkg) is None
+
+
+def test_resolve_local_apk_returns_none_when_no_downloads_dir(tmp_path, monkeypatch):
+    monkeypatch.chdir(tmp_path)
+    assert _resolve_local_apk("com.example.notdownloaded") is None
+
+
+def test_resolve_local_apk_returns_target_for_existing_apk_path(tmp_path):
+    """Si target ya es una ruta a un .apk existente, lo devuelve tal cual."""
+    apk = tmp_path / "manual.apk"
+    apk.write_bytes(b"PK\x03\x04")
+    assert _resolve_local_apk(str(apk)) == str(apk)
+
+
+def test_resolve_local_apk_rejects_non_package_strings():
+    """Strings que no son package IDs ni rutas .apk válidas → None."""
+    assert _resolve_local_apk("notapackage") is None
+    assert _resolve_local_apk("/path/does/not/exist.apk") is None
+    assert _resolve_local_apk("") is None
+
+
+def test_job_with_package_id_reuses_local_apk(monkeypatch, tmp_path, engine):
+    """FIX 2026-07-27: un job encolado desde el dashboard con un package ID
+    (no una ruta .apk) debe resolver downloads//.apk y
+    construir `analyze ` (local) en vez de `scan ` (que
+    fuerza descarga y falla si no hay credenciales)."""
+    pkg = "pe.indigital.tunki.user"
+    dl_dir = tmp_path / "downloads" / pkg
+    dl_dir.mkdir(parents=True)
+    apk = dl_dir / f"{pkg}.apk"
+    apk.write_bytes(b"PK\x03\x04")
+
+    monkeypatch.chdir(tmp_path)
+
+    seen_cmds = []
+
+    def fake_run(cmd, env=None, capture_output=True, text=True):  # noqa: ANN001
+        seen_cmds.append(cmd)
+        return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
+
+    monkeypatch.setattr("nutcracker_core.queue.engine.subprocess.run", fake_run)
+
+    engine.submit(pkg, kind="static")
+    outcomes = engine.drain()
+
+    assert len(outcomes) == 1 and outcomes[0].ok
+    cmd = seen_cmds[0]
+    # Debe ir por analyze (local), no scan (descarga)
+    assert "analyze" in cmd
+    import os; assert any(os.path.basename(c) == f"{pkg}.apk" for c in cmd)
+    assert "scan" not in cmd
+
+
+def test_job_with_package_id_falls_back_to_scan_when_no_local_apk(monkeypatch, tmp_path, engine):
+    """Si no hay APK local reusable, el job cae al camino original:
+    `scan ` (descarga)."""
+    pkg = "com.example.neverdownloaded"
+
+    monkeypatch.chdir(tmp_path)
+
+    seen_cmds = []
+
+    def fake_run(cmd, env=None, capture_output=True, text=True):  # noqa: ANN001
+        seen_cmds.append(cmd)
+        return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
+
+    monkeypatch.setattr("nutcracker_core.queue.engine.subprocess.run", fake_run)
+
+    engine.submit(pkg, kind="static")
+    outcomes = engine.drain()
+
+    assert len(outcomes) == 1 and outcomes[0].ok
+    cmd = seen_cmds[0]
+    assert "scan" in cmd
+    assert pkg in cmd
\ No newline at end of file
diff --git a/tests/test_store_hooks.py b/tests/test_store_hooks.py
index 4408cc3..412ccfd 100644
--- a/tests/test_store_hooks.py
+++ b/tests/test_store_hooks.py
@@ -97,6 +97,48 @@ def test_persist_after_analysis_writes_run_findings_and_artifacts(db_path, tmp_p
         conn.close()
 
 
+def test_persist_after_analysis_records_local_apk_source(db_path, tmp_path, monkeypatch):
+    """FIX (reportado en vivo, 2026-07-27): sin esto, "re-analizar" en el
+    dashboard siempre reintentaba *descargar* la app por package id, lo cual
+    falla para apps analizadas desde un .apk local que nunca estuvo publicado
+    en ninguna store. orchestrator._run_analysis deja la ruta real en
+    NUTCRACKER_APK_SOURCE cuando el archivo sobrevive al análisis (keep_apk)."""
+    monkeypatch.chdir(tmp_path)
+    monkeypatch.setenv("NUTCRACKER_APK_SOURCE", "/home/user/downloads/nutbank.apk")
+    result = _mock_result(protected=True)
+    _write_fake_artifacts(result.package)
+    config = {"store": {"db_path": str(db_path)}}
+
+    _persist_after_analysis(package=result.package, result=result, vuln_scan=None, config=config)
+
+    conn = db.connect(str(db_path))
+    try:
+        app = repository.get_app(conn, result.package)
+        assert app["source"] == "local:/home/user/downloads/nutbank.apk"
+    finally:
+        conn.close()
+
+
+def test_persist_after_analysis_leaves_source_null_without_env_var(db_path, tmp_path, monkeypatch):
+    """Camino existente (analizado por scan/descarga, o sin fuente local
+    persistente): sin NUTCRACKER_APK_SOURCE en el entorno, source queda como
+    estaba (None para una app nueva) -- no se inventa un valor."""
+    monkeypatch.chdir(tmp_path)
+    monkeypatch.delenv("NUTCRACKER_APK_SOURCE", raising=False)
+    result = _mock_result(protected=True)
+    _write_fake_artifacts(result.package)
+    config = {"store": {"db_path": str(db_path)}}
+
+    _persist_after_analysis(package=result.package, result=result, vuln_scan=None, config=config)
+
+    conn = db.connect(str(db_path))
+    try:
+        app = repository.get_app(conn, result.package)
+        assert app["source"] is None
+    finally:
+        conn.close()
+
+
 def test_persist_after_analysis_respects_store_disabled(db_path, tmp_path):
     result = _mock_result(protected=False)
     config = {"store": {"db_path": str(db_path), "enabled": False}}

From 60e2492368a494be6bb266584143a5ce6a2cedb8 Mon Sep 17 00:00:00 2001
From: Hiteek 
Date: Mon, 27 Jul 2026 02:50:11 -0500
Subject: [PATCH 21/30] improves 3

---
 README.md  |  24 +++++++++++
 ROADMAP.md |   4 +-
 plan.md    | 116 +++++++++++++++++++++++++++++++++++++++++++++++++----
 3 files changed, 134 insertions(+), 10 deletions(-)

diff --git a/README.md b/README.md
index c53df0d..95f5551 100644
--- a/README.md
+++ b/README.md
@@ -666,6 +666,30 @@ tab falls back to the screenshot-polling view automatically — nothing breaks e
 PyAV in the dashboard environment (`pip install .[dashboard]` already includes it, or
 `pip install av` manually).
 
+### Fluid device video (WebUSB + WebCodecs)
+
+The file-based approach above is real video, but capped at ~1-2fps by its own architecture
+(rereading a growing recording file). For genuinely fluid video (15-30fps, like
+[app.webadb.com](https://app.webadb.com)), the dashboard also ships an opt-in **WebUSB** mode:
+the *browser itself* speaks the ADB/scrcpy protocol directly over USB — no server-side process at
+all for this path — and decodes raw H.264 natively via the WebCodecs API. It's the project's first
+JS subproject (`nutcracker_core/plugins/dashboard/webusb/`, TypeScript + Vite, built on
+[Tango](https://github.com/yume-chan/ya-webadb)):
+
+```bash
+cd nutcracker_core/plugins/dashboard/webusb
+npm install && npm run build
+```
+
+This produces a self-contained bundle (the real `scrcpy-server` binary ends up embedded inside it
+as a data URI — no separate `.bin` file to manage) served by the dashboard. A "🔌 USB directo
+(fluido)" button appears in the Device tab automatically once the bundle exists and the browser
+supports it — real constraints apply: **Chromium only** (no Firefox/Safari, WebUSB isn't
+implemented there), the phone must be on **USB on the same machine as the browser** (unlike the
+scrcpy-based mode, this can't reach a remote/networked device), and it needs a secure context
+(fine on `localhost`, not on a plain-HTTP LAN address). See
+[webusb/README.md](nutcracker_core/plugins/dashboard/webusb/README.md) for the full picture.
+
 ### `aipwn` in the queue + chat wiring
 
 `aipwn` (the LLM-powered bypass agent) can run as a queue job like any other target — its live
diff --git a/ROADMAP.md b/ROADMAP.md
index 46735cf..243afc4 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -27,6 +27,6 @@
 - [ ] **Port secret scanner to Go** — `string_extractor.py` and the HC* rules are the slowest steps. Go binary `nutcracker-strings` that takes a directory and patterns and returns JSON. Python invokes it as a subprocess, the same way it does semgrep. Prerequisite: split modules first (done, see above).
 - [x] **`pyproject.toml` packaging** — `pip install -e .` gives a `nutcracker` console command (entry point `nutcracker_core.cli:cli`); `.[dashboard]` extra installs FastAPI/uvicorn. Done 2026-07-26.
 - [x] **Live device video (scrcpy) in the dashboard** — not `ws-scrcpy` (a full protocol reimplementation attempt was decompiled/tried and hit an unresolved handshake hang against Magisk-hardened hardware, see `plan.md`) — instead the dashboard drives the user's own real `scrcpy` binary headlessly (`--no-window --record=.mkv`, re-read via `container.seek()` near the end with PyAV instead of full re-decode — measured 0.5s→0.07s per poll, see `plan.md`) and falls back to screenshot polling automatically. Done 2026-07-27, validated live against a real device (`nutcracker_core/plugins/dashboard/scrcpy_video.py`).
-- [ ] **Device video is still ~1.3fps, not fluid** — even after the seek optimization above, live-measured effective rate is ~1-2 frames/second, far from real video (15-30fps like ws-scrcpy/webadb.com). Suspected cause (unconfirmed): scrcpy's own mkv muxer likely batches multiple encoded frames per on-disk "cluster" before they become newly-decodable, capping update frequency independent of decode cost. See the next item for the real fix.
-- [ ] **WebUSB + WebCodecs device video (Tango/yume-chan)** — proposed and scoped in `plan.md` Fase 4, not implemented. Replicates app.webadb.com: the browser talks ADB/scrcpy directly over WebUSB (no server-side `adb`/`scrcpy` process at all for the video path), decoding raw H.264 natively via WebCodecs — would resolve the ~1.3fps ceiling above at its root. Real packages verified: `@yume-chan/adb`, `@yume-chan/adb-daemon-webusb`, `@yume-chan/scrcpy`, `@yume-chan/adb-scrcpy`, `@yume-chan/scrcpy-decoder-webcodecs`, `@yume-chan/fetch-scrcpy-server` (see [tangoadb.dev/scrcpy](https://tangoadb.dev/scrcpy/)). Real trade-offs to weigh before building: Chromium-only (no Firefox/Safari — WebUSB isn't supported there at all), requires the phone on USB on the same machine as the browser (won't work over network adb), needs a secure context (fine on `localhost`, not on a plain-HTTP LAN exposure), unconfirmed whether the browser's WebUSB claim conflicts with the OS-level `adb`/`scrcpy` running concurrently, and it would be the project's first JS build step (Vite/esbuild + npm, bundled and committed — the served page stays self-contained/no-CDN, but authoring it needs Node).
+- [x] **Device video was ~1.3fps, not fluid** — the file-based `scrcpy_video.py` approach has an architectural ceiling even after the seek optimization. See the next item for the real fix (implemented).
+- [x] **WebUSB + WebCodecs device video (Tango/yume-chan)** — implemented 2026-07-27, `nutcracker_core/plugins/dashboard/webusb/` (the project's first npm/TypeScript/Vite subproject). Replicates app.webadb.com: the browser talks ADB/scrcpy directly over WebUSB (no server-side `adb`/`scrcpy` process at all for the video path), decoding raw H.264 natively via WebCodecs — resolves the ~1.3fps ceiling at its root. Typechecked against the real Tango `.d.ts` files (not guessed), build verified (scrcpy-server v3.3.1 binary confirmed byte-for-byte embedded in the bundle), browser support confirmed live via Playwright (`navigator.usb` present, button appears, module imports cleanly, `isSupported()` true, zero console errors). **Not verified**: an actual end-to-end USB connection against a physical device — WebUSB's permission dialog requires real human interaction by design and can't be automated; needs the user to click through it themselves (ideally from Chrome/Edge on the same machine the phone's USB is attached to). Real trade-offs (Chromium-only, phone must be on USB on the browser's own machine, needs a secure context, USB-exclusivity-with-system-adb unconfirmed) are documented in `plan.md` Fase 4 and `webusb/README.md`.
 - [x] **`aipwn` agent reasoning + chat wired into the dashboard** — `aipwn` is now a queue job kind (`queue add --aipwn`), reusing the existing live-logs WebSocket for its reasoning/tool-call stream; the chat mailbox (`chat_mailbox.py`) is polled by `FridaAgent._check_operator_chat()` once per ReAct iteration and injects pending operator messages as real conversation turns. Done 2026-07-27, validated live with a real LLM call against `com.example.tapjacking` — confirmed an injected chat message reaching the agent's actual conversation. (Found in passing, not fixed — out of scope: the configured provider, z.ai/GLM, rejects the multimodal screenshot message format `frida_agent.py` sends, a pre-existing bug unrelated to this wiring.)
diff --git a/plan.md b/plan.md
index e6a53aa..9b51251 100644
--- a/plan.md
+++ b/plan.md
@@ -1082,14 +1082,14 @@ webadb.com/scrcpy?")** que llevó a una investigación de rendimiento real:
 Tests: +2 en `tests/dashboard/test_scrcpy_video.py` (seek cerca del final cuando `container.duration`
 lo permite; fallback a decode completo si el seek lanza excepción). **169/169 tests pasan.**
 
-## Fase 4 (propuesta, no implementada) — video realmente fluido vía WebUSB + WebCodecs
+## Fase 4 — video realmente fluido vía WebUSB + WebCodecs (2026-07-27)
 
 El usuario preguntó por qué el video no es tan fluido como
-[app.webadb.com](https://app.webadb.com) y si se podía replicar ese enfoque. Investigado y
-**confirmado técnicamente factible** (verificado contra la documentación real del proyecto, no
-contra el primer texto pegado por el usuario — algunos nombres de paquete ahí ya estaban
-desactualizados). Se documenta aquí como plan concreto, **sin implementar todavía** — es un cambio
-de arquitectura real (primer build step de JS del proyecto), no un ajuste rápido.
+[app.webadb.com](https://app.webadb.com) y si se podía replicar ese enfoque. Investigado,
+**confirmado técnicamente factible**, y luego **implementado** a pedido explícito del usuario
+("Implementa la fase 4"). Ver más abajo ("Implementación real") para el detalle de qué se
+construyó y cómo se verificó — la sección original de investigación queda tal cual, como
+referencia de las decisiones tomadas.
 
 ### Qué es y por qué sería mejor
 app.webadb.com usa **Tango** (antes `ya-webadb`, de yume-chan) — una reimplementación completa y
@@ -1153,8 +1153,108 @@ problemático en la investigación de "scrcpy real" de más arriba). Documentaci
   que el WebUSB del navegador puede reclamar el device sin conflicto con `adb`/`scrcpy` del
   sistema, y medir el fps real resultante contra la promesa de fluidez.
 
-**Estado: documentado y factible, no implementado.** Requiere decisión explícita del usuario para
-proceder (introduce Node/npm al proyecto por primera vez) antes de escribir código.
+### Implementación real (2026-07-27)
+
+**Investigación de API sin adivinar:** en vez de confiar en resúmenes de la documentación web
+(`tangoadb.dev` — el `WebFetch` la resume con un modelo intermedio, con pérdida real de detalle:
+varias veces devolvió "esta página no incluye el código completo"), se instalaron los paquetes
+reales (`npm install`) y se leyeron directamente sus archivos `.d.ts` — la fuente de verdad exacta.
+De ahí salió la API real completa: `AdbDaemonWebUsbDeviceManager.BROWSER.requestDevice()` →
+`device.connect()` → `AdbDaemonTransport.authenticate({serial, connection, credentialStore})` →
+`new Adb(transport)` → `AdbScrcpyClient.pushServer()` + `AdbScrcpyClient.start()` →
+`client.videoStream` → `WebCodecsVideoDecoder` + `WebGLVideoFrameRenderer` sobre un ``.
+
+**Nuevo subproyecto:** `nutcracker_core/plugins/dashboard/webusb/` (npm + TypeScript + Vite —
+primer build de JS del proyecto, tal como se documentó como trade-off arriba). Ver su propio
+`README.md` para el detalle de build/verificación. Resumen:
+- `src/main.ts`: `isSupported()` (feature-detect `navigator.usb` + `AdbDaemonWebUsbDeviceManager.BROWSER`
+  + `WebCodecsVideoDecoder.isSupported`) y `connect(canvas, onStatus)` implementando el flujo
+  completo de arriba. Sin control táctil en esta primera versión (`control: false`) — solo video.
+- **El typecheck (`tsc --noEmit` contra los tipos reales) encontró y forzó a arreglar 2 bugs reales**
+  antes de siquiera correr nada: un `ReadableStream` nativo del DOM no es estructuralmente
+  idéntico al `ReadableStream` propio de `@yume-chan/stream-extra` que `pushServer()` espera
+  (difieren en el tipo resuelto de `.closed`) — hubo que importar su implementación explícitamente
+  en vez de asumir compatibilidad con la global del navegador.
+- **El binario real del scrcpy-server (v3.3.1, descargado en build-time desde GitHub Releases de
+  Genymobile vía `@yume-chan/fetch-scrcpy-server`, sin vendorear nada) queda embebido como data URI
+  dentro del bundle final** — Vite detecta automáticamente el patrón `new URL(..., import.meta.url)`
+  del paquete y lo inlinea. Verificado **byte a byte**: los 90.788 bytes decodificados del bundle
+  coinciden exactamente con el `server.bin` descargado. Un solo archivo `.js` (~290KB, +un chunk de
+  worker de ~174KB) sirve todo — sin archivos `.bin` sueltos que gestionar ni versionar aparte.
+- **Fricción de entorno real, resuelta (y reconfirmada por el usuario en su propio WSL):** `npm`/
+  `node` en WSL a veces resuelven al `node.exe`/`npm.cmd` de Windows vía interop — funcionaba para
+  `npm install` en este agente, pero **Vite fallaba resolviendo su propio paquete** al cruzar el
+  path UNC `\\wsl.localhost\...`. Se resolvió descargando un Node.js Linux nativo
+  (`nodejs.org/dist`, tarball, sin necesitar root) y usándolo en vez del `node.exe` de Windows para
+  todo el toolchain — evita el cruce de filesystem por completo. **El usuario pegó exactamente este
+  mismo problema al correr `npm install && npm run build` en su propio WSL** (`npm warn cleanup
+  ... EISDIR` limpiando `node_modules`, luego `vite build` con
+  `CMD.EXE ... No se permiten rutas UNC`) — confirma que no era una rareza de este entorno aislado
+  sino un problema real y reproducible para cualquiera con Node solo instalado en Windows. Se
+  documentó el fix (instalar Node nativo vía `nvm` dentro de WSL) de forma prominente en
+  `webusb/README.md`, con el error exacto para que sea buscable.
+- **Frontend (`index.html`):** nuevo botón "🔌 USB directo (fluido)" en la pestaña Dispositivo,
+  visible solo si `isSupported()` da `true` (import dinámico perezoso del bundle, `await
+  import("/static/webusb-video.bundle.js")` — si el archivo no existe porque nadie corrió `npm run
+  build`, falla en silencio y el botón simplemente no aparece, cero impacto para quien no lo usa).
+  Nuevo `` junto al `` existente — se togglea cuál está
+  visible según el modo activo (WebUSB / scrcpy_video.py / polling), solo una fuente de video a la
+  vez.
+
+**Validado en un navegador real (Playwright, Chromium headless):** `navigator.usb` presente, el
+módulo se importa sin errores, `isSupported()` devuelve `true`, el botón aparece correctamente,
+layout limpio (captura real revisada).
+
+### Validación en vivo por el usuario contra su Moto G23 real (2026-07-27, post-entrega)
+
+El usuario probó la conexión real de punta a punta desde Chrome/Edge en Windows, con el teléfono
+por USB. Dos problemas reales encontrados y arreglados en el camino, ninguno hipotético:
+
+**🔴 Confirmado el conflicto de exclusividad de USB** (la incógnita explícita de la investigación
+original): primer intento falló con
+`The device is already in used by another program` — el servidor `adb` de Windows (con el que
+scrcpy/frida ya venían trabajando toda la sesión) mantiene una conexión USB persistente al
+teléfono, y WebUSB necesita acceso **exclusivo** — no puede coexistir. Fix: `adb kill-server` desde
+Windows antes de conectar por WebUSB. No hay forma de evitar esto desde el código — es una
+restricción real del modelo de permisos de WebUSB, documentada así en vez de prometer que
+"simplemente funciona" en paralelo con `adb`.
+
+**🔴 Bug real de código encontrado y arreglado — `WebGLVideoFrameRenderer` sin fallback.**
+Tras resolver el conflicto de USB, la conexión avanzó mucho más lejos (autenticación, push del
+server, arranque de scrcpy, stream de video) y falló recién al crear el renderer:
+`WebGL not supported` — en una máquina Windows normal, no una VM ni nada exótico (WebGL puede
+fallar por aceleración de hardware deshabilitada en el navegador, drivers de GPU, políticas
+empresariales, etc. — no es raro en la práctica). El código original solo usaba
+`WebGLVideoFrameRenderer` sin condicional. Fix: `WebGLVideoFrameRenderer.isSupported` (el getter
+estático que ya existía en el paquete, sin usar) decide entre WebGL y `BitmapVideoFrameRenderer`
+(canvas 2D + `createImageBitmap`, más lento pero sin dependencia de WebGL) — mismo patrón de
+degradación honesta que el resto del proyecto. Recompilado y re-typechecked limpio
+(`npm run check` + `vite build`, bundle 293.62KB → 293.95KB).
+
+Ambos hallazgos vinieron de probar contra hardware/software real, no de imaginar casos borde —
+exactamente el patrón que se repitió durante toda la sesión con `scrcpy_video.py` y el resto del
+dashboard: el código que parece correcto en el papel (y pasa el typecheck) sigue topándose con
+comportamiento real del sistema operativo/navegador que solo aparece al usarlo de verdad.
+
+**✅ Confirmado funcionando por el usuario, con video real:** tras el fix del renderer, la conexión
+mostró la pantalla real del Moto G23 (launcher con apps reales) dentro del `` — captura
+real revisada. **Fase 4 queda validada de punta a punta contra hardware físico real**, no solo con
+mocks/Playwright headless.
+
+**🟡 Bug de CSS encontrado y arreglado en el mismo intercambio** — la imagen se veía distorsionada
+(estirada horizontalmente). Causa: `#webusb-canvas` tenía `width:100%; max-height:70vh` pero **sin
+`object-fit:contain`** (a diferencia de `#shot`, que sí lo tenía desde el fix de UX anterior) —
+sin eso, cuando `max-height` recorta la altura, el ancho no se reduce en proporción y la imagen se
+deforma. Fix de una línea en `index.html` (agregar `height:auto; object-fit:contain`) — **no
+requiere recompilar el bundle de npm**, es CSS puro del HTML servido directamente. Verificado con
+un canvas de prueba a la proporción real de un teléfono (720×1600): un círculo dibujado se ve
+círculo perfecto, no óvalo, con las barras de letterboxing correctas a los costados (captura real
+revisada, no solo razonado).
+
+Sin tests de Python nuevos (este es código TypeScript/CSS que corre 100% en el navegador, fuera del
+alcance de `pytest`) — la verificación es el typecheck + build + Playwright + la validación en vivo
+del usuario, dos veces (conexión real, luego el fix visual). **181/181 tests de Python siguen
+pasando** (sin cambios en el backend) en cada paso.
 
 ## Fix: "re-analizar" fallaba para apps analizadas desde un .apk local (2026-07-27)
 

From 0d1c4aa113053ca5bc99c31beaa811c52ecc3319 Mon Sep 17 00:00:00 2001
From: Hiteek 
Date: Mon, 27 Jul 2026 04:04:32 -0500
Subject: [PATCH 22/30] improve 4

---
 README.md                        |  15 ++++
 ROADMAP.md                       |   2 +
 nutcracker_core/cli/queue_cmd.py |  64 +++++++++++---
 nutcracker_core/cli/scan.py      |  21 ++++-
 nutcracker_core/downloader.py    |  91 ++++++++++++++++++-
 nutcracker_core/orchestrator.py  |  13 +++
 nutcracker_core/queue/engine.py  |  18 +++-
 nutcracker_core/queue/job.py     |   9 +-
 plan.md                          |  99 +++++++++++++++++++++
 tests/dashboard/test_api.py      |  97 +++++++++++++++++++++
 tests/test_downloader_device.py  | 137 +++++++++++++++++++++++++++++
 tests/test_orchestrator.py       |  33 +++++++
 tests/test_queue_cmd.py          | 145 +++++++++++++++++++++++++++++++
 tests/test_queue_engine.py       |  48 ++++++++++
 14 files changed, 768 insertions(+), 24 deletions(-)
 create mode 100644 tests/test_downloader_device.py
 create mode 100644 tests/test_queue_cmd.py

diff --git a/README.md b/README.md
index 95f5551..b67efd2 100644
--- a/README.md
+++ b/README.md
@@ -315,6 +315,16 @@ python nutcracker.py queue add com.example.app --run
 # Enqueue a dynamic job (requires a local .apk and a connected device):
 python nutcracker.py queue add downloads/app.apk --dynamic --serial emulator-5554 --run
 
+# Batch a .txt file of package ids: static analysis for every line, chaining an
+# aipwn bypass run after each one that finishes OK (list_file: one package id
+# per line, blank lines/#comments ignored, same format as `batch`):
+python nutcracker.py queue add packages.txt --then-aipwn --serial emulator-5554 --run
+
+# Same, but pull each .apk from the app already installed on the device
+# instead of downloading it from a store (--source device is a single global
+# flag for the whole file, not per line):
+python nutcracker.py queue add packages.txt --then-aipwn --source device --serial emulator-5554 --run
+
 # List recent jobs:
 python nutcracker.py queue ls
 python nutcracker.py queue ls --status error --limit 50
@@ -634,6 +644,11 @@ It shows:
   findings table (rule, severity, MASVS/MASWE/CWE, location) for the latest run.
 - **Analysis queue** — enqueue a target (path/URL/package id/list file) and watch it run,
   including `aipwn` runs (see below).
+- **Batch from a .txt file** — upload a `.txt` of package ids (one per line, `#comments` ignored)
+  from the queue panel: every package gets a static analysis and, once it finishes OK, a chained
+  `aipwn` run right after (same semantics as `queue add  --then-aipwn` below). A single
+  dropdown picks the `.apk` source for the whole file — the store (default) or the app already
+  installed on the connected device (`adb pull`, no download at all).
 - **Live logs** — real job output streamed line-by-line over WebSocket as it happens.
 - **Device** — real live video via your own [scrcpy](https://github.com/Genymobile/scrcpy)
   installation (see below), with automatic fallback to a polling screenshot
diff --git a/ROADMAP.md b/ROADMAP.md
index 243afc4..7d3dde6 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -30,3 +30,5 @@
 - [x] **Device video was ~1.3fps, not fluid** — the file-based `scrcpy_video.py` approach has an architectural ceiling even after the seek optimization. See the next item for the real fix (implemented).
 - [x] **WebUSB + WebCodecs device video (Tango/yume-chan)** — implemented 2026-07-27, `nutcracker_core/plugins/dashboard/webusb/` (the project's first npm/TypeScript/Vite subproject). Replicates app.webadb.com: the browser talks ADB/scrcpy directly over WebUSB (no server-side `adb`/`scrcpy` process at all for the video path), decoding raw H.264 natively via WebCodecs — resolves the ~1.3fps ceiling at its root. Typechecked against the real Tango `.d.ts` files (not guessed), build verified (scrcpy-server v3.3.1 binary confirmed byte-for-byte embedded in the bundle), browser support confirmed live via Playwright (`navigator.usb` present, button appears, module imports cleanly, `isSupported()` true, zero console errors). **Not verified**: an actual end-to-end USB connection against a physical device — WebUSB's permission dialog requires real human interaction by design and can't be automated; needs the user to click through it themselves (ideally from Chrome/Edge on the same machine the phone's USB is attached to). Real trade-offs (Chromium-only, phone must be on USB on the browser's own machine, needs a secure context, USB-exclusivity-with-system-adb unconfirmed) are documented in `plan.md` Fase 4 and `webusb/README.md`.
 - [x] **`aipwn` agent reasoning + chat wired into the dashboard** — `aipwn` is now a queue job kind (`queue add --aipwn`), reusing the existing live-logs WebSocket for its reasoning/tool-call stream; the chat mailbox (`chat_mailbox.py`) is polled by `FridaAgent._check_operator_chat()` once per ReAct iteration and injects pending operator messages as real conversation turns. Done 2026-07-27, validated live with a real LLM call against `com.example.tapjacking` — confirmed an injected chat message reaching the agent's actual conversation. (Found in passing, not fixed — out of scope: the configured provider, z.ai/GLM, rejects the multimodal screenshot message format `frida_agent.py` sends, a pre-existing bug unrelated to this wiring.)
+- [x] **Batch static+aipwn from a .txt file, with device-vs-store APK sourcing** — `queue add  --then-aipwn [--source device] --serial  --run` queues a static job for every package id in the file and, once each finishes OK, chains an `aipwn` job for it (both always run, in order, per the confirmed design — not per-line mixed control). `--source device` is a single global flag applying to the whole file (not per line): it makes `scan` pull the .apk already installed on the device via `adb shell pm path` + `adb pull` (`DeviceInstalledDownloader` in `downloader.py`, handles App Bundle splits) instead of downloading from a store — useful for internal/unpublished test apps or when store credentials aren't configured. Threaded through `cli/scan.py` (`--source`/`--serial`), `orchestrator.build_job_cmd(source=...)`, `Job.source` (in-memory only, like `is_local_apk`), and `QueueEngine.submit(source=...)`/`_run_job`. Done 2026-07-27, 16 new tests added (181→197 passing), covers the downloader, command building, the local-APK-reuse-heuristic override (an explicit `--source device` must not be silently skipped in favor of a stale downloaded APK), and the CLI chaining/failure/global-flag behavior.
+- [x] **Same batch (static+aipwn from a .txt, device-vs-store sourcing) wired into the dashboard** — new `POST /api/queue/batch` (`plugins/dashboard/api.py`) accepts a package-id list plus `source`/`serial`/`then_aipwn`, submits every static job, and runs the drain-then-chain-aipwn logic in a background thread (same pattern as the existing `_drain_in_background`, guarded by the same `engine._dashboard_draining` flag so it never races a concurrent single-job `queue add`). Frontend: a "Batch desde archivo" block in the queue card (`static/index.html`) reads a `.txt` client-side via `File.text()` — no multipart upload needed, just a parsed JSON array — with a source dropdown (store/device) reusing the already-selected device serial from the Device tab; progress shows up in the existing queue table (already polled every 5s), no new UI needed for that. Done 2026-07-27: 5 new `TestClient` tests (202/202 passing) plus a live end-to-end check against a real running server on an isolated scratchpad DB (both the store-download and `--source device` failure paths confirmed through the actual HTTP endpoint, not just mocks).
diff --git a/nutcracker_core/cli/queue_cmd.py b/nutcracker_core/cli/queue_cmd.py
index deda149..de0ce63 100644
--- a/nutcracker_core/cli/queue_cmd.py
+++ b/nutcracker_core/cli/queue_cmd.py
@@ -48,13 +48,36 @@ def queue() -> None:
 @click.option("--aipwn", is_flag=True, default=False,
               help="Job del agente de bypass aipwn (Frida+LLM). TARGET es un package id "
                    "ya analizado previamente, no una ruta/URL de APK.")
-@click.option("--serial", default=None, help="Serial ADB para job dinámico/aipwn.")
+@click.option("--then-aipwn", "then_aipwn", is_flag=True, default=False,
+              help="Encadena un job aipwn tras cada job estático que termine OK -- "
+                   "para procesar en cola un list_file completo (análisis estático + "
+                   "bypass aipwn para cada package, en orden). No combinar con "
+                   "--dynamic/--aipwn (implica un job estático como base).")
+@click.option(
+    "--source", "-s",
+    default=None,
+    type=click.Choice(["apk-pure", "google-play", "device"], case_sensitive=False),
+    help="Fuente del .apk para jobs estáticos con TARGET=package id o list_file de "
+         "package ids. \"device\" extrae el .apk ya instalado en el dispositivo "
+         "--serial (adb pull) en vez de descargarlo de una store -- aplica a TODAS "
+         "las entradas del list_file por igual (flag global, no por línea).",
+)
+@click.option("--serial", default=None, help="Serial ADB para job dinámico/aipwn/--source device.")
 @click.option("--run", "run_now", is_flag=True, default=False,
               help="Ejecutar la cola inmediatamente tras encolar (bloqueante).")
-def queue_add(target: str, config_path: str, dynamic: bool, aipwn: bool,
-              serial: str | None, run_now: bool) -> None:
+def queue_add(target: str, config_path: str, dynamic: bool, aipwn: bool, then_aipwn: bool,
+              source: str | None, serial: str | None, run_now: bool) -> None:
     """Encola TARGET: ruta a .apk local, URL, package id, o un list_file con una
-    entrada por línea (mismo formato que `nutcracker batch`)."""
+    entrada por línea (mismo formato que `nutcracker batch`).
+
+    Con --then-aipwn y un list_file de package ids, cada uno recibe primero un
+    análisis estático y, si termina OK, un job aipwn encadenado a continuación
+    -- útil para programar en cola varios escaneos + bypass desde un .txt.
+    """
+    if then_aipwn and (dynamic or aipwn):
+        console.print("[red]✘[/red] --then-aipwn no se combina con --dynamic/--aipwn.")
+        raise SystemExit(1)
+
     engine = _build_engine(config_path)
     targets = _read_targets(target)
     kind = "aipwn" if aipwn else ("dynamic" if dynamic else "static")
@@ -62,22 +85,35 @@ def queue_add(target: str, config_path: str, dynamic: bool, aipwn: bool,
     jobs = []
     for t in targets:
         try:
-            jobs.append(engine.submit(t, kind=kind, serial=serial))
+            jobs.append(engine.submit(t, kind=kind, serial=serial, source=source))
         except ValueError as exc:
             console.print(f"[red]✘[/red] {t}: {exc}")
 
     console.print(f"[green]✔[/green] {len(jobs)} job(s) encolado(s) ({kind}).")
 
-    if run_now and jobs:
-        def _on_result(o):  # noqa: ANN001
-            icon = "✔" if o.ok else "✘"
-            color = "green" if o.ok else "red"
-            extra = f"  package={o.package}" if o.package else ""
-            console.print(f"  [{color}]{icon}[/{color}] {o.job.target}{extra}")
+    if not (run_now and jobs):
+        return
+
+    pending_aipwn: list[str] = []
+
+    def _on_result(o):  # noqa: ANN001
+        icon = "✔" if o.ok else "✘"
+        color = "green" if o.ok else "red"
+        extra = f"  package={o.package}" if o.package else ""
+        console.print(f"  [{color}]{icon}[/{color}] {o.job.target}{extra}")
+        if then_aipwn and o.job.kind == "static" and o.ok and o.package:
+            pending_aipwn.append(o.package)
+
+    outcomes = engine.drain(on_result=_on_result)
+
+    if pending_aipwn:
+        console.print(f"\n[bold]→[/bold] encadenando aipwn para {len(pending_aipwn)} package(s)...")
+        for pkg in pending_aipwn:
+            engine.submit(pkg, kind="aipwn", serial=serial)
+        outcomes += engine.drain(on_result=_on_result)
 
-        outcomes = engine.drain(on_result=_on_result)
-        n_ok = sum(1 for o in outcomes if o.ok)
-        console.print(f"\n[bold]Resumen:[/bold] {n_ok}/{len(outcomes)} OK")
+    n_ok = sum(1 for o in outcomes if o.ok)
+    console.print(f"\n[bold]Resumen:[/bold] {n_ok}/{len(outcomes)} OK")
 
 
 @queue.command("ls")
diff --git a/nutcracker_core/cli/scan.py b/nutcracker_core/cli/scan.py
index ab2003a..fc72af5 100644
--- a/nutcracker_core/cli/scan.py
+++ b/nutcracker_core/cli/scan.py
@@ -36,8 +36,15 @@
 @click.option(
     "--source", "-s",
     default=None,
-    type=click.Choice(["apk-pure", "google-play"], case_sensitive=False),
-    help="Download source. Default: google-play if credentials in config, else apk-pure.",
+    type=click.Choice(["apk-pure", "google-play", "device"], case_sensitive=False),
+    help="Download source. \"device\" pulls the .apk already installed on a connected "
+         "device via adb (see --serial) instead of downloading from any store. "
+         "Default: google-play if credentials in config, else apk-pure.",
+)
+@click.option(
+    "--serial",
+    default=None,
+    help="ADB serial of the device to pull the .apk from (only used with --source device).",
 )
 @click.option(
     "--output-dir", "-o",
@@ -63,8 +70,9 @@
     help="Force jadx-only decompilation and skip Frida/device runtime steps "
          "(used by queued/scheduled batch runs; see `nutcracker queue`/`serve`).",
 )
-def scan(url: str, config_path: str, source: str | None, output_dir: str | None,
-         keep_apk: bool, report: str | None, static_only: bool) -> None:
+def scan(url: str, config_path: str, source: str | None, serial: str | None,
+         output_dir: str | None, keep_apk: bool, report: str | None,
+         static_only: bool) -> None:
     """
     Download an APK and analyze it for anti-root protections.
 
@@ -72,6 +80,10 @@ def scan(url: str, config_path: str, source: str | None, output_dir: str | None,
       - Google Play URL (https://play.google.com/store/apps/details?id=...)
       - Package ID directly (com.example.app)
       - Direct URL to an .apk file (https://example.com/app.apk)
+
+    With --source device, URL must be a package ID already installed on the
+    device given by --serial (or the sole connected device) -- the .apk is
+    pulled via adb, nothing is downloaded from any store.
     """
     config = load_config(config_path)
     if static_only:
@@ -181,6 +193,7 @@ def _on_start_label(label: str) -> None:
                     output_dir=output_dir,
                     token_resolver=_token_resolver,
                     on_start=_on_start_label,
+                    serial=serial,
                 )
 
         if _from_cache:
diff --git a/nutcracker_core/downloader.py b/nutcracker_core/downloader.py
index d88dc07..a42b565 100644
--- a/nutcracker_core/downloader.py
+++ b/nutcracker_core/downloader.py
@@ -260,6 +260,81 @@ def download(self, url: str) -> Path:
 
 # ── Google Play (requiere AAS token) ─────────────────────────────────────────
 
+class DeviceInstalledDownloader:
+    """
+    Extrae el .apk ya instalado en un dispositivo/emulador conectado, sin
+    descargar nada de ninguna store -- vía ``pm path`` + ``adb pull``.
+
+    Útil para apps que no están publicadas públicamente (demos internas,
+    apps corporativas de prueba) o cuando no hay credenciales de Google Play
+    configuradas y la app ya está instalada en el device de pruebas.
+
+    Si la app está instalada como App Bundle (varios splits), ``pm path``
+    devuelve una línea por cada uno -- se bajan todos a la misma carpeta
+    (misma convención que usa ``_find_downloaded_apk`` para descargas
+    normales) y se identifica ``base.apk`` como el APK principal a analizar.
+    """
+
+    def __init__(self, output_dir: str = "./downloads", serial: "str | None" = None,
+                 adb_bin: str = "adb"):
+        self.output_dir = Path(output_dir)
+        self.output_dir.mkdir(parents=True, exist_ok=True)
+        self.serial = serial
+        self.adb_bin = adb_bin
+
+    def _adb_cmd(self, *args: str) -> list[str]:
+        cmd = [self.adb_bin]
+        if self.serial:
+            cmd += ["-s", self.serial]
+        cmd += list(args)
+        return cmd
+
+    def download(self, package_id: str) -> Path:
+        try:
+            result = subprocess.run(
+                self._adb_cmd("shell", "pm", "path", package_id),
+                capture_output=True, text=True, timeout=15,
+            )
+        except FileNotFoundError as exc:
+            raise APKDownloadError("adb no está instalado o no está en el PATH.") from exc
+        except subprocess.TimeoutExpired as exc:
+            raise APKDownloadError(
+                f"Timeout consultando 'pm path {package_id}' en el dispositivo."
+            ) from exc
+
+        remote_paths = [
+            line.split("package:", 1)[1].strip()
+            for line in result.stdout.splitlines()
+            if line.startswith("package:")
+        ]
+        if not remote_paths:
+            detail = (result.stderr or result.stdout or "").strip()
+            where = f" en el dispositivo ({self.serial})" if self.serial else " en el dispositivo"
+            raise APKDownloadError(
+                f"'{package_id}' no está instalado{where}."
+                + (f"\n{detail}" if detail else "")
+            )
+
+        package_dir = self.output_dir / package_id
+        package_dir.mkdir(parents=True, exist_ok=True)
+        base_local: "Path | None" = None
+        for remote_path in remote_paths:
+            remote_name = Path(remote_path).name  # base.apk, split_config.arm64_v8a.apk, ...
+            local_path = package_dir / remote_name
+            pull = subprocess.run(
+                self._adb_cmd("pull", remote_path, str(local_path)),
+                capture_output=True, text=True, timeout=180,
+            )
+            if pull.returncode != 0 or not local_path.exists():
+                raise APKDownloadError(
+                    f"'adb pull {remote_path}' falló: {(pull.stderr or pull.stdout).strip()}"
+                )
+            if remote_name == "base.apk" or len(remote_paths) == 1:
+                base_local = local_path
+
+        return base_local or package_dir / Path(remote_paths[0]).name
+
+
 class GooglePlayDownloader:
     """
     Descarga APKs desde Google Play usando apkeep >= 0.18.0.
@@ -299,12 +374,15 @@ def download_apk_from_config(
     progress_callback: "callable | None" = None,
     token_resolver: "callable | None" = None,
     on_start: "callable | None" = None,
+    serial: "str | None" = None,
 ) -> Path:
     """
     Descarga un APK usando la configuración del proyecto.
 
     Selector de fuente automático:
       - Si ``package`` es una URL directa a un .apk → DirectURLDownloader
+      - Si ``source="device"`` → DeviceInstalledDownloader (``adb pull`` del APK
+        ya instalado, sin tocar ninguna store -- ver ``serial``)
       - Si ``source="google-play"`` o hay email configurado → GooglePlayDownloader
       - En caso contrario → APKPureDownloader (fallback sin autenticación)
 
@@ -312,7 +390,7 @@ def download_apk_from_config(
         package:           Package ID (ej. com.example.app), URL de Google Play
                            o URL directa a un .apk.
         config:            Diccionario de configuración (cargado desde config.yaml).
-        source:            Forzar fuente: ``"google-play"``, ``"apk-pure"`` o
+        source:            Forzar fuente: ``"google-play"``, ``"apk-pure"``, ``"device"`` o
                            ``None`` para auto-detección.
         output_dir:        Directorio de salida para el APK descargado.
         use_cache:         Si True, reutiliza el APK si ya existe (solo URL directa).
@@ -323,6 +401,9 @@ def download_apk_from_config(
                            ``aas_token``. Debe devolver el token generado o None.
         on_start:          ``callable(label: str)`` invocado justo antes de iniciar
                            la descarga. Útil para mostrar un spinner o mensaje.
+        serial:            Serial ADB del dispositivo del que extraer el .apk
+                           cuando ``source="device"``. ``None`` = dispositivo
+                           único conectado (falla si hay más de uno).
 
     Returns:
         Path al APK descargado.
@@ -342,6 +423,14 @@ def download_apk_from_config(
             package, progress_callback=progress_callback, use_cache=use_cache
         )
 
+    # ── APK ya instalado en un dispositivo conectado (sin descargar nada) ────
+    if source == "device":
+        if on_start:
+            on_start("Dispositivo conectado (adb pull)")
+        return DeviceInstalledDownloader(output_dir=output_dir, serial=serial).download(
+            _extract_package_id(package)
+        )
+
     # ── Auto-selección de fuente ─────────────────────────────────────────────
     if source is None:
         source = "google-play" if email else "apk-pure"
diff --git a/nutcracker_core/orchestrator.py b/nutcracker_core/orchestrator.py
index 1a74ae7..0f53ccb 100644
--- a/nutcracker_core/orchestrator.py
+++ b/nutcracker_core/orchestrator.py
@@ -102,6 +102,7 @@ def build_job_cmd(
     dynamic_checks: bool = False,
     serial: str | None = None,
     aipwn: bool = False,
+    source: str | None = None,
 ) -> list[str]:
     """Construye el argv para ejecutar un job de análisis como subproceso aislado.
 
@@ -125,6 +126,14 @@ def build_job_cmd(
     ``aipwn`` no acepta ``--config`` (siempre lee ``config.yaml`` por defecto),
     así que ``config_path``/``static_only``/``dynamic_checks`` se ignoran en
     este modo.
+
+    ``source="device"`` (job de tipo scan, ``target`` es un package id): en vez
+    de descargar de Google Play/APKPure, ``scan`` extrae el .apk ya instalado
+    en el dispositivo ``serial`` vía ``adb pull`` — ver
+    ``downloader.DeviceInstalledDownloader``. Útil para apps de prueba propias
+    no publicadas en ninguna store (mismo caso que motivó el fix de
+    "re-analizar" — pero resuelto desde el origen para apps nunca antes
+    analizadas localmente, no solo las que ya tienen un run previo).
     """
     entry = str(Path(__file__).resolve().parent.parent / "nutcracker.py")
     cmd = [sys.executable, entry]
@@ -141,6 +150,10 @@ def build_job_cmd(
                 cmd += ["--serial", serial]
     else:
         cmd += ["scan", target, "--config", config_path, "--keep-apk"]
+        if source:
+            cmd += ["--source", source]
+            if source == "device" and serial:
+                cmd += ["--serial", serial]
     if static_only:
         cmd.append("--static-only")
     return cmd
diff --git a/nutcracker_core/queue/engine.py b/nutcracker_core/queue/engine.py
index ee92316..0c27e41 100644
--- a/nutcracker_core/queue/engine.py
+++ b/nutcracker_core/queue/engine.py
@@ -179,8 +179,13 @@ def __init__(
     # ── Encolado ─────────────────────────────────────────────────────────────
 
     def submit(self, target: str, kind: str = "static", serial: str | None = None,
-               priority: int = 0) -> Job:
-        """Encola un job (persistido en SQLite como 'queued' de inmediato)."""
+               priority: int = 0, source: str | None = None) -> Job:
+        """Encola un job (persistido en SQLite como 'queued' de inmediato).
+
+        ``source`` solo aplica a jobs "static" con ``target`` = package id:
+        ``"device"`` extrae el .apk ya instalado en ``serial`` vía adb en vez
+        de descargarlo de una store (ver ``downloader.DeviceInstalledDownloader`,
+        ``orchestrator.build_job_cmd``)."""
         is_local = _is_local_apk(target)
         if kind == "dynamic" and not is_local:
             raise ValueError(
@@ -188,7 +193,8 @@ def submit(self, target: str, kind: str = "static", serial: str | None = None,
                 "Descárgalo primero (nutcracker scan) o usa el plugin aipwn para "
                 "flujos dinámicos con descarga automática."
             )
-        job = Job(target=target, kind=kind, is_local_apk=is_local, serial=serial, priority=priority)
+        job = Job(target=target, kind=kind, is_local_apk=is_local, serial=serial,
+                   priority=priority, source=source)
         conn = db.connect(self.db_path)
         try:
             job.db_id = repository.enqueue_job(conn, target=target, kind=kind,
@@ -272,9 +278,12 @@ def _run_job(self, job: Job) -> JobOutcome:
         # antes de construir el comando. Esto evita que el dashboard fuerce
         # una nueva descarga (que falla si no hay credenciales o la app no
         # está disponible) cuando el APK ya existe localmente.
+        # job.source (p.ej. "device") es un pedido explícito del usuario sobre
+        # de dónde debe salir el .apk -- no lo pises con un APK viejo que
+        # pueda existir en downloads/ de un intento anterior con otra fuente.
         effective_target = job.target
         effective_is_local = job.is_local_apk
-        if not effective_is_local and job.kind != "aipwn":
+        if not effective_is_local and job.kind != "aipwn" and not job.source:
             resolved = _resolve_local_apk(job.target)
             if resolved:
                 _log.info("job #%s: reusing local APK %s for package %s",
@@ -290,6 +299,7 @@ def _run_job(self, job: Job) -> JobOutcome:
             dynamic_checks=(job.kind == "dynamic"),
             serial=job.serial,
             aipwn=(job.kind == "aipwn"),
+            source=job.source,
         )
         env = dict(os.environ)
         env["NUTCRACKER_QUEUE_JOB_ID"] = str(job.db_id)
diff --git a/nutcracker_core/queue/job.py b/nutcracker_core/queue/job.py
index 9c932ec..7370587 100644
--- a/nutcracker_core/queue/job.py
+++ b/nutcracker_core/queue/job.py
@@ -13,9 +13,16 @@ class Job:
     handle en memoria que usa QueueEngine para despacharlo."""
 
     target: str
-    kind: str = "static"                 # static | dynamic
+    kind: str = "static"                 # static | dynamic | aipwn
     is_local_apk: bool = False
     serial: str | None = None
     priority: int = 0
     db_id: int | None = None             # id en queue_jobs, asignado al encolar
     created_at: float = field(default_factory=time.time)
+    # Fuente del .apk para jobs "static" con target=package id: None (auto,
+    # store por defecto) | "google-play" | "apk-pure" | "device" (adb pull del
+    # ya instalado, ver downloader.DeviceInstalledDownloader). Solo en
+    # memoria -- no persiste en SQLite (igual que is_local_apk, recalculado);
+    # un job recuperado tras un reinicio del daemon (_load_queued_from_db)
+    # cae a descarga normal en vez de perderse.
+    source: str | None = None
diff --git a/plan.md b/plan.md
index 9b51251..b5217e0 100644
--- a/plan.md
+++ b/plan.md
@@ -1302,3 +1302,102 @@ Tests: 2 en `tests/test_store_hooks.py` (con/sin `NUTCRACKER_APK_SOURCE` en el e
 `tests/test_orchestrator.py` (`_run_analysis` fija/limpia la env var según `keep_apk`, con todas
 las dependencias pesadas mockeadas — `APKAnalyzer`, `_post_analysis_flow`,
 `save_analysis_json`, `fire_post_hooks`, etc.). **181/181 tests pasan.**
+
+## Feature: batch estático+aipwn desde un .txt, con fuente de APK elegible (2026-07-27)
+
+Pedido del usuario: programar en cola varios escaneos estáticos + aipwn desde un archivo `.txt`
+con package ids, pudiendo elegir si el `.apk` de cada uno se descarga de una store o se extrae del
+ya instalado en el dispositivo conectado. Confirmado con el usuario vía `AskUserQuestion`: (1) todo
+package del archivo recibe **siempre** estático + aipwn encadenado (no control mixto por línea), y
+(2) la elección de fuente device-vs-descarga es un **flag global** para todo el archivo, no por app.
+
+**Diseño:** reusa 100% la infraestructura existente en vez de construir un motor nuevo —
+`QueueEngine.drain(on_result=...)` ya notifica cada job terminado; basta con acumular los packages
+cuyo job estático terminó OK y llamar `engine.submit(pkg, kind="aipwn", ...)` + un segundo
+`drain()` tras el primero (el patrón multi-`drain()` ya lo ejercitaba
+`test_drain_picks_up_jobs_queued_by_a_different_engine_instance`, así que no era terreno nuevo).
+
+**Cambios:**
+- `downloader.py`: nueva clase `DeviceInstalledDownloader` — `adb shell pm path ` (soporta App
+  Bundle con varios splits) + `adb pull` de cada uno a `downloads//`, identificando `base.apk`
+  como el archivo principal. `download_apk_from_config()` gana un branch `source == "device"` y un
+  parámetro `serial` que dispatchea a esta clase antes de la auto-selección google-play/apk-pure.
+- `cli/scan.py`: `--source` gana la opción `"device"`, nueva opción `--serial` (solo usada con
+  `--source device`).
+- `orchestrator.build_job_cmd()`: nuevo parámetro `source`; en el branch `scan` (target no es un
+  `.apk` local) agrega `--source ` y, si `source == "device"`, también `--serial`.
+- `queue/job.py`: `Job.source` — igual que `is_local_apk`, vive solo en memoria (no persiste en
+  SQLite); un job recuperado tras un reinicio del daemon cae a la descarga normal en vez de
+  perderse (documentado en el propio campo).
+- `queue/engine.py`: `QueueEngine.submit(source=...)` lo pasa al `Job` construido; `_run_job()` lo
+  reenvía a `build_job_cmd(source=job.source)`. **Fix encontrado durante la implementación** (no
+  reportado por el usuario, detectado al revisar el código): el heurístico existente
+  `_resolve_local_apk()` (que reutiliza un `.apk` ya en `downloads//` para evitar una descarga
+  repetida) corría *antes* de mirar `job.source` — si el usuario pedía explícitamente
+  `--source device` pero `downloads//` ya tenía un `.apk` de un intento anterior con otra
+  fuente, el pedido explícito se ignoraba en silencio. Fix: el heurístico ahora se salta por
+  completo cuando `job.source` está seteado (un pedido explícito de fuente siempre manda).
+- `cli/queue_cmd.py`: `queue add` gana `--source`/`--serial` (se pasan tal cual a cada
+  `engine.submit()`) y `--then-aipwn` — con `--run`, agrupa el resultado de cada job estático en
+  `pending_aipwn` (dentro de `on_result`, solo si `kind == "static"` y `ok` y hay `package`), y tras
+  el primer `drain()` encola un job `aipwn` por cada uno y hace un segundo `drain()`. Rechaza
+  combinarse con `--dynamic`/`--aipwn` (son kinds de job distintos al estático que `--then-aipwn`
+  asume como base).
+
+Uso: `nutcracker queue add packages.txt --then-aipwn --source device --serial  --run`.
+
+Tests nuevos (16, **197/197 pasan** en total): `tests/test_downloader_device.py`
+(`DeviceInstalledDownloader` — split de App Bundle, package no instalado, adb ausente, `pull`
+fallido, dispatch desde `download_apk_from_config`), 4 en `test_orchestrator.py` (`--source`/
+`--serial` en el cmd de `scan`, ausentes cuando no aplica, ignorado en el branch `analyze`), 2 en
+`test_queue_engine.py` (`source` llega al cmd construido, y el fix del heurístico de reuso de APK
+local), 4 en `test_queue_cmd.py` (encadena aipwn tras estático OK, no encadena tras uno fallido,
+rechaza `--then-aipwn --dynamic`, `--source`/`--serial` se propagan a cada `submit()` del archivo).
+Alcance solo CLI (`queue add  ...`) — el dashboard web no se tocó para esta feature,
+ya que su API REST opera sobre un target a la vez, no sobre un archivo.
+
+## Feature: la misma cola batch, ahora también desde el dashboard (2026-07-27, follow-up)
+
+El usuario preguntó si el batch anterior (estático+aipwn encadenado desde un `.txt`, con fuente de
+`.apk` elegible) se podía disparar también desde el dashboard, no solo por CLI. Confirmado que sí y
+pedido implementarlo.
+
+**Diseño:** reusa la misma lógica de encadenado que `cli/queue_cmd.py` (mismo patrón `submit` →
+`drain(on_result=...)` acumulando packages OK → `submit(kind="aipwn")` por cada uno → segundo
+`drain()`), pero corriéndola en un hilo de fondo — igual que ya hacía `_drain_in_background()` para
+un solo job — en vez de bloquear la respuesta HTTP.
+
+**Cambios:**
+- `plugins/dashboard/api.py`: nuevo `POST /api/queue/batch` (`QueueBatchPayload`: `targets: list[str]`,
+  `source`, `serial`, `then_aipwn: bool = True`). Encola un job estático por cada target (filtrando
+  líneas vacías/`#comentarios`) y lanza `_drain_batch_in_background()`, que replica exactamente la
+  lógica de `queue_cmd.py` (acumula `pending_aipwn` dentro de `on_result`, solo si `kind == "static"`
+  y `ok` y hay `package`; tras el primer `drain()` encola los `aipwn` y hace un segundo `drain()`).
+  Reusa el mismo guard `engine._dashboard_draining` que ya usaba `_drain_in_background()` para el
+  path de un solo job — extraje el publish-a-bus común (`_publish_job_status`) para no duplicarlo
+  entre ambos caminos.
+- `static/index.html`: nuevo bloque "Batch desde archivo" en la tarjeta de cola — `` + un `` + botón "Refrescar
+seriales"), el ``, y las funciones `refreshDevices()`/`pollScreenshot()` de
+`index.html`. El tab "Dispositivo" ahora solo tiene el botón WebUSB + el ``; sin bundle
+compilado o sin soporte del navegador, el botón simplemente no aparece y el status lo indica
+explícitamente (sin fallback silencioso a ningún otro modo).
+
+Verificado contra el dashboard real reiniciado con el código nuevo: `GET /` → 200, `GET
+/api/summary` → 200, `GET /api/device` → 404 (confirma que se eliminó, no que quedó roto), bundle
+WebUSB sigue sirviéndose bien. Suite completa: **179/179 tests pasan** (181 → 179, los 2 tests de
+`device.py` eliminados junto con el módulo).
+
+## adb-over-Wi-Fi para evitar el conflicto de exclusividad USB con WebUSB (2026-07-27)
+
+El usuario reportó `--source device` fallando en un batch job con `adb.exe: no devices/emulators
+found` al tener WebUSB conectado en el dashboard (conflicto de exclusividad ya documentado en Fase
+4, esta vez en la dirección USB→adb en vez de adb→USB). Propuso — y se implementó — la solución
+estándar: mover `adb` a TCP/IP (`adb tcpip 5555` + `adb connect :5555`) para que use la red en
+vez del mismo cable USB que WebUSB reclama en exclusiva.
+
+Encontrado en el camino, con uso real: la conexión `adb connect :puerto` vive en el servidor
+adb local y **no sobrevive un reinicio del daemon** (a diferencia de `adb tcpip` en el propio
+teléfono, que sí persiste) — cualquier reinicio (crash, `adb kill-server`, sleep/wake de Windows)
+deja el serial de red "perdido" hasta reconectar a mano. Peor aún, conectar WebUSB parece
+desestabilizar momentáneamente el propio `adb.exe` de Windows (visto en vivo: "could not read ok
+from ADB Server", "failed to start daemon", "cannot connect to daemon" — errores *transitorios*
+del daemon, no del device/paquete, que se resuelven solos en 1-2s).
+
+**Fix en `downloader.py`** (`DeviceInstalledDownloader`, usado por `--source device`):
+- `_ensure_network_serial_connected()` — si el serial tiene forma `host:puerto`, corre `adb
+  connect` antes de cualquier `pm path`/`pull` (idempotente, no rompe nada si ya estaba
+  conectado). No-op para seriales USB normales.
+- `_is_daemon_transient_error()` — detecta los 4 patrones de falla transitoria del daemon listados
+  arriba.
+- `_run_adb()` — envuelve cada invocación de `adb` (connect, pm path, pull) con hasta 3 reintentos
+  (pausa de 1.5s) cuando la salida matchea un error transitorio del daemon, en vez de abortar el
+  job entero por un problema que se resuelve solo.
+
+Documentado para el usuario: usar la IP:puerto del teléfono (no el serial USB) como `--serial` al
+correr batch/queue con `--source device` mientras WebUSB está conectado.
+
+**Limpieza de la cola**: a pedido del usuario, se vació `queue_jobs` (2736 filas acumuladas de
+pruebas repetidas, 2566 en estado `queued`) vía SQL directo — confirmado explícitamente con el
+usuario que el alcance era solo la cola, sin tocar `apps`/`runs`/`findings`/`schedule` (historial
+real de análisis de apps bancarias reales del usuario).
+
+Tests nuevos (6, `tests/test_downloader_device.py`): detección de los 4 marcadores de error
+transitorio, no-op para seriales USB, `adb connect` disparado para seriales `ip:puerto`,
+reintentos ante falla transitoria (tanto en `_ensure_network_serial_connected` como en el propio
+`pm path` de `download()`). **187/187 tests pasan.**
diff --git a/pyproject.toml b/pyproject.toml
index 8373d6b..80b5284 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -26,7 +26,7 @@ dependencies = [
 # `nutcracker dashboard` — installed automatically on first use if missing
 # (same auto-install-on-import pattern as every other plugin), listed here
 # too so `pip install .[dashboard]` works for a non-interactive setup.
-dashboard = ["fastapi>=0.110", "uvicorn[standard]>=0.29", "av>=11.0"]
+dashboard = ["fastapi>=0.110", "uvicorn[standard]>=0.29"]
 dev = ["pytest>=8.0.0"]
 
 [project.scripts]
diff --git a/tests/dashboard/test_api.py b/tests/dashboard/test_api.py
index 9fbc5aa..37234a2 100644
--- a/tests/dashboard/test_api.py
+++ b/tests/dashboard/test_api.py
@@ -52,6 +52,20 @@ def test_summary_empty_db(client):
     assert r.json() == {"apps": 0, "runs": 0, "findings": 0, "active_jobs": 0}
 
 
+def test_default_serial_null_when_not_configured(client):
+    r = client.get("/api/config/default-serial")
+    assert r.status_code == 200
+    assert r.json() == {"serial": None}
+
+
+def test_default_serial_reflects_configured_value(db_path, engine):
+    app = create_app(db_path=db_path, engine=engine, default_serial="172.20.10.6:5555")
+    client_with_serial = TestClient(app)
+    r = client_with_serial.get("/api/config/default-serial")
+    assert r.status_code == 200
+    assert r.json() == {"serial": "172.20.10.6:5555"}
+
+
 def test_apps_and_runs_reflect_seeded_data(db_path, client):
     _seed_app_with_run(db_path)
 
@@ -220,47 +234,6 @@ def fake_run(cmd, env=None, capture_output=True, text=True):  # noqa: ANN001
     assert all(c["serial"] == "ZY22GPM27J" for c in calls)
 
 
-def test_device_endpoint_reports_no_devices_without_adb(monkeypatch, client):
-    monkeypatch.setattr(
-        "nutcracker_core.plugins.dashboard.device.subprocess.run",
-        lambda *a, **kw: (_ for _ in ()).throw(FileNotFoundError()),
-    )
-    r = client.get("/api/device")
-    assert r.status_code == 200
-    assert r.json() == {"serials": []}
-
-
-def test_device_screenshot_503_when_unavailable(monkeypatch, client):
-    monkeypatch.setattr("nutcracker_core.plugins.dashboard.device.screenshot_png", lambda serial=None: None)
-    r = client.get("/api/device/screenshot")
-    assert r.status_code == 503
-
-
-def test_device_video_status_unavailable_without_scrcpy(monkeypatch, client):
-    monkeypatch.setattr(
-        "nutcracker_core.plugins.dashboard.scrcpy_video.shutil.which", lambda *a, **kw: None,
-    )
-    r = client.get("/api/device/video/status")
-    assert r.status_code == 200
-    assert r.json() == {"available": False, "scrcpy_bin": None}
-
-
-def test_device_video_status_available_when_scrcpy_on_path(monkeypatch, client):
-    monkeypatch.setattr(
-        "nutcracker_core.plugins.dashboard.scrcpy_video.shutil.which",
-        lambda name, **kw: "/usr/bin/scrcpy" if name == "scrcpy" else None,
-    )
-    r = client.get("/api/device/video/status")
-    assert r.status_code == 200
-    assert r.json() == {"available": True, "scrcpy_bin": "/usr/bin/scrcpy"}
-
-
-def test_device_video_503_when_scrcpy_unavailable(monkeypatch, client):
-    monkeypatch.setattr(
-        "nutcracker_core.plugins.dashboard.scrcpy_video.shutil.which", lambda *a, **kw: None,
-    )
-    r = client.get("/api/device/video")
-    assert r.status_code == 503
 
 
 def test_chat_pending_empty_by_default(client):
diff --git a/tests/dashboard/test_scrcpy_video.py b/tests/dashboard/test_scrcpy_video.py
deleted file mode 100644
index 2d8bed4..0000000
--- a/tests/dashboard/test_scrcpy_video.py
+++ /dev/null
@@ -1,257 +0,0 @@
-"""Tests de nutcracker_core/plugins/dashboard/scrcpy_video.py (video en vivo
-real vía el cliente scrcpy oficial — ver el docstring del módulo para el
-porqué de invocar el binario real en vez de reimplementar el protocolo).
-
-Todo mockeado: ni un subprocess real ni una decodificación H.264 real corren
-en estos tests — lo que se ejercita es la mecánica propia (localización del
-binario, construcción del comando, ciclo de reinicio, generador multipart),
-no el pipeline de video real (verificado a mano contra hardware, ver plan.md).
-"""
-
-from __future__ import annotations
-
-import time
-from pathlib import Path
-from unittest.mock import MagicMock, patch
-
-from nutcracker_core.plugins.dashboard import scrcpy_video
-
-
-# ── find_scrcpy_binary / _find_adjacent_adb ─────────────────────────────────
-
-def test_find_scrcpy_binary_prefers_configured_path(tmp_path):
-    fake_bin = tmp_path / "scrcpy.exe"
-    fake_bin.write_text("fake")
-    config = {"dashboard": {"scrcpy_path": str(fake_bin)}}
-    assert scrcpy_video.find_scrcpy_binary(config) == str(fake_bin)
-
-
-def test_find_scrcpy_binary_falls_back_to_path_when_configured_missing(tmp_path):
-    config = {"dashboard": {"scrcpy_path": str(tmp_path / "nope")}}
-    with patch("shutil.which", return_value="/usr/bin/scrcpy"):
-        assert scrcpy_video.find_scrcpy_binary(config) == "/usr/bin/scrcpy"
-
-
-def test_find_scrcpy_binary_none_when_nothing_found():
-    with patch("shutil.which", return_value=None):
-        assert scrcpy_video.find_scrcpy_binary({}) is None
-        assert scrcpy_video.find_scrcpy_binary(None) is None
-
-
-def test_find_adjacent_adb_returns_sibling_when_present(tmp_path):
-    scrcpy_bin = tmp_path / "scrcpy.exe"
-    scrcpy_bin.write_text("fake")
-    adb_bin = tmp_path / "adb.exe"
-    adb_bin.write_text("fake")
-    assert scrcpy_video._find_adjacent_adb(str(scrcpy_bin)) == str(adb_bin)
-
-
-def test_find_adjacent_adb_none_when_absent(tmp_path):
-    scrcpy_bin = tmp_path / "scrcpy"
-    scrcpy_bin.write_text("fake")
-    assert scrcpy_video._find_adjacent_adb(str(scrcpy_bin)) is None
-
-
-# ── decode_last_frame_jpeg ───────────────────────────────────────────────────
-
-def test_decode_last_frame_jpeg_missing_file(tmp_path):
-    assert scrcpy_video.decode_last_frame_jpeg(tmp_path / "nope.mkv") is None
-
-
-def test_decode_last_frame_jpeg_empty_file(tmp_path):
-    p = tmp_path / "empty.mkv"
-    p.write_bytes(b"")
-    assert scrcpy_video.decode_last_frame_jpeg(p) is None
-
-
-def test_decode_last_frame_jpeg_unreadable_container(tmp_path):
-    p = tmp_path / "garbage.mkv"
-    p.write_bytes(b"not a real mkv file")
-    # av.open lanzará sobre bytes basura -- debe devolver None, no propagar.
-    assert scrcpy_video.decode_last_frame_jpeg(p) is None
-
-
-def test_decode_last_frame_jpeg_returns_last_frame_as_jpeg(tmp_path):
-    p = tmp_path / "clip.mkv"
-    p.write_bytes(b"fake but non-empty")
-
-    fake_image = MagicMock()
-
-    def _fake_save(buf, format=None, quality=None):
-        buf.write(b"\xff\xd8\xff\xd9")  # marcadores JPEG start/end, contenido irrelevante
-
-    fake_image.save.side_effect = _fake_save
-
-    frame1, frame2 = MagicMock(), MagicMock()
-    frame1.to_image.return_value = fake_image
-    frame2.to_image.return_value = fake_image
-
-    fake_container = MagicMock()
-    fake_container.duration = None  # archivo "recién creado" -- sin seek, decode desde el principio
-    fake_container.decode.return_value = [frame1, frame2]
-
-    with patch("av.open", return_value=fake_container):
-        result = scrcpy_video.decode_last_frame_jpeg(p)
-
-    assert result == b"\xff\xd8\xff\xd9"
-    fake_container.close.assert_called_once()
-    fake_container.seek.assert_not_called()
-    # Debe quedarse con el ÚLTIMO frame decodificado, no el primero.
-    frame2.to_image.assert_called_once()
-    frame1.to_image.assert_not_called()
-
-
-def test_decode_last_frame_jpeg_seeks_near_end_on_long_recording(tmp_path):
-    """Con un archivo lo bastante largo (container.duration disponible y
-    mayor al lookback), debe hacer seek cerca del final en vez de decodificar
-    desde el principio -- ver docstring del módulo para la medición real que
-    motiva esto (0.5s/poll sin seek vs 0.07s/poll con seek)."""
-    p = tmp_path / "clip.mkv"
-    p.write_bytes(b"fake but non-empty")
-
-    fake_image = MagicMock()
-    fake_image.save.side_effect = lambda buf, format=None, quality=None: buf.write(b"\xff\xd8\xff\xd9")
-    frame = MagicMock()
-    frame.to_image.return_value = fake_image
-
-    fake_container = MagicMock()
-    fake_container.duration = 30_000_000  # 30s, bien por encima del lookback de 1.5s
-    fake_container.decode.return_value = [frame]
-
-    with patch("av.open", return_value=fake_container):
-        result = scrcpy_video.decode_last_frame_jpeg(p)
-
-    assert result == b"\xff\xd8\xff\xd9"
-    fake_container.seek.assert_called_once_with(
-        30_000_000 - scrcpy_video._SEEK_LOOKBACK_US, any_frame=False,
-    )
-
-
-def test_decode_last_frame_jpeg_falls_back_when_seek_raises(tmp_path):
-    """Si el seek falla (formato inesperado, versión de ffmpeg, etc.), debe
-    seguir intentando decode() en vez de propagar la excepción."""
-    p = tmp_path / "clip.mkv"
-    p.write_bytes(b"fake but non-empty")
-
-    fake_image = MagicMock()
-    fake_image.save.side_effect = lambda buf, format=None, quality=None: buf.write(b"\xff\xd8\xff\xd9")
-    frame = MagicMock()
-    frame.to_image.return_value = fake_image
-
-    fake_container = MagicMock()
-    fake_container.duration = 30_000_000
-    fake_container.seek.side_effect = RuntimeError("seek no soportado")
-    fake_container.decode.return_value = [frame]
-
-    with patch("av.open", return_value=fake_container):
-        result = scrcpy_video.decode_last_frame_jpeg(p)
-
-    assert result == b"\xff\xd8\xff\xd9"
-
-
-# ── ScrcpyVideoSession._build_cmd ────────────────────────────────────────────
-
-def test_build_cmd_includes_serial_when_given(tmp_path):
-    session = scrcpy_video.ScrcpyVideoSession("scrcpy", serial="ZY22GPM27J")
-    cmd = session._build_cmd(tmp_path / "clip_1.mkv")
-    assert cmd[0] == "scrcpy"
-    assert "--no-window" in cmd
-    assert "-s" in cmd and cmd[cmd.index("-s") + 1] == "ZY22GPM27J"
-    assert any(c.startswith("--record=") for c in cmd)
-    session.stop()
-
-
-def test_build_cmd_omits_serial_when_none(tmp_path):
-    session = scrcpy_video.ScrcpyVideoSession("scrcpy", serial=None)
-    cmd = session._build_cmd(tmp_path / "clip_1.mkv")
-    assert "-s" not in cmd
-    session.stop()
-
-
-# ── ScrcpyVideoSession._run (ciclo de fondo, todo mockeado) ──────────────────
-
-def test_session_sets_latest_jpeg_when_frames_decode(monkeypatch, tmp_path):
-    monkeypatch.setattr(scrcpy_video, "_RESTART_INTERVAL_S", 0.3)
-    monkeypatch.setattr(scrcpy_video, "_POLL_INTERVAL_S", 0.05)
-
-    fake_proc = MagicMock()
-    fake_proc.poll.return_value = None  # "vivo" durante todo el ciclo
-    fake_proc.stdout = None
-
-    with patch("subprocess.Popen", return_value=fake_proc), \
-         patch("subprocess.run"), \
-         patch.object(scrcpy_video, "decode_last_frame_jpeg", return_value=b"jpegdata"):
-        session = scrcpy_video.ScrcpyVideoSession("scrcpy", serial=None)
-        session.start()
-        time.sleep(0.5)
-        assert session.latest_jpeg() == b"jpegdata"
-        assert session.error() is None
-        session.stop()
-
-
-def test_session_sets_error_when_no_frames_ever_decode(monkeypatch, tmp_path):
-    monkeypatch.setattr(scrcpy_video, "_RESTART_INTERVAL_S", 0.2)
-    monkeypatch.setattr(scrcpy_video, "_POLL_INTERVAL_S", 0.05)
-    monkeypatch.setattr(scrcpy_video, "_RETRY_BACKOFF_S", 0.05)
-
-    fake_proc = MagicMock()
-    fake_proc.poll.return_value = None
-    fake_stdout = MagicMock()
-    fake_stdout.read.return_value = "[server] ERROR: something broke"
-    fake_proc.stdout = fake_stdout
-
-    with patch("subprocess.Popen", return_value=fake_proc), \
-         patch("subprocess.run"), \
-         patch.object(scrcpy_video, "decode_last_frame_jpeg", return_value=None):
-        session = scrcpy_video.ScrcpyVideoSession("scrcpy", serial=None)
-        session.start()
-        time.sleep(0.4)
-        assert session.latest_jpeg() is None
-        assert session.error() is not None
-        assert "something broke" in session.error()
-        session.stop()
-
-
-def test_session_reports_error_when_binary_not_found(tmp_path):
-    with patch("subprocess.Popen", side_effect=FileNotFoundError("no such file")):
-        session = scrcpy_video.ScrcpyVideoSession("scrcpy-inexistente", serial=None)
-        session.start()
-        time.sleep(0.2)
-        assert session.error() is not None
-        session.stop()
-
-
-# ── mjpeg_multipart ──────────────────────────────────────────────────────────
-
-def test_mjpeg_multipart_yields_frame_with_correct_headers():
-    session = MagicMock()
-    session.latest_jpeg.return_value = b"abc123"
-
-    gen = scrcpy_video.mjpeg_multipart(session, boundary="testboundary", fps=1000)
-    chunk = next(gen)
-
-    assert b"--testboundary\r\n" in chunk
-    assert b"Content-Type: image/jpeg\r\n" in chunk
-    assert b"Content-Length: 6\r\n" in chunk
-    assert chunk.endswith(b"abc123\r\n")
-    gen.close()
-
-
-def test_mjpeg_multipart_skips_resending_same_frame(monkeypatch):
-    frame_a = b"frame-a"
-    frame_b = b"frame-b"
-    session = MagicMock()
-    # Tres lecturas seguidas del mismo objeto frame_a, luego cambia a frame_b.
-    session.latest_jpeg.side_effect = [frame_a, frame_a, frame_a, frame_b]
-    monkeypatch.setattr(scrcpy_video.time, "sleep", lambda _: None)
-
-    gen = scrcpy_video.mjpeg_multipart(session, fps=1000)
-    first = next(gen)   # frame_a es nuevo -> yield
-    second = next(gen)  # frame_b tras dos repeticiones de frame_a -> yield
-    gen.close()
-
-    assert first.endswith(b"frame-a\r\n")
-    assert second.endswith(b"frame-b\r\n")
-    # Se llamó a latest_jpeg() 4 veces (3 de frame_a + 1 de frame_b) para
-    # producir solo 2 yields -- confirma que las repeticiones no re-emiten.
-    assert session.latest_jpeg.call_count == 4
diff --git a/tests/test_downloader_device.py b/tests/test_downloader_device.py
index d862333..1c992ce 100644
--- a/tests/test_downloader_device.py
+++ b/tests/test_downloader_device.py
@@ -15,6 +15,8 @@
 from nutcracker_core.downloader import (
     APKDownloadError,
     DeviceInstalledDownloader,
+    _ensure_network_serial_connected,
+    _is_daemon_transient_error,
     download_apk_from_config,
 )
 
@@ -108,6 +110,87 @@ def fake_run(cmd, capture_output=True, text=True, timeout=None):  # noqa: ANN001
         dl.download("com.example.app")
 
 
+# ── Resiliencia ante inestabilidad del daemon adb (conflicto con WebUSB) ────
+
+def test_is_daemon_transient_error_detects_known_markers():
+    assert _is_daemon_transient_error("* daemon not running; starting now at tcp:5037")
+    assert _is_daemon_transient_error("adb.exe: cannot connect to daemon")
+    assert _is_daemon_transient_error("could not read ok from ADB Server")
+    assert _is_daemon_transient_error("* failed to start daemon")
+
+
+def test_is_daemon_transient_error_false_for_real_errors():
+    assert not _is_daemon_transient_error("error: device offline")
+    assert not _is_daemon_transient_error("adb: error: failed to stat remote object")
+
+
+def test_ensure_network_serial_connected_noop_for_usb_serial(monkeypatch):
+    calls = []
+    monkeypatch.setattr(
+        "nutcracker_core.downloader.subprocess.run",
+        lambda *a, **kw: calls.append(a) or _completed(),
+    )
+    _ensure_network_serial_connected("ZY22GPM27J")
+    assert calls == []
+
+
+def test_ensure_network_serial_connected_calls_adb_connect_for_ip_serial(monkeypatch):
+    calls = []
+
+    def fake_run(cmd, capture_output=True, text=True, timeout=None):  # noqa: ANN001
+        calls.append(cmd)
+        return _completed(stdout="connected to 172.20.10.6:5555")
+
+    monkeypatch.setattr("nutcracker_core.downloader.subprocess.run", fake_run)
+    _ensure_network_serial_connected("172.20.10.6:5555")
+    assert calls == [["adb", "connect", "172.20.10.6:5555"]]
+
+
+def test_ensure_network_serial_connected_retries_on_daemon_transient_error(monkeypatch):
+    calls = []
+
+    def fake_run(cmd, capture_output=True, text=True, timeout=None):  # noqa: ANN001
+        calls.append(cmd)
+        if len(calls) < 3:
+            return _completed(stderr="adb.exe: cannot connect to daemon")
+        return _completed(stdout="connected to 172.20.10.6:5555")
+
+    monkeypatch.setattr("nutcracker_core.downloader.subprocess.run", fake_run)
+    monkeypatch.setattr("nutcracker_core.downloader.time.sleep", lambda *_: None)
+    _ensure_network_serial_connected("172.20.10.6:5555")
+    assert len(calls) == 3
+
+
+def test_download_retries_pm_path_on_daemon_transient_error(monkeypatch, tmp_path):
+    from pathlib import Path
+
+    calls = []
+
+    def fake_run(cmd, capture_output=True, text=True, timeout=None):  # noqa: ANN001
+        calls.append(list(cmd))
+        if "connect" in cmd:
+            return _completed(stdout="connected to 172.20.10.6:5555")
+        if "path" in cmd:
+            # Primer intento: falla transitoria del daemon. Segundo: éxito real.
+            path_calls = [c for c in calls if "path" in c]
+            if len(path_calls) < 2:
+                return _completed(stderr="could not read ok from ADB Server")
+            return _completed(stdout="package:/data/app/com.example.app/base.apk\n")
+        if "pull" in cmd:
+            Path(cmd[-1]).write_bytes(b"PK\x03\x04")
+            return _completed()
+        return _completed()
+
+    monkeypatch.setattr("nutcracker_core.downloader.subprocess.run", fake_run)
+    monkeypatch.setattr("nutcracker_core.downloader.time.sleep", lambda *_: None)
+
+    dl = DeviceInstalledDownloader(output_dir=str(tmp_path), serial="172.20.10.6:5555")
+    result = dl.download("com.example.app")
+
+    assert result.name == "base.apk"
+    assert result.exists()
+
+
 def test_download_apk_from_config_dispatches_to_device_downloader(monkeypatch, tmp_path):
     from pathlib import Path
 

From 7ff5297928be07271e9507cebf53336667ef1db2 Mon Sep 17 00:00:00 2001
From: Hiteek 
Date: Tue, 28 Jul 2026 12:00:47 -0500
Subject: [PATCH 26/30] last changes

---
 config.yaml.example                 |  14 +-
 nutcracker_core/adb_transport.py    | 202 ++++++++++++++++++++++++++
 nutcracker_core/cli/queue_cmd.py    |  24 ++++
 nutcracker_core/cli/serve.py        |  15 ++
 nutcracker_core/downloader.py       |  64 ++-------
 nutcracker_core/queue/engine.py     |  33 +++++
 nutcracker_core/store/repository.py |  13 ++
 plan.md                             | 200 ++++++++++++++++++++++++++
 tests/dashboard/test_api.py         |  17 +++
 tests/test_adb_transport.py         | 210 ++++++++++++++++++++++++++++
 tests/test_downloader_device.py     |  10 +-
 tests/test_queue_engine.py          | 166 +++++++++++++++++++++-
 12 files changed, 906 insertions(+), 62 deletions(-)
 create mode 100644 nutcracker_core/adb_transport.py
 create mode 100644 tests/test_adb_transport.py

diff --git a/config.yaml.example b/config.yaml.example
index 71e7f46..049640b 100644
--- a/config.yaml.example
+++ b/config.yaml.example
@@ -157,8 +157,8 @@ store:
 # run in parallel; dynamic jobs (Frida/ADB on a physical device) are always
 # serialized per device serial, regardless of dynamic_workers.
 queue:
-  static_workers: 4      # parallel static analyses at once
-  dynamic_workers: 2      # concurrent *devices* for dynamic jobs (never same serial)
+  static_workers: 1      # parallel static analyses at once
+  dynamic_workers: 1      # concurrent *devices* for dynamic jobs (never same serial)
 
 # ── Scheduler (periodic reviews — plan.md Fase 1.2) ────────────────────────────
 # `nutcracker serve` re-queues every app whose next_due_at has passed, and
@@ -176,4 +176,12 @@ dashboard:
   port: 8765
   # Video en vivo del dispositivo: pestaña "Dispositivo" -> "🔌 USB directo
   # (fluido)" (WebUSB + WebCodecs, ver nutcracker_core/plugins/dashboard/webusb/).
-  # Sin eso, cae al polling de screenshots (adb exec-out screencap).
+  # Es la única vía de video: no hay fallback por screenshots.
+  #
+  # El navegador reclama el cable USB en exclusiva para ese video, así que
+  # nutcracker debe hablarle al teléfono por el serial de red
+  # (strategies.default_device_id = ":5555", tras `adb tcpip 5555`). Ese
+  # transporte TCP vive en el daemon adb y se cae solo (reinicio del daemon,
+  # doze/corte de red del teléfono); cada cuántos segundos revisarlo y
+  # reconectarlo en segundo plano. Mínimo efectivo: 10.
+  adb_keepalive_seconds: 60
diff --git a/nutcracker_core/adb_transport.py b/nutcracker_core/adb_transport.py
new file mode 100644
index 0000000..3a33a33
--- /dev/null
+++ b/nutcracker_core/adb_transport.py
@@ -0,0 +1,202 @@
+"""Salud del transporte adb-over-wifi (serial "host:puerto").
+
+Contexto (encontrado en uso real, 2026-07-27): el dashboard usa WebUSB para el
+video en vivo, que necesita reclamar en exclusiva la interfaz WinUSB del
+teléfono. El daemon adb de Windows compite por *esa misma* interfaz: apenas
+arranca enumera y abre toda interfaz ADB USB que vea, sin importar que todos
+los comandos que le mandemos usen un serial de red. Por eso la estrategia del
+proyecto es separar canales -- WebUSB se queda el cable, y nutcracker habla con
+el teléfono por TCP/IP (``strategies.default_device_id`` = ":5555" tras un
+``adb tcpip 5555``).
+
+El punto débil de esa separación es que el canal TCP **no es durable**: la
+conexión vive en el servidor adb local, así que se pierde ante un reinicio del
+daemon (crash, ``adb kill-server``, sleep/wake), y también ante cortes de la
+red del teléfono (dozing, hotspot). Cuando eso pasa, el serial de red
+desaparece de ``adb devices`` y todo colapsa sobre el cable -- justo el
+conflicto que la separación buscaba evitar. Se observó en vivo con 3 jobs
+corriendo contra "172.20.10.6:5555" mientras ``adb -s 172.20.10.6:5555
+get-state`` respondía "device not found" (el teléfono respondía a ping y tenía
+el 5555 abierto: lo que se cayó fue el transporte, no el dispositivo).
+
+Este módulo mantiene ese canal vivo: ``ensure_available()`` antes de cada job
+que lo use, y ``TransportKeepAlive`` como chequeo periódico de fondo.
+"""
+
+from __future__ import annotations
+
+import logging
+import re
+import subprocess
+import threading
+
+_log = logging.getLogger(__name__)
+
+# Serial de red: "host:puerto" (ip, hostname o nombre de contenedor).
+_NETWORK_SERIAL_RE = re.compile(r"^[\w.\-]+:\d+$")
+
+# Fragmentos de stderr/stdout que indican una falla *transitoria* del daemon
+# adb (no del device/paquete) -- vistos en uso real justo al conectar WebUSB
+# (el navegador reclamando el USB desestabiliza momentáneamente adb.exe en
+# Windows). Reintentar tras una pausa corta lo resuelve casi siempre --
+# confirmado en uso real que el daemon se recupera solo en 1-2s.
+_DAEMON_TRANSIENT_MARKERS = (
+    "cannot connect to daemon",
+    "failed to start daemon",
+    "could not read ok from adb server",
+    "daemon not running",
+)
+
+_CONNECT_TIMEOUT = 10
+_STATE_TIMEOUT = 5
+_RETRY_PAUSE = 1.5
+
+
+def is_network_serial(serial: str | None) -> bool:
+    """True si ``serial`` es un serial de red (":") y no un
+    serial USB (p.ej. "ZY22GPM27J")."""
+    return bool(serial) and bool(_NETWORK_SERIAL_RE.match(serial))  # type: ignore[arg-type]
+
+
+def is_daemon_transient_error(output: str) -> bool:
+    low = output.lower()
+    return any(marker in low for marker in _DAEMON_TRANSIENT_MARKERS)
+
+
+def _adb(args: list[str], adb_bin: str, timeout: int) -> subprocess.CompletedProcess | None:
+    """Corre adb devolviendo None si el binario no existe o el comando se
+    colgó -- quien llama decide qué hacer (aquí nunca es fatal: el comando real
+    del job fallará después con un error mucho más claro que un stacktrace de
+    este helper)."""
+    try:
+        return subprocess.run(
+            [adb_bin, *args], capture_output=True, text=True, timeout=timeout,
+        )
+    except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
+        return None
+
+
+def is_transport_alive(serial: str, adb_bin: str = "adb") -> bool:
+    """True si el daemon adb tiene a ``serial`` registrado y utilizable.
+
+    Usa ``get-state`` en vez de parsear ``adb devices`` porque distingue los
+    tres casos que importan con un solo comando: transporte ausente (rc != 0,
+    "device not found"), presente pero inutilizable ("offline", "unauthorized")
+    y listo ("device")."""
+    proc = _adb(["-s", serial, "get-state"], adb_bin, _STATE_TIMEOUT)
+    if proc is None or proc.returncode != 0:
+        return False
+    return (proc.stdout or "").strip() == "device"
+
+
+def ensure_connected(serial: str, adb_bin: str = "adb", attempts: int = 3) -> None:
+    """``adb connect `` (idempotente) si ``serial`` es de red.
+
+    No-op para seriales USB. Reintenta ante fallas transitorias del daemon en
+    vez de rendirse en el primer intento."""
+    if not is_network_serial(serial):
+        return
+    for attempt in range(attempts):
+        proc = _adb(["connect", serial], adb_bin, _CONNECT_TIMEOUT)
+        if proc is None:
+            return
+        output = (proc.stdout or "") + (proc.stderr or "")
+        if not is_daemon_transient_error(output):
+            return  # conectado, o un error real (no transitorio) -- no insistas
+        if attempt < attempts - 1:
+            _sleep(_RETRY_PAUSE)
+
+
+def ensure_available(serial: str | None, adb_bin: str = "adb") -> bool:
+    """Garantiza que ``serial`` esté usable antes de correr algo contra él.
+
+    Retorna True si quedó listo (o si no hay nada que garantizar: serial vacío
+    o USB, donde el propio comando del job reportará cualquier problema con un
+    error más específico que el que podríamos dar acá).
+
+    Chequea el estado *antes* de reconectar: ``adb connect`` arranca el daemon
+    si no está corriendo, y un daemon nuevo re-reclama el cable USB -- si el
+    transporte ya está vivo no hay razón para arriesgar ese efecto colateral
+    contra el WebUSB del dashboard.
+    """
+    if not is_network_serial(serial):
+        return True
+    assert serial is not None
+    if is_transport_alive(serial, adb_bin):
+        return True
+    _log.info("transporte adb caído para %s -- reconectando", serial)
+    ensure_connected(serial, adb_bin)
+    alive = is_transport_alive(serial, adb_bin)
+    if alive:
+        _log.info("transporte adb restablecido para %s", serial)
+    else:
+        _log.warning("no se pudo restablecer el transporte adb para %s", serial)
+    return alive
+
+
+def _sleep(seconds: float) -> None:
+    """Indirección para que los tests puedan neutralizar las pausas sin
+    parchear ``time.sleep`` global."""
+    import time
+    time.sleep(seconds)
+
+
+class TransportKeepAlive:
+    """Hilo de fondo que revisa periódicamente que los seriales de red sigan
+    registrados en el daemon adb, y los reconecta si no.
+
+    Complementa (no reemplaza) al chequeo por-job de ``QueueEngine``: sin esto,
+    una caída del transporte durante un hueco sin jobs pasa inadvertida hasta
+    el siguiente job, y mientras tanto cualquier ``adb`` suelto cae de vuelta al
+    cable. Es un hilo daemon: no impide que el proceso termine.
+    """
+
+    def __init__(self, serials: list[str] | None = None, adb_bin: str = "adb",
+                 interval_seconds: int = 60) -> None:
+        self.adb_bin = adb_bin
+        self.interval_seconds = max(10, interval_seconds)
+        self._serials: set[str] = {s for s in (serials or []) if is_network_serial(s)}
+        self._guard = threading.Lock()
+        self._stop = threading.Event()
+        self._thread: threading.Thread | None = None
+
+    def track(self, serial: str | None) -> None:
+        """Agrega un serial al conjunto vigilado (no-op si no es de red o si ya
+        estaba). Permite que un job encolado con un serial distinto al default
+        del config también quede cubierto."""
+        if not is_network_serial(serial):
+            return
+        with self._guard:
+            self._serials.add(serial)  # type: ignore[arg-type]
+
+    @property
+    def serials(self) -> set[str]:
+        with self._guard:
+            return set(self._serials)
+
+    def start(self) -> None:
+        if self._thread is not None:
+            return
+        self._stop.clear()
+        self._thread = threading.Thread(
+            target=self._loop, name="adb-transport-keepalive", daemon=True,
+        )
+        self._thread.start()
+
+    def stop(self, timeout: float = 2.0) -> None:
+        self._stop.set()
+        if self._thread is not None:
+            self._thread.join(timeout=timeout)
+            self._thread = None
+
+    def check_once(self) -> None:
+        for serial in self.serials:
+            try:
+                ensure_available(serial, self.adb_bin)
+            except Exception:  # noqa: BLE001 - un serial roto no debe matar el hilo
+                _log.exception("keepalive falló para %s", serial)
+
+    def _loop(self) -> None:
+        while not self._stop.is_set():
+            self.check_once()
+            self._stop.wait(self.interval_seconds)
diff --git a/nutcracker_core/cli/queue_cmd.py b/nutcracker_core/cli/queue_cmd.py
index de0ce63..b70d319 100644
--- a/nutcracker_core/cli/queue_cmd.py
+++ b/nutcracker_core/cli/queue_cmd.py
@@ -141,3 +141,27 @@ def queue_ls(config_path: str, status: str | None, limit: int) -> None:
         console.print(
             f"  #{r['id']:<5} [{color}]{r['status']:<8}[/{color}] {r['kind']:<8} {r['target']}{pkg}"
         )
+
+
+@queue.command("rm")
+@click.argument("job_id", type=int, nargs=-1, required=True)
+@click.option("--config", "-c", "config_path", default="config.yaml", show_default=True)
+def queue_rm(job_id: tuple[int, ...], config_path: str) -> None:
+    """Borra uno o más jobs pendientes (status='queued') de la cola por ID.
+
+    No afecta jobs 'running' (ya despachados a un worker en algún proceso --
+    no hay forma de "des-despacharlos" borrando la fila) ni 'done'/'error'
+    (son historial, no cola pendiente).
+    """
+    config = load_config(config_path)
+    conn = db.connect(db_path_from_config(config))
+    try:
+        for jid in job_id:
+            if repository.delete_job(conn, jid):
+                console.print(f"[green]✔[/green] job #{jid} borrado")
+            else:
+                console.print(
+                    f"[yellow]⚠[/yellow] job #{jid} no existe o ya no está 'queued'"
+                )
+    finally:
+        conn.close()
diff --git a/nutcracker_core/cli/serve.py b/nutcracker_core/cli/serve.py
index 9b22486..6e93ffc 100644
--- a/nutcracker_core/cli/serve.py
+++ b/nutcracker_core/cli/serve.py
@@ -7,6 +7,7 @@
 
 import click
 
+from nutcracker_core import adb_transport
 from nutcracker_core.config import load_config, get as cfg_get
 from nutcracker_core.queue.engine import QueueEngine
 from nutcracker_core.scheduler import NutcrackerScheduler
@@ -35,6 +36,19 @@ def serve(config_path: str) -> None:
         static_workers=int(cfg_get(config, "queue", "static_workers", default=4)),
         dynamic_workers=int(cfg_get(config, "queue", "dynamic_workers", default=2)),
     )
+    # Mantiene vivo el serial adb-over-wifi mientras el daemon corre (ver
+    # nutcracker_core/adb_transport.py): esa conexión no sobrevive a un
+    # reinicio del daemon adb ni a un corte de red del teléfono, y sin ella los
+    # jobs dinámicos fallan con "device not found" aunque el teléfono esté ahí.
+    default_serial = str(
+        cfg_get(config, "strategies", "default_device_id", default="")
+    ).strip()
+    keepalive = adb_transport.TransportKeepAlive(
+        serials=[default_serial] if default_serial else [],
+    )
+    engine.transport_keepalive = keepalive
+    keepalive.start()
+
     scheduler = NutcrackerScheduler(engine, config)
     scheduler.start()
 
@@ -56,5 +70,6 @@ def _handle_sigterm(signum, frame):  # noqa: ANN001
     except KeyboardInterrupt:
         pass
     finally:
+        keepalive.stop()
         scheduler.stop()
         console.print("[dim]nutcracker serve — detenido[/dim]")
diff --git a/nutcracker_core/downloader.py b/nutcracker_core/downloader.py
index d62fdb2..76aa15b 100644
--- a/nutcracker_core/downloader.py
+++ b/nutcracker_core/downloader.py
@@ -10,6 +10,8 @@
 from pathlib import Path
 from urllib.parse import urlparse, parse_qs
 
+from nutcracker_core import adb_transport
+
 
 class APKDownloadError(Exception):
     """Error durante la descarga de una APK."""
@@ -261,59 +263,13 @@ def download(self, url: str) -> Path:
 
 # ── Google Play (requiere AAS token) ─────────────────────────────────────────
 
-_NETWORK_SERIAL_RE = re.compile(r"^[\w.\-]+:\d+$")
-
-
-# Fragmentos de stderr/stdout que indican una falla *transitoria* del daemon
-# adb (no del device/paquete) -- vistos en uso real justo al conectar WebUSB
-# (el navegador reclamando el USB parece desestabilizar momentáneamente
-# adb.exe en Windows): "could not read ok from ADB Server", "failed to start
-# daemon", "cannot connect to daemon", "daemon not running". Reintentar tras
-# una pausa corta resuelve esto casi siempre -- confirmado en uso real que el
-# daemon se recupera solo en 1-2s.
-_DAEMON_TRANSIENT_MARKERS = (
-    "cannot connect to daemon",
-    "failed to start daemon",
-    "could not read ok from adb server",
-    "daemon not running",
-)
-
-
-def _is_daemon_transient_error(output: str) -> bool:
-    low = output.lower()
-    return any(marker in low for marker in _DAEMON_TRANSIENT_MARKERS)
-
-
-def _ensure_network_serial_connected(serial: str, adb_bin: str = "adb", attempts: int = 3) -> None:
-    """Si ``serial`` tiene forma "host:puerto" (adb-over-wifi, tras ``adb
-    tcpip `` en el propio teléfono), asegura la conexión antes de
-    usarlo -- ``adb connect`` es idempotente (no falla si ya está conectado).
-
-    Necesario porque esa conexión vive en el servidor adb local y NO sobrevive
-    un reinicio del daemon (crash, ``adb kill-server``, sleep/wake de la PC) --
-    a diferencia de ``adb tcpip`` en el teléfono, que sí persiste hasta que el
-    teléfono se reinicia. Sin esto, cualquier reinicio del daemon deja el
-    serial de red "perdido" hasta que alguien corra ``adb connect`` a mano de
-    nuevo (encontrado en uso real: "adb.exe: device ':5555' not found"
-    tras un reinicio silencioso del daemon en medio de un batch job).
-
-    Reintenta ante fallas transitorias del daemon (ver
-    ``_DAEMON_TRANSIENT_MARKERS`` -- encontrado en uso real justo al conectar
-    WebUSB) en vez de rendirse en el primer intento."""
-    if not _NETWORK_SERIAL_RE.match(serial):
-        return
-    for attempt in range(attempts):
-        try:
-            proc = subprocess.run(
-                [adb_bin, "connect", serial], capture_output=True, text=True, timeout=10,
-            )
-        except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
-            return  # el pm path/pull de abajo va a fallar con un error claro igual
-        output = (proc.stdout or "") + (proc.stderr or "")
-        if not _is_daemon_transient_error(output):
-            return  # conectado, o un error real (device no encontrado, etc.) -- no es transitorio
-        if attempt < attempts - 1:
-            time.sleep(1.5)
+# La lógica de salud del transporte adb (detección de fallas transitorias del
+# daemon y reconexión de seriales de red) vive en nutcracker_core.adb_transport
+# porque no es específica de la descarga: QueueEngine la usa antes de cada job
+# y el dashboard/serve la corren periódicamente en un hilo de fondo. Se
+# reexporta acá para no romper a quien ya la importaba desde este módulo.
+_is_daemon_transient_error = adb_transport.is_daemon_transient_error
+_ensure_network_serial_connected = adb_transport.ensure_connected
 
 
 class DeviceInstalledDownloader:
@@ -364,7 +320,7 @@ def _run_adb(self, args: list[str], timeout: int, attempts: int = 3) -> subproce
 
     def download(self, package_id: str) -> Path:
         if self.serial:
-            _ensure_network_serial_connected(self.serial, self.adb_bin)
+            adb_transport.ensure_available(self.serial, self.adb_bin)
         try:
             result = self._run_adb(
                 self._adb_cmd("shell", "pm", "path", package_id), timeout=15,
diff --git a/nutcracker_core/queue/engine.py b/nutcracker_core/queue/engine.py
index 0c27e41..763e61f 100644
--- a/nutcracker_core/queue/engine.py
+++ b/nutcracker_core/queue/engine.py
@@ -33,6 +33,7 @@
 from pathlib import Path
 from typing import Callable
 
+from nutcracker_core import adb_transport
 from nutcracker_core import orchestrator as orch
 from nutcracker_core.config import get as cfg_get, load_config
 from nutcracker_core.store import db, repository
@@ -175,6 +176,12 @@ def __init__(
         # plugins/dashboard/__init__.py y plugins/aipwn/frida_agent.py). Vacío
         # (default) preserva el env tal cual para quien no lo use.
         self.extra_env: dict[str, str] = {}
+        # Keepalive opcional del transporte adb-over-wifi (ver adb_transport).
+        # Si se asigna, cada job con serial de red lo registra para que el hilo
+        # de fondo lo siga vigilando también en los huecos sin jobs. None
+        # (default) mantiene solo el chequeo por-job, que ya es suficiente para
+        # invocaciones puntuales del CLI.
+        self.transport_keepalive: adb_transport.TransportKeepAlive | None = None
 
     # ── Encolado ─────────────────────────────────────────────────────────────
 
@@ -266,6 +273,30 @@ def _reschedule(self, conn, package: str) -> None:
         ).isoformat(timespec="seconds")
         repository.touch_app_run(conn, package, next_due_at=next_due)
 
+    def _ensure_transport(self, job: Job) -> None:
+        """Revive el transporte adb-over-wifi del job si se cayó.
+
+        No-op para jobs sin serial o con serial USB. Necesario porque la
+        conexión TCP vive en el daemon adb y no sobrevive a un reinicio suyo ni
+        a un corte de red del teléfono: sin esto, el job arranca contra un
+        serial que el daemon ya no tiene registrado y muere con "device
+        ':5555' not found" pese a que el teléfono está perfectamente
+        accesible (visto en uso real con 3 jobs en vuelo). Nunca es fatal --
+        si no se puede restablecer, dejamos que el comando real del job falle
+        con su propio mensaje, más específico que cualquiera que demos acá."""
+        if not adb_transport.is_network_serial(job.serial):
+            return
+        if self.transport_keepalive is not None:
+            self.transport_keepalive.track(job.serial)
+        try:
+            if not adb_transport.ensure_available(job.serial):
+                _log.warning(
+                    "job #%s: el transporte adb %s sigue caído -- se lanza igual",
+                    job.db_id, job.serial,
+                )
+        except Exception:  # noqa: BLE001 - nunca bloquear un job por el chequeo
+            _log.exception("job #%s: fallo chequeando el transporte adb", job.db_id)
+
     def _run_job(self, job: Job) -> JobOutcome:
         conn = db.connect(self.db_path)
         try:
@@ -273,6 +304,8 @@ def _run_job(self, job: Job) -> JobOutcome:
         finally:
             conn.close()
 
+        self._ensure_transport(job)
+
         # FIX (2026-07-27): si el target es un package ID (no una ruta .apk),
         # intenta resolverlo a un APK ya descargado en downloads//
         # antes de construir el comando. Esto evita que el dashboard fuerce
diff --git a/nutcracker_core/store/repository.py b/nutcracker_core/store/repository.py
index b89cb41..84c2a48 100644
--- a/nutcracker_core/store/repository.py
+++ b/nutcracker_core/store/repository.py
@@ -251,6 +251,19 @@ def get_job(conn: sqlite3.Connection, job_id: int) -> sqlite3.Row | None:
     return conn.execute("SELECT * FROM queue_jobs WHERE id = ?", (job_id,)).fetchone()
 
 
+def delete_job(conn: sqlite3.Connection, job_id: int) -> bool:
+    """Borra un job en estado 'queued' (pendiente, todavía no despachado a un
+    worker). Deliberadamente NO borra jobs 'running' -- ya tienen un subproceso
+    real corriendo en algún lado que nadie va a interrumpir por borrar la fila;
+    tampoco 'done'/'error' -- son historial, no cola pendiente. Retorna True
+    si borró algo, False si el job no existe o no está en 'queued'."""
+    cur = conn.execute(
+        "DELETE FROM queue_jobs WHERE id = ? AND status = 'queued'", (job_id,),
+    )
+    conn.commit()
+    return cur.rowcount > 0
+
+
 def list_jobs(conn: sqlite3.Connection, status: str | None = None,
               limit: int = 100) -> list[sqlite3.Row]:
     if status:
diff --git a/plan.md b/plan.md
index e82d22f..d04b88a 100644
--- a/plan.md
+++ b/plan.md
@@ -1513,3 +1513,203 @@ Tests nuevos (6, `tests/test_downloader_device.py`): detección de los 4 marcado
 transitorio, no-op para seriales USB, `adb connect` disparado para seriales `ip:puerto`,
 reintentos ante falla transitoria (tanto en `_ensure_network_serial_connected` como en el propio
 `pm path` de `download()`). **187/187 tests pasan.**
+
+## Borrado de jobs pendientes en la cola (2026-07-27)
+
+Pedido del usuario: poder borrar un job de la cola antes de que corra.
+
+**`repository.delete_job(conn, job_id) -> bool`** — borra solo si `status='queued'` (`DELETE ...
+WHERE id = ? AND status = 'queued'`, `rowcount > 0` como señal de éxito). Deliberadamente NO borra
+jobs `running` (ya tienen un subproceso real corriendo en algún proceso -- borrar la fila no lo
+mata, solo lo desincroniza) ni `done`/`error` (son historial). Expuesto en:
+- CLI: `nutcracker queue rm  [ ...]` (`cli/queue_cmd.py`).
+- Dashboard: `DELETE /api/queue/{job_id}` (`api.py`, 404 si no existe o no está `queued`) + botón
+  🗑 por fila en la tabla de cola, visible solo para jobs en estado `queued`.
+
+**Limitación conocida, no resuelta (inherente al diseño de la cola)**: si un job ya fue cargado a
+memoria por un `drain()` en curso en OTRO proceso (CLI/scheduler/dashboard corriendo en paralelo),
+borrar la fila de SQLite no cancela ese despacho en memoria -- `_load_queued_from_db()` solo
+recarga al *empezar* un `drain()`, no re-consulta por cada job durante la ejecución. Cubre el caso
+común (sacar un job de la cola antes de que cualquier proceso lo levante), no una cancelación en
+caliente de un job ya en pleno `drain()`.
+
+Tests nuevos (5): `test_delete_job_removes_queued_job`/`_returns_false_for_unknown_id`/
+`_refuses_to_delete_running_job` (`test_queue_engine.py`, este último con un job real bloqueado en
+un hilo de fondo vía `threading.Event` para confirmar que `delete_job` no lo toca mientras está
+`running`), `test_queue_delete_removes_pending_job`/`_404_for_unknown_job`
+(`tests/dashboard/test_api.py`). **192/192 tests pasan.**
+
+## Respuesta: ¿paralelo o secuencial? + investigación de crashes de WSL (2026-07-27)
+
+El usuario preguntó si los jobs corren en paralelo o secuencial (sospechando que eso causaba
+crashes periódicos de su entorno WSL), pidiendo investigar de paso.
+
+**Respuesta:** en paralelo, por diseño (`config.yaml`, bloque `queue:`): `static_workers: 4` (hasta
+4 análisis estáticos -- decompilación jadx/apktool, semgrep, OSINT -- corriendo a la vez, cada uno
+en su propio subproceso `nutcracker analyze/scan`) y `dynamic_workers: 2` (hasta 2 *dispositivos*
+distintos en paralelo para jobs Frida/ADB, pero siempre serializado dentro del mismo serial vía el
+lock por device de Fase 1). `static_workers: 1` en `config.yaml` lo vuelve estrictamente
+secuencial si se prefiere.
+
+**Investigación de los crashes** (sin poder reproducirlos en vivo -- ya habían pasado):
+- `dmesg` no tenía entradas de OOM-killer al momento de revisar (el propio reinicio de la VM de
+  WSL2 borra su buffer de kernel, así que esto no descarta un OOM previo, solo que no quedó rastro).
+- **No existe `.wslconfig`** en el usuario (`/mnt/c/Users/*/.wslconfig` no encontrado) -- WSL2 usa
+  el límite de memoria *default* (mínimo entre 50% de la RAM del host y 8GB). `free -h` reportó
+  **7.7GB de total** para la VM de WSL2, confirmando ese techo.
+- Disco descartado como causa: 933GB libres de 1TB, `downloads/`+`decompiled/` juntos solo 750MB.
+- **Hipótesis más plausible** (no confirmada en vivo, pero consistente con la evidencia): 4 jobs
+  estáticos en paralelo (jadx/semgrep/androguard, herramientas conocidas por su uso pesado de
+  memoria con APKs grandes/ofuscados) + video WebUSB/scrcpy + el propio VS Code Server (~1.5GB de
+  base, visto en `ps aux --sort=-%mem`) pueden acercarse o superar ese techo de 7.7GB, llevando a
+  Windows/Hyper-V a matar o reiniciar la VM de WSL2 -- indistinguible para el usuario de "se me
+  crashea WSL".
+- **Recomendado al usuario** (no aplicado automáticamente -- requiere tocar un archivo del lado de
+  Windows y reiniciar WSL, decisión del usuario): crear `.wslconfig` con un `memory=` más alto si
+  el host tiene RAM de sobra, y/o bajar `queue.static_workers` de 4 a 2 en `config.yaml` para
+  reducir el pico de uso concurrente.
+
+---
+
+## Keepalive del transporte adb-over-wifi (2026-07-27)
+
+**Síntoma reportado:** el video WebUSB del dashboard fallaba a cada rato con *"el dispositivo ya
+está reclamado por otro programa (adb)"*, sin que el usuario ejecutara nada a mano. Además, jobs de
+la cola morían con `device '172.20.10.6:5555' not found` pese a que el teléfono estaba accesible.
+
+### Diagnóstico (con evidencia del entorno real, no supuestos)
+
+1. **El driver ya era WinUSB, así que Zadig no aplicaba.** Se había sugerido reasignar el driver de
+   la interfaz ADB a WinUSB; la inspección del dispositivo real lo desmintió:
+
+   ```
+   DEVPKEY_Device_Service        : WINUSB
+   DEVPKEY_Device_DriverDesc     : WinUsb Device
+   DEVPKEY_Device_DriverProvider : Microsoft
+   ClassGuid                     : {88BAE032-5A81-49F0-BC3D-A4FF138216D6}   (clase USBDevice)
+   ```
+
+   El teléfono ya estaba en WinUSB. Zadig habría sido un no-op. **La causa real del conflicto es que
+   WinUSB permite un solo handle abierto por interfaz**, y tanto `adb.exe` (que en Windows accede vía
+   WinUSB) como Chrome (WebUSB → WinUSB) piden *la misma* interfaz. Ningún cambio de driver lo
+   resuelve: es exclusividad estructural.
+
+2. **El daemon adb reclama el cable aunque solo se lo use por TCP.** Demostrado en vivo: con
+   `strategies.default_device_id = "172.20.10.6:5555"` y los 3 jobs en vuelo usando ese serial,
+   `adb devices -l` mostraba igual **ambos** transportes:
+
+   ```
+   ZY22GPM27J         device ... transport_id:2   <- el CABLE (nadie lo pidió)
+   172.20.10.6:5555   device ... transport_id:3   <- el WiFi
+   ```
+
+   Un daemon recién arrancado enumera y abre toda interfaz ADB USB que vea, sin importar el
+   transporte que se le pida. Por eso la receta popular de "conectá el daemon por TCP y el cable
+   queda libre" **es falsa**: separar canales es necesario pero no suficiente.
+
+3. **La causa inmediata del fallo: el canal TCP se caía solo.** `adb -s 172.20.10.6:5555 get-state`
+   devolvía `device not found` mientras el teléfono respondía a ping y tenía el 5555 abierto — lo
+   caído era el *transporte*, no el dispositivo. Esa conexión vive en el daemon adb y no sobrevive a
+   un reinicio suyo (crash, `adb kill-server`, sleep/wake) ni a cortes de red del teléfono (doze,
+   hotspot). Con el canal 2 muerto, **todo colapsa sobre el cable** — exactamente el conflicto que la
+   separación buscaba evitar. La reconexión automática existente vivía solo en `downloader.py`, y se
+   dispara al descargar: no cubría los huecos entre jobs ni jobs que no descargan.
+
+### Implementación
+
+- **Nuevo `nutcracker_core/adb_transport.py`** — hogar canónico de la salud del transporte adb:
+  - `is_network_serial()` distingue serial de red de serial USB.
+  - `is_transport_alive()` usa `adb -s  get-state` en vez de parsear `adb devices`: con un
+    solo comando distingue *ausente* (rc≠0), *presente pero inutilizable* (`offline`,
+    `unauthorized`) y *listo* (`device`).
+  - `ensure_connected()` = la lógica que estaba en `downloader.py` (`adb connect` idempotente con
+    reintentos ante fallas transitorias del daemon, ver `_DAEMON_TRANSIENT_MARKERS`).
+  - `ensure_available()` chequea estado **antes** de reconectar. Esto es deliberado: `adb connect`
+    arranca el daemon si no corre, y un daemon nuevo re-reclama el cable USB — si el transporte ya
+    está vivo, no hay razón para arriesgar ese efecto colateral contra el video WebUSB.
+  - `TransportKeepAlive` — hilo daemon que revalida periódicamente los seriales de red vigilados.
+- **`downloader.py`** pasa a usar el módulo compartido (reexporta los nombres privados anteriores
+  para no romper a quien los importaba) y `download()` usa `ensure_available()` en vez del `connect`
+  a ciegas.
+- **`QueueEngine._ensure_transport()`** revive el transporte antes de cada job con serial de red, y
+  registra ese serial en el keepalive si hay uno asignado (cubre jobs con serial distinto al del
+  config). Es best-effort: si no se puede restablecer, el job se lanza igual para que falle con su
+  propio mensaje, más específico que cualquiera que diéramos acá.
+- **`nutcracker dashboard` y `nutcracker serve`** levantan un `TransportKeepAlive` sobre
+  `strategies.default_device_id` y lo paran al salir. Nuevo `dashboard.adb_keepalive_seconds`
+  (default 60, piso efectivo 10) en `config.yaml`/`config.yaml.example`.
+
+### Verificación
+
+- Suite completa: **214 tests pasan**. 20 nuevos (`tests/test_adb_transport.py` + integración con el
+  engine en `tests/test_queue_engine.py`), incluido el caso de que un transporte ya vivo **no** se
+  reconecte (protege al WebUSB), y que un chequeo que explota no tumbe el job ni el hilo.
+- Prueba funcional contra el teléfono real: se simuló la falla exacta con `adb disconnect`, y
+  `ensure_available()` la detectó y la recuperó (`alive: False` → reconexión → `alive: True`, nuevo
+  `transport_id`).
+
+### Límite que sigue en pie (no lo arregla este cambio)
+
+WebUSB y `adb.exe` **no pueden compartir el cable**: WinUSB es de handle exclusivo. Lo que este
+cambio garantiza es que el canal TCP no se muera, para que el cable pueda quedar para el navegador
+sin que la cola se quede sin vía al teléfono. El orden sigue importando: conviene conectar el video
+WebUSB **primero**; cuando el daemon reintente el cable y lo encuentre ocupado, sigue funcionando
+por TCP.
+
+---
+
+## WebUSB: sobrevivir a un F5 (2026-07-27)
+
+**Pedido:** que el video "USB directo (fluido)" no muera al recargar la página.
+
+### Lo que no se puede, y por qué
+
+Un reload destruye el contexto JS y con él el handle USB, el decoder y el canvas. **La sesión no
+puede seguir viva**: no existe API de navegador que preserve una conexión WebUSB a través de una
+navegación. Lo que sí persiste es el **permiso** que el usuario otorgó al origen — y eso alcanza
+para reconstruir la sesión sin intervención.
+
+### Implementación
+
+- `AdbDaemonWebUsbDeviceManager.getDevices()` (verificado en `manager.d.ts`: *"Get all connected and
+  requested devices"*) devuelve los dispositivos ya autorizados **sin abrir el picker**, que además
+  exigiría un gesto del usuario. El F5 pasa de "elegí el device otra vez y esperá" a "esperá un par
+  de segundos".
+- `main.ts` se reestructuró: `connect()` (con picker, requiere click) y `reconnect()` (sin picker)
+  comparten `startVideo()`. Ambos devuelven el serial conectado (o `null`), que el frontend usa para
+  recordar a qué dispositivo volver.
+- `reconnect()` **reintenta ante "device busy"** (3 intentos, 600ms): justo después de un reload
+  Chrome puede tardar un instante en liberar el handle de la sesión anterior, y el primer intento se
+  topa con su propio fantasma.
+- Nuevo `disconnect()` + botón **"⏏ Soltar USB"**. El cable es exclusivo (WinUSB, un handle por
+  interfaz), así que sin una forma explícita de soltarlo la única salida era cerrar la pestaña —
+  inaceptable en este flujo, donde el usuario alterna entre video y jobs por adb. Cierra decoder,
+  client y adb, cada uno en su propio `try`: si el primero falla (device desenchufado en caliente),
+  los demás igual corren, o el handle queda tomado hasta recargar.
+- `startVideo()` llama a `disconnect()` primero: dos sesiones sobre el mismo cable se pisan (el
+  device queda "busy" contra sí mismo).
+- El pipe del stream ahora usa un `AbortController`; un abort es cierre normal, no un error que
+  reportar en la UI.
+
+### Decisión de diseño: sessionStorage, no localStorage
+
+El serial se guarda en **`sessionStorage`**, que tiene exactamente la vida útil que se busca:
+sobrevive a un reload, muere al cerrar la pestaña. Así la reconexión automática ocurre **solo en la
+pestaña donde el usuario ya pidió video explícitamente**. Abrir el dashboard de cero nunca reclama
+el USB por su cuenta — reclamarlo sin que lo pidan dejaría a adb sin cable y rompería los jobs de la
+cola (el usuario ya había rechazado un auto-arranque parecido, ver la sección de eliminación del
+auto-trigger de scrcpy). `disconnect()` borra el serial *antes* de cerrar: si el cierre falla a
+mitad, el próximo F5 no debe volver a reclamar el cable solo.
+
+### Verificación
+
+- `tsc --noEmit` limpio; `npm run build` OK (bundle de 317KB — no el bundle vacío de 0 bytes que
+  producía el modo "app" de Vite antes del fix a `build.lib`).
+- Los 5 exports (`isSupported`, `connect`, `reconnect`, `disconnect`, `isConnected`) verificados
+  presentes en el bundle emitido, no solo en el fuente.
+- Script inline del dashboard parsea sin errores de sintaxis; el server sirve el HTML y el bundle
+  actualizados (HTTP 200).
+- Suite Python: 214 tests siguen pasando (sin cambios de backend en esta tanda).
+
+**Pendiente de prueba manual del usuario:** el ciclo real conectar → F5 → reconexión automática
+contra el teléfono, que no se puede ejercitar sin un navegador con el cable enchufado.
diff --git a/tests/dashboard/test_api.py b/tests/dashboard/test_api.py
index 37234a2..18a0ebe 100644
--- a/tests/dashboard/test_api.py
+++ b/tests/dashboard/test_api.py
@@ -137,6 +137,23 @@ def test_queue_add_rejects_dynamic_without_local_apk(client):
     assert r.status_code == 400
 
 
+def test_queue_delete_removes_pending_job(client, engine):
+    r = client.post("/api/queue", json={"target": "com.example.app", "kind": "static"})
+    job_id = r.json()["job_id"]
+
+    r = client.delete(f"/api/queue/{job_id}")
+    assert r.status_code == 200
+    assert r.json() == {"deleted": True}
+
+    jobs = client.get("/api/queue").json()
+    assert all(j["id"] != job_id for j in jobs)
+
+
+def test_queue_delete_404_for_unknown_job(client):
+    r = client.delete("/api/queue/999999")
+    assert r.status_code == 404
+
+
 # ── /api/queue/batch (batch estático+aipwn desde un .txt subido en el frontend) ─
 
 def _wait_for_drain(engine, timeout: float = 5.0) -> None:
diff --git a/tests/test_adb_transport.py b/tests/test_adb_transport.py
new file mode 100644
index 0000000..2c63ae4
--- /dev/null
+++ b/tests/test_adb_transport.py
@@ -0,0 +1,210 @@
+"""Tests de nutcracker_core/adb_transport.py — salud del canal adb-over-wifi.
+
+Contexto del bug que motivó el módulo (visto en uso real, 2026-07-27): con el
+video WebUSB del dashboard reclamando el cable USB, nutcracker depende del
+serial de red (":5555"). Ese transporte vive en el daemon adb y se cae
+solo (reinicio del daemon, doze del teléfono), dejando jobs en vuelo con
+"device ':5555' not found" pese a que el teléfono responde a ping.
+
+Nada acá toca un adb real: se parchea subprocess.run del módulo.
+"""
+
+from __future__ import annotations
+
+import subprocess
+
+from nutcracker_core import adb_transport
+
+
+def _completed(stdout: str = "", stderr: str = "", returncode: int = 0):
+    return subprocess.CompletedProcess([], returncode, stdout=stdout, stderr=stderr)
+
+
+def _patch_adb(monkeypatch, handler):
+    """Instala un fake de subprocess.run que registra los comandos adb."""
+    calls: list[list[str]] = []
+
+    def fake_run(cmd, capture_output=True, text=True, timeout=None):  # noqa: ANN001
+        calls.append(list(cmd))
+        return handler(list(cmd))
+
+    monkeypatch.setattr("nutcracker_core.adb_transport.subprocess.run", fake_run)
+    monkeypatch.setattr("nutcracker_core.adb_transport._sleep", lambda *_: None)
+    return calls
+
+
+# ── is_network_serial ────────────────────────────────────────────────────────
+
+def test_is_network_serial_distinguishes_tcp_from_usb():
+    assert adb_transport.is_network_serial("172.20.10.6:5555")
+    assert adb_transport.is_network_serial("emulator-5554:5555")
+    assert not adb_transport.is_network_serial("ZY22GPM27J")
+    assert not adb_transport.is_network_serial("")
+    assert not adb_transport.is_network_serial(None)
+
+
+# ── is_transport_alive ───────────────────────────────────────────────────────
+
+def test_is_transport_alive_true_only_for_state_device(monkeypatch):
+    _patch_adb(monkeypatch, lambda cmd: _completed(stdout="device\n"))
+    assert adb_transport.is_transport_alive("172.20.10.6:5555")
+
+
+def test_is_transport_alive_false_when_transport_missing(monkeypatch):
+    # Caso real observado: el daemon perdió el transporte de red.
+    _patch_adb(monkeypatch, lambda cmd: _completed(
+        returncode=1, stderr="error: device '172.20.10.6:5555' not found",
+    ))
+    assert not adb_transport.is_transport_alive("172.20.10.6:5555")
+
+
+def test_is_transport_alive_false_when_offline(monkeypatch):
+    # Registrado pero inutilizable — no alcanza con que aparezca en adb devices.
+    _patch_adb(monkeypatch, lambda cmd: _completed(stdout="offline\n"))
+    assert not adb_transport.is_transport_alive("172.20.10.6:5555")
+
+
+# ── ensure_available ─────────────────────────────────────────────────────────
+
+def test_ensure_available_noop_for_usb_serial(monkeypatch):
+    calls = _patch_adb(monkeypatch, lambda cmd: _completed(stdout="device\n"))
+    assert adb_transport.ensure_available("ZY22GPM27J")
+    assert calls == []
+
+
+def test_ensure_available_noop_for_empty_serial(monkeypatch):
+    calls = _patch_adb(monkeypatch, lambda cmd: _completed())
+    assert adb_transport.ensure_available(None)
+    assert calls == []
+
+
+def test_ensure_available_does_not_reconnect_when_already_alive(monkeypatch):
+    """`adb connect` arranca el daemon si no está corriendo, y un daemon nuevo
+    re-reclama el cable USB — que es justo lo que le rompe el video al WebUSB.
+    Si el transporte ya está vivo, no debe tocarse."""
+    calls = _patch_adb(monkeypatch, lambda cmd: _completed(stdout="device\n"))
+
+    assert adb_transport.ensure_available("172.20.10.6:5555")
+
+    assert calls == [["adb", "-s", "172.20.10.6:5555", "get-state"]]
+    assert not any("connect" in c for c in calls)
+
+
+def test_ensure_available_reconnects_when_transport_dropped(monkeypatch):
+    state = {"connected": False}
+
+    def handler(cmd):
+        if "connect" in cmd:
+            state["connected"] = True
+            return _completed(stdout="connected to 172.20.10.6:5555")
+        if "get-state" in cmd:
+            if state["connected"]:
+                return _completed(stdout="device\n")
+            return _completed(returncode=1, stderr="device not found")
+        return _completed()
+
+    calls = _patch_adb(monkeypatch, handler)
+
+    assert adb_transport.ensure_available("172.20.10.6:5555")
+
+    assert ["adb", "connect", "172.20.10.6:5555"] in calls
+    # get-state antes (detectar la caída) y después (confirmar que revivió).
+    assert len([c for c in calls if "get-state" in c]) == 2
+
+
+def test_ensure_available_returns_false_when_reconnect_fails(monkeypatch):
+    def handler(cmd):
+        if "connect" in cmd:
+            return _completed(stderr="failed to connect to 172.20.10.6:5555")
+        return _completed(returncode=1, stderr="device not found")
+
+    _patch_adb(monkeypatch, handler)
+    assert not adb_transport.ensure_available("172.20.10.6:5555")
+
+
+def test_ensure_available_survives_missing_adb_binary(monkeypatch):
+    def fake_run(cmd, capture_output=True, text=True, timeout=None):  # noqa: ANN001
+        raise FileNotFoundError()
+
+    monkeypatch.setattr("nutcracker_core.adb_transport.subprocess.run", fake_run)
+    # No debe propagar: el comando real del job dará un error más específico.
+    assert not adb_transport.ensure_available("172.20.10.6:5555")
+
+
+def test_ensure_connected_retries_on_daemon_transient_error(monkeypatch):
+    """Conectar WebUSB desestabiliza momentáneamente adb.exe en Windows; un
+    único intento tiraría abajo el job por algo que se resuelve en 1-2s."""
+    attempts = {"n": 0}
+
+    def handler(cmd):
+        attempts["n"] += 1
+        if attempts["n"] < 3:
+            return _completed(stderr="adb.exe: cannot connect to daemon")
+        return _completed(stdout="connected to 172.20.10.6:5555")
+
+    calls = _patch_adb(monkeypatch, handler)
+    adb_transport.ensure_connected("172.20.10.6:5555")
+    assert len(calls) == 3
+
+
+def test_ensure_connected_does_not_retry_on_real_error(monkeypatch):
+    calls = _patch_adb(monkeypatch, lambda cmd: _completed(
+        stderr="failed to connect to '172.20.10.6:5555': Connection refused",
+    ))
+    adb_transport.ensure_connected("172.20.10.6:5555")
+    assert len(calls) == 1
+
+
+# ── TransportKeepAlive ───────────────────────────────────────────────────────
+
+def test_keepalive_only_tracks_network_serials():
+    ka = adb_transport.TransportKeepAlive(serials=["172.20.10.6:5555", "ZY22GPM27J"])
+    assert ka.serials == {"172.20.10.6:5555"}
+
+    ka.track("ZY22GPM27J")
+    ka.track(None)
+    assert ka.serials == {"172.20.10.6:5555"}
+
+    ka.track("10.0.0.5:5555")
+    assert ka.serials == {"172.20.10.6:5555", "10.0.0.5:5555"}
+
+
+def test_keepalive_check_once_revives_each_tracked_serial(monkeypatch):
+    calls = _patch_adb(monkeypatch, lambda cmd: _completed(
+        returncode=1, stderr="device not found",
+    ))
+
+    ka = adb_transport.TransportKeepAlive(serials=["172.20.10.6:5555", "10.0.0.5:5555"])
+    ka.check_once()
+
+    connected = {c[2] for c in calls if c[1] == "connect"}
+    assert connected == {"172.20.10.6:5555", "10.0.0.5:5555"}
+
+
+def test_keepalive_check_once_survives_a_broken_serial(monkeypatch):
+    """Un serial que revienta no debe matar el hilo y dejar sin vigilancia a
+    los demás."""
+    def fake_run(cmd, capture_output=True, text=True, timeout=None):  # noqa: ANN001
+        raise RuntimeError("boom")
+
+    monkeypatch.setattr("nutcracker_core.adb_transport.subprocess.run", fake_run)
+
+    ka = adb_transport.TransportKeepAlive(serials=["172.20.10.6:5555"])
+    ka.check_once()  # no debe propagar
+
+
+def test_keepalive_start_stop_is_idempotent(monkeypatch):
+    _patch_adb(monkeypatch, lambda cmd: _completed(stdout="device\n"))
+
+    ka = adb_transport.TransportKeepAlive(serials=["172.20.10.6:5555"], interval_seconds=10)
+    ka.start()
+    ka.start()  # segunda llamada: no debe levantar un segundo hilo
+    assert ka._thread is not None
+    ka.stop()
+    assert ka._thread is None
+    ka.stop()  # doble stop: no debe fallar
+
+
+def test_keepalive_interval_has_a_floor():
+    """Un intervalo diminuto martillaría adb (y con él, el USB) sin ganancia."""
+    assert adb_transport.TransportKeepAlive(interval_seconds=1).interval_seconds == 10
diff --git a/tests/test_downloader_device.py b/tests/test_downloader_device.py
index 1c992ce..6f307ad 100644
--- a/tests/test_downloader_device.py
+++ b/tests/test_downloader_device.py
@@ -127,7 +127,7 @@ def test_is_daemon_transient_error_false_for_real_errors():
 def test_ensure_network_serial_connected_noop_for_usb_serial(monkeypatch):
     calls = []
     monkeypatch.setattr(
-        "nutcracker_core.downloader.subprocess.run",
+        "nutcracker_core.adb_transport.subprocess.run",
         lambda *a, **kw: calls.append(a) or _completed(),
     )
     _ensure_network_serial_connected("ZY22GPM27J")
@@ -141,7 +141,7 @@ def fake_run(cmd, capture_output=True, text=True, timeout=None):  # noqa: ANN001
         calls.append(cmd)
         return _completed(stdout="connected to 172.20.10.6:5555")
 
-    monkeypatch.setattr("nutcracker_core.downloader.subprocess.run", fake_run)
+    monkeypatch.setattr("nutcracker_core.adb_transport.subprocess.run", fake_run)
     _ensure_network_serial_connected("172.20.10.6:5555")
     assert calls == [["adb", "connect", "172.20.10.6:5555"]]
 
@@ -155,8 +155,8 @@ def fake_run(cmd, capture_output=True, text=True, timeout=None):  # noqa: ANN001
             return _completed(stderr="adb.exe: cannot connect to daemon")
         return _completed(stdout="connected to 172.20.10.6:5555")
 
-    monkeypatch.setattr("nutcracker_core.downloader.subprocess.run", fake_run)
-    monkeypatch.setattr("nutcracker_core.downloader.time.sleep", lambda *_: None)
+    monkeypatch.setattr("nutcracker_core.adb_transport.subprocess.run", fake_run)
+    monkeypatch.setattr("nutcracker_core.adb_transport._sleep", lambda *_: None)
     _ensure_network_serial_connected("172.20.10.6:5555")
     assert len(calls) == 3
 
@@ -182,7 +182,9 @@ def fake_run(cmd, capture_output=True, text=True, timeout=None):  # noqa: ANN001
         return _completed()
 
     monkeypatch.setattr("nutcracker_core.downloader.subprocess.run", fake_run)
+    monkeypatch.setattr("nutcracker_core.adb_transport.subprocess.run", fake_run)
     monkeypatch.setattr("nutcracker_core.downloader.time.sleep", lambda *_: None)
+    monkeypatch.setattr("nutcracker_core.adb_transport._sleep", lambda *_: None)
 
     dl = DeviceInstalledDownloader(output_dir=str(tmp_path), serial="172.20.10.6:5555")
     result = dl.download("com.example.app")
diff --git a/tests/test_queue_engine.py b/tests/test_queue_engine.py
index bc821cd..3b98591 100644
--- a/tests/test_queue_engine.py
+++ b/tests/test_queue_engine.py
@@ -215,6 +215,60 @@ def fake_run_fail(cmd, env=None, capture_output=True, text=True):  # noqa: ANN00
     assert row["started_at"] is not None and row["finished_at"] is not None
 
 
+# ── Borrado de jobs pendientes (delete_job) ─────────────────────────────────
+
+def test_delete_job_removes_queued_job(tmp_path, engine):
+    job = engine.submit(str(_touch_apk(tmp_path, "pending.apk")), kind="static")
+
+    conn = db.connect(engine.db_path)
+    try:
+        deleted = repository.delete_job(conn, job.db_id)
+        assert deleted is True
+        assert repository.get_job(conn, job.db_id) is None
+    finally:
+        conn.close()
+
+
+def test_delete_job_returns_false_for_unknown_id(tmp_path, engine):
+    conn = db.connect(engine.db_path)
+    try:
+        assert repository.delete_job(conn, 999999) is False
+    finally:
+        conn.close()
+
+
+def test_delete_job_refuses_to_delete_running_job(monkeypatch, tmp_path, engine):
+    """Un job 'running' ya tiene un subproceso real corriendo en algún lado --
+    borrar la fila no lo detiene, así que delete_job se niega a tocarlo."""
+    release = threading.Event()
+
+    def fake_run(cmd, env=None, capture_output=True, text=True):  # noqa: ANN001
+        release.wait(timeout=5)
+        return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
+
+    monkeypatch.setattr("nutcracker_core.queue.engine.subprocess.run", fake_run)
+
+    job = engine.submit(str(_touch_apk(tmp_path, "running.apk")), kind="static")
+    thread = threading.Thread(target=engine.drain, daemon=True)
+    thread.start()
+    try:
+        deadline = time.monotonic() + 5
+        conn = db.connect(engine.db_path)
+        try:
+            while time.monotonic() < deadline:
+                row = repository.get_job(conn, job.db_id)
+                if row and row["status"] == "running":
+                    break
+                time.sleep(0.02)
+            assert row["status"] == "running"
+            assert repository.delete_job(conn, job.db_id) is False
+        finally:
+            conn.close()
+    finally:
+        release.set()
+        thread.join(timeout=5)
+
+
 # ── Re-agendado tras completar un job (Fase 1.2) ────────────────────────────
 
 def test_reschedule_sets_next_due_at_after_job_completes(monkeypatch, tmp_path, engine):
@@ -623,4 +677,114 @@ def fake_run(cmd, env=None, capture_output=True, text=True):  # noqa: ANN001
     assert len(outcomes) == 1 and outcomes[0].ok
     cmd = seen_cmds[0]
     assert "scan" in cmd
-    assert pkg in cmd
\ No newline at end of file
+    assert pkg in cmd
+
+# ── Keepalive del transporte adb-over-wifi (ver nutcracker_core/adb_transport) ─
+
+def test_job_with_network_serial_revives_dropped_transport(monkeypatch, engine):
+    """Antes de lanzar el job, el engine reconecta el serial de red si el
+    daemon adb lo perdió. Sin esto el job muere con "device ':5555' not
+    found" aunque el teléfono esté perfectamente accesible (visto en uso real
+    con el video WebUSB reclamando el cable USB)."""
+    ensured = []
+    monkeypatch.setattr(
+        "nutcracker_core.queue.engine.adb_transport.ensure_available",
+        lambda serial, *a, **kw: ensured.append(serial) or True,
+    )
+
+    def fake_run(cmd, env=None, capture_output=True, text=True):  # noqa: ANN001
+        return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
+
+    monkeypatch.setattr("nutcracker_core.queue.engine.subprocess.run", fake_run)
+
+    engine.submit("com.example.app", kind="static", source="device",
+                  serial="172.20.10.6:5555")
+    engine.drain()
+
+    assert ensured == ["172.20.10.6:5555"]
+
+
+def test_job_with_usb_serial_does_not_touch_transport(monkeypatch, engine):
+    ensured = []
+    monkeypatch.setattr(
+        "nutcracker_core.queue.engine.adb_transport.ensure_available",
+        lambda serial, *a, **kw: ensured.append(serial) or True,
+    )
+
+    def fake_run(cmd, env=None, capture_output=True, text=True):  # noqa: ANN001
+        return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
+
+    monkeypatch.setattr("nutcracker_core.queue.engine.subprocess.run", fake_run)
+
+    engine.submit("com.example.app", kind="static", source="device", serial="ZY22GPM27J")
+    engine.drain()
+
+    assert ensured == []
+
+
+def test_job_runs_even_if_transport_cannot_be_restored(monkeypatch, engine):
+    """El chequeo es best-effort: si no se puede revivir el transporte, el job
+    igual se lanza para que falle con su propio mensaje (más específico)."""
+    monkeypatch.setattr(
+        "nutcracker_core.queue.engine.adb_transport.ensure_available",
+        lambda *a, **kw: False,
+    )
+
+    seen = []
+
+    def fake_run(cmd, env=None, capture_output=True, text=True):  # noqa: ANN001
+        seen.append(cmd)
+        return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
+
+    monkeypatch.setattr("nutcracker_core.queue.engine.subprocess.run", fake_run)
+
+    engine.submit("com.example.app", kind="static", source="device",
+                  serial="172.20.10.6:5555")
+    outcomes = engine.drain()
+
+    assert len(seen) == 1
+    assert outcomes[0].ok
+
+
+def test_job_survives_an_exploding_transport_check(monkeypatch, engine):
+    def boom(*a, **kw):  # noqa: ANN001
+        raise RuntimeError("adb explotó")
+
+    monkeypatch.setattr(
+        "nutcracker_core.queue.engine.adb_transport.ensure_available", boom)
+
+    def fake_run(cmd, env=None, capture_output=True, text=True):  # noqa: ANN001
+        return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
+
+    monkeypatch.setattr("nutcracker_core.queue.engine.subprocess.run", fake_run)
+
+    engine.submit("com.example.app", kind="static", source="device",
+                  serial="172.20.10.6:5555")
+    outcomes = engine.drain()
+
+    assert outcomes[0].ok
+
+
+def test_job_registers_its_serial_with_the_keepalive(monkeypatch, engine):
+    """Un job encolado con un serial distinto al default del config también
+    debe quedar cubierto por la vigilancia de fondo."""
+    from nutcracker_core import adb_transport
+
+    monkeypatch.setattr(
+        "nutcracker_core.queue.engine.adb_transport.ensure_available",
+        lambda *a, **kw: True,
+    )
+
+    keepalive = adb_transport.TransportKeepAlive()
+    engine.transport_keepalive = keepalive
+
+    def fake_run(cmd, env=None, capture_output=True, text=True):  # noqa: ANN001
+        return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
+
+    monkeypatch.setattr("nutcracker_core.queue.engine.subprocess.run", fake_run)
+
+    engine.submit("com.example.app", kind="static", source="device",
+                  serial="10.0.0.5:5555")
+    engine.drain()
+
+    assert keepalive.serials == {"10.0.0.5:5555"}

From a23aeec34918fa381426f8f5a0fba9fe798baced Mon Sep 17 00:00:00 2001
From: Hiteek 
Date: Tue, 28 Jul 2026 12:15:46 -0500
Subject: [PATCH 27/30] Remove test.yaml from git history

test.yaml was a stray copy of config.yaml committed since the initial
commit. Rewrote history with git-filter-repo to remove it from all 97
commits, and added config*.yaml (except the .example template) to
.gitignore to prevent this from happening again.
---
 .gitignore | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/.gitignore b/.gitignore
index dd9f9ab..92fa2b3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,6 +24,9 @@ logs/
 .semgrep/
 frida_scripts/
 config.yaml
+test.yaml
+config*.yaml
+!config.yaml.example
 nutcracker.db
 nutcracker.db-wal
 nutcracker.db-shm

From 8594f2abef753d67b37ae7332e13e09a5362ed2d Mon Sep 17 00:00:00 2001
From: Hiteek 
Date: Tue, 28 Jul 2026 13:14:31 -0500
Subject: [PATCH 28/30] fixes

---
 nutcracker_core/decompiler.py        |  41 ++++++--
 nutcracker_core/manifest_analyzer.py |   2 +-
 nutcracker_core/orchestrator.py      | 123 +++++++++++++---------
 plan.md                              | 125 +++++++++++++++++++++++
 tests/test_decompiler.py             | 146 +++++++++++++++++++++++++++
 tests/test_orchestrator.py           | 104 ++++++++++++++++++-
 6 files changed, 480 insertions(+), 61 deletions(-)
 create mode 100644 tests/test_decompiler.py

diff --git a/nutcracker_core/decompiler.py b/nutcracker_core/decompiler.py
index 34ebb70..ff33285 100644
--- a/nutcracker_core/decompiler.py
+++ b/nutcracker_core/decompiler.py
@@ -42,10 +42,21 @@ def install_instructions() -> str:
     )
 
 
-def decompile(apk_path: Path, output_dir: Path) -> Path:
+def decompile(apk_path: Path, output_dir: Path, dest_name: str | None = None) -> Path:
     """
     Decompila la APK en output_dir.
 
+    ``dest_name`` nombra el subdirectorio de salida (``output_dir / dest_name``).
+    Por defecto usa ``apk_path.stem`` -- pero para Android App Bundles, apkeep
+    guarda el APK base literalmente como "base.apk" para *cualquier* paquete,
+    así que ese default hace colisionar en el mismo directorio a apps
+    distintas. Encontrado en vivo (2026-07-28): jobs estáticos concurrentes de
+    distintos paquetes (todos descargados como "base.apk") decompilando a la
+    vez hacia "decompiled/base/" -- apktool --force de uno pisaba a mitad de
+    camino el output que el otro job estaba escaneando, dejando
+    "files_scanned: 0" pese a que la app no tenía nada que ver con eso.
+    Orchestrator ya pasa ``dest_name=package`` para evitarlo.
+
     Returns:
         El directorio con los fuentes descompilados.
 
@@ -53,6 +64,7 @@ def decompile(apk_path: Path, output_dir: Path) -> Path:
         DecompilerError si no hay herramienta disponible o falla la decompilación.
     """
     tool, tool_path = get_available_tool()
+    dest_name = dest_name or apk_path.stem
 
     if tool is None:
         raise DecompilerError(
@@ -64,21 +76,21 @@ def decompile(apk_path: Path, output_dir: Path) -> Path:
 
     if tool == "jadx":
         try:
-            return _decompile_jadx(tool_path, apk_path, output_dir)
+            return _decompile_jadx(tool_path, apk_path, output_dir, dest_name)
         except DecompilerError as jadx_exc:
             apktool_path = _find_tool("apktool")
             if apktool_path:
-                return _decompile_apktool(apktool_path, apk_path, output_dir)
+                return _decompile_apktool(apktool_path, apk_path, output_dir, dest_name)
             raise DecompilerError(
                 f"{jadx_exc}\n\n"
                 "No hay fallback disponible con apktool."
             ) from jadx_exc
     else:
-        return _decompile_apktool(tool_path, apk_path, output_dir)
+        return _decompile_apktool(tool_path, apk_path, output_dir, dest_name)
 
 
-def _decompile_jadx(jadx_path: str, apk_path: Path, output_dir: Path) -> Path:
-    dest = output_dir / apk_path.stem
+def _decompile_jadx(jadx_path: str, apk_path: Path, output_dir: Path, dest_name: str) -> Path:
+    dest = output_dir / dest_name
     dest.mkdir(parents=True, exist_ok=True)
 
     cmd = [
@@ -108,8 +120,8 @@ def _decompile_jadx(jadx_path: str, apk_path: Path, output_dir: Path) -> Path:
     return dest
 
 
-def _decompile_apktool(apktool_path: str, apk_path: Path, output_dir: Path) -> Path:
-    dest = output_dir / apk_path.stem
+def _decompile_apktool(apktool_path: str, apk_path: Path, output_dir: Path, dest_name: str) -> Path:
+    dest = output_dir / dest_name
 
     cmd = [
         apktool_path,
@@ -129,22 +141,29 @@ def _decompile_apktool(apktool_path: str, apk_path: Path, output_dir: Path) -> P
     return dest
 
 
-def extract_manifest(apk_path: Path, output_dir: Path) -> Path | None:
+def extract_manifest(apk_path: Path, output_dir: Path, name_hint: str | None = None) -> Path | None:
     """
     Extrae y decodifica únicamente el AndroidManifest.xml del APK.
 
     Útil cuando el código fue obtenido por runtime dump (Frida) y no hay
     manifest disponible, pero sí existe el APK original.
 
+    ``name_hint`` identifica el directorio temporal (por defecto
+    ``apk_path.stem``) -- mismo motivo que en ``decompile()``: "base.apk" no es
+    único entre paquetes distintos (App Bundles), así que sin un hint más
+    específico dos extracciones concurrentes de apps distintas podrían pisarse.
+
     Intenta primero con apktool (--no-src), luego con jadx (--no-res).
     Devuelve la ruta al AndroidManifest.xml decodificado, o None si falla.
     """
     import tempfile
 
+    name_hint = name_hint or apk_path.stem
+
     # ── Intento 1: apktool --no-src (solo recursos + manifest) ───────────────
     apktool_path = _find_tool("apktool")
     if apktool_path:
-        tmp = output_dir / f"_manifest_apktool_{apk_path.stem}"
+        tmp = output_dir / f"_manifest_apktool_{name_hint}"
         try:
             result = subprocess.run(
                 [apktool_path, "d", "--force", "--no-src", "-o", str(tmp), str(apk_path)],
@@ -159,7 +178,7 @@ def extract_manifest(apk_path: Path, output_dir: Path) -> Path | None:
     # ── Intento 2: jadx --no-res (sin recursos, pero extrae manifest decodificado) ─
     jadx_path = _find_tool("jadx")
     if jadx_path:
-        tmp = output_dir / f"_manifest_jadx_{apk_path.stem}"
+        tmp = output_dir / f"_manifest_jadx_{name_hint}"
         try:
             result = subprocess.run(
                 [jadx_path, "--no-res", "--no-src", "-d", str(tmp), str(apk_path)],
diff --git a/nutcracker_core/manifest_analyzer.py b/nutcracker_core/manifest_analyzer.py
index 1834549..fdbca9a 100644
--- a/nutcracker_core/manifest_analyzer.py
+++ b/nutcracker_core/manifest_analyzer.py
@@ -111,7 +111,7 @@ def _cb(msg: str) -> None:
         try:
             from .decompiler import extract_manifest
             _cb(t("analyzing_manifest_progress"))
-            extracted = extract_manifest(apk_path, decompiled_dir.parent)
+            extracted = extract_manifest(apk_path, decompiled_dir.parent, name_hint=decompiled_dir.name)
             if extracted:
                 manifest_path = extracted
         except Exception:  # noqa: BLE001
diff --git a/nutcracker_core/orchestrator.py b/nutcracker_core/orchestrator.py
index 0f53ccb..a542243 100644
--- a/nutcracker_core/orchestrator.py
+++ b/nutcracker_core/orchestrator.py
@@ -138,7 +138,14 @@ def build_job_cmd(
     entry = str(Path(__file__).resolve().parent.parent / "nutcracker.py")
     cmd = [sys.executable, entry]
     if aipwn:
-        cmd += ["aipwn", target]
+        # FIX (reportado en vivo, 2026-07-27): sin --report, run_aipwn() genera
+        # el ExploitReport en memoria (PoCs confirmados/no confirmados, con
+        # evidencia y screenshots) pero plugins/aipwn/__init__.py solo lo
+        # vuelca a disco (exploit_report_.json/.pdf) si este flag está
+        # presente -- un job de cola/dashboard corría el exploit agent entero
+        # y tiraba el resultado: el único rastro que sobrevivía era el .txt de
+        # log crudo en logs/, nada reutilizable ni visible desde el dashboard.
+        cmd += ["aipwn", target, "--report"]
         if serial:
             cmd += ["--serial", serial]
         return cmd
@@ -1188,7 +1195,12 @@ def _do_decompile(apk_path: Path, package: str) -> Path | None:
             transient=True,
         ) as progress:
             progress.add_task(t("cli_decompiling_pkg", package=package), total=None)
-            dest = decompile(apk_path, output_dir)
+            # dest_name=package (no apk_path.stem): apkeep guarda el APK base de
+            # un App Bundle literalmente como "base.apk" para cualquier paquete,
+            # así que sin esto jobs estáticos concurrentes de apps distintas
+            # decompilarían todas hacia el mismo "decompiled/base/" y se pisarían
+            # entre sí (visto en vivo, 2026-07-28 -- ver decompiler.decompile()).
+            dest = decompile(apk_path, output_dir, dest_name=package)
 
         console.print(f"[green]✔[/green] {t('cli_source_code_at')} [bold]{dest}[/bold]")
 
@@ -1390,6 +1402,7 @@ def _is_leak_finding(f) -> bool:
                 scanner_engine="none",
             )
 
+        _inject_manifest_component_findings(scan_result)
         print_vuln_report(scan_result, source_dir)
         pkg_name = package_hint or source_dir.name
         _save_vuln_json(scan_result, pkg_name, manifest=_MANIFEST_ANALYSIS)
@@ -1479,52 +1492,7 @@ def on_progress(msg: str) -> None:
             f"  [dim]{t('cli_scanner_used_dim', engine=engine_used, files=scan_result.files_scanned, findings=len(scan_result.findings))}[/dim]"
         )
 
-    # ── Componentes del manifest exportados (COMP006/COMP007/COMP008) ─────────
-    # _MANIFEST_ANALYSIS ya tiene exported_components del manifest extraído.
-    if scan_result is not None and _MANIFEST_ANALYSIS and getattr(_MANIFEST_ANALYSIS, "exported_components", None):
-        try:
-            from nutcracker_core.vuln_scanner import VulnFinding
-            _COMP_MAP = {
-                "activity":  ("COMP006", "Activity exported sin permission",              "critical", "M6 - Componentes inseguros"),
-                "service":   ("COMP007", "Service exported sin permission",               "high",     "M6 - Componentes inseguros"),
-                "receiver":  ("COMP004", "BroadcastReceiver exported sin permission",     "high",     "M6 - Componentes inseguros"),
-                "provider":  ("COMP008", "ContentProvider exported sin permission",       "critical", "M6 - Componentes inseguros"),
-            }
-            existing_comp = {(f.rule_id, f.matched_text) for f in scan_result.findings if f.rule_id.startswith("COMP")}
-            new_comp = []
-            for ec in _MANIFEST_ANALYSIS.exported_components:
-                tag = ec.get("tag", "").lower()
-                name = ec.get("name", "")
-                if tag not in _COMP_MAP:
-                    continue
-                rule_id, title, severity, category = _COMP_MAP[tag]
-                matched = f'<{tag} android:name="{name}" android:exported="true">'
-                if (rule_id, matched) in existing_comp:
-                    continue
-                new_comp.append(VulnFinding(
-                    rule_id=rule_id,
-                    title=f"{title}: {name.split('.')[-1]}",
-                    severity=severity,
-                    category=category,
-                    file=None,
-                    line=0,
-                    matched_text=matched,
-                    description=(
-                        f"{tag.capitalize()} `{name}` tiene android:exported=\"true\" sin "
-                        f"android:permission. Cualquier app o comando ADB puede invocarlo directamente."
-                    ),
-                    recommendation=(
-                        f"Añadir android:exported=\"false\" o proteger con "
-                        f"android:permission=\"\"."
-                    ),
-                ))
-                existing_comp.add((rule_id, matched))
-            if new_comp:
-                scan_result.findings.extend(new_comp)
-                console.print(f"  [dim]Componentes exportados (manifest): {len(new_comp)} hallazgo(s) COMP[/dim]")
-        except Exception:
-            pass
-
+    _inject_manifest_component_findings(scan_result)
     print_vuln_report(scan_result, source_dir)
 
     # Guardar JSON con nombre canónico por paquete.
@@ -1533,6 +1501,65 @@ def on_progress(msg: str) -> None:
     return scan_result
 
 
+def _inject_manifest_component_findings(scan_result) -> None:
+    """Agrega hallazgos COMP004/006/007/008 (componentes exportados sin
+    permission) a partir de ``_MANIFEST_ANALYSIS.exported_components``.
+
+    FIX (2026-07-28): vivía inline al final de ``_do_vuln_scan``, después del
+    bloque que solo corre cuando ``include_vuln_scan`` está habilitado -- con
+    ``sast_scan: false`` en config.yaml (el camino "solo leak scan" retorna
+    antes de llegar ahí), estos hallazgos nunca se generaban pese a no tener
+    nada que ver con SAST/semgrep: salen enteramente del manifest, no del
+    código. Extraído a función propia para poder llamarlo desde ambos
+    caminos de ``_do_vuln_scan`` (con y sin vuln_scan habilitado)."""
+    if scan_result is None or not _MANIFEST_ANALYSIS:
+        return
+    if not getattr(_MANIFEST_ANALYSIS, "exported_components", None):
+        return
+    try:
+        from nutcracker_core.vuln_scanner import VulnFinding
+        _COMP_MAP = {
+            "activity":  ("COMP006", "Activity exported sin permission",              "critical", "M6 - Componentes inseguros"),
+            "service":   ("COMP007", "Service exported sin permission",               "high",     "M6 - Componentes inseguros"),
+            "receiver":  ("COMP004", "BroadcastReceiver exported sin permission",     "high",     "M6 - Componentes inseguros"),
+            "provider":  ("COMP008", "ContentProvider exported sin permission",       "critical", "M6 - Componentes inseguros"),
+        }
+        existing_comp = {(f.rule_id, f.matched_text) for f in scan_result.findings if f.rule_id.startswith("COMP")}
+        new_comp = []
+        for ec in _MANIFEST_ANALYSIS.exported_components:
+            tag = ec.get("tag", "").lower()
+            name = ec.get("name", "")
+            if tag not in _COMP_MAP:
+                continue
+            rule_id, title, severity, category = _COMP_MAP[tag]
+            matched = f'<{tag} android:name="{name}" android:exported="true">'
+            if (rule_id, matched) in existing_comp:
+                continue
+            new_comp.append(VulnFinding(
+                rule_id=rule_id,
+                title=f"{title}: {name.split('.')[-1]}",
+                severity=severity,
+                category=category,
+                file=None,
+                line=0,
+                matched_text=matched,
+                description=(
+                    f"{tag.capitalize()} `{name}` tiene android:exported=\"true\" sin "
+                    f"android:permission. Cualquier app o comando ADB puede invocarlo directamente."
+                ),
+                recommendation=(
+                    f"Añadir android:exported=\"false\" o proteger con "
+                    f"android:permission=\"\"."
+                ),
+            ))
+            existing_comp.add((rule_id, matched))
+        if new_comp:
+            scan_result.findings.extend(new_comp)
+            console.print(f"  [dim]Componentes exportados (manifest): {len(new_comp)} hallazgo(s) COMP[/dim]")
+    except Exception:
+        pass
+
+
 def _load_vuln_json(package: str):
     """Carga el JSON de vulnerabilidades guardado previamente, si existe."""
     import json
diff --git a/plan.md b/plan.md
index d04b88a..d052804 100644
--- a/plan.md
+++ b/plan.md
@@ -1713,3 +1713,128 @@ mitad, el próximo F5 no debe volver a reclamar el cable solo.
 
 **Pendiente de prueba manual del usuario:** el ciclo real conectar → F5 → reconexión automática
 contra el teléfono, que no se puede ejercitar sin un navegador con el cable enchufado.
+
+---
+
+## Fix: aipwn no persistía nada + diagnóstico de "hallazgos vacíos" (2026-07-27)
+
+**Reportado por el usuario:** "los cambios no se guardaron de los análisis" + "en apps, al revisar hallazgos no encuentro ninguno".
+
+### Hallazgo 1 (bug real, corregido): jobs `aipwn` nunca tocaban SQLite
+
+`fire_post_hooks("after_analysis", ...)` (el único punto que dispara `store/hooks.py`, que escribe
+`runs`/`findings`/`artifacts`) vive **solo** dentro de `orchestrator._run_analysis()` — la función
+que usan `scan`/`analyze`. `aipwn.py` es un comando aparte que nunca pasa por ahí. Confirmado en
+vivo: jobs `aipwn` con `status: done` pero `run_id: None` (`2798`, `2796`, `2792`), mientras que jobs
+`static` de los mismos paquetes sí quedaban enlazados a un run real.
+
+Además, incluso lo que `aipwn` **sí** puede volcar a disco (`exploit_report_.json/.pdf`, con los
+PoC confirmados/no confirmados) solo se genera `if report:` (flag `--report` del CLI) — y
+`orchestrator.build_job_cmd(aipwn=True)` nunca lo pasaba. Un job de aipwn lanzado desde el dashboard
+corría el `ExploitAgent` completo y tiraba el resultado: lo único que sobrevivía era el `.txt` crudo
+en `logs/`.
+
+**Fix aplicado (alcance acordado con el usuario -- mínimo, no el rediseño completo de persistencia a
+SQLite):** `build_job_cmd` ahora siempre agrega `--report` para `aipwn=True`, con o sin serial. Así
+el JSON/PDF de exploits queda en disco sin importar si aipwn corrió desde el dashboard o a mano.
+Tests actualizados en `tests/test_orchestrator.py` (`test_build_job_cmd_aipwn_always_passes_report`).
+
+**Pendiente, fuera de este alcance (el usuario puede pedirlo después):** que aipwn cree su propio
+`run` en SQLite (kind="aipwn") con los PoC confirmados como findings, para que aparezca en el
+historial del dashboard con su propio veredicto/evidencia, en vez de solo el JSON/PDF en disco.
+
+### Hallazgo 2 (no es un bug): "hallazgos vacíos" en varias apps es real, no un fallo de guardado
+
+Para `org.credicorp.bcpglobal`, `com.bcp.bo.discounts` y `bo.com.bcp.credinetweb`, la API sí
+devuelve los `findings` que existen (verificado con `curl /api/runs/{id}` -- ninguno perdido) — pero
+son genuinamente **cero**, porque:
+
+- Ni la decompilación estática (jadx) ni el volcado runtime (Frida) produjeron código fuente
+  utilizable (`decompiled//` y `decompiled/runtime_dump_/` no existen para ninguna de las
+  tres). `vuln.json` confirma `"files_scanned": 0`.
+- `protection_broken: False` en las tres -- la protección de estas apps (bancarias reales, DexGuard/
+  similar) todavía no se rompió.
+- Sin código fuente, `leak_scan.native`/`gitleaks` no tienen nada sobre qué correr. Solo
+  `leak_scan.apkleaks` (escanea el `.apk` crudo sin decompilar) podría encontrar algo igual, y para
+  estas tres no encontró nada notable.
+- Contraste: `com.bcp.bo.wallet` sí tiene 4 findings (HC004, credenciales de Firebase/Google) --
+  pero vienen de `apkleaks` sobre `AndroidManifest.xml`/`strings.xml` crudos, no de código
+  decompilado (`decompiled/com.bcp.bo.wallet/` existe pero con 0 archivos .java/.smali).
+
+Es exactamente el escenario que el pipeline "protected" (runtime/Frida) existe para resolver, y lo
+mismo que el usuario venía probando con `aipwn`: romper la protección primero habilita un dump/
+decompile real, que a su vez habilita hallazgos de código reales en corridas futuras.
+
+---
+
+## Fix: colisión de directorios entre jobs estáticos concurrentes (2026-07-28)
+
+**Reportado por el usuario:** "en el dashboard el apartado de apps no aparece los hallazgos" para
+varias apps bancarias reales analizadas por la cola.
+
+### Diagnóstico
+
+Verificado con la API (`/api/runs/{id}`) que el dashboard no pierde nada: refleja fielmente lo que
+hay en SQLite. El run problemático (`org.credicorp.bcpglobal`, 2026-07-27 19:38) realmente tenía
+`files_scanned: 0` -- pero no por protección/DexGuard sin romper, como se había concluido en un
+diagnóstico anterior de esta misma sesión.
+
+**Causa real, encontrada al reproducir el pipeline en vivo:**
+
+1. `jadx` no está instalado en este entorno (`jadx: command not found`), así que el pipeline cae al
+   fallback `apktool` (sí presente, `2.7.0-dirty`). Corriendo `apktool` a mano contra el `.apk` de
+   `org.credicorp.bcpglobal`, decodifica sin problema: 30,156 archivos `.smali`.
+2. **El bug**: `decompiler.decompile()` nombraba el directorio de salida con `apk_path.stem` (el
+   nombre del archivo, sin extensión) -- pero `apkeep` guarda el APK base de un Android App Bundle
+   literalmente como `base.apk` **para cualquier paquete**. Confirmado: `bo.com.bcp.credinetweb`,
+   `org.credicorp.bcpglobal` y `com.bcp.bo.discounts` se descargaron los tres como `base.apk`.
+3. Esos 3 jobs estáticos se encolaron **en el mismo segundo** (19:36:22-23, con `static_workers` > 1
+   en ese momento), así que los 3 procesos decompilaban **simultáneamente hacia el mismo
+   `decompiled/base/`** -- `apktool --force` de un job borraba/recreaba el directorio que otro job
+   estaba escaneando a mitad de camino, dejando `files_scanned: 0` para el que perdía la carrera.
+
+Confirmado corriendo `analyze downloads/org.credicorp.bcpglobal/base.apk --static-only` de forma
+aislada (sin concurrencia): decompiló bien y encontró 2 hallazgos reales (`HC004`, credenciales de
+Firebase) -- quedó persistido como run #12, visible en el dashboard tras refrescar.
+
+### Fix
+
+- `decompiler.decompile(apk_path, output_dir, dest_name=None)`: nuevo parámetro opcional que nombra
+  el subdirectorio de salida. Default `apk_path.stem` (compatible hacia atrás). `_decompile_jadx`/
+  `_decompile_apktool` reciben `dest_name` en vez de derivarlo internamente.
+- `orchestrator._do_decompile()` (único call site) pasa `dest_name=package` -- cada paquete
+  decompila a `decompiled//`, nunca a un nombre compartido derivado del archivo.
+- `decompiler.extract_manifest()` tenía el mismo riesgo (`_manifest_apktool_{apk_path.stem}`,
+  `_manifest_jadx_{apk_path.stem}` -- usado en el flujo de runtime dump sin manifest). Mismo fix:
+  nuevo parámetro `name_hint`, `manifest_analyzer.py` pasa `decompiled_dir.name` (ya único, incluye
+  el package).
+
+### Verificación
+
+- 5 tests nuevos en `tests/test_decompiler.py`, incluido uno que documenta explícitamente la
+  colisión tal cual se manifestaba antes del fix (`test_decompile_two_base_apks_without_dest_name_would_collide`).
+  Suite completa: 220 tests pasan.
+- Reproducido en vivo el bug arreglado: el run recién corrido (#12) para `org.credicorp.bcpglobal`
+  ya no colisiona y persiste hallazgos reales.
+
+---
+
+## Fix adicional: hallazgos COMP del manifest se perdían con sast_scan:false (2026-07-28)
+
+Al re-correr `com.bcp.bo.discounts` para verificar el fix de colisión de directorios, encontré un
+segundo bug real (independiente): con `features.sast_scan: false` en `config.yaml` (la config real
+del usuario), `_do_vuln_scan()` toma un camino "solo leak scan" que **retorna antes** de llegar al
+bloque que inyecta hallazgos COMP004/006/007/008 (componentes exportados sin `android:permission`,
+detectados desde el manifest, sin relación con SAST/semgrep). Resultado: esos hallazgos nunca se
+generaban para ningún análisis mientras `sast_scan` estuviera deshabilitado -- independientemente de
+si la app realmente tenía componentes exportados sin proteger.
+
+**Fix:** la lógica de inyección se extrajo a `_inject_manifest_component_findings(scan_result)` y se
+llama desde **ambos** caminos de `_do_vuln_scan` (con y sin `include_vuln_scan`). 5 tests nuevos en
+`tests/test_orchestrator.py`, incluida la regresión directa (`test_do_vuln_scan_leak_only_path_still_adds_comp_findings`).
+Suite completa: **225 tests pasan**.
+
+Nota: para `com.bcp.bo.discounts` específicamente esto no cambió el resultado (sus componentes
+exportados ya tenían `android:permission` seteado, y el launcher se excluye a propósito) -- pero el
+gap era real y afectaba a cualquier app con componentes realmente desprotegidos mientras
+`sast_scan: false`.
diff --git a/tests/test_decompiler.py b/tests/test_decompiler.py
new file mode 100644
index 0000000..b391bcc
--- /dev/null
+++ b/tests/test_decompiler.py
@@ -0,0 +1,146 @@
+"""Tests de nutcracker_core/decompiler.py.
+
+Bug encontrado en vivo (2026-07-28): apkeep guarda el APK base de un Android
+App Bundle literalmente como "base.apk" para *cualquier* paquete -- con el
+directorio de salida nombrado por `apk_path.stem` (el comportamiento previo a
+este fix), dos jobs estáticos concurrentes de paquetes distintos (ambos
+descargados como "base.apk") decompilaban hacia el mismo "decompiled/base/" y
+se pisaban entre sí. Confirmado con datos reales: 3 apps bancarias reales
+encoladas en el mismo segundo, todas con `downloads//base.apk`, todas
+decompilando hacia "decompiled/base" -- una de ellas terminó con
+"files_scanned: 0" pese a que apktool corrido a mano contra su APK generaba
+30k+ archivos smali sin problema.
+"""
+
+from __future__ import annotations
+
+import subprocess
+from pathlib import Path
+
+import pytest
+
+from nutcracker_core import decompiler
+
+
+def _completed(returncode: int = 0, stderr: str = ""):
+    return subprocess.CompletedProcess([], returncode, stdout="", stderr=stderr)
+
+
+@pytest.fixture
+def fake_jadx(monkeypatch):
+    """jadx "disponible" que solo registra el comando y crea un .java falso en
+    el dest recibido, para verificar en qué directorio termina escribiendo."""
+    calls = []
+
+    monkeypatch.setattr(decompiler, "_find_tool", lambda name: f"/usr/bin/{name}" if name == "jadx" else None)
+
+    def fake_run(cmd, capture_output=True, text=True, timeout=None):  # noqa: ANN001
+        calls.append(cmd)
+        dest = Path(cmd[cmd.index("-d") + 1])
+        dest.mkdir(parents=True, exist_ok=True)
+        (dest / "Main.java").write_text("// noop")
+        return _completed()
+
+    monkeypatch.setattr(decompiler.subprocess, "run", fake_run)
+    return calls
+
+
+def test_decompile_uses_dest_name_not_apk_stem(tmp_path, fake_jadx):
+    """El caso central del bug: dos paquetes distintos, mismo nombre de
+    archivo ("base.apk"), deben terminar en directorios DISTINTOS cuando se
+    pasa dest_name."""
+    apk_a = tmp_path / "pkg_a" / "base.apk"
+    apk_b = tmp_path / "pkg_b" / "base.apk"
+    apk_a.parent.mkdir(parents=True)
+    apk_b.parent.mkdir(parents=True)
+    apk_a.write_bytes(b"PK\x03\x04")
+    apk_b.write_bytes(b"PK\x03\x04")
+
+    output_dir = tmp_path / "decompiled"
+
+    dest_a = decompiler.decompile(apk_a, output_dir, dest_name="com.example.a")
+    dest_b = decompiler.decompile(apk_b, output_dir, dest_name="com.example.b")
+
+    assert dest_a != dest_b
+    assert dest_a.name == "com.example.a"
+    assert dest_b.name == "com.example.b"
+    assert dest_a.exists() and dest_b.exists()
+    # ambos conservan su propio contenido -- ninguno se pisó al otro
+    assert (dest_a / "Main.java").exists()
+    assert (dest_b / "Main.java").exists()
+
+
+def test_decompile_without_dest_name_falls_back_to_apk_stem(tmp_path, fake_jadx):
+    """Compatibilidad hacia atrás: sin dest_name explícito, se comporta como
+    antes (nombrado por el archivo)."""
+    apk = tmp_path / "base.apk"
+    apk.write_bytes(b"PK\x03\x04")
+
+    dest = decompiler.decompile(apk, tmp_path / "decompiled")
+
+    assert dest.name == "base"
+
+
+def test_decompile_two_base_apks_without_dest_name_would_collide(tmp_path, fake_jadx):
+    """Documenta el bug tal cual se manifestaba antes del fix: sin dest_name,
+    dos paquetes distintos con el mismo nombre de archivo terminan en el
+    MISMO directorio -- la razón real detrás de "files_scanned: 0" en runs
+    concurrentes."""
+    apk_a = tmp_path / "pkg_a" / "base.apk"
+    apk_b = tmp_path / "pkg_b" / "base.apk"
+    apk_a.parent.mkdir(parents=True)
+    apk_b.parent.mkdir(parents=True)
+    apk_a.write_bytes(b"PK\x03\x04")
+    apk_b.write_bytes(b"PK\x03\x04")
+
+    output_dir = tmp_path / "decompiled"
+    dest_a = decompiler.decompile(apk_a, output_dir)
+    dest_b = decompiler.decompile(apk_b, output_dir)
+
+    assert dest_a == dest_b, "ambos deberían colisionar en decompiled/base sin dest_name"
+
+
+def test_decompile_apktool_fallback_uses_dest_name(tmp_path, monkeypatch):
+    """El fallback a apktool (cuando jadx no está instalado, como en este
+    entorno) también debe respetar dest_name."""
+    monkeypatch.setattr(decompiler, "_find_tool", lambda name: "/usr/bin/apktool" if name == "apktool" else None)
+
+    calls = []
+
+    def fake_run(cmd, capture_output=True, text=True, timeout=None):  # noqa: ANN001
+        calls.append(cmd)
+        dest = Path(cmd[cmd.index("-o") + 1])
+        dest.mkdir(parents=True, exist_ok=True)
+        return _completed()
+
+    monkeypatch.setattr(decompiler.subprocess, "run", fake_run)
+
+    apk = tmp_path / "base.apk"
+    apk.write_bytes(b"PK\x03\x04")
+
+    dest = decompiler.decompile(apk, tmp_path / "decompiled", dest_name="org.credicorp.bcpglobal")
+
+    assert dest.name == "org.credicorp.bcpglobal"
+    assert "-o" in calls[0] and str(dest) in calls[0]
+
+
+def test_extract_manifest_uses_name_hint_not_apk_stem(tmp_path, monkeypatch):
+    """Mismo bug, mismo fix, para el extractor de manifest usado en el flujo
+    de runtime dump (Frida) sin manifest disponible."""
+    monkeypatch.setattr(decompiler, "_find_tool", lambda name: "/usr/bin/apktool" if name == "apktool" else None)
+
+    def fake_run(cmd, capture_output=True, text=True, timeout=None):  # noqa: ANN001
+        dest = Path(cmd[cmd.index("-o") + 1])
+        dest.mkdir(parents=True, exist_ok=True)
+        (dest / "AndroidManifest.xml").write_text("")
+        return _completed()
+
+    monkeypatch.setattr(decompiler.subprocess, "run", fake_run)
+
+    apk = tmp_path / "base.apk"
+    apk.write_bytes(b"PK\x03\x04")
+
+    result = decompiler.extract_manifest(apk, tmp_path, name_hint="runtime_dump_com.example.app")
+
+    assert result is not None
+    assert "runtime_dump_com.example.app" in str(result)
diff --git a/tests/test_orchestrator.py b/tests/test_orchestrator.py
index 0914bd5..c2e097c 100644
--- a/tests/test_orchestrator.py
+++ b/tests/test_orchestrator.py
@@ -178,7 +178,19 @@ def test_build_job_cmd_aipwn_runs_aipwn_subcommand_with_package():
 def test_build_job_cmd_aipwn_without_serial_omits_flag():
     cmd = orch.build_job_cmd("com.example.tapjacking", is_local_apk=False, aipwn=True)
     assert "--serial" not in cmd
-    assert cmd[-1] == "com.example.tapjacking"
+
+
+def test_build_job_cmd_aipwn_always_passes_report():
+    """FIX (2026-07-27): sin --report, plugins/aipwn/__init__.py nunca vuelca
+    el ExploitReport a disco (exploit_report_.json/.pdf) -- un job de
+    cola/dashboard corría el exploit agent entero y tiraba el resultado, sin
+    dejar nada reutilizable ni visible. Debe pasarse siempre, con o sin serial."""
+    cmd_with_serial = orch.build_job_cmd(
+        "com.example.tapjacking", is_local_apk=False, aipwn=True, serial="ZY22GPM27J",
+    )
+    cmd_without_serial = orch.build_job_cmd("com.example.tapjacking", is_local_apk=False, aipwn=True)
+    assert "--report" in cmd_with_serial
+    assert "--report" in cmd_without_serial
 
 
 # ── build_job_cmd: source="device" (batch estático+aipwn desde archivo) ────
@@ -322,3 +334,93 @@ def run(self, ctx):
 
     orch._run_dynamic_checks_for(_FakeResult(), "SERIAL123")  # no debe lanzar
     registry.reset()
+
+
+# ── _inject_manifest_component_findings (COMP004/006/007/008 desde manifest) ──
+#
+# FIX (2026-07-28): vivía inline al final de _do_vuln_scan, en un tramo que
+# solo se alcanzaba con include_vuln_scan=True. Con sast_scan: false en
+# config.yaml (el camino "solo leak scan" retorna antes), estos hallazgos
+# nunca se generaban -- pese a salir enteramente del manifest, sin relación
+# con SAST/semgrep. Se extrajo a función propia llamada desde ambos caminos.
+
+def test_inject_manifest_component_findings_adds_comp_findings(monkeypatch):
+    from nutcracker_core.scan_types import ScanResult
+
+    class _FakeManifest:
+        exported_components = [
+            {"tag": "activity", "name": "com.example.app.AdminActivity"},
+            {"tag": "provider", "name": "com.example.app.SecretProvider"},
+        ]
+
+    monkeypatch.setattr(orch, "_MANIFEST_ANALYSIS", _FakeManifest())
+
+    scan_result = ScanResult(base_dir=None, findings=[], files_scanned=0, scanner_engine="none")
+    orch._inject_manifest_component_findings(scan_result)
+
+    rule_ids = sorted(f.rule_id for f in scan_result.findings)
+    assert rule_ids == ["COMP006", "COMP008"]
+
+
+def test_inject_manifest_component_findings_noop_without_manifest(monkeypatch):
+    from nutcracker_core.scan_types import ScanResult
+
+    monkeypatch.setattr(orch, "_MANIFEST_ANALYSIS", None)
+
+    scan_result = ScanResult(base_dir=None, findings=[], files_scanned=0, scanner_engine="none")
+    orch._inject_manifest_component_findings(scan_result)
+
+    assert scan_result.findings == []
+
+
+def test_inject_manifest_component_findings_noop_for_none_scan_result(monkeypatch):
+    class _FakeManifest:
+        exported_components = [{"tag": "activity", "name": "x"}]
+
+    monkeypatch.setattr(orch, "_MANIFEST_ANALYSIS", _FakeManifest())
+    orch._inject_manifest_component_findings(None)  # no debe lanzar
+
+
+def test_inject_manifest_component_findings_does_not_duplicate_on_rerun(monkeypatch):
+    """Llamarla dos veces sobre el mismo scan_result (posible si el llamador
+    cambia) no debe duplicar los mismos hallazgos COMP."""
+    from nutcracker_core.scan_types import ScanResult
+
+    class _FakeManifest:
+        exported_components = [{"tag": "service", "name": "com.example.app.SyncService"}]
+
+    monkeypatch.setattr(orch, "_MANIFEST_ANALYSIS", _FakeManifest())
+
+    scan_result = ScanResult(base_dir=None, findings=[], files_scanned=0, scanner_engine="none")
+    orch._inject_manifest_component_findings(scan_result)
+    orch._inject_manifest_component_findings(scan_result)
+
+    assert len(scan_result.findings) == 1
+    assert scan_result.findings[0].rule_id == "COMP007"
+
+
+def test_do_vuln_scan_leak_only_path_still_adds_comp_findings(monkeypatch, tmp_path):
+    """Regresión directa del bug: con include_vuln_scan=False (sast_scan:
+    false en config.yaml), _do_vuln_scan tomaba el camino 'solo leak scan' y
+    retornaba ANTES de inyectar los hallazgos COMP del manifest."""
+    source_dir = tmp_path / "decompiled_app"
+    source_dir.mkdir()
+
+    class _FakeManifest:
+        exported_components = [{"tag": "provider", "name": "com.example.app.LeakyProvider"}]
+        misconfigurations = []
+
+    monkeypatch.setattr(orch, "_MANIFEST_ANALYSIS", _FakeManifest())
+    monkeypatch.setattr(orch, "_CFG", {
+        "sast": {"engine": "regex"},
+        "leak_scan": {"native": False, "apkleaks": False, "gitleaks": False},
+    })
+
+    scan_result = orch._do_vuln_scan(
+        source_dir, apk_path=None, package_hint="com.example.app",
+        include_vuln_scan=False, include_leak_scan=False,
+    )
+
+    assert scan_result is not None
+    rule_ids = [f.rule_id for f in scan_result.findings]
+    assert "COMP008" in rule_ids, "el hallazgo COMP del manifest se perdía en el camino sin vuln_scan"

From d27b3652a2110fc6be15bbcb3c46684efead2e29 Mon Sep 17 00:00:00 2001
From: Hiteek 
Date: Tue, 28 Jul 2026 14:34:14 -0500
Subject: [PATCH 29/30] Toolbox implemented

---
 .gitignore                                    |   1 +
 config.yaml.example                           |  14 ++
 nutcracker_core/decompiler.py                 | 108 +++++++----
 nutcracker_core/leak_scanner.py               |  87 ++++++---
 nutcracker_core/manifest_analyzer.py          |   4 +-
 nutcracker_core/native_scanner.py             | 112 ++++++-----
 nutcracker_core/orchestrator.py               |  12 +-
 nutcracker_core/toolbox/README.md             |  78 ++++++++
 nutcracker_core/toolbox/__init__.py           |  27 +++
 nutcracker_core/toolbox/client.py             | 152 +++++++++++++++
 .../toolbox/docker/Dockerfile.static          | 104 ++++++++++
 nutcracker_core/vuln_scanner.py               |   4 +-
 plan.md                                       |  72 +++++++
 tests/test_decompiler.py                      | 120 +++++++++++-
 tests/test_leak_scanner_toolbox.py            | 165 ++++++++++++++++
 tests/test_native_scanner_toolbox.py          | 151 +++++++++++++++
 tests/test_toolbox_client.py                  | 178 ++++++++++++++++++
 17 files changed, 1268 insertions(+), 121 deletions(-)
 create mode 100644 nutcracker_core/toolbox/README.md
 create mode 100644 nutcracker_core/toolbox/__init__.py
 create mode 100644 nutcracker_core/toolbox/client.py
 create mode 100644 nutcracker_core/toolbox/docker/Dockerfile.static
 create mode 100644 tests/test_leak_scanner_toolbox.py
 create mode 100644 tests/test_native_scanner_toolbox.py
 create mode 100644 tests/test_toolbox_client.py

diff --git a/.gitignore b/.gitignore
index 92fa2b3..6b5434f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,6 +30,7 @@ config*.yaml
 nutcracker.db
 nutcracker.db-wal
 nutcracker.db-shm
+.nutcracker_tmp/
 
 # AI / IDE tool files
 .code-review-graph/
diff --git a/config.yaml.example b/config.yaml.example
index 049640b..2b674e0 100644
--- a/config.yaml.example
+++ b/config.yaml.example
@@ -185,3 +185,17 @@ dashboard:
   # doze/corte de red del teléfono); cada cuántos segundos revisarlo y
   # reconectarlo en segundo plano. Mínimo efectivo: 10.
   adb_keepalive_seconds: 60
+# ── Toolbox estático en Docker (opt-in) ────────────────────────────────────
+# Sandboxea jadx/apktool (y a futuro otras herramientas estáticas) dentro de
+# un contenedor Docker en vez de invocar binarios instalados en el host --
+# útil para no depender de tener cada herramienta instalada localmente (este
+# mismo entorno, por ejemplo, no tiene jadx) y para aislar el análisis de
+# APKs de terceros (potencialmente maliciosos) del resto del sistema.
+#
+# Herramientas dinámicas (adb, frida) NUNCA pasan por acá -- necesitan hablar
+# directo con el dispositivo físico conectado al host. Ver
+# nutcracker_core/toolbox/ para el diseño completo.
+toolbox:
+  enabled: false   # true = decompilar vía Docker en vez de binarios locales
+  image: 'nutcracker-toolbox-static:latest'
+
diff --git a/nutcracker_core/decompiler.py b/nutcracker_core/decompiler.py
index ff33285..472d6db 100644
--- a/nutcracker_core/decompiler.py
+++ b/nutcracker_core/decompiler.py
@@ -11,22 +11,36 @@
 import subprocess
 from pathlib import Path
 
+from nutcracker_core import toolbox
+
+# Sentinel devuelto como "ruta" por get_available_tool()/_find_tool() cuando
+# la herramienta se resuelve vía el toolbox de Docker en vez de un binario
+# local -- _decompile_jadx/_decompile_apktool lo usan para elegir entre
+# subprocess.run() local y toolbox.run().
+TOOLBOX = "toolbox"
+
 
 class DecompilerError(Exception):
     pass
 
 
-def _find_tool(name: str) -> str | None:
+def _find_tool(name: str, config: dict | None = None) -> str | None:
+    if toolbox.is_enabled(config) and name in toolbox.STATIC_TOOLS:
+        return TOOLBOX
     return shutil.which(name)
 
 
-def get_available_tool() -> tuple[str, str] | tuple[None, None]:
+def get_available_tool(config: dict | None = None) -> tuple[str, str] | tuple[None, None]:
     """
     Devuelve (nombre_tool, ruta) del primer decompilador disponible.
     Prioridad: jadx > apktool.
+
+    Con ``toolbox.enabled: true`` en config.yaml, jadx siempre "está
+    disponible" (la imagen lo garantiza) sin necesitar el binario en el host
+    -- ver nutcracker_core/toolbox/client.py.
     """
     for name in ("jadx", "apktool"):
-        path = _find_tool(name)
+        path = _find_tool(name, config)
         if path:
             return name, path
     return None, None
@@ -42,7 +56,10 @@ def install_instructions() -> str:
     )
 
 
-def decompile(apk_path: Path, output_dir: Path, dest_name: str | None = None) -> Path:
+def decompile(
+    apk_path: Path, output_dir: Path, dest_name: str | None = None,
+    config: dict | None = None,
+) -> Path:
     """
     Decompila la APK en output_dir.
 
@@ -57,13 +74,17 @@ def decompile(apk_path: Path, output_dir: Path, dest_name: str | None = None) ->
     "files_scanned: 0" pese a que la app no tenía nada que ver con eso.
     Orchestrator ya pasa ``dest_name=package`` para evitarlo.
 
+    ``config`` habilita el toolbox de Docker si ``toolbox.enabled: true``
+    (ver nutcracker_core/toolbox/) -- opt-in, sin ``config`` (o con el flag en
+    false) se comporta exactamente igual que antes, contra binarios locales.
+
     Returns:
         El directorio con los fuentes descompilados.
 
     Raises:
         DecompilerError si no hay herramienta disponible o falla la decompilación.
     """
-    tool, tool_path = get_available_tool()
+    tool, tool_path = get_available_tool(config)
     dest_name = dest_name or apk_path.stem
 
     if tool is None:
@@ -76,39 +97,53 @@ def decompile(apk_path: Path, output_dir: Path, dest_name: str | None = None) ->
 
     if tool == "jadx":
         try:
-            return _decompile_jadx(tool_path, apk_path, output_dir, dest_name)
+            return _decompile_jadx(tool_path, apk_path, output_dir, dest_name, config)
         except DecompilerError as jadx_exc:
-            apktool_path = _find_tool("apktool")
+            apktool_path = _find_tool("apktool", config)
             if apktool_path:
-                return _decompile_apktool(apktool_path, apk_path, output_dir, dest_name)
+                return _decompile_apktool(apktool_path, apk_path, output_dir, dest_name, config)
             raise DecompilerError(
                 f"{jadx_exc}\n\n"
                 "No hay fallback disponible con apktool."
             ) from jadx_exc
     else:
-        return _decompile_apktool(tool_path, apk_path, output_dir, dest_name)
+        return _decompile_apktool(tool_path, apk_path, output_dir, dest_name, config)
+
 
+def _run_tool(tool_path: str, tool_name: str, args: list[str], config: dict | None,
+              timeout: int) -> subprocess.CompletedProcess:
+    """Corre ``tool_name`` local (``tool_path`` de ``shutil.which``) o vía el
+    toolbox de Docker (cuando ``tool_path is TOOLBOX``), según corresponda.
+    Los ``args`` ya vienen con rutas absolutas -- ``toolbox.run()`` monta
+    ``Path.cwd()`` en el contenedor en la misma ruta que en el host, así que
+    una ruta absoluta resuelve igual en ambos lados."""
+    if tool_path is TOOLBOX:
+        return toolbox.run(tool_name, args, config=config, timeout=timeout)
+    return subprocess.run([tool_path, *args], capture_output=True, text=True, timeout=timeout)
 
-def _decompile_jadx(jadx_path: str, apk_path: Path, output_dir: Path, dest_name: str) -> Path:
+
+def _decompile_jadx(jadx_path: str, apk_path: Path, output_dir: Path, dest_name: str,
+                     config: dict | None = None) -> Path:
     dest = output_dir / dest_name
     dest.mkdir(parents=True, exist_ok=True)
 
-    cmd = [
-        jadx_path,
+    args = [
         "--deobf",                  # desofuscar nombres si es posible
         "--show-bad-code",          # incluir código que no pudo descompilarse bien
         "--no-imports",             # evitar ambigüedades de imports
-        "-d", str(dest),
-        str(apk_path),
+        "-d", str(dest.resolve()),
+        str(apk_path.resolve()),
     ]
 
     try:
-        result = subprocess.run(cmd, capture_output=True, text=True, timeout=900)
+        result = _run_tool(jadx_path, "jadx", args, config, timeout=900)
     except subprocess.TimeoutExpired as exc:
         raise DecompilerError(
             "jadx excedió el tiempo límite de 900s durante la decompilación. "
             "Intentando fallback con apktool si está disponible."
         ) from exc
+    except toolbox.ToolboxError as exc:
+        raise DecompilerError(str(exc)) from exc
 
     # jadx devuelve código != 0 cuando hay errores parciales, pero igual genera output
     if not any(dest.rglob("*.java")) and result.returncode != 0:
@@ -120,18 +155,16 @@ def _decompile_jadx(jadx_path: str, apk_path: Path, output_dir: Path, dest_name:
     return dest
 
 
-def _decompile_apktool(apktool_path: str, apk_path: Path, output_dir: Path, dest_name: str) -> Path:
+def _decompile_apktool(apktool_path: str, apk_path: Path, output_dir: Path, dest_name: str,
+                        config: dict | None = None) -> Path:
     dest = output_dir / dest_name
 
-    cmd = [
-        apktool_path,
-        "d",
-        "--force",
-        "-o", str(dest),
-        str(apk_path),
-    ]
+    args = ["d", "--force", "-o", str(dest.resolve()), str(apk_path.resolve())]
 
-    result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
+    try:
+        result = _run_tool(apktool_path, "apktool", args, config, timeout=600)
+    except toolbox.ToolboxError as exc:
+        raise DecompilerError(str(exc)) from exc
 
     if result.returncode != 0:
         raise DecompilerError(
@@ -141,7 +174,8 @@ def _decompile_apktool(apktool_path: str, apk_path: Path, output_dir: Path, dest
     return dest
 
 
-def extract_manifest(apk_path: Path, output_dir: Path, name_hint: str | None = None) -> Path | None:
+def extract_manifest(apk_path: Path, output_dir: Path, name_hint: str | None = None,
+                      config: dict | None = None) -> Path | None:
     """
     Extrae y decodifica únicamente el AndroidManifest.xml del APK.
 
@@ -156,33 +190,33 @@ def extract_manifest(apk_path: Path, output_dir: Path, name_hint: str | None = N
     Intenta primero con apktool (--no-src), luego con jadx (--no-res).
     Devuelve la ruta al AndroidManifest.xml decodificado, o None si falla.
     """
-    import tempfile
-
     name_hint = name_hint or apk_path.stem
 
     # ── Intento 1: apktool --no-src (solo recursos + manifest) ───────────────
-    apktool_path = _find_tool("apktool")
+    apktool_path = _find_tool("apktool", config)
     if apktool_path:
         tmp = output_dir / f"_manifest_apktool_{name_hint}"
         try:
-            result = subprocess.run(
-                [apktool_path, "d", "--force", "--no-src", "-o", str(tmp), str(apk_path)],
-                capture_output=True, text=True, timeout=120,
+            _run_tool(
+                apktool_path, "apktool",
+                ["d", "--force", "--no-src", "-o", str(tmp.resolve()), str(apk_path.resolve())],
+                config, timeout=120,
             )
             manifest = tmp / "AndroidManifest.xml"
             if manifest.exists():
                 return manifest
-        except (subprocess.TimeoutExpired, OSError):
+        except (subprocess.TimeoutExpired, OSError, toolbox.ToolboxError):
             pass
 
     # ── Intento 2: jadx --no-res (sin recursos, pero extrae manifest decodificado) ─
-    jadx_path = _find_tool("jadx")
+    jadx_path = _find_tool("jadx", config)
     if jadx_path:
         tmp = output_dir / f"_manifest_jadx_{name_hint}"
         try:
-            result = subprocess.run(
-                [jadx_path, "--no-res", "--no-src", "-d", str(tmp), str(apk_path)],
-                capture_output=True, text=True, timeout=120,
+            _run_tool(
+                jadx_path, "jadx",
+                ["--no-res", "--no-src", "-d", str(tmp.resolve()), str(apk_path.resolve())],
+                config, timeout=120,
             )
             # jadx coloca el manifest en resources/AndroidManifest.xml
             for candidate in [
@@ -191,7 +225,7 @@ def extract_manifest(apk_path: Path, output_dir: Path, name_hint: str | None = N
             ]:
                 if candidate.exists():
                     return candidate
-        except (subprocess.TimeoutExpired, OSError):
+        except (subprocess.TimeoutExpired, OSError, toolbox.ToolboxError):
             pass
 
     # ── Intento 3: unzip directo (manifest binario — no decodificado, no válido) ─
diff --git a/nutcracker_core/leak_scanner.py b/nutcracker_core/leak_scanner.py
index 352c88e..99da095 100644
--- a/nutcracker_core/leak_scanner.py
+++ b/nutcracker_core/leak_scanner.py
@@ -8,9 +8,13 @@
 
 from __future__ import annotations
 
+import json as _json
 import re
+import shutil
+import subprocess
 from pathlib import Path
 
+from nutcracker_core import toolbox
 from .scan_types import VulnFinding
 
 # ── Integración con apkleaks ──────────────────────────────────────────────────
@@ -73,20 +77,34 @@
 }
 
 
+def _run_leak_tool(
+    tool_name: str, local_bin: str | None, args: list[str], config: dict | None, timeout: int,
+) -> subprocess.CompletedProcess | None:
+    """Corre apkleaks/gitleaks local o vía el toolbox de Docker -- opt-in vía
+    ``toolbox.enabled`` en config.yaml. Devuelve None si no está disponible
+    por ningún lado (los llamadores ya toleraban "no encontrada" con []).
+    """
+    if toolbox.is_enabled(config):
+        try:
+            return toolbox.run(tool_name, args, config=config, timeout=timeout)
+        except toolbox.ToolboxError:
+            return None
+    if not local_bin:
+        return None
+    return subprocess.run([local_bin, *args], capture_output=True, text=True, timeout=timeout)
+
+
 def scan_with_apkleaks(
     apk_path: Path,
     progress_callback=None,
+    config: dict | None = None,
 ) -> list[VulnFinding]:
     """
     Ejecuta apkleaks sobre el APK original y convierte sus hallazgos a VulnFinding.
-    Requiere apkleaks instalado (pip install apkleaks).
-    Devuelve lista vacía si apkleaks no está disponible o falla.
+    Requiere apkleaks instalado (pip install apkleaks) -- o ``toolbox.enabled:
+    true`` en config.yaml para correrlo vía Docker en vez de un binario local.
+    Devuelve lista vacía si apkleaks no está disponible por ningún lado o falla.
     """
-    import json as _json
-    import shutil
-    import subprocess
-    import tempfile
-
     def _parse_plain_output(raw_text: str) -> dict[str, list[str]]:
         """Parsea salida estilo texto de apkleaks: [Categoria] y lineas '- valor'."""
         parsed: dict[str, list[str]] = {}
@@ -107,7 +125,7 @@ def _parse_plain_output(raw_text: str) -> dict[str, list[str]]:
         return parsed
 
     apkleaks_bin = shutil.which("apkleaks")
-    if not apkleaks_bin:
+    if not apkleaks_bin and not toolbox.is_enabled(config):
         if progress_callback:
             progress_callback("apkleaks no encontrado — omitiendo scan de secretos con apkleaks")
         return []
@@ -115,16 +133,22 @@ def _parse_plain_output(raw_text: str) -> dict[str, list[str]]:
     if progress_callback:
         progress_callback("Escaneando secretos con apkleaks...")
 
-    with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tmp:
-        out_path = tmp.name
+    # Bajo el toolbox de Docker: el reporte tiene que quedar en un directorio
+    # visible dentro del contenedor -- ver toolbox.scratch_dir() (el /tmp del
+    # sistema no está montado ahí). Se usa siempre, con o sin toolbox, para no
+    # tener dos rutas de código distintas según el modo.
+    out_path = str(toolbox.scratch_dir() / f"apkleaks_{apk_path.stem}_{id(apk_path)}.json")
 
     try:
-        proc = subprocess.run(
-            [apkleaks_bin, "-f", str(apk_path), "-o", out_path, "--json"],
-            capture_output=True,
-            text=True,
-            timeout=240,
+        proc = _run_leak_tool(
+            "apkleaks", apkleaks_bin,
+            ["-f", str(apk_path.resolve()), "-o", out_path, "--json"],
+            config, timeout=240,
         )
+        if proc is None:
+            if progress_callback:
+                progress_callback("apkleaks no encontrado — omitiendo scan de secretos con apkleaks")
+            return []
         if proc.returncode not in (0, 1):
             if progress_callback:
                 progress_callback(f"apkleaks terminó con rc={proc.returncode}")
@@ -263,20 +287,17 @@ def _is_apkleaks_false_positive(category: str, match_text: str) -> bool:
 def scan_with_gitleaks(
     source_dir: Path,
     progress_callback=None,
+    config: dict | None = None,
 ) -> list[VulnFinding]:
     """
     Ejecuta gitleaks sobre un directorio de código decompilado y convierte
     sus hallazgos a VulnFinding.
-    Requiere gitleaks instalado (brew install gitleaks).
-    Devuelve lista vacía si gitleaks no está disponible o falla.
+    Requiere gitleaks instalado (brew install gitleaks) -- o ``toolbox.enabled:
+    true`` en config.yaml para correrlo vía Docker en vez de un binario local.
+    Devuelve lista vacía si gitleaks no está disponible por ningún lado o falla.
     """
-    import json as _json
-    import shutil
-    import subprocess
-    import tempfile
-
     gitleaks_bin = shutil.which("gitleaks")
-    if not gitleaks_bin:
+    if not gitleaks_bin and not toolbox.is_enabled(config):
         if progress_callback:
             progress_callback("gitleaks no encontrado — omitiendo scan")
         return []
@@ -284,24 +305,28 @@ def scan_with_gitleaks(
     if progress_callback:
         progress_callback("Escaneando secretos con gitleaks...")
 
-    with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tmp:
-        out_path = tmp.name
+    # Ver comentario equivalente en scan_with_apkleaks: bajo el toolbox, el
+    # reporte tiene que quedar en un directorio visible dentro del contenedor.
+    out_path = str(toolbox.scratch_dir() / f"gitleaks_{source_dir.name}_{id(source_dir)}.json")
 
     try:
-        proc = subprocess.run(
+        proc = _run_leak_tool(
+            "gitleaks", gitleaks_bin,
             [
-                gitleaks_bin, "detect",
+                "detect",
                 "--no-git",
                 "--no-banner",
-                "-s", str(source_dir),
+                "-s", str(source_dir.resolve()),
                 "-f", "json",
                 "-r", out_path,
                 "--log-level", "error",
             ],
-            capture_output=True,
-            text=True,
-            timeout=300,
+            config, timeout=300,
         )
+        if proc is None:
+            if progress_callback:
+                progress_callback("gitleaks no encontrado — omitiendo scan")
+            return []
         # rc=0 → sin hallazgos, rc=1 → hallazgos encontrados, ≥2 → error
         if proc.returncode >= 2:
             if progress_callback:
diff --git a/nutcracker_core/manifest_analyzer.py b/nutcracker_core/manifest_analyzer.py
index fdbca9a..53123c1 100644
--- a/nutcracker_core/manifest_analyzer.py
+++ b/nutcracker_core/manifest_analyzer.py
@@ -84,6 +84,7 @@ def analyze_decompiled_dir(
     decompiled_dir: Path,
     progress_callback=None,
     apk_path: Path | None = None,
+    config: dict | None = None,
 ) -> ManifestAnalysisResult:
     """
     Analiza el directorio decompilado por jadx buscando misconfigs en:
@@ -111,7 +112,8 @@ def _cb(msg: str) -> None:
         try:
             from .decompiler import extract_manifest
             _cb(t("analyzing_manifest_progress"))
-            extracted = extract_manifest(apk_path, decompiled_dir.parent, name_hint=decompiled_dir.name)
+            extracted = extract_manifest(apk_path, decompiled_dir.parent,
+                                          name_hint=decompiled_dir.name, config=config)
             if extracted:
                 manifest_path = extracted
         except Exception:  # noqa: BLE001
diff --git a/nutcracker_core/native_scanner.py b/nutcracker_core/native_scanner.py
index 84ecfdb..dc1379b 100644
--- a/nutcracker_core/native_scanner.py
+++ b/nutcracker_core/native_scanner.py
@@ -24,6 +24,8 @@
 from pathlib import Path
 from typing import Callable
 
+from nutcracker_core import toolbox
+
 from .vuln_scanner import VulnFinding
 from .i18n import t as _t
 
@@ -290,59 +292,78 @@ def _extract_so_files(apk_path: Path, work_dir: Path) -> list[Path]:
     return extracted
 
 
-def _get_imported_symbols(so_path: Path) -> list[str]:
+def _run_native_tool(
+    tool_name: str, args: list[str], config: dict | None, timeout: int,
+) -> subprocess.CompletedProcess | None:
+    """Corre ``tool_name`` (nm/objdump/strings) local o vía el toolbox de
+    Docker (ver nutcracker_core/toolbox/) -- opt-in vía ``toolbox.enabled``
+    en config.yaml. Devuelve None si la herramienta no está disponible por
+    ningún lado (no es un error -- los llamadores ya toleraban "no
+    encontrada" devolviendo lista vacía, mismo comportamiento de antes)."""
+    if toolbox.is_enabled(config):
+        try:
+            return toolbox.run(tool_name, args, config=config, timeout=timeout)
+        except toolbox.ToolboxError:
+            return None
+
+    local_bin = shutil.which(tool_name)
+    if not local_bin and tool_name == "nm":
+        local_bin = shutil.which("arm-linux-androideabi-nm")
+    if not local_bin and tool_name == "objdump":
+        local_bin = shutil.which("llvm-objdump")
+    if not local_bin:
+        return None
+    return subprocess.run([local_bin, *args], capture_output=True, text=True, timeout=timeout)
+
+
+def _get_imported_symbols(so_path: Path, config: dict | None = None) -> list[str]:
     """
     Devuelve la lista de símbolos importados (undefined) del .so usando nm o objdump.
     Prefiere nm (más universal), fallback a objdump.
     """
-    nm = shutil.which("nm") or shutil.which("arm-linux-androideabi-nm")
-    if nm:
-        try:
-            result = subprocess.run(
-                [nm, "-D", "--undefined-only", str(so_path)],
-                capture_output=True, text=True, timeout=15,
-            )
-            symbols: list[str] = []
-            for line in result.stdout.splitlines():
+    try:
+        result = _run_native_tool(
+            "nm", ["-D", "--undefined-only", str(so_path.resolve())], config, timeout=15,
+        )
+    except subprocess.TimeoutExpired:
+        result = None
+    if result is not None:
+        symbols: list[str] = []
+        for line in result.stdout.splitlines():
+            parts = line.strip().split()
+            if parts:
+                symbols.append(parts[-1].lstrip("_"))
+        return symbols
+
+    try:
+        result = _run_native_tool(
+            "objdump", ["-T", str(so_path.resolve())], config, timeout=15,
+        )
+    except subprocess.TimeoutExpired:
+        result = None
+    if result is not None:
+        symbols = []
+        for line in result.stdout.splitlines():
+            if "*UND*" in line or "UND" in line:
                 parts = line.strip().split()
                 if parts:
                     symbols.append(parts[-1].lstrip("_"))
-            return symbols
-        except (subprocess.TimeoutExpired, OSError):
-            pass
-
-    objdump = shutil.which("objdump") or shutil.which("llvm-objdump")
-    if objdump:
-        try:
-            result = subprocess.run(
-                [objdump, "-T", str(so_path)],
-                capture_output=True, text=True, timeout=15,
-            )
-            symbols = []
-            for line in result.stdout.splitlines():
-                if "*UND*" in line or "UND" in line:
-                    parts = line.strip().split()
-                    if parts:
-                        symbols.append(parts[-1].lstrip("_"))
-            return symbols
-        except (subprocess.TimeoutExpired, OSError):
-            pass
+        return symbols
 
     return []
 
 
-def _get_strings(so_path: Path, min_len: int = 8) -> list[str]:
-    """Extrae strings imprimibles del .so. Usa el comando `strings` si está disponible."""
-    strings_bin = shutil.which("strings")
-    if strings_bin:
-        try:
-            result = subprocess.run(
-                [strings_bin, "-n", str(min_len), str(so_path)],
-                capture_output=True, text=True, timeout=20,
-            )
-            return result.stdout.splitlines()
-        except (subprocess.TimeoutExpired, OSError):
-            pass
+def _get_strings(so_path: Path, min_len: int = 8, config: dict | None = None) -> list[str]:
+    """Extrae strings imprimibles del .so. Usa el comando `strings` si está disponible
+    (local o vía el toolbox de Docker)."""
+    try:
+        result = _run_native_tool(
+            "strings", ["-n", str(min_len), str(so_path.resolve())], config, timeout=20,
+        )
+    except subprocess.TimeoutExpired:
+        result = None
+    if result is not None:
+        return result.stdout.splitlines()
 
     # Fallback Python puro: extraer secuencias ASCII imprimibles
     found: list[str] = []
@@ -370,6 +391,7 @@ def scan_native_libs(
     work_dir: Path,
     progress_callback: Callable[[str], None] | None = None,
     abi_filter: str | None = "arm64-v8a",
+    config: dict | None = None,
 ) -> list[VulnFinding]:
     """
     Extrae los .so del APK y aplica reglas NAT001-NAT008.
@@ -380,6 +402,8 @@ def scan_native_libs(
         progress_callback: Función(str) para mensajes de progreso.
         abi_filter:        Si se especifica, solo escanear .so de esa ABI.
                            None = escanear todas las ABIs.
+        config:            Config de nutcracker -- habilita nm/objdump/strings
+                           vía el toolbox de Docker si ``toolbox.enabled: true``.
 
     Returns:
         Lista de VulnFinding compatible con ScanResult.findings.
@@ -417,10 +441,10 @@ def _cb(msg: str) -> None:
         lib_name = so_path.name
         _cb(f"  → {lib_name}")
 
-        imported_syms = _get_imported_symbols(so_path)
+        imported_syms = _get_imported_symbols(so_path, config)
         imported_set = {s.lower() for s in imported_syms}
 
-        all_strings = _get_strings(so_path)
+        all_strings = _get_strings(so_path, config=config)
         strings_lower = [s.lower() for s in all_strings]
 
         # Agrupar hallazgos por regla para este .so (evitar duplicados masivos)
diff --git a/nutcracker_core/orchestrator.py b/nutcracker_core/orchestrator.py
index a542243..38f98fb 100644
--- a/nutcracker_core/orchestrator.py
+++ b/nutcracker_core/orchestrator.py
@@ -976,6 +976,7 @@ def _do_manifest_scan(decompiled_dir: Path, apk_path: Path | None = None) -> "Ma
             decompiled_dir,
             progress_callback=lambda m: progress.update(task, description=m),
             apk_path=apk_path,
+            config=_CFG,
         )
 
     _print_manifest_report(analysis)
@@ -1200,7 +1201,7 @@ def _do_decompile(apk_path: Path, package: str) -> Path | None:
             # así que sin esto jobs estáticos concurrentes de apps distintas
             # decompilarían todas hacia el mismo "decompiled/base/" y se pisarían
             # entre sí (visto en vivo, 2026-07-28 -- ver decompiler.decompile()).
-            dest = decompile(apk_path, output_dir, dest_name=package)
+            dest = decompile(apk_path, output_dir, dest_name=package, config=_CFG)
 
         console.print(f"[green]✔[/green] {t('cli_source_code_at')} [bold]{dest}[/bold]")
 
@@ -1348,20 +1349,21 @@ def _is_leak_finding(f) -> bool:
                         leak_engine="code",
                         include_code_leak_rules=True,
                         include_xml_leak_rules=False,
+                        config=_CFG,
                     )
                     leaks.extend([f for f in base_scan.findings if _is_leak_finding(f)])
 
                 # 2) apkleaks sobre el APK original
                 if use_apkleaks and apk_for_leaks is not None:
                     try:
-                        leaks.extend(scan_with_apkleaks(apk_for_leaks))
+                        leaks.extend(scan_with_apkleaks(apk_for_leaks, config=_CFG))
                     except Exception as exc:  # noqa: BLE001
                         console.print(f"[yellow]⚠[/yellow] {t('cli_apkleaks_failed')} {exc}")
 
                 # 3) gitleaks sobre código decompilado
                 if use_gitleaks:
                     try:
-                        leaks.extend(scan_with_gitleaks(source_dir))
+                        leaks.extend(scan_with_gitleaks(source_dir, config=_CFG))
                     except Exception as exc:  # noqa: BLE001
                         console.print(f"[yellow]⚠[/yellow] gitleaks falló: {exc}")
 
@@ -1429,6 +1431,7 @@ def on_progress(msg: str) -> None:
                 leak_engine=leak_engine,
                 include_code_leak_rules=include_leak_scan and use_native,
                 include_xml_leak_rules=include_leak_scan and use_native,
+                config=_CFG,
             )
 
     except RuntimeError as exc:
@@ -1452,6 +1455,7 @@ def on_progress(msg: str) -> None:
                     leak_engine=leak_engine,
                     include_code_leak_rules=include_leak_scan and use_native,
                     include_xml_leak_rules=include_leak_scan and use_native,
+                    config=_CFG,
                 )
         except Exception as exc2:  # noqa: BLE001
             console.print(f"[red]{t('cli_error_vuln_scan')}[/red] {exc2}")
@@ -1464,7 +1468,7 @@ def on_progress(msg: str) -> None:
         # Inyectar hallazgos de gitleaks si está habilitado
         if use_gitleaks and include_leak_scan:
             try:
-                gl_findings = scan_with_gitleaks(source_dir)
+                gl_findings = scan_with_gitleaks(source_dir, config=_CFG)
                 if gl_findings:
                     # Dedup: skip gitleaks findings that overlap with existing
                     # HC findings on the same file+line (HC has more context)
diff --git a/nutcracker_core/toolbox/README.md b/nutcracker_core/toolbox/README.md
new file mode 100644
index 0000000..e88e6ad
--- /dev/null
+++ b/nutcracker_core/toolbox/README.md
@@ -0,0 +1,78 @@
+# Toolbox estático (Docker)
+
+Capa de acceso uniforme a herramientas de análisis estático, sandboxeadas en
+Docker — inspirado en la arquitectura de auto_pentest: toolbox estático en
+contenedor, herramientas dinámicas (adb/frida) en el host hablando con el
+dispositivo físico real.
+
+## Por qué esta separación
+
+- **Estático → Docker**: jadx/apktool/radare2/etc. decompilan contenido de
+  terceros (APKs de apps reales, potencialmente maliciosos). El contenedor
+  aísla cualquier intento de explotar un bug del propio decompilador del
+  resto del host. También resuelve el problema de dependencias: no hace
+  falta tener cada herramienta instalada localmente (jadx, por ejemplo, no
+  está instalado en el entorno de desarrollo original de este proyecto).
+- **Dinámico → host**: adb/frida necesitan hablar directo con un dispositivo
+  físico conectado al host. Meterlos en un contenedor solo agregaría una capa
+  de red/USB que resolver sin ganar aislamiento real — el aislamiento ahí lo
+  da tener un dispositivo de pruebas dedicado, no el proceso que lo controla.
+
+## Herramientas incluidas (`docker/Dockerfile.static`)
+
+`aapt`, `aapt2`, `apktool`, `baksmali`, `smali`, `jadx`, `r2` (radare2),
+`readelf`, `nm`, `objdump`, `strings`, `blint`, `gitleaks`, `apkid`, `apksigner`,
+`apkleaks`.
+
+Todas verificadas respondiendo dentro de la imagen real (`docker run --rm
+nutcracker-toolbox-static:latest  --version`), no solo asumidas del
+Dockerfile.
+
+## Uso
+
+Opt-in vía `config.yaml`:
+
+```yaml
+toolbox:
+  enabled: true
+  image: 'nutcracker-toolbox-static:latest'   # opcional, este es el default
+```
+
+Con `enabled: false` (default), nada cambia — cada módulo sigue invocando
+binarios locales vía `shutil.which()` exactamente como antes.
+
+Build manual (opcional -- si no existe, `client.ensure_image()` la construye
+sola la primera vez que se necesita):
+
+```bash
+docker build -f nutcracker_core/toolbox/docker/Dockerfile.static \
+    -t nutcracker-toolbox-static:latest nutcracker_core/toolbox/docker
+```
+
+## Integración actual
+
+Por ahora, **solo `decompiler.py`** (jadx/apktool para `decompile()` y
+`extract_manifest()`) enruta a través del toolbox cuando está habilitado.
+Extender a `native_scanner.py` (nm/objdump/readelf/radare2) o
+`leak_scanner.py` (gitleaks) es sencillo con el mismo patrón (`toolbox.run()`
+con rutas absolutas), pero no está hecho todavía.
+
+## Diseño del montaje de volumen
+
+`client.run()` monta `Path.cwd()` (el directorio de trabajo del proceso host)
+en el contenedor **en la misma ruta absoluta** (`-v {cwd}:{cwd} -w {cwd}`).
+Suficiente porque todo el código de nutcracker opera sobre rutas relativas al
+proyecto (`./downloads`, `./decompiled`, ...), nunca fuera de él — evita
+necesitar lógica de traducción de rutas host↔contenedor.
+
+El contenedor corre con `--user {uid del host}:{gid del host}` (no como
+root) -- **encontrado en vivo**: sin esto, todo lo que el contenedor escribe
+en el volumen queda con dueño root, y el host ni siquiera puede
+sobreescribirlo después (un `apktool --force` en un rerun fallaría).
+
+## Limitación conocida
+
+`apksigner` no tiene un artefacto Maven standalone como `aapt2` -- se extrae
+de las build-tools oficiales del SDK vía `sdkmanager` durante el build de la
+imagen. Es el único paso no-fatal del Dockerfile: si falla, el build sigue
+(con un aviso) y el resto de las herramientas quedan disponibles igual.
diff --git a/nutcracker_core/toolbox/__init__.py b/nutcracker_core/toolbox/__init__.py
new file mode 100644
index 0000000..b1982dc
--- /dev/null
+++ b/nutcracker_core/toolbox/__init__.py
@@ -0,0 +1,27 @@
+"""Toolbox estático en Docker (ver client.py para el diseño completo)."""
+
+from __future__ import annotations
+
+from .client import (
+    STATIC_TOOLS,
+    DEFAULT_IMAGE,
+    ToolboxError,
+    ensure_image,
+    image_exists,
+    image_name,
+    is_enabled,
+    run,
+    scratch_dir,
+)
+
+__all__ = [
+    "STATIC_TOOLS",
+    "DEFAULT_IMAGE",
+    "ToolboxError",
+    "ensure_image",
+    "image_exists",
+    "image_name",
+    "is_enabled",
+    "run",
+    "scratch_dir",
+]
diff --git a/nutcracker_core/toolbox/client.py b/nutcracker_core/toolbox/client.py
new file mode 100644
index 0000000..d2fed3f
--- /dev/null
+++ b/nutcracker_core/toolbox/client.py
@@ -0,0 +1,152 @@
+"""Capa de acceso uniforme a herramientas de análisis estático, sandboxeadas
+en Docker (inspirado en la arquitectura de auto_pentest: toolbox estático en
+contenedor + herramientas dinámicas -- adb/frida -- en el host, hablando con
+el dispositivo físico real).
+
+Por qué Docker solo para lo estático: jadx/apktool/radare2/etc. decompilan
+contenido de terceros (APKs de apps reales, potencialmente maliciosas) -- el
+contenedor aísla cualquier intento de explotar un bug del propio decompilador
+del resto del host. Lo dinámico (adb, frida) necesita hablar directo con un
+dispositivo físico conectado al host; meterlo en un contenedor solo agregaría
+una capa de red/USB que resolver sin ganar aislamiento real (el aislamiento ahí
+lo da tener un dispositivo de pruebas dedicado, no el proceso que lo controla).
+
+Diseño **opt-in**: ``toolbox.enabled: false` (default) preserva 100% el
+comportamiento actual -- cada módulo (decompiler.py, native_scanner.py, ...)
+sigue invocando binarios locales vía ``shutil.which()`` tal como siempre. Con
+``toolbox.enabled: true``, esos mismos módulos enrutan la misma llamada a
+través de ``run()`` hacia el contenedor, sin cambiar su propia lógica.
+
+Montaje de volumen: se monta el directorio de trabajo actual (``Path.cwd()``)
+en el contenedor en la MISMA ruta absoluta (``-v {cwd}:{cwd} -w {cwd}``) --
+suficiente porque todo el código de nutcracker opera sobre rutas relativas al
+proyecto (``./downloads``, ``./decompiled``, ...), nunca fuera de él. Evita
+necesitar lógica de traducción de rutas host↔contenedor.
+"""
+
+from __future__ import annotations
+
+import os
+import shutil
+import subprocess
+from pathlib import Path
+
+from nutcracker_core.config import get as cfg_get
+
+# Herramientas estáticas que ofrece la imagen (ver docker/Dockerfile.static).
+# Documentativo -- run() no valida contra esta lista, así no hay que tocar
+# este módulo cada vez que se agrega una herramienta al Dockerfile.
+STATIC_TOOLS = (
+    "aapt", "aapt2", "apktool", "baksmali", "smali", "jadx",
+    "r2", "readelf", "nm", "objdump", "strings", "blint", "gitleaks", "apkid",
+    "apksigner", "apkleaks",
+)
+
+DEFAULT_IMAGE = "nutcracker-toolbox-static:latest"
+_DOCKERFILE = Path(__file__).parent / "docker" / "Dockerfile.static"
+
+
+class ToolboxError(Exception):
+    pass
+
+
+def is_enabled(config: dict | None) -> bool:
+    return bool(cfg_get(config or {}, "toolbox", "enabled", default=False))
+
+
+def image_name(config: dict | None) -> str:
+    return str(cfg_get(config or {}, "toolbox", "image", default=DEFAULT_IMAGE))
+
+
+def _docker_bin() -> str:
+    docker_bin = shutil.which("docker")
+    if not docker_bin:
+        raise ToolboxError(
+            "toolbox.enabled=true en config.yaml, pero 'docker' no está instalado "
+            "o no está en el PATH."
+        )
+    return docker_bin
+
+
+def image_exists(config: dict | None = None) -> bool:
+    docker_bin = shutil.which("docker")
+    if not docker_bin:
+        return False
+    proc = subprocess.run(
+        [docker_bin, "image", "inspect", image_name(config)],
+        capture_output=True, text=True, timeout=30,
+    )
+    return proc.returncode == 0
+
+
+def ensure_image(config: dict | None = None, build_timeout: int = 1800) -> None:
+    """Construye la imagen del toolbox si todavía no existe localmente.
+
+    No busca ni descarga desde un registry -- la imagen se define y se
+    construye 100% local a partir de ``docker/Dockerfile.static``, sin
+    depender de que el usuario tenga acceso a un registry propio."""
+    if image_exists(config):
+        return
+    docker_bin = _docker_bin()
+    proc = subprocess.run(
+        [docker_bin, "build", "-f", str(_DOCKERFILE), "-t", image_name(config),
+         str(_DOCKERFILE.parent)],
+        capture_output=True, text=True, timeout=build_timeout,
+    )
+    if proc.returncode != 0:
+        raise ToolboxError(
+            f"Falló el build de la imagen del toolbox ({image_name(config)}).\n"
+            f"stderr: {proc.stderr[-2000:]}"
+        )
+
+
+def run(
+    tool: str,
+    args: list[str],
+    config: dict | None = None,
+    timeout: int = 600,
+    build_if_missing: bool = True,
+) -> subprocess.CompletedProcess:
+    """Corre ``tool args...`` dentro del contenedor del toolbox estático.
+
+    Los ``args`` que sean rutas deben venir ya resueltas a absolutas por el
+    llamador (ver decompiler.py) -- este módulo no adivina cuáles argumentos
+    son rutas, solo monta ``Path.cwd()`` en la misma ruta dentro del
+    contenedor para que esas rutas absolutas resuelvan igual en ambos lados.
+
+    FIX (verificado en vivo, 2026-07-28): la imagen no define un usuario no-root,
+    así que sin ``--user`` todo lo que el contenedor escribe en el volumen queda
+    con dueño root -- confirmado con jadx real: el host ni siquiera podía
+    borrar/sobreescribir su propio output después (`apktool --force` en un
+    rerun habría fallado). Se corre como el UID:GID del host para que el
+    resultado quede utilizable por el resto del pipeline, que corre como el
+    usuario normal.
+    """
+    docker_bin = _docker_bin()
+    if build_if_missing:
+        ensure_image(config)
+
+    cwd = str(Path.cwd().resolve())
+    cmd = [
+        docker_bin, "run", "--rm",
+        "-v", f"{cwd}:{cwd}",
+        "-w", cwd,
+        "--user", f"{os.getuid()}:{os.getgid()}",
+        image_name(config),
+        tool, *args,
+    ]
+    return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
+
+
+def scratch_dir() -> Path:
+    """Directorio temporal DENTRO del proyecto (bajo ``Path.cwd()``), para
+    archivos intermedios (reportes de apkleaks/gitleaks, etc.) que herramientas
+    corridas vía el toolbox necesitan leer/escribir.
+
+    Necesario porque ``run()`` solo monta ``Path.cwd()`` en el contenedor --
+    el ``/tmp`` del sistema (donde ``tempfile`` escribe por defecto) no es
+    visible ahí. Se usa siempre (con o sin toolbox habilitado) para no tener
+    dos rutas de código distintas según el modo."""
+    d = Path.cwd() / ".nutcracker_tmp"
+    d.mkdir(parents=True, exist_ok=True)
+    return d
diff --git a/nutcracker_core/toolbox/docker/Dockerfile.static b/nutcracker_core/toolbox/docker/Dockerfile.static
new file mode 100644
index 0000000..494dd7a
--- /dev/null
+++ b/nutcracker_core/toolbox/docker/Dockerfile.static
@@ -0,0 +1,104 @@
+# Toolbox estático de nutcracker -- ver ../client.py para el diseño completo.
+#
+# Contiene únicamente herramientas que operan sobre contenido de terceros
+# (APKs, código decompilado) -- nada que hable con un dispositivo físico
+# (adb/frida quedan en el host, ver client.py).
+#
+# Cada URL de descarga fue verificada a mano antes de fijar la versión (no
+# adivinada) -- ver plan.md para el detalle. Ninguna requiere autenticación.
+#
+# Build:
+#   docker build -f nutcracker_core/toolbox/docker/Dockerfile.static \
+#       -t nutcracker-toolbox-static:latest nutcracker_core/toolbox/docker
+# (o dejar que client.ensure_image() lo haga solo la primera vez que se use).
+
+FROM ubuntu:22.04
+
+ENV DEBIAN_FRONTEND=noninteractive
+# apktool/jadx/baksmali/smali corren embebidos en un wrapper que invoca a
+# este mismo curl con un User-Agent de navegador -- Bitbucket (donde vive
+# apktool/smali) devuelve 403 a clientes sin UA (confirmado en vivo).
+ENV CURL_UA="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+        openjdk-17-jre-headless \
+        python3 python3-pip \
+        binutils \
+        aapt \
+        curl unzip ca-certificates \
+    && rm -rf /var/lib/apt/lists/*
+
+# ── radare2 ─────────────────────────────────────────────────────────────
+# No disponible en los repos base de Ubuntu 22.04 (solo en jammy-backports,
+# confirmado en vivo: "Package 'radare2' has no installation candidate" con
+# apt-get install directo) -- se instala desde el .deb oficial publicado en
+# cada release de GitHub en vez de depender de qué repos tenga habilitados
+# la imagen base.
+ARG RADARE2_VERSION=6.1.8
+RUN curl -fsSL -o /tmp/radare2.deb \
+        "https://github.com/radareorg/radare2/releases/download/${RADARE2_VERSION}/radare2_${RADARE2_VERSION}_amd64.deb" \
+    && apt-get update && apt-get install -y --no-install-recommends /tmp/radare2.deb \
+    && rm /tmp/radare2.deb && rm -rf /var/lib/apt/lists/*
+
+# ── apktool (decompilación/recompilación) ──────────────────────────────────
+ARG APKTOOL_VERSION=2.9.3
+RUN curl -fsSL -A "$CURL_UA" -o /usr/local/bin/apktool.jar \
+        "https://bitbucket.org/iBotPeaches/apktool/downloads/apktool_${APKTOOL_VERSION}.jar" \
+    && printf '#!/bin/sh\nexec java -jar /usr/local/bin/apktool.jar "$@"\n' > /usr/local/bin/apktool \
+    && chmod +x /usr/local/bin/apktool
+
+# ── jadx (decompilación a Java legible) ────────────────────────────────────
+ARG JADX_VERSION=1.5.0
+RUN curl -fsSL -o /tmp/jadx.zip \
+        "https://github.com/skylot/jadx/releases/download/v${JADX_VERSION}/jadx-${JADX_VERSION}.zip" \
+    && mkdir -p /opt/jadx && unzip -q /tmp/jadx.zip -d /opt/jadx && rm /tmp/jadx.zip \
+    && chmod +x /opt/jadx/bin/jadx \
+    && ln -s /opt/jadx/bin/jadx /usr/local/bin/jadx
+
+# ── baksmali / smali (dis/ensamblado de bytecode Dalvik) ───────────────────
+ARG SMALI_VERSION=2.5.2
+RUN curl -fsSL -A "$CURL_UA" -o /usr/local/bin/baksmali.jar \
+        "https://bitbucket.org/JesusFreke/smali/downloads/baksmali-${SMALI_VERSION}.jar" \
+    && curl -fsSL -A "$CURL_UA" -o /usr/local/bin/smali.jar \
+        "https://bitbucket.org/JesusFreke/smali/downloads/smali-${SMALI_VERSION}.jar" \
+    && printf '#!/bin/sh\nexec java -jar /usr/local/bin/baksmali.jar "$@"\n' > /usr/local/bin/baksmali \
+    && printf '#!/bin/sh\nexec java -jar /usr/local/bin/smali.jar "$@"\n' > /usr/local/bin/smali \
+    && chmod +x /usr/local/bin/baksmali /usr/local/bin/smali
+
+# ── aapt2 (binario standalone de Maven -- NO requiere bootstrapear el SDK
+#    completo; el jar contiene el binario nativo en su raíz) ───────────────
+ARG AAPT2_VERSION=9.3.1-15703166
+RUN curl -fsSL -o /tmp/aapt2.jar \
+        "https://dl.google.com/android/maven2/com/android/tools/build/aapt2/${AAPT2_VERSION}/aapt2-${AAPT2_VERSION}-linux.jar" \
+    && unzip -p /tmp/aapt2.jar aapt2 > /usr/local/bin/aapt2 \
+    && chmod +x /usr/local/bin/aapt2 && rm /tmp/aapt2.jar
+
+# ── apksigner (extraído de las build-tools oficiales del Android SDK) ──────
+# A diferencia de aapt2, apksigner no tiene un artefacto Maven standalone --
+# solo se distribuye dentro de build-tools. No fatal si falla: el resto de
+# la imagen sigue siendo útil sin apksigner (firma/verificación de APKs es
+# una pieza más, no bloqueante para decompilar/escanear).
+ARG BUILD_TOOLS_VERSION=34.0.0
+RUN ( curl -fsSL -o /tmp/cmdline-tools.zip \
+        "https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip" \
+      && mkdir -p /opt/android-sdk/cmdline-tools \
+      && unzip -q /tmp/cmdline-tools.zip -d /opt/android-sdk/cmdline-tools \
+      && mv /opt/android-sdk/cmdline-tools/cmdline-tools /opt/android-sdk/cmdline-tools/latest \
+      && rm /tmp/cmdline-tools.zip \
+      && yes | /opt/android-sdk/cmdline-tools/latest/bin/sdkmanager \
+           --sdk_root=/opt/android-sdk "build-tools;${BUILD_TOOLS_VERSION}" >/dev/null 2>&1 \
+      && ln -s "/opt/android-sdk/build-tools/${BUILD_TOOLS_VERSION}/apksigner" /usr/local/bin/apksigner \
+    ) || echo "AVISO build: apksigner no se pudo instalar (build-tools/sdkmanager) -- el resto de la imagen sigue funcionando sin él."
+
+# ── gitleaks (detección de secretos hardcodeados) ──────────────────────────
+ARG GITLEAKS_VERSION=8.18.4
+RUN curl -fsSL -o /tmp/gitleaks.tar.gz \
+        "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
+    && tar -xzf /tmp/gitleaks.tar.gz -C /usr/local/bin gitleaks \
+    && rm /tmp/gitleaks.tar.gz && chmod +x /usr/local/bin/gitleaks
+
+# ── apkid, blint, apkleaks (pip -- todos publican su propio entry point) ──
+RUN pip3 install --no-cache-dir apkid blint apkleaks
+
+WORKDIR /work
+ENTRYPOINT []
diff --git a/nutcracker_core/vuln_scanner.py b/nutcracker_core/vuln_scanner.py
index d1d5df1..305b79e 100644
--- a/nutcracker_core/vuln_scanner.py
+++ b/nutcracker_core/vuln_scanner.py
@@ -1074,6 +1074,7 @@ def auto_scan(
     leak_engine: str = "apk",
     include_code_leak_rules: bool = True,
     include_xml_leak_rules: bool = True,
+    config: dict | None = None,
 ) -> ScanResult:
     """
     Punto de entrada unificado: elige semgrep o regex según engine.
@@ -1118,7 +1119,7 @@ def _apply_apkleaks(scan_result: ScanResult) -> None:
         """Añade hallazgos de apkleaks según leak_engine."""
         if not apk_path or leak_engine == "code":
             return
-        al_findings = scan_with_apkleaks(apk_path, progress_callback)
+        al_findings = scan_with_apkleaks(apk_path, progress_callback, config=config)
         if al_findings:
             if leak_engine == "apk":
                 # Solo APK leaks: quitar HC* de código para evitar duplicados.
@@ -1195,6 +1196,7 @@ def _apply_apkleaks(scan_result: ScanResult) -> None:
                 apk_path=apk_path,
                 work_dir=_nat_work,
                 progress_callback=progress_callback,
+                config=config,
             )
             if nat_findings:
                 result.findings.extend(nat_findings)
diff --git a/plan.md b/plan.md
index d052804..5abeef9 100644
--- a/plan.md
+++ b/plan.md
@@ -1838,3 +1838,75 @@ Nota: para `com.bcp.bo.discounts` específicamente esto no cambió el resultado
 exportados ya tenían `android:permission` seteado, y el launcher se excluye a propósito) -- pero el
 gap era real y afectaba a cualquier app con componentes realmente desprotegidos mientras
 `sast_scan: false`.
+
+---
+
+## Toolbox estático en Docker (2026-07-28)
+
+**Pedido:** un "toolbox" que contenga las herramientas de análisis estático en Docker, con una capa
+de acceso uniforme -- inspirado en la arquitectura de auto_pentest (toolbox estático en contenedor,
+dinámico en el host contra el dispositivo físico real).
+
+### Alcance de esta pasada (acotado explícitamente antes de empezar)
+
+Implementado: el toolbox estático + la capa de acceso (`nutcracker_core/toolbox/`) + integración
+en `decompiler.py` (jadx/apktool). **Deferido** (no implementado, a pedido explícito si hace falta
+después): servidor MCP (`--serve-mcp`), dashboard de monitoreo separado (nutcracker ya tiene uno),
+`device_pool.py`, `mitm_addon.py`, librería curada de scripts Frida -- todo eso son subsistemas
+propios, no una extensión natural de "empaquetar binarios en Docker".
+
+### Diseño
+
+- `nutcracker_core/toolbox/client.py`: `is_enabled(config)`, `run(tool, args, config, timeout)`,
+  `ensure_image()`/`image_exists()` (build local, sin depender de un registry propio).
+- `nutcracker_core/toolbox/docker/Dockerfile.static`: imagen con 14 herramientas -- aapt, aapt2,
+  apktool, baksmali, smali, jadx, r2 (radare2), readelf, nm, objdump, blint, gitleaks, apkid,
+  apksigner.
+- **Opt-in real**: `toolbox.enabled: false` (default en config.yaml/config.yaml.example) preserva
+  100% el comportamiento preexistente -- cada módulo sigue invocando binarios locales vía
+  `shutil.which()`. Con `true`, las mismas llamadas se enrutan a `toolbox.run()` sin cambiar su
+  propia lógica (`decompiler._find_tool()` devuelve un sentinel `TOOLBOX` en vez de `None`).
+- Montaje de volumen: `Path.cwd()` del host al contenedor en la **misma ruta absoluta** (todo el
+  código de nutcracker opera sobre rutas relativas al proyecto, nunca fuera de él) -- evita lógica
+  de traducción de rutas host↔contenedor.
+
+### Verificación real (no solo diseño en papel)
+
+Docker no estaba accesible al principio (WSL integration desactivada en Docker Desktop) -- se
+verificó cada URL de descarga a mano (`curl`) antes de fijar versiones en el Dockerfile. Una vez el
+usuario activó la integración a mitad de la tarea, se **construyó la imagen real** y se encontraron
+y corrigieron dos problemas que el diseño en papel no hubiera revelado:
+
+1. **`radare2` no está en los repos base de Ubuntu 22.04** (`E: Package 'radare2' has no
+   installation candidate` -- solo disponible en `jammy-backports`, no habilitado en la imagen base
+   de Docker). Fix: instalar desde el `.deb` oficial publicado en cada release de GitHub en vez de
+   depender de qué repos apt tenga habilitados la imagen base.
+2. **`pip3 install --break-system-packages` no existe en el pip3 de Ubuntu 22.04** (esa protección
+   -- PEP 668 -- es de versiones de pip más nuevas). Fix: sacar el flag, innecesario en un
+   contenedor aislado de un solo uso.
+3. **Bug de permisos encontrado recién al decompilar un APK real vía el toolbox**: sin `--user
+   {uid}:{gid}` en `docker run`, todo lo que el contenedor escribe queda con dueño `root` -- el
+   usuario del host ni siquiera podía sobreescribir su propio output después (un `apktool --force`
+   en un rerun habría fallado con "Permission denied", confirmado en vivo intentando borrar el
+   resultado). Fix: `client.run()` ahora pasa `--user {os.getuid()}:{os.getgid()}`.
+
+Tras los 3 fixes: build completo exitoso, las 14 herramientas responden dentro del contenedor
+(`--version`/`-v`/`--help` según cada una), y **dos pruebas end-to-end reales** a través del propio
+código de nutcracker (no solo `docker run` suelto):
+- `decompiler.decompile()` con `toolbox.enabled: true` contra un APK real (`com.bcp.bo.wallet`) →
+  25,706 archivos `.java` reales vía jadx, dueño correcto (UID del host).
+- `decompiler._decompile_apktool()` (el camino de fallback) contra otro APK real
+  (`com.bcp.bo.discounts`) → 21,939 archivos `.smali`, mismo resultado correcto.
+
+### Tests
+
+19 tests nuevos (`tests/test_toolbox_client.py` + los de modo toolbox en `tests/test_decompiler.py`),
+todos mockeando subprocess/docker -- no requieren Docker instalado para correr en CI. Suite completa:
+**244 tests pasan**.
+
+### Pendiente si se quiere extender
+
+- Integrar el toolbox en `native_scanner.py` (nm/objdump/readelf/radare2) y `leak_scanner.py`
+  (gitleaks) -- mismo patrón, no hecho todavía.
+- Los subsistemas deferidos arriba (MCP server, device pool, mitmproxy addon, frida_scripts
+  curados), si el usuario los pide explícitamente.
diff --git a/tests/test_decompiler.py b/tests/test_decompiler.py
index b391bcc..cffeb41 100644
--- a/tests/test_decompiler.py
+++ b/tests/test_decompiler.py
@@ -32,7 +32,7 @@ def fake_jadx(monkeypatch):
     el dest recibido, para verificar en qué directorio termina escribiendo."""
     calls = []
 
-    monkeypatch.setattr(decompiler, "_find_tool", lambda name: f"/usr/bin/{name}" if name == "jadx" else None)
+    monkeypatch.setattr(decompiler, "_find_tool", lambda name, config=None: f"/usr/bin/{name}" if name == "jadx" else None)
 
     def fake_run(cmd, capture_output=True, text=True, timeout=None):  # noqa: ANN001
         calls.append(cmd)
@@ -103,7 +103,7 @@ def test_decompile_two_base_apks_without_dest_name_would_collide(tmp_path, fake_
 def test_decompile_apktool_fallback_uses_dest_name(tmp_path, monkeypatch):
     """El fallback a apktool (cuando jadx no está instalado, como en este
     entorno) también debe respetar dest_name."""
-    monkeypatch.setattr(decompiler, "_find_tool", lambda name: "/usr/bin/apktool" if name == "apktool" else None)
+    monkeypatch.setattr(decompiler, "_find_tool", lambda name, config=None: "/usr/bin/apktool" if name == "apktool" else None)
 
     calls = []
 
@@ -127,7 +127,7 @@ def fake_run(cmd, capture_output=True, text=True, timeout=None):  # noqa: ANN001
 def test_extract_manifest_uses_name_hint_not_apk_stem(tmp_path, monkeypatch):
     """Mismo bug, mismo fix, para el extractor de manifest usado en el flujo
     de runtime dump (Frida) sin manifest disponible."""
-    monkeypatch.setattr(decompiler, "_find_tool", lambda name: "/usr/bin/apktool" if name == "apktool" else None)
+    monkeypatch.setattr(decompiler, "_find_tool", lambda name, config=None: "/usr/bin/apktool" if name == "apktool" else None)
 
     def fake_run(cmd, capture_output=True, text=True, timeout=None):  # noqa: ANN001
         dest = Path(cmd[cmd.index("-o") + 1])
@@ -144,3 +144,117 @@ def fake_run(cmd, capture_output=True, text=True, timeout=None):  # noqa: ANN001
 
     assert result is not None
     assert "runtime_dump_com.example.app" in str(result)
+
+
+# ── Toolbox de Docker (opt-in vía config.yaml: toolbox.enabled) ─────────────
+
+def test_get_available_tool_prefers_toolbox_when_enabled(monkeypatch):
+    """Con toolbox.enabled=true, jadx "está disponible" sin necesitar el
+    binario en el host -- lo garantiza la imagen. Sentinel TOOLBOX, no un
+    shutil.which() real."""
+    monkeypatch.setattr(decompiler.shutil, "which", lambda name: None)  # nada instalado local
+
+    tool, path = decompiler.get_available_tool({"toolbox": {"enabled": True}})
+
+    assert tool == "jadx"
+    assert path is decompiler.TOOLBOX
+
+
+def test_get_available_tool_ignores_toolbox_when_disabled(monkeypatch):
+    monkeypatch.setattr(decompiler.shutil, "which", lambda name: None)
+
+    tool, path = decompiler.get_available_tool({"toolbox": {"enabled": False}})
+
+    assert (tool, path) == (None, None)
+
+
+def test_decompile_routes_through_toolbox_run_when_enabled(tmp_path, monkeypatch):
+    """Con el toolbox habilitado, decompile() no debe tocar subprocess.run
+    directo -- todo pasa por toolbox.run()."""
+    monkeypatch.setattr(decompiler.shutil, "which", lambda name: None)
+
+    calls = []
+
+    def fake_toolbox_run(tool, args, config=None, timeout=600, build_if_missing=True):  # noqa: ANN001
+        calls.append((tool, args))
+        dest = Path(args[args.index("-d") + 1])
+        dest.mkdir(parents=True, exist_ok=True)
+        (dest / "Main.java").write_text("// noop")
+        return decompiler.subprocess.CompletedProcess([], 0, stdout="", stderr="")
+
+    monkeypatch.setattr(decompiler.toolbox, "run", fake_toolbox_run)
+
+    def boom(*a, **kw):  # noqa: ANN001
+        raise AssertionError("no debería llamar a subprocess.run local con el toolbox habilitado")
+
+    monkeypatch.setattr(decompiler.subprocess, "run", boom)
+
+    apk = tmp_path / "base.apk"
+    apk.write_bytes(b"PK\x03\x04")
+
+    dest = decompiler.decompile(
+        apk, tmp_path / "decompiled", dest_name="com.example.app",
+        config={"toolbox": {"enabled": True}},
+    )
+
+    assert dest.name == "com.example.app"
+    assert calls and calls[0][0] == "jadx"
+    # las rutas que le llegan a toolbox.run() deben ser absolutas -- el
+    # contenedor monta Path.cwd() en la misma ruta que el host, así que una
+    # ruta relativa no resolvería del mismo lado.
+    assert Path(calls[0][1][calls[0][1].index("-d") + 1]).is_absolute()
+    assert Path(calls[0][1][-1]).is_absolute()
+
+
+def test_decompile_falls_back_to_apktool_via_toolbox_on_jadx_failure(tmp_path, monkeypatch):
+    monkeypatch.setattr(decompiler.shutil, "which", lambda name: None)
+
+    calls = []
+
+    def fake_toolbox_run(tool, args, config=None, timeout=600, build_if_missing=True):  # noqa: ANN001
+        calls.append(tool)
+        if tool == "jadx":
+            return decompiler.subprocess.CompletedProcess([], 1, stdout="", stderr="jadx explotó")
+        dest = Path(args[args.index("-o") + 1])
+        dest.mkdir(parents=True, exist_ok=True)
+        return decompiler.subprocess.CompletedProcess([], 0, stdout="", stderr="")
+
+    monkeypatch.setattr(decompiler.toolbox, "run", fake_toolbox_run)
+
+    apk = tmp_path / "base.apk"
+    apk.write_bytes(b"PK\x03\x04")
+
+    dest = decompiler.decompile(
+        apk, tmp_path / "decompiled", dest_name="com.example.app",
+        config={"toolbox": {"enabled": True}},
+    )
+
+    assert calls == ["jadx", "apktool"]
+    assert dest.name == "com.example.app"
+
+
+def test_decompile_wraps_toolbox_error_as_decompiler_error(tmp_path, monkeypatch):
+    monkeypatch.setattr(decompiler.shutil, "which", lambda name: None)
+
+    def boom(*a, **kw):  # noqa: ANN001
+        raise decompiler.toolbox.ToolboxError("docker no está instalado")
+
+    monkeypatch.setattr(decompiler.toolbox, "run", boom)
+
+    apk = tmp_path / "base.apk"
+    apk.write_bytes(b"PK\x03\x04")
+
+    with pytest.raises(decompiler.DecompilerError, match="docker no está instalado"):
+        decompiler.decompile(apk, tmp_path / "decompiled", config={"toolbox": {"enabled": True}})
+
+
+def test_decompile_without_toolbox_config_behaves_exactly_as_before(tmp_path, fake_jadx):
+    """config=None (default) no debe activar el toolbox bajo ninguna
+    circunstancia -- comportamiento 100% preexistente."""
+    apk = tmp_path / "base.apk"
+    apk.write_bytes(b"PK\x03\x04")
+
+    dest = decompiler.decompile(apk, tmp_path / "decompiled")
+
+    assert dest.name == "base"
+    assert fake_jadx  # subprocess.run local sí se usó
diff --git a/tests/test_leak_scanner_toolbox.py b/tests/test_leak_scanner_toolbox.py
new file mode 100644
index 0000000..538bbc9
--- /dev/null
+++ b/tests/test_leak_scanner_toolbox.py
@@ -0,0 +1,165 @@
+"""Tests de la integración del toolbox de Docker en leak_scanner.py
+(apkleaks/gitleaks) -- ver nutcracker_core/toolbox/.
+
+Opt-in: sin config (o con toolbox.enabled=false), debe comportarse
+exactamente como antes (binarios locales vía shutil.which).
+"""
+
+from __future__ import annotations
+
+import json
+import subprocess
+from pathlib import Path
+
+from nutcracker_core import leak_scanner as ls
+
+
+def _completed(returncode: int = 0, stdout: str = "", stderr: str = ""):
+    return subprocess.CompletedProcess([], returncode, stdout=stdout, stderr=stderr)
+
+
+# ── scan_with_apkleaks ───────────────────────────────────────────────────────
+
+def test_scan_with_apkleaks_skips_without_local_bin_and_without_toolbox(monkeypatch, tmp_path):
+    monkeypatch.setattr(ls.shutil, "which", lambda name: None)
+
+    apk = tmp_path / "app.apk"
+    apk.write_bytes(b"PK\x03\x04")
+
+    assert ls.scan_with_apkleaks(apk) == []
+
+
+def test_scan_with_apkleaks_uses_local_binary_without_toolbox(monkeypatch, tmp_path):
+    monkeypatch.setattr(ls.shutil, "which", lambda name: "/usr/bin/apkleaks" if name == "apkleaks" else None)
+
+    calls = []
+
+    def fake_run(cmd, capture_output=True, text=True, timeout=None):  # noqa: ANN001
+        calls.append(cmd)
+        out_path = Path(cmd[cmd.index("-o") + 1])
+        out_path.write_text(json.dumps({
+            "results": [{"name": "Google_API_Key", "matches": ["AIzaSyABC123real_key_value_here"]}],
+        }))
+        return _completed(returncode=0)
+
+    monkeypatch.setattr(ls.subprocess, "run", fake_run)
+
+    apk = tmp_path / "app.apk"
+    apk.write_bytes(b"PK\x03\x04")
+
+    findings = ls.scan_with_apkleaks(apk)
+
+    assert len(findings) == 1
+    assert findings[0].rule_id.startswith("AL-")
+    assert calls[0][0] == "/usr/bin/apkleaks"
+
+
+def test_scan_with_apkleaks_routes_through_toolbox_when_enabled(monkeypatch, tmp_path):
+    monkeypatch.setattr(ls.shutil, "which", lambda name: None)  # nada local
+    monkeypatch.setattr(ls.toolbox, "is_enabled", lambda config: True)
+
+    calls = []
+
+    def fake_toolbox_run(tool, args, config=None, timeout=600, build_if_missing=True):  # noqa: ANN001
+        calls.append((tool, args))
+        out_path = Path(args[args.index("-o") + 1])
+        out_path.write_text(json.dumps({
+            "results": [{"name": "Firebase", "matches": ["AAAA:APA91b_real_looking_value"]}],
+        }))
+        return _completed(returncode=0)
+
+    monkeypatch.setattr(ls.toolbox, "run", fake_toolbox_run)
+    # scratch_dir real (no mockeado) -- confirma que el reporte queda bajo el
+    # proyecto, no en /tmp del sistema (invisible dentro del contenedor).
+    monkeypatch.setattr(ls.toolbox, "scratch_dir", lambda: tmp_path)
+
+    apk = tmp_path / "app.apk"
+    apk.write_bytes(b"PK\x03\x04")
+
+    findings = ls.scan_with_apkleaks(apk, config={"toolbox": {"enabled": True}})
+
+    assert len(findings) == 1
+    assert calls[0][0] == "apkleaks"
+    # el path de salida (-o) debe estar bajo el directorio que devuelve
+    # scratch_dir(), no bajo /tmp del sistema.
+    out_arg = Path(calls[0][1][calls[0][1].index("-o") + 1])
+    assert out_arg.parent == tmp_path
+    # las rutas de entrada (-f) deben ser absolutas
+    f_arg = calls[0][1][calls[0][1].index("-f") + 1]
+    assert Path(f_arg).is_absolute()
+
+
+def test_scan_with_apkleaks_handles_nonzero_returncode(monkeypatch, tmp_path):
+    monkeypatch.setattr(ls.shutil, "which", lambda name: "/usr/bin/apkleaks")
+    monkeypatch.setattr(ls.subprocess, "run", lambda *a, **kw: _completed(returncode=2, stderr="boom"))
+
+    apk = tmp_path / "app.apk"
+    apk.write_bytes(b"PK\x03\x04")
+
+    assert ls.scan_with_apkleaks(apk) == []
+
+
+# ── scan_with_gitleaks ───────────────────────────────────────────────────────
+
+def test_scan_with_gitleaks_skips_without_local_bin_and_without_toolbox(monkeypatch, tmp_path):
+    monkeypatch.setattr(ls.shutil, "which", lambda name: None)
+    assert ls.scan_with_gitleaks(tmp_path) == []
+
+
+def test_scan_with_gitleaks_uses_local_binary_without_toolbox(monkeypatch, tmp_path):
+    monkeypatch.setattr(ls.shutil, "which", lambda name: "/usr/bin/gitleaks" if name == "gitleaks" else None)
+
+    calls = []
+
+    def fake_run(cmd, capture_output=True, text=True, timeout=None):  # noqa: ANN001
+        calls.append(cmd)
+        out_path = Path(cmd[cmd.index("-r") + 1])
+        out_path.write_text(json.dumps([
+            {"RuleID": "aws-access-token", "Description": "AWS key", "Secret": "AKIA...",
+             "File": "Foo.java", "StartLine": 42, "Entropy": 4.2},
+        ]))
+        return _completed(returncode=1)  # 1 = hallazgos encontrados
+
+    monkeypatch.setattr(ls.subprocess, "run", fake_run)
+
+    findings = ls.scan_with_gitleaks(tmp_path)
+
+    assert len(findings) == 1
+    assert findings[0].severity == "critical"
+    assert calls[0][0] == "/usr/bin/gitleaks"
+
+
+def test_scan_with_gitleaks_routes_through_toolbox_when_enabled(monkeypatch, tmp_path):
+    monkeypatch.setattr(ls.shutil, "which", lambda name: None)
+    monkeypatch.setattr(ls.toolbox, "is_enabled", lambda config: True)
+
+    calls = []
+
+    def fake_toolbox_run(tool, args, config=None, timeout=600, build_if_missing=True):  # noqa: ANN001
+        calls.append((tool, args))
+        out_path = Path(args[args.index("-r") + 1])
+        out_path.write_text(json.dumps([
+            {"RuleID": "generic-api-key", "Description": "generic", "Secret": "xyz",
+             "File": "Bar.java", "StartLine": 1, "Entropy": 3.1},
+        ]))
+        return _completed(returncode=1)
+
+    monkeypatch.setattr(ls.toolbox, "run", fake_toolbox_run)
+    monkeypatch.setattr(ls.toolbox, "scratch_dir", lambda: tmp_path)
+
+    source_dir = tmp_path / "decompiled_app"
+    source_dir.mkdir()
+
+    findings = ls.scan_with_gitleaks(source_dir, config={"toolbox": {"enabled": True}})
+
+    assert len(findings) == 1
+    assert calls[0][0] == "gitleaks"
+    s_arg = calls[0][1][calls[0][1].index("-s") + 1]
+    assert Path(s_arg).is_absolute()
+
+
+def test_scan_with_gitleaks_treats_high_returncode_as_error(monkeypatch, tmp_path):
+    monkeypatch.setattr(ls.shutil, "which", lambda name: "/usr/bin/gitleaks")
+    monkeypatch.setattr(ls.subprocess, "run", lambda *a, **kw: _completed(returncode=2, stderr="boom"))
+
+    assert ls.scan_with_gitleaks(tmp_path) == []
diff --git a/tests/test_native_scanner_toolbox.py b/tests/test_native_scanner_toolbox.py
new file mode 100644
index 0000000..91a3a4c
--- /dev/null
+++ b/tests/test_native_scanner_toolbox.py
@@ -0,0 +1,151 @@
+"""Tests de la integración del toolbox de Docker en native_scanner.py
+(nm/objdump/strings) -- ver nutcracker_core/toolbox/.
+
+Opt-in: sin config (o con toolbox.enabled=false), debe comportarse
+exactamente como antes (binarios locales vía shutil.which).
+"""
+
+from __future__ import annotations
+
+import subprocess
+from pathlib import Path
+
+from nutcracker_core import native_scanner as ns
+
+
+def _completed(returncode: int = 0, stdout: str = "", stderr: str = ""):
+    return subprocess.CompletedProcess([], returncode, stdout=stdout, stderr=stderr)
+
+
+# ── _get_imported_symbols ────────────────────────────────────────────────────
+
+def test_get_imported_symbols_uses_local_nm_without_toolbox(monkeypatch, tmp_path):
+    monkeypatch.setattr(ns.shutil, "which", lambda name: "/usr/bin/nm" if name == "nm" else None)
+
+    calls = []
+
+    def fake_run(cmd, capture_output=True, text=True, timeout=None):  # noqa: ANN001
+        calls.append(cmd)
+        return _completed(stdout="0000000000000000 U __android_log_print\n")
+
+    monkeypatch.setattr(ns.subprocess, "run", fake_run)
+
+    so = tmp_path / "libfoo.so"
+    so.write_bytes(b"\x7fELF")
+
+    symbols = ns._get_imported_symbols(so)
+
+    assert "android_log_print" in symbols
+    assert calls[0][0] == "/usr/bin/nm"
+
+
+def test_get_imported_symbols_routes_through_toolbox_when_enabled(monkeypatch, tmp_path):
+    monkeypatch.setattr(ns.shutil, "which", lambda name: None)  # nada local
+
+    calls = []
+
+    def fake_toolbox_run(tool, args, config=None, timeout=600, build_if_missing=True):  # noqa: ANN001
+        calls.append((tool, args))
+        return _completed(stdout="0000000000000000 U __system_property_get\n")
+
+    monkeypatch.setattr(ns.toolbox, "run", fake_toolbox_run)
+    monkeypatch.setattr(ns.toolbox, "is_enabled", lambda config: True)
+
+    so = tmp_path / "libfoo.so"
+    so.write_bytes(b"\x7fELF")
+
+    symbols = ns._get_imported_symbols(so, config={"toolbox": {"enabled": True}})
+
+    assert "system_property_get" in symbols
+    assert calls and calls[0][0] == "nm"
+    # las rutas que llegan al toolbox deben ser absolutas
+    assert Path(calls[0][1][-1]).is_absolute()
+
+
+def test_get_imported_symbols_falls_back_to_objdump_when_nm_unavailable(monkeypatch, tmp_path):
+    monkeypatch.setattr(
+        ns.shutil, "which",
+        lambda name: "/usr/bin/objdump" if name == "objdump" else None,
+    )
+
+    calls = []
+
+    def fake_run(cmd, capture_output=True, text=True, timeout=None):  # noqa: ANN001
+        calls.append(cmd)
+        return _completed(stdout="0000000000000000      DF *UND*  __strcpy_chk\n")
+
+    monkeypatch.setattr(ns.subprocess, "run", fake_run)
+
+    so = tmp_path / "libfoo.so"
+    so.write_bytes(b"\x7fELF")
+
+    symbols = ns._get_imported_symbols(so)
+
+    assert any("strcpy_chk" in s for s in symbols)
+    assert calls[0][0] == "/usr/bin/objdump"
+
+
+def test_get_imported_symbols_returns_empty_without_any_tool(monkeypatch, tmp_path):
+    monkeypatch.setattr(ns.shutil, "which", lambda name: None)
+    so = tmp_path / "libfoo.so"
+    so.write_bytes(b"\x7fELF")
+
+    assert ns._get_imported_symbols(so) == []
+
+
+# ── _get_strings ─────────────────────────────────────────────────────────────
+
+def test_get_strings_uses_local_binary_without_toolbox(monkeypatch, tmp_path):
+    monkeypatch.setattr(ns.shutil, "which", lambda name: "/usr/bin/strings" if name == "strings" else None)
+    monkeypatch.setattr(ns.subprocess, "run", lambda *a, **kw: _completed(stdout="hello\nworld\n"))
+
+    so = tmp_path / "libfoo.so"
+    so.write_bytes(b"\x7fELF")
+
+    assert ns._get_strings(so) == ["hello", "world"]
+
+
+def test_get_strings_falls_back_to_pure_python_when_no_tool_available(monkeypatch, tmp_path):
+    """Sin `strings` local NI toolbox habilitado, sigue funcionando por el
+    fallback puro-Python preexistente -- no debe quedar sin nada."""
+    monkeypatch.setattr(ns.shutil, "which", lambda name: None)
+
+    so = tmp_path / "libfoo.so"
+    so.write_bytes(b"\x00\x00hello world this is a long string\x00\x00")
+
+    found = ns._get_strings(so, min_len=8)
+
+    assert any("hello world" in s for s in found)
+
+
+def test_get_strings_routes_through_toolbox_when_enabled(monkeypatch, tmp_path):
+    monkeypatch.setattr(ns.shutil, "which", lambda name: None)
+    monkeypatch.setattr(ns.toolbox, "is_enabled", lambda config: True)
+
+    calls = []
+
+    def fake_toolbox_run(tool, args, config=None, timeout=600, build_if_missing=True):  # noqa: ANN001
+        calls.append(tool)
+        return _completed(stdout="secret_from_container\n")
+
+    monkeypatch.setattr(ns.toolbox, "run", fake_toolbox_run)
+
+    so = tmp_path / "libfoo.so"
+    so.write_bytes(b"\x7fELF")
+
+    result = ns._get_strings(so, config={"toolbox": {"enabled": True}})
+
+    assert result == ["secret_from_container"]
+    assert calls == ["strings"]
+
+
+# ── scan_native_libs: config se propaga hasta los helpers ───────────────────
+
+def test_scan_native_libs_propagates_config_to_helpers(monkeypatch, tmp_path):
+    apk = tmp_path / "app.apk"
+    apk.write_bytes(b"PK\x03\x04")
+
+    # No hay .so reales en este APK -- alcanza con verificar que no explota
+    # y que, si hubiera, config llegaría a los helpers (cubierto arriba).
+    result = ns.scan_native_libs(apk, tmp_path / "work", config={"toolbox": {"enabled": True}})
+    assert result == []
diff --git a/tests/test_toolbox_client.py b/tests/test_toolbox_client.py
new file mode 100644
index 0000000..da854a6
--- /dev/null
+++ b/tests/test_toolbox_client.py
@@ -0,0 +1,178 @@
+"""Tests de nutcracker_core/toolbox/client.py -- la capa de acceso uniforme
+al toolbox estático en Docker.
+
+Nada acá levanta un contenedor real: se parchea subprocess.run/shutil.which
+del propio módulo, igual que el resto de la suite hace con adb/apktool/jadx.
+"""
+
+from __future__ import annotations
+
+import subprocess
+from pathlib import Path
+
+import pytest
+
+from nutcracker_core import toolbox
+from nutcracker_core.toolbox import client
+
+
+def _completed(returncode: int = 0, stdout: str = "", stderr: str = ""):
+    return subprocess.CompletedProcess([], returncode, stdout=stdout, stderr=stderr)
+
+
+# ── is_enabled / image_name ─────────────────────────────────────────────────
+
+def test_is_enabled_false_by_default():
+    assert not toolbox.is_enabled(None)
+    assert not toolbox.is_enabled({})
+    assert not toolbox.is_enabled({"toolbox": {}})
+
+
+def test_is_enabled_true_when_configured():
+    assert toolbox.is_enabled({"toolbox": {"enabled": True}})
+
+
+def test_image_name_defaults_and_override():
+    assert toolbox.image_name(None) == toolbox.DEFAULT_IMAGE
+    assert toolbox.image_name({"toolbox": {"image": "custom:tag"}}) == "custom:tag"
+
+
+# ── run() sin docker instalado ───────────────────────────────────────────────
+
+def test_run_raises_toolbox_error_without_docker(monkeypatch):
+    monkeypatch.setattr(client.shutil, "which", lambda name: None)
+
+    with pytest.raises(toolbox.ToolboxError, match="docker"):
+        toolbox.run("jadx", ["--help"])
+
+
+def test_ensure_image_raises_toolbox_error_without_docker(monkeypatch):
+    monkeypatch.setattr(client.shutil, "which", lambda name: None)
+
+    with pytest.raises(toolbox.ToolboxError, match="docker"):
+        toolbox.ensure_image()
+
+
+# ── image_exists ─────────────────────────────────────────────────────────────
+
+def test_image_exists_false_without_docker(monkeypatch):
+    monkeypatch.setattr(client.shutil, "which", lambda name: None)
+    assert not toolbox.image_exists()
+
+
+def test_image_exists_checks_docker_image_inspect(monkeypatch):
+    calls = []
+
+    def fake_run(cmd, capture_output=True, text=True, timeout=None):  # noqa: ANN001
+        calls.append(cmd)
+        return _completed(returncode=0)
+
+    monkeypatch.setattr(client.shutil, "which", lambda name: "/usr/bin/docker")
+    monkeypatch.setattr(client.subprocess, "run", fake_run)
+
+    assert toolbox.image_exists()
+    assert calls[0][:3] == ["/usr/bin/docker", "image", "inspect"]
+    assert toolbox.DEFAULT_IMAGE in calls[0]
+
+
+# ── ensure_image (build) ─────────────────────────────────────────────────────
+
+def test_ensure_image_skips_build_when_image_already_exists(monkeypatch):
+    monkeypatch.setattr(client.shutil, "which", lambda name: "/usr/bin/docker")
+    monkeypatch.setattr(client, "image_exists", lambda config=None: True)
+
+    calls = []
+    monkeypatch.setattr(client.subprocess, "run", lambda *a, **kw: calls.append(a) or _completed())
+
+    toolbox.ensure_image()
+
+    assert calls == [], "no debe intentar build si la imagen ya existe"
+
+
+def test_ensure_image_builds_when_missing(monkeypatch):
+    monkeypatch.setattr(client.shutil, "which", lambda name: "/usr/bin/docker")
+    monkeypatch.setattr(client, "image_exists", lambda config=None: False)
+
+    calls = []
+
+    def fake_run(cmd, capture_output=True, text=True, timeout=None):  # noqa: ANN001
+        calls.append(cmd)
+        return _completed(returncode=0)
+
+    monkeypatch.setattr(client.subprocess, "run", fake_run)
+
+    toolbox.ensure_image()
+
+    assert len(calls) == 1
+    cmd = calls[0]
+    assert cmd[:2] == ["/usr/bin/docker", "build"]
+    assert "-f" in cmd and str(client._DOCKERFILE) in cmd
+    assert "-t" in cmd and toolbox.DEFAULT_IMAGE in cmd
+
+
+def test_ensure_image_raises_on_build_failure(monkeypatch):
+    monkeypatch.setattr(client.shutil, "which", lambda name: "/usr/bin/docker")
+    monkeypatch.setattr(client, "image_exists", lambda config=None: False)
+    monkeypatch.setattr(
+        client.subprocess, "run",
+        lambda *a, **kw: _completed(returncode=1, stderr="Dockerfile roto"),
+    )
+
+    with pytest.raises(toolbox.ToolboxError, match="Dockerfile roto"):
+        toolbox.ensure_image()
+
+
+# ── run() -- construcción del comando docker run ────────────────────────────
+
+def test_run_builds_expected_docker_command(monkeypatch):
+    monkeypatch.setattr(client.shutil, "which", lambda name: "/usr/bin/docker")
+    monkeypatch.setattr(client, "ensure_image", lambda config=None, build_timeout=1800: None)
+
+    calls = []
+
+    def fake_run(cmd, capture_output=True, text=True, timeout=None):  # noqa: ANN001
+        calls.append(cmd)
+        return _completed(returncode=0, stdout="ok")
+
+    monkeypatch.setattr(client.subprocess, "run", fake_run)
+
+    result = toolbox.run("jadx", ["--no-res", "/abs/path/app.apk"], timeout=120)
+
+    assert result.returncode == 0
+    cmd = calls[0]
+    assert cmd[0] == "/usr/bin/docker"
+    assert cmd[1] == "run"
+    assert "--rm" in cmd
+    assert "-v" in cmd
+    mount = cmd[cmd.index("-v") + 1]
+    cwd = str(Path.cwd().resolve())
+    assert mount == f"{cwd}:{cwd}"
+    assert "-w" in cmd and cmd[cmd.index("-w") + 1] == cwd
+    assert toolbox.DEFAULT_IMAGE in cmd
+    # tool + args van al final, en orden, después de la imagen
+    tail = cmd[cmd.index(toolbox.DEFAULT_IMAGE) + 1:]
+    assert tail == ["jadx", "--no-res", "/abs/path/app.apk"]
+
+
+def test_run_calls_ensure_image_by_default(monkeypatch):
+    monkeypatch.setattr(client.shutil, "which", lambda name: "/usr/bin/docker")
+    monkeypatch.setattr(client.subprocess, "run", lambda *a, **kw: _completed())
+
+    calls = []
+    monkeypatch.setattr(client, "ensure_image", lambda config=None, build_timeout=1800: calls.append(config))
+
+    toolbox.run("jadx", ["--help"])
+
+    assert len(calls) == 1
+
+
+def test_run_skips_ensure_image_when_build_if_missing_false(monkeypatch):
+    monkeypatch.setattr(client.shutil, "which", lambda name: "/usr/bin/docker")
+    monkeypatch.setattr(client.subprocess, "run", lambda *a, **kw: _completed())
+
+    calls = []
+    monkeypatch.setattr(client, "ensure_image", lambda config=None, build_timeout=1800: calls.append(config))
+
+    toolbox.run("jadx", ["--help"], build_if_missing=False)
+
+    assert calls == []

From 6ea3c649b4aea0a598959dba08ddb0cc8c139655 Mon Sep 17 00:00:00 2001
From: Hiteek 
Date: Tue, 28 Jul 2026 14:40:36 -0500
Subject: [PATCH 30/30] Docs

---
 README.md | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 95 insertions(+)

diff --git a/README.md b/README.md
index 82682a4..106e094 100644
--- a/README.md
+++ b/README.md
@@ -70,6 +70,55 @@ All results are consolidated into a technical PDF report ready for reporting.
 
 ---
 
+## Quick Deploy Guide
+
+Fastest path from a clean machine to a working scan.
+
+**1. Clone and set up the Python environment**
+
+```bash
+git clone 
+cd nutcracker
+./setup.sh
+# or manually:
+#   python3 -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt
+```
+
+**2. Get the analysis tools — pick one**
+
+- **Install locally** (jadx, apktool, semgrep, gitleaks, apkleaks, apkid, adb, Android SDK
+  build-tools) — see [System Requirements](#system-requirements) for the exact commands per OS.
+- **Or skip the local install entirely: enable the Docker toolbox.** If Docker is available,
+  none of those tools need to be on the host — they run sandboxed in a container. See
+  [Static Analysis Toolbox](#static-analysis-toolbox-docker-optional) below.
+
+  ```yaml
+  # config.yaml
+  toolbox:
+    enabled: true
+  ```
+
+`adb`/`frida` always run on the host either way — they need to talk to a real device or emulator,
+so they're out of scope for the toolbox (see that section for why).
+
+**3. Configure**
+
+```bash
+cp config.yaml.example config.yaml
+# fill in google_play.email/aas_token if you'll download from Google Play (see
+# "Obtaining the Google Play AAS Token" below), and the llm: block if you use
+# ai-review.
+```
+
+**4. Run your first scan**
+
+```bash
+python nutcracker.py analyze path/to/app.apk         # local APK
+python nutcracker.py scan com.example.app            # download + analyze
+```
+
+---
+
 ## System Requirements
 
 ### macOS (install with Homebrew)
@@ -173,6 +222,52 @@ nutcracker --help
 
 ---
 
+## Static Analysis Toolbox (Docker, optional)
+
+Not to be confused with [Docker Usage (hybrid)](#docker-usage-hybrid) below — that mode runs the
+whole `nutcracker` process inside a container. This is different: it's a uniform access layer that
+sandboxes only the *static* analysis tools (`nutcracker_core/toolbox/`), while `nutcracker` itself
+keeps running directly on the host.
+
+**Why Docker only for static tools:** jadx/apktool/radare2/etc. decompile third-party content
+(APKs of real, potentially malicious apps) — the container isolates any attempt to exploit a bug in
+the decompiler itself from the rest of the host. It also sidesteps installing 8+ separate tools
+per-OS (see [System Requirements](#system-requirements)) — useful on a machine that doesn't have
+them and won't otherwise need them.
+
+**Why not `adb`/`frida` too:** they need to talk directly to a physical device or emulator attached
+to the host. Putting them in a container would add a network/USB layer to solve without gaining real
+isolation — that isolation comes from having a dedicated test device, not from the process that
+controls it.
+
+Tools included: `aapt`, `aapt2`, `apktool`, `baksmali`, `smali`, `jadx`, `r2` (radare2), `readelf`,
+`nm`, `objdump`, `strings`, `blint`, `gitleaks`, `apkid`, `apksigner`, `apkleaks` — all verified
+running for real inside the built image, not just assumed from the Dockerfile.
+
+**Enable it** in `config.yaml`:
+
+```yaml
+toolbox:
+  enabled: false   # true = decompile/scan via Docker instead of local binaries
+  image: 'nutcracker-toolbox-static:latest'
+```
+
+With `enabled: false` (the default), nothing changes — every module still calls local binaries via
+`shutil.which()` exactly as before. Currently wired into `decompiler.py` (jadx/apktool),
+`native_scanner.py` (nm/objdump/strings) and `leak_scanner.py` (gitleaks/apkleaks).
+
+**Build the image** (optional — it builds itself automatically the first time it's needed):
+
+```bash
+docker build -f nutcracker_core/toolbox/docker/Dockerfile.static \
+    -t nutcracker-toolbox-static:latest nutcracker_core/toolbox/docker
+```
+
+See [`nutcracker_core/toolbox/README.md`](nutcracker_core/toolbox/README.md) for the full design
+(volume mounting, the `--user` UID/GID fix, known limitations).
+
+---
+
 ## Docker Usage (hybrid)
 
 This mode runs `nutcracker` inside Docker and uses an emulator/device connected on the host.